Initial certificate system
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
APP_ENV=development
|
||||
DATABASE_URL=mysql+pymysql://certificate:certificate@mysql:3306/certificate_system
|
||||
SECRET_KEY=change-me
|
||||
PUBLIC_BASE_URL=http://localhost:8080
|
||||
CERTIFICATE_NO_SECRET=change-me
|
||||
PDF_CACHE_DAYS=30
|
||||
DATA_ROOT=/data/certificate-system
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
dev.db
|
||||
*.log
|
||||
local_app/data/
|
||||
67
README.md
Normal file
67
README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 培训结业证书管理与查询系统
|
||||
|
||||
一期目标:快速上线一个可导入、可管理、可查询、可核验、可下载 PDF 证书的闭环系统。
|
||||
|
||||
## 本地验收启动
|
||||
|
||||
请优先使用这个文件:
|
||||
|
||||
```bat
|
||||
start-local.bat
|
||||
```
|
||||
|
||||
也可以在命令行运行:
|
||||
|
||||
```bat
|
||||
cd /d C:\Users\nelso\Documents\projects\certificate-system
|
||||
start-local.bat
|
||||
```
|
||||
|
||||
启动后保持窗口不要关闭,然后打开:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8000/admin/login
|
||||
```
|
||||
|
||||
默认账号:
|
||||
|
||||
```text
|
||||
admin / Admin123!
|
||||
```
|
||||
|
||||
公开查询页:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8000/query
|
||||
```
|
||||
|
||||
如果 `.ps1` 被记事本打开,不用管它,改用 `start-local.bat`。如果想清空本地测试数据,运行:
|
||||
|
||||
```bat
|
||||
reset-local-data.bat
|
||||
```
|
||||
|
||||
当前本地验收版已支持:
|
||||
|
||||
- 学员手动新增、查询、修改、删除。
|
||||
- 项目新增、搜索、修改、启用、停用。
|
||||
- 证书手动新增、后台预览、后台下载、作废、重置链接。
|
||||
- Excel 模板下载、导入、确认导入、导出证书。
|
||||
- 操作日志中文显示。
|
||||
- 公开查询、证书直达页、PDF 下载。
|
||||
|
||||
## 正式工程
|
||||
|
||||
- 后端:Python 3.12 + FastAPI + SQLAlchemy + Alembic
|
||||
- 前端:Vue 3 + TypeScript + Vite + Element Plus
|
||||
- 数据库:MySQL 8
|
||||
- 部署:Docker Compose + Nginx
|
||||
|
||||
本地验收版在 `local_app/`,用于快速验证业务闭环;正式部署仍使用 `backend/`、`frontend/` 和 `docker-compose.yml`。
|
||||
|
||||
## 文档
|
||||
|
||||
- [开发决策记录](docs/decision-log.md)
|
||||
- [MVP 范围说明](docs/mvp-scope.md)
|
||||
- [部署与使用手册](docs/usage-deployment-manual.md)
|
||||
- [上线检查清单](docs/deployment-checklist.md)
|
||||
20
backend/Dockerfile
Normal file
20
backend/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl fonts-noto-cjk \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt \
|
||||
&& python -m playwright install --with-deps chromium
|
||||
|
||||
COPY app ./app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
38
backend/alembic.ini
Normal file
38
backend/alembic.ini
Normal file
@@ -0,0 +1,38 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = sqlite:///./dev.db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
45
backend/alembic/env.py
Normal file
45
backend/alembic/env.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.base import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=settings.database_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
23
backend/alembic/script.py.mako
Normal file
23
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,23 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
188
backend/alembic/versions/20260601_0001_initial.py
Normal file
188
backend/alembic/versions/20260601_0001_initial.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 20260601_0001
|
||||
Revises:
|
||||
Create Date: 2026-06-01
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "20260601_0001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"roles",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("code", sa.String(64), nullable=False),
|
||||
sa.Column("name", sa.String(64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_roles_code", "roles", ["code"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"admin_users",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("username", sa.String(64), nullable=False),
|
||||
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||
sa.Column("role_code", sa.String(64), nullable=False),
|
||||
sa.Column("status", sa.String(32), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_admin_users_username", "admin_users", ["username"], unique=True)
|
||||
op.create_index("ix_admin_users_role_code", "admin_users", ["role_code"])
|
||||
|
||||
op.create_table(
|
||||
"learners",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("phone", sa.String(32), nullable=False),
|
||||
sa.Column("current_name", sa.String(64), nullable=False),
|
||||
sa.Column("id_type", sa.String(32), nullable=True),
|
||||
sa.Column("id_no_encrypted", sa.String(256), nullable=True),
|
||||
sa.Column("id_no_masked", sa.String(64), nullable=True),
|
||||
sa.Column("student_no", sa.String(64), nullable=True),
|
||||
sa.Column("status", sa.String(32), nullable=False),
|
||||
sa.Column("remark", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_learners_phone", "learners", ["phone"], unique=True)
|
||||
op.create_index("ix_learners_current_name", "learners", ["current_name"])
|
||||
|
||||
op.create_table(
|
||||
"learner_name_histories",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("learner_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(64), nullable=False),
|
||||
sa.Column("source", sa.String(64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_learner_name_histories_learner_id", "learner_name_histories", ["learner_id"])
|
||||
|
||||
op.create_table(
|
||||
"learner_phone_histories",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("learner_id", sa.Integer(), nullable=False),
|
||||
sa.Column("old_phone", sa.String(32), nullable=False),
|
||||
sa.Column("new_phone", sa.String(32), nullable=False),
|
||||
sa.Column("reason", sa.String(255), nullable=False),
|
||||
sa.Column("changed_by", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_learner_phone_histories_learner_id", "learner_phone_histories", ["learner_id"])
|
||||
|
||||
op.create_table(
|
||||
"project_courses",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("code", sa.String(16), nullable=False),
|
||||
sa.Column("name", sa.String(128), nullable=False),
|
||||
sa.Column("status", sa.String(32), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_project_courses_code", "project_courses", ["code"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"certificates",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("learner_id", sa.Integer(), nullable=False),
|
||||
sa.Column("project_code", sa.String(16), nullable=False),
|
||||
sa.Column("certificate_no", sa.String(64), nullable=False),
|
||||
sa.Column("certificate_name", sa.String(128), nullable=False),
|
||||
sa.Column("class_name", sa.String(128), nullable=True),
|
||||
sa.Column("course_name", sa.String(128), nullable=True),
|
||||
sa.Column("stage_name", sa.String(128), nullable=True),
|
||||
sa.Column("issue_date", sa.Date(), nullable=False),
|
||||
sa.Column("issuer_name", sa.String(128), nullable=False),
|
||||
sa.Column("status", sa.String(32), nullable=False),
|
||||
sa.Column("pdf_status", sa.String(32), nullable=False),
|
||||
sa.Column("pdf_file_path", sa.String(512), nullable=True),
|
||||
sa.Column("public_token_id", sa.Integer(), nullable=True),
|
||||
sa.Column("qr_token_id", sa.Integer(), nullable=True),
|
||||
sa.Column("remark", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_certificates_learner_id", "certificates", ["learner_id"])
|
||||
op.create_index("ix_certificates_project_code", "certificates", ["project_code"])
|
||||
op.create_index("ix_certificates_certificate_no", "certificates", ["certificate_no"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"certificate_access_tokens",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("certificate_id", sa.Integer(), nullable=False),
|
||||
sa.Column("token_hash", sa.String(128), nullable=False),
|
||||
sa.Column("token_value", sa.String(128), nullable=False),
|
||||
sa.Column("token_type", sa.String(32), nullable=False),
|
||||
sa.Column("status", sa.String(32), nullable=False),
|
||||
sa.Column("created_reason", sa.String(128), nullable=False),
|
||||
sa.Column("revoked_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_access_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_certificate_access_tokens_certificate_id", "certificate_access_tokens", ["certificate_id"])
|
||||
op.create_index("ix_certificate_access_tokens_token_hash", "certificate_access_tokens", ["token_hash"], unique=True)
|
||||
op.create_index("ix_certificate_access_tokens_token_value", "certificate_access_tokens", ["token_value"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"import_batches",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("filename", sa.String(255), nullable=False),
|
||||
sa.Column("file_path", sa.String(512), nullable=False),
|
||||
sa.Column("status", sa.String(32), nullable=False),
|
||||
sa.Column("total_rows", sa.Integer(), nullable=False),
|
||||
sa.Column("valid_rows", sa.Integer(), nullable=False),
|
||||
sa.Column("failed_rows", sa.Integer(), nullable=False),
|
||||
sa.Column("conflict_rows", sa.Integer(), nullable=False),
|
||||
sa.Column("error_report_path", sa.String(512), nullable=True),
|
||||
sa.Column("created_by", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_import_batches_created_by", "import_batches", ["created_by"])
|
||||
|
||||
op.create_table(
|
||||
"import_batch_rows",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("batch_id", sa.Integer(), nullable=False),
|
||||
sa.Column("row_no", sa.Integer(), nullable=False),
|
||||
sa.Column("status", sa.String(32), nullable=False),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("raw_json", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_import_batch_rows_batch_id", "import_batch_rows", ["batch_id"])
|
||||
|
||||
op.create_table(
|
||||
"operation_logs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("admin_user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("action", sa.String(64), nullable=False),
|
||||
sa.Column("object_type", sa.String(64), nullable=False),
|
||||
sa.Column("object_id", sa.String(64), nullable=True),
|
||||
sa.Column("ip_address", sa.String(64), nullable=True),
|
||||
sa.Column("detail_json", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_operation_logs_admin_user_id", "operation_logs", ["admin_user_id"])
|
||||
op.create_index("ix_operation_logs_action", "operation_logs", ["action"])
|
||||
op.create_index("ix_operation_logs_object_type", "operation_logs", ["object_type"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("operation_logs")
|
||||
op.drop_table("import_batch_rows")
|
||||
op.drop_table("import_batches")
|
||||
op.drop_table("certificate_access_tokens")
|
||||
op.drop_table("certificates")
|
||||
op.drop_table("project_courses")
|
||||
op.drop_table("learner_phone_histories")
|
||||
op.drop_table("learner_name_histories")
|
||||
op.drop_table("learners")
|
||||
op.drop_table("admin_users")
|
||||
op.drop_table("roles")
|
||||
34
backend/alembic/versions/20260602_0002_project_defaults.py
Normal file
34
backend/alembic/versions/20260602_0002_project_defaults.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""add project certificate defaults
|
||||
|
||||
Revision ID: 20260602_0002
|
||||
Revises: 20260601_0001
|
||||
Create Date: 2026-06-02
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260602_0002"
|
||||
down_revision = "20260601_0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("project_courses", sa.Column("default_certificate_name", sa.String(length=128), nullable=True))
|
||||
op.add_column("project_courses", sa.Column("default_course_name", sa.String(length=128), nullable=True))
|
||||
op.add_column("project_courses", sa.Column("default_stage_name", sa.String(length=128), nullable=True))
|
||||
op.add_column("project_courses", sa.Column("default_issuer_name", sa.String(length=128), nullable=True))
|
||||
op.execute("update project_courses set default_certificate_name='培训结业证书' where default_certificate_name is null")
|
||||
op.execute("update project_courses set default_course_name=name where default_course_name is null")
|
||||
op.execute("update project_courses set default_issuer_name='本公司' where default_issuer_name is null")
|
||||
op.alter_column("project_courses", "default_certificate_name", nullable=False)
|
||||
op.alter_column("project_courses", "default_issuer_name", nullable=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("project_courses", "default_issuer_name")
|
||||
op.drop_column("project_courses", "default_stage_name")
|
||||
op.drop_column("project_courses", "default_course_name")
|
||||
op.drop_column("project_courses", "default_certificate_name")
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
33
backend/app/api/deps.py
Normal file
33
backend/app/api/deps.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import decode_access_token
|
||||
from app.db.session import get_db
|
||||
from app.models import AdminUser
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_current_admin(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AdminUser:
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
admin = db.get(AdminUser, int(payload["sub"]))
|
||||
if not admin or admin.status != "active":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
|
||||
return admin
|
||||
|
||||
|
||||
def require_roles(*role_codes: str):
|
||||
def checker(admin: AdminUser = Depends(get_current_admin)) -> AdminUser:
|
||||
if admin.role_code not in role_codes:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
|
||||
return admin
|
||||
|
||||
return checker
|
||||
26
backend/app/api/router.py
Normal file
26
backend/app/api/router.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import (
|
||||
admin_certificates,
|
||||
admin_dashboard,
|
||||
admin_exports,
|
||||
admin_imports,
|
||||
admin_learners,
|
||||
admin_logs,
|
||||
admin_projects,
|
||||
auth,
|
||||
health,
|
||||
public,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health.router, tags=["health"])
|
||||
api_router.include_router(auth.router, prefix="/admin/auth", tags=["admin-auth"])
|
||||
api_router.include_router(admin_projects.router, prefix="/admin/projects", tags=["admin-projects"])
|
||||
api_router.include_router(admin_dashboard.router, prefix="/admin/dashboard", tags=["admin-dashboard"])
|
||||
api_router.include_router(admin_learners.router, prefix="/admin/learners", tags=["admin-learners"])
|
||||
api_router.include_router(admin_certificates.router, prefix="/admin/certificates", tags=["admin-certificates"])
|
||||
api_router.include_router(admin_imports.router, prefix="/admin/import-batches", tags=["admin-imports"])
|
||||
api_router.include_router(admin_exports.router, prefix="/admin/exports", tags=["admin-exports"])
|
||||
api_router.include_router(admin_logs.router, prefix="/admin/logs", tags=["admin-logs"])
|
||||
api_router.include_router(public.router, prefix="/public", tags=["public"])
|
||||
1
backend/app/api/routes/__init__.py
Normal file
1
backend/app/api/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
195
backend/app/api/routes/admin_certificates.py
Normal file
195
backend/app/api/routes/admin_certificates.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import require_roles
|
||||
from app.core.security import generate_public_token, hash_token
|
||||
from app.db.session import get_db
|
||||
from app.models import AdminUser, Certificate, CertificateAccessToken, Learner, ProjectCourse
|
||||
from app.schemas.certificate import CertificateCreate, CertificateOut
|
||||
from app.services.certificate_number import build_certificate_no
|
||||
from app.services.logs import log_action
|
||||
from app.services.pdf import render_certificate_pdf
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _create_token(db: Session, certificate_id: int, token_type: str) -> int:
|
||||
raw_token = generate_public_token()
|
||||
token = CertificateAccessToken(
|
||||
certificate_id=certificate_id,
|
||||
token_hash=hash_token(raw_token),
|
||||
token_value=raw_token,
|
||||
token_type=token_type,
|
||||
)
|
||||
db.add(token)
|
||||
db.flush()
|
||||
return token.id
|
||||
|
||||
|
||||
@router.get("", response_model=list[CertificateOut])
|
||||
def list_certificates(
|
||||
keyword: str | None = Query(default=None),
|
||||
status_value: str | None = Query(default=None, alias="status"),
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
|
||||
) -> list[Certificate]:
|
||||
query = db.query(Certificate).order_by(Certificate.id.desc())
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
Certificate.certificate_no.like(like),
|
||||
Certificate.certificate_name.like(like),
|
||||
Certificate.project_code.like(like),
|
||||
Certificate.course_name.like(like),
|
||||
Certificate.stage_name.like(like),
|
||||
)
|
||||
)
|
||||
if status_value:
|
||||
query = query.filter(Certificate.status == status_value)
|
||||
return query.limit(100).all()
|
||||
|
||||
|
||||
@router.post("", response_model=CertificateOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_certificate(
|
||||
payload: CertificateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||
) -> Certificate:
|
||||
learner = db.get(Learner, payload.learner_id)
|
||||
if not learner:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
|
||||
project = db.query(ProjectCourse).filter(ProjectCourse.code == payload.project_code, ProjectCourse.status == "active").first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Project code is inactive or missing")
|
||||
|
||||
certificate = Certificate(
|
||||
learner_id=payload.learner_id,
|
||||
project_code=payload.project_code,
|
||||
certificate_no="PENDING",
|
||||
certificate_name=project.name,
|
||||
class_name=payload.class_name,
|
||||
course_name=payload.course_name or project.default_course_name or project.name,
|
||||
stage_name=payload.stage_name or project.default_stage_name,
|
||||
issue_date=payload.issue_date,
|
||||
issuer_name=payload.issuer_name or project.default_issuer_name,
|
||||
remark=payload.remark,
|
||||
)
|
||||
db.add(certificate)
|
||||
db.flush()
|
||||
certificate.certificate_no = build_certificate_no(certificate.id, certificate.project_code, certificate.issue_date)
|
||||
certificate.public_token_id = _create_token(db, certificate.id, "public_link")
|
||||
certificate.qr_token_id = _create_token(db, certificate.id, "qr_verify")
|
||||
log_action(db, admin, "create_certificate", "certificate", certificate.id, {"certificate_no": certificate.certificate_no, "learner_name": learner.current_name, "project_code": certificate.project_code, "issue_date": certificate.issue_date})
|
||||
db.commit()
|
||||
db.refresh(certificate)
|
||||
return certificate
|
||||
|
||||
|
||||
@router.get("/{certificate_id}", response_model=CertificateOut)
|
||||
def get_certificate(
|
||||
certificate_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
|
||||
) -> Certificate:
|
||||
certificate = db.get(Certificate, certificate_id)
|
||||
if not certificate:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
|
||||
return certificate
|
||||
|
||||
|
||||
@router.get("/{certificate_id}/preview")
|
||||
def preview_certificate(
|
||||
certificate_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
|
||||
) -> dict[str, object]:
|
||||
certificate = db.get(Certificate, certificate_id)
|
||||
if not certificate:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
|
||||
learner = db.get(Learner, certificate.learner_id)
|
||||
public_token = db.get(CertificateAccessToken, certificate.public_token_id) if certificate.public_token_id else None
|
||||
qr_token = db.get(CertificateAccessToken, certificate.qr_token_id) if certificate.qr_token_id else None
|
||||
return {
|
||||
"certificate_no": certificate.certificate_no,
|
||||
"learner_name": learner.current_name if learner else "",
|
||||
"certificate_name": certificate.certificate_name,
|
||||
"project_code": certificate.project_code,
|
||||
"course_name": certificate.course_name,
|
||||
"stage_name": certificate.stage_name,
|
||||
"issue_date": certificate.issue_date.isoformat(),
|
||||
"issuer_name": certificate.issuer_name,
|
||||
"status": certificate.status,
|
||||
"public_url": f"/cert/{public_token.token_value}" if public_token else "",
|
||||
"verify_url": f"/verify/{qr_token.token_value}" if qr_token else "",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{certificate_id}/download")
|
||||
def download_certificate(
|
||||
certificate_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||
) -> FileResponse:
|
||||
certificate = db.get(Certificate, certificate_id)
|
||||
if not certificate:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
|
||||
if certificate.status != "valid":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Voided certificate cannot download normal PDF")
|
||||
learner = db.get(Learner, certificate.learner_id)
|
||||
token = db.get(CertificateAccessToken, certificate.qr_token_id or certificate.public_token_id)
|
||||
project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first()
|
||||
if not learner or not token:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Certificate data is incomplete")
|
||||
pdf_path = render_certificate_pdf(certificate, learner, project, token)
|
||||
db.commit()
|
||||
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{certificate.certificate_no}.pdf")
|
||||
|
||||
|
||||
@router.post("/{certificate_id}/void", response_model=CertificateOut)
|
||||
def void_certificate(
|
||||
certificate_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||
) -> Certificate:
|
||||
certificate = db.get(Certificate, certificate_id)
|
||||
if not certificate:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
|
||||
certificate.status = "voided"
|
||||
certificate.pdf_status = "not_generated"
|
||||
certificate.pdf_file_path = None
|
||||
learner = db.get(Learner, certificate.learner_id)
|
||||
log_action(db, admin, "void_certificate", "certificate", certificate.id, {"certificate_no": certificate.certificate_no, "learner_name": learner.current_name if learner else "", "previous_status": "valid", "status": "voided"})
|
||||
db.commit()
|
||||
db.refresh(certificate)
|
||||
return certificate
|
||||
|
||||
|
||||
@router.post("/{certificate_id}/token/regenerate", response_model=CertificateOut)
|
||||
def regenerate_public_token(
|
||||
certificate_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||
) -> Certificate:
|
||||
certificate = db.get(Certificate, certificate_id)
|
||||
if not certificate:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found")
|
||||
if certificate.public_token_id:
|
||||
old_token = db.get(CertificateAccessToken, certificate.public_token_id)
|
||||
if old_token:
|
||||
old_token.status = "revoked"
|
||||
certificate.public_token_id = _create_token(db, certificate.id, "public_link")
|
||||
learner = db.get(Learner, certificate.learner_id)
|
||||
log_action(
|
||||
db,
|
||||
admin,
|
||||
"regenerate_public_token",
|
||||
"certificate",
|
||||
certificate.id,
|
||||
{"certificate_no": certificate.certificate_no, "learner_name": learner.current_name if learner else ""},
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(certificate)
|
||||
return certificate
|
||||
22
backend/app/api/routes/admin_dashboard.py
Normal file
22
backend/app/api/routes/admin_dashboard.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import require_roles
|
||||
from app.db.session import get_db
|
||||
from app.models import AdminUser, Certificate, ImportBatch, Learner
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def get_summary(
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
|
||||
) -> dict[str, int]:
|
||||
return {
|
||||
"learner_count": db.query(Learner).count(),
|
||||
"certificate_count": db.query(Certificate).count(),
|
||||
"valid_certificate_count": db.query(Certificate).filter(Certificate.status == "valid").count(),
|
||||
"voided_certificate_count": db.query(Certificate).filter(Certificate.status == "voided").count(),
|
||||
"recent_import_count": db.query(ImportBatch).count(),
|
||||
}
|
||||
84
backend/app/api/routes/admin_exports.py
Normal file
84
backend/app/api/routes/admin_exports.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from openpyxl import Workbook
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import require_roles
|
||||
from app.core.config import settings
|
||||
from app.core.paths import data_path
|
||||
from app.db.session import get_db
|
||||
from app.models import AdminUser, Certificate, CertificateAccessToken, Learner
|
||||
from app.services.logs import log_action
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/certificates")
|
||||
def export_certificates(
|
||||
project_code: str | None = Query(default=None),
|
||||
status_value: str | None = Query(default=None, alias="status"),
|
||||
db: Session = Depends(get_db),
|
||||
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||
) -> StreamingResponse:
|
||||
query = (
|
||||
db.query(Certificate, Learner, CertificateAccessToken)
|
||||
.join(Learner, Learner.id == Certificate.learner_id)
|
||||
.join(CertificateAccessToken, CertificateAccessToken.id == Certificate.public_token_id)
|
||||
.order_by(Certificate.id.desc())
|
||||
)
|
||||
if project_code:
|
||||
query = query.filter(Certificate.project_code == project_code.strip().upper())
|
||||
if status_value:
|
||||
query = query.filter(Certificate.status == status_value)
|
||||
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "证书导出"
|
||||
sheet.append(
|
||||
[
|
||||
"姓名",
|
||||
"手机号",
|
||||
"证书编号",
|
||||
"证书对外直达链接",
|
||||
"项目代码",
|
||||
"证书名称",
|
||||
"课程名称",
|
||||
"阶段名称",
|
||||
"发证日期",
|
||||
"证书状态",
|
||||
"PDF状态",
|
||||
]
|
||||
)
|
||||
for certificate, learner, token in query.limit(50_000).all():
|
||||
sheet.append(
|
||||
[
|
||||
learner.current_name,
|
||||
_mask_phone(learner.phone),
|
||||
certificate.certificate_no,
|
||||
f"{settings.public_base_url}/cert/{token.token_value}",
|
||||
certificate.project_code,
|
||||
certificate.certificate_name,
|
||||
certificate.course_name,
|
||||
certificate.stage_name,
|
||||
certificate.issue_date.isoformat(),
|
||||
certificate.status,
|
||||
certificate.pdf_status,
|
||||
]
|
||||
)
|
||||
|
||||
export_path = data_path("exports") / "certificates-export.xlsx"
|
||||
workbook.save(export_path)
|
||||
log_action(db, admin, "export_certificates", "certificate", detail={"project_code": project_code, "status": status_value})
|
||||
db.commit()
|
||||
file_handle = export_path.open("rb")
|
||||
return StreamingResponse(
|
||||
file_handle,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="certificates-export.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
def _mask_phone(phone: str) -> str:
|
||||
if len(phone) < 7:
|
||||
return phone
|
||||
return f"{phone[:3]}****{phone[-4:]}"
|
||||
362
backend/app/api/routes/admin_imports.py
Normal file
362
backend/app/api/routes/admin_imports.py
Normal file
@@ -0,0 +1,362 @@
|
||||
import json
|
||||
import shutil
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import require_roles
|
||||
from app.core.paths import data_path
|
||||
from app.core.security import generate_public_token, hash_token
|
||||
from app.db.session import get_db
|
||||
from app.models import (
|
||||
AdminUser,
|
||||
Certificate,
|
||||
CertificateAccessToken,
|
||||
ImportBatch,
|
||||
ImportBatchRow,
|
||||
Learner,
|
||||
LearnerNameHistory,
|
||||
ProjectCourse,
|
||||
)
|
||||
from app.schemas.import_batch import ImportBatchOut
|
||||
from app.services.certificate_number import build_certificate_no
|
||||
from app.services.logs import log_action
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
COL_NAME = "\u59d3\u540d"
|
||||
COL_PHONE = "\u624b\u673a\u53f7"
|
||||
COL_PROJECT = "\u9879\u76ee\u4ee3\u7801"
|
||||
COL_ISSUE_DATE = "\u53d1\u8bc1\u65e5\u671f"
|
||||
|
||||
TEMPLATE_HEADERS = [
|
||||
COL_NAME,
|
||||
COL_PHONE,
|
||||
COL_PROJECT,
|
||||
COL_ISSUE_DATE,
|
||||
]
|
||||
REQUIRED_HEADERS = [COL_NAME, COL_PHONE, COL_PROJECT, COL_ISSUE_DATE]
|
||||
|
||||
|
||||
@router.get("/template")
|
||||
def download_template(_: AdminUser = Depends(require_roles("system_admin", "certificate_admin"))) -> StreamingResponse:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "\u8bc1\u4e66\u5bfc\u5165\u6a21\u677f"
|
||||
sheet.append(TEMPLATE_HEADERS)
|
||||
stream_path = data_path("exports") / "certificate-import-template.xlsx"
|
||||
workbook.save(stream_path)
|
||||
file_handle = stream_path.open("rb")
|
||||
return StreamingResponse(
|
||||
file_handle,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="certificate-import-template.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ImportBatchOut, status_code=status.HTTP_201_CREATED)
|
||||
def upload_import_file(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||
) -> ImportBatch:
|
||||
if not file.filename or not file.filename.lower().endswith(".xlsx"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only .xlsx files are supported")
|
||||
|
||||
upload_path = data_path("uploads") / file.filename
|
||||
with upload_path.open("wb") as target:
|
||||
shutil.copyfileobj(file.file, target)
|
||||
|
||||
batch = ImportBatch(filename=file.filename, file_path=str(upload_path), created_by=admin.id)
|
||||
db.add(batch)
|
||||
db.flush()
|
||||
validate_batch(db, batch, upload_path)
|
||||
log_action(db, admin, "upload_import_file", "import_batch", batch.id, {"filename": file.filename, "total_rows": batch.total_rows, "valid_rows": batch.valid_rows, "failed_rows": batch.failed_rows, "status": batch.status})
|
||||
db.commit()
|
||||
db.refresh(batch)
|
||||
return batch
|
||||
|
||||
|
||||
@router.get("", response_model=list[ImportBatchOut])
|
||||
def list_import_batches(
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
|
||||
) -> list[ImportBatch]:
|
||||
return db.query(ImportBatch).order_by(ImportBatch.id.desc()).limit(50).all()
|
||||
|
||||
|
||||
@router.get("/{batch_id}/error-report")
|
||||
def download_error_report(
|
||||
batch_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||
) -> FileResponse:
|
||||
batch = db.get(ImportBatch, batch_id)
|
||||
if not batch or not batch.error_report_path:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Error report not found")
|
||||
return FileResponse(
|
||||
batch.error_report_path,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
filename=f"import-errors-{batch.id}.xlsx",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{batch_id}/file")
|
||||
def download_source_file(
|
||||
batch_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||
) -> FileResponse:
|
||||
batch = db.get(ImportBatch, batch_id)
|
||||
if not batch or not Path(batch.file_path).exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Source file not found")
|
||||
return FileResponse(
|
||||
batch.file_path,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
filename=batch.filename,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{batch_id}")
|
||||
def delete_import_batch(
|
||||
batch_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||
) -> dict[str, bool]:
|
||||
batch = db.get(ImportBatch, batch_id)
|
||||
if not batch:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Import batch not found")
|
||||
|
||||
for file_name in [batch.file_path, batch.error_report_path]:
|
||||
if file_name:
|
||||
path = Path(file_name)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
db.query(ImportBatchRow).filter(ImportBatchRow.batch_id == batch.id).delete()
|
||||
db.delete(batch)
|
||||
log_action(db, admin, "delete_import_batch", "import_batch", batch.id, {"filename": batch.filename, "status": batch.status, "total_rows": batch.total_rows})
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{batch_id}/confirm", response_model=ImportBatchOut)
|
||||
def confirm_import_batch(
|
||||
batch_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||
) -> ImportBatch:
|
||||
batch = db.get(ImportBatch, batch_id)
|
||||
if not batch:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Import batch not found")
|
||||
if batch.status not in {"validated", "imported"}:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Import batch is not ready")
|
||||
if batch.status == "imported":
|
||||
return batch
|
||||
|
||||
rows = db.query(ImportBatchRow).filter(ImportBatchRow.batch_id == batch.id, ImportBatchRow.status == "valid").all()
|
||||
ok_rows = 0
|
||||
failed_rows = 0
|
||||
for row in rows:
|
||||
row_data = json.loads(row.raw_json or "{}")
|
||||
learner = upsert_learner(db, row_data)
|
||||
issue_date = parse_issue_date(row_data[COL_ISSUE_DATE])
|
||||
project_code = str(row_data[COL_PROJECT]).strip().upper()
|
||||
project = db.query(ProjectCourse).filter(ProjectCourse.code == project_code, ProjectCourse.status == "active").first()
|
||||
if not project:
|
||||
row.status = "failed"
|
||||
row.error_message = f"Project code is inactive or missing: {project_code}"
|
||||
failed_rows += 1
|
||||
continue
|
||||
duplicate = find_duplicate_certificate(db, learner.id, project, issue_date)
|
||||
if duplicate:
|
||||
row.status = "skipped"
|
||||
row.error_message = "\u5df2\u5b58\u5728\uff0c\u65e0\u9700\u5904\u7406"
|
||||
ok_rows += 1
|
||||
continue
|
||||
|
||||
certificate = Certificate(
|
||||
learner_id=learner.id,
|
||||
project_code=project.code,
|
||||
certificate_no="PENDING",
|
||||
certificate_name=project.default_certificate_name,
|
||||
course_name=project.default_course_name,
|
||||
stage_name=project.default_stage_name,
|
||||
issue_date=issue_date,
|
||||
issuer_name=project.default_issuer_name,
|
||||
remark=None,
|
||||
)
|
||||
db.add(certificate)
|
||||
db.flush()
|
||||
certificate.certificate_no = build_certificate_no(certificate.id, certificate.project_code, certificate.issue_date)
|
||||
certificate.public_token_id = create_access_token(db, certificate.id, "public_link")
|
||||
certificate.qr_token_id = create_access_token(db, certificate.id, "qr_verify")
|
||||
row.status = "imported"
|
||||
ok_rows += 1
|
||||
|
||||
batch.status = "imported" if ok_rows else "failed"
|
||||
if failed_rows:
|
||||
batch.failed_rows = (batch.failed_rows or 0) + failed_rows
|
||||
log_action(db, admin, "confirm_import_batch", "import_batch", batch.id, {"filename": batch.filename, "valid_rows": batch.valid_rows, "imported_rows": ok_rows, "failed_rows": failed_rows, "status": batch.status})
|
||||
db.commit()
|
||||
db.refresh(batch)
|
||||
return batch
|
||||
|
||||
|
||||
def validate_batch(db: Session, batch: ImportBatch, upload_path: Path) -> None:
|
||||
workbook = load_workbook(upload_path, read_only=True, data_only=True)
|
||||
sheet = workbook.active
|
||||
header = [cell.value for cell in next(sheet.iter_rows(min_row=1, max_row=1))]
|
||||
header_index = {name: idx for idx, name in enumerate(header)}
|
||||
missing = [name for name in TEMPLATE_HEADERS if name not in header_index]
|
||||
if missing:
|
||||
batch.status = "failed"
|
||||
batch.failed_rows = 1
|
||||
db.add(ImportBatchRow(batch_id=batch.id, row_no=1, status="failed", error_message=f"Missing columns: {missing}"))
|
||||
return
|
||||
|
||||
active_codes = {row[0] for row in db.query(ProjectCourse.code).filter(ProjectCourse.status == "active").all()}
|
||||
total = valid = failed = 0
|
||||
for row_no, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
|
||||
if not any(row):
|
||||
continue
|
||||
total += 1
|
||||
row_data = {name: row[header_index[name]] for name in TEMPLATE_HEADERS}
|
||||
errors = row_errors(row_data, active_codes)
|
||||
if errors:
|
||||
failed += 1
|
||||
db.add(
|
||||
ImportBatchRow(
|
||||
batch_id=batch.id,
|
||||
row_no=row_no,
|
||||
status="failed",
|
||||
error_message="; ".join(errors),
|
||||
raw_json=json.dumps(row_data, ensure_ascii=False, default=str),
|
||||
)
|
||||
)
|
||||
else:
|
||||
valid += 1
|
||||
db.add(
|
||||
ImportBatchRow(
|
||||
batch_id=batch.id,
|
||||
row_no=row_no,
|
||||
status="valid",
|
||||
raw_json=json.dumps(row_data, ensure_ascii=False, default=str),
|
||||
)
|
||||
)
|
||||
|
||||
batch.total_rows = total
|
||||
batch.valid_rows = valid
|
||||
batch.failed_rows = failed
|
||||
batch.status = "validated"
|
||||
if failed:
|
||||
batch.error_report_path = str(write_error_report(db, batch.id))
|
||||
|
||||
|
||||
def row_errors(row_data: dict[str, object], active_codes: set[str]) -> list[str]:
|
||||
errors = []
|
||||
for name in REQUIRED_HEADERS:
|
||||
if not row_data.get(name):
|
||||
errors.append(f"{name} is required")
|
||||
project_code = str(row_data.get(COL_PROJECT) or "").strip().upper()
|
||||
if project_code and project_code not in active_codes:
|
||||
errors.append("Project code is inactive or missing")
|
||||
if row_data.get(COL_ISSUE_DATE) and not date_is_valid(row_data[COL_ISSUE_DATE]):
|
||||
errors.append("Issue date format is invalid")
|
||||
return errors
|
||||
|
||||
|
||||
def upsert_learner(db: Session, row_data: dict[str, object]) -> Learner:
|
||||
phone = str(row_data[COL_PHONE]).strip()
|
||||
name = str(row_data[COL_NAME]).strip()
|
||||
learner = db.query(Learner).filter(Learner.phone == phone).first()
|
||||
if learner:
|
||||
if learner.current_name != name:
|
||||
db.add(LearnerNameHistory(learner_id=learner.id, name=name, source="import"))
|
||||
learner.current_name = name
|
||||
return learner
|
||||
|
||||
learner = Learner(phone=phone, current_name=name)
|
||||
db.add(learner)
|
||||
db.flush()
|
||||
db.add(LearnerNameHistory(learner_id=learner.id, name=name, source="import"))
|
||||
return learner
|
||||
|
||||
|
||||
def get_active_project(db: Session, project_code: str) -> ProjectCourse:
|
||||
project = db.query(ProjectCourse).filter(ProjectCourse.code == project_code, ProjectCourse.status == "active").first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Project code is inactive or missing: {project_code}")
|
||||
return project
|
||||
|
||||
|
||||
def find_duplicate_certificate(db: Session, learner_id: int, project: ProjectCourse, issue_date: date) -> Certificate | None:
|
||||
return (
|
||||
db.query(Certificate)
|
||||
.filter(Certificate.learner_id == learner_id)
|
||||
.filter(Certificate.project_code == project.code)
|
||||
.filter(Certificate.certificate_name == project.default_certificate_name)
|
||||
.filter(Certificate.course_name == project.default_course_name)
|
||||
.filter(Certificate.stage_name == project.default_stage_name)
|
||||
.filter(Certificate.issue_date == issue_date)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def create_access_token(db: Session, certificate_id: int, token_type: str) -> int:
|
||||
raw_token = generate_public_token()
|
||||
access_token = CertificateAccessToken(
|
||||
certificate_id=certificate_id,
|
||||
token_hash=hash_token(raw_token),
|
||||
token_value=raw_token,
|
||||
token_type=token_type,
|
||||
)
|
||||
db.add(access_token)
|
||||
db.flush()
|
||||
return access_token.id
|
||||
|
||||
|
||||
def optional_text(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def parse_issue_date(value: object) -> date:
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
text = str(value).strip()
|
||||
for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"]:
|
||||
try:
|
||||
return datetime.strptime(text, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid issue date: {text}")
|
||||
|
||||
|
||||
def date_is_valid(value: object) -> bool:
|
||||
try:
|
||||
parse_issue_date(value)
|
||||
return True
|
||||
except HTTPException:
|
||||
return False
|
||||
|
||||
|
||||
def write_error_report(db: Session, batch_id: int) -> Path:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "\u9519\u8bef\u62a5\u544a"
|
||||
sheet.append(["\u884c\u53f7", "\u9519\u8bef\u539f\u56e0", "\u539f\u59cb\u6570\u636e"])
|
||||
rows = db.query(ImportBatchRow).filter(ImportBatchRow.batch_id == batch_id, ImportBatchRow.status == "failed").all()
|
||||
for row in rows:
|
||||
sheet.append([row.row_no, row.error_message, row.raw_json])
|
||||
report_path = data_path("error-reports") / f"import-errors-{batch_id}.xlsx"
|
||||
workbook.save(report_path)
|
||||
return report_path
|
||||
126
backend/app/api/routes/admin_learners.py
Normal file
126
backend/app/api/routes/admin_learners.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import require_roles
|
||||
from app.db.session import get_db
|
||||
from app.models import AdminUser, Learner, LearnerNameHistory
|
||||
from app.schemas.learner import LearnerCreate, LearnerOut, LearnerUpdate
|
||||
from app.services.logs import diff_values, log_action, mask_phone
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[LearnerOut])
|
||||
def list_learners(
|
||||
keyword: str | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
|
||||
) -> list[Learner]:
|
||||
query = db.query(Learner).filter(Learner.status != "deleted").order_by(Learner.id.desc())
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
query = query.filter(or_(Learner.current_name.like(like), Learner.phone.like(like), Learner.student_no.like(like)))
|
||||
return query.limit(100).all()
|
||||
|
||||
|
||||
@router.post("", response_model=LearnerOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_learner(
|
||||
payload: LearnerCreate,
|
||||
db: Session = Depends(get_db),
|
||||
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||
) -> Learner:
|
||||
existing = db.query(Learner).filter(Learner.phone == payload.phone).first()
|
||||
if existing:
|
||||
if existing.current_name != payload.current_name:
|
||||
db.add(LearnerNameHistory(learner_id=existing.id, name=payload.current_name, source="manual"))
|
||||
existing.current_name = payload.current_name
|
||||
existing.student_no = payload.student_no
|
||||
existing.remark = payload.remark
|
||||
log_action(db, admin, "update_learner", "learner", existing.id, {"name": existing.current_name, "phone": mask_phone(existing.phone)})
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return existing
|
||||
learner = Learner(
|
||||
phone=payload.phone,
|
||||
current_name=payload.current_name,
|
||||
student_no=payload.student_no,
|
||||
remark=payload.remark,
|
||||
)
|
||||
db.add(learner)
|
||||
db.commit()
|
||||
db.refresh(learner)
|
||||
db.add(LearnerNameHistory(learner_id=learner.id, name=learner.current_name, source="manual"))
|
||||
log_action(db, admin, "create_learner", "learner", learner.id, {"name": learner.current_name, "phone": mask_phone(learner.phone), "status": learner.status})
|
||||
db.commit()
|
||||
return learner
|
||||
|
||||
|
||||
@router.get("/{learner_id}", response_model=LearnerOut)
|
||||
def get_learner(
|
||||
learner_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
|
||||
) -> Learner:
|
||||
learner = db.get(Learner, learner_id)
|
||||
if not learner:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
|
||||
return learner
|
||||
|
||||
|
||||
@router.put("/{learner_id}", response_model=LearnerOut)
|
||||
def update_learner(
|
||||
learner_id: int,
|
||||
payload: LearnerUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||
) -> Learner:
|
||||
learner = db.get(Learner, learner_id)
|
||||
if not learner:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
|
||||
duplicate = db.query(Learner).filter(Learner.phone == payload.phone, Learner.id != learner_id).first()
|
||||
if duplicate:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Phone already exists")
|
||||
before = {
|
||||
"phone": learner.phone,
|
||||
"current_name": learner.current_name,
|
||||
"student_no": learner.student_no,
|
||||
"status": learner.status,
|
||||
"remark": learner.remark,
|
||||
}
|
||||
if learner.current_name != payload.current_name:
|
||||
db.add(LearnerNameHistory(learner_id=learner.id, name=payload.current_name, source="manual"))
|
||||
learner.phone = payload.phone
|
||||
learner.current_name = payload.current_name
|
||||
learner.student_no = payload.student_no
|
||||
learner.status = payload.status
|
||||
learner.remark = payload.remark
|
||||
after = {
|
||||
"phone": learner.phone,
|
||||
"current_name": learner.current_name,
|
||||
"student_no": learner.student_no,
|
||||
"status": learner.status,
|
||||
"remark": learner.remark,
|
||||
}
|
||||
changes = diff_values(before, after, list(after.keys()))
|
||||
if "phone" in changes:
|
||||
changes["phone"] = {"before": mask_phone(changes["phone"]["before"]), "after": mask_phone(changes["phone"]["after"])}
|
||||
log_action(db, admin, "update_learner", "learner", learner.id, {"name": learner.current_name, "phone": mask_phone(learner.phone), "changes": changes})
|
||||
db.commit()
|
||||
db.refresh(learner)
|
||||
return learner
|
||||
|
||||
|
||||
@router.delete("/{learner_id}")
|
||||
def delete_learner(
|
||||
learner_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||
) -> dict[str, bool]:
|
||||
learner = db.get(Learner, learner_id)
|
||||
if not learner:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
|
||||
learner.status = "deleted"
|
||||
log_action(db, admin, "delete_learner", "learner", learner.id, {"name": learner.current_name, "phone": mask_phone(learner.phone)})
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
115
backend/app/api/routes/admin_logs.py
Normal file
115
backend/app/api/routes/admin_logs.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from openpyxl import Workbook
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import require_roles
|
||||
from app.core.paths import data_path
|
||||
from app.db.session import get_db
|
||||
from app.models import AdminUser, OperationLog
|
||||
from app.schemas.log import OperationLogOut
|
||||
from app.services.logs import HIGH_RISK_LOG_RETENTION_DAYS, LOG_RETENTION_DAYS, cleanup_expired_logs, format_log
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[OperationLogOut])
|
||||
def list_logs(
|
||||
action: str | None = Query(default=None),
|
||||
object_type: str | None = Query(default=None),
|
||||
risk: str | None = Query(default=None),
|
||||
keyword: str | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
|
||||
) -> dict[str, object]:
|
||||
items = _query_logs(db, action, object_type, risk, keyword)
|
||||
total = len(items)
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
page = min(page, total_pages)
|
||||
start = (page - 1) * page_size
|
||||
return {
|
||||
"items": items[start:start + page_size],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_logs(
|
||||
action: str | None = Query(default=None),
|
||||
object_type: str | None = Query(default=None),
|
||||
risk: str | None = Query(default=None),
|
||||
keyword: str | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
|
||||
) -> StreamingResponse:
|
||||
items = _query_logs(db, action, object_type, risk, keyword)
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "\u64cd\u4f5c\u65e5\u5fd7"
|
||||
sheet.append(["\u65f6\u95f4", "\u64cd\u4f5c\u4ebaID", "\u64cd\u4f5c", "\u5bf9\u8c61", "\u5bf9\u8c61ID", "\u98ce\u9669\u7b49\u7ea7", "\u6458\u8981", "\u8be6\u60c5"])
|
||||
for item in items:
|
||||
sheet.append([
|
||||
item.get("created_at"),
|
||||
item.get("admin_user_id"),
|
||||
item.get("action_label"),
|
||||
item.get("object_label"),
|
||||
item.get("object_id"),
|
||||
item.get("risk_label"),
|
||||
item.get("summary"),
|
||||
item.get("detail_json"),
|
||||
])
|
||||
for column in "ABCDEFGH":
|
||||
sheet.column_dimensions[column].width = 22 if column != "H" else 60
|
||||
export_path = data_path("exports") / "operation-logs.xlsx"
|
||||
workbook.save(export_path)
|
||||
file_handle = export_path.open("rb")
|
||||
return StreamingResponse(
|
||||
file_handle,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="operation-logs.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cleanup")
|
||||
def cleanup_logs(
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||
) -> dict[str, int]:
|
||||
removed = cleanup_expired_logs(db)
|
||||
db.commit()
|
||||
return {
|
||||
"removed": removed,
|
||||
"normal_retention_days": LOG_RETENTION_DAYS,
|
||||
"high_risk_retention_days": HIGH_RISK_LOG_RETENTION_DAYS,
|
||||
}
|
||||
|
||||
|
||||
def _query_logs(
|
||||
db: Session,
|
||||
action: str | None,
|
||||
object_type: str | None,
|
||||
risk: str | None,
|
||||
keyword: str | None,
|
||||
) -> list[dict[str, object]]:
|
||||
query = db.query(OperationLog).order_by(OperationLog.id.desc())
|
||||
cleanup_expired_logs(db)
|
||||
db.commit()
|
||||
if action:
|
||||
query = query.filter(OperationLog.action == action)
|
||||
if object_type:
|
||||
query = query.filter(OperationLog.object_type == object_type)
|
||||
items = [format_log(item) for item in query.limit(500).all()]
|
||||
if risk:
|
||||
items = [item for item in items if item["risk"] == risk]
|
||||
if keyword:
|
||||
word = keyword.strip().lower()
|
||||
items = [
|
||||
item for item in items
|
||||
if word in " ".join(str(item.get(key, "")) for key in ["object_id", "summary", "detail_json", "action_label", "object_label"]).lower()
|
||||
]
|
||||
return items[:200]
|
||||
82
backend/app/api/routes/admin_projects.py
Normal file
82
backend/app/api/routes/admin_projects.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import require_roles
|
||||
from app.db.session import get_db
|
||||
from app.models import AdminUser, ProjectCourse
|
||||
from app.schemas.project import ProjectCourseCreate, ProjectCourseOut, ProjectCourseUpdate
|
||||
from app.services.logs import diff_values, log_action
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProjectCourseOut])
|
||||
def list_projects(
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
|
||||
) -> list[ProjectCourse]:
|
||||
return db.query(ProjectCourse).order_by(ProjectCourse.code.asc()).all()
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectCourseOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_project(
|
||||
payload: ProjectCourseCreate,
|
||||
db: Session = Depends(get_db),
|
||||
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||
) -> ProjectCourse:
|
||||
if db.query(ProjectCourse).filter(ProjectCourse.code == payload.code).first():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目代码已存在")
|
||||
project = ProjectCourse(
|
||||
code=payload.code,
|
||||
name=payload.name,
|
||||
default_certificate_name=payload.name,
|
||||
default_course_name=payload.default_course_name,
|
||||
default_stage_name=payload.default_stage_name,
|
||||
default_issuer_name=payload.default_issuer_name,
|
||||
)
|
||||
db.add(project)
|
||||
log_action(db, admin, "create_project", "project_course", detail={"code": payload.code, "name": payload.name, "status": project.status})
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@router.put("/{project_id}", response_model=ProjectCourseOut)
|
||||
def update_project(
|
||||
project_id: int,
|
||||
payload: ProjectCourseUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||
) -> ProjectCourse:
|
||||
project = db.get(ProjectCourse, project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
before = {
|
||||
"name": project.name,
|
||||
"default_course_name": project.default_course_name,
|
||||
"default_stage_name": project.default_stage_name,
|
||||
"default_issuer_name": project.default_issuer_name,
|
||||
"status": project.status,
|
||||
}
|
||||
if payload.name is not None:
|
||||
project.name = payload.name
|
||||
project.default_certificate_name = payload.name
|
||||
if payload.default_course_name is not None:
|
||||
project.default_course_name = payload.default_course_name
|
||||
if payload.default_stage_name is not None:
|
||||
project.default_stage_name = payload.default_stage_name
|
||||
if payload.default_issuer_name is not None:
|
||||
project.default_issuer_name = payload.default_issuer_name
|
||||
if payload.status is not None:
|
||||
project.status = payload.status
|
||||
after = {
|
||||
"name": project.name,
|
||||
"default_course_name": project.default_course_name,
|
||||
"default_stage_name": project.default_stage_name,
|
||||
"default_issuer_name": project.default_issuer_name,
|
||||
"status": project.status,
|
||||
}
|
||||
log_action(db, admin, "update_project", "project_course", project.id, {"code": project.code, "name": project.name, "changes": diff_values(before, after, list(after.keys()))})
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
return project
|
||||
21
backend/app/api/routes/auth.py
Normal file
21
backend/app/api/routes/auth.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import create_access_token, verify_password
|
||||
from app.db.session import get_db
|
||||
from app.models import AdminUser
|
||||
from app.schemas.auth import LoginRequest, LoginResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> LoginResponse:
|
||||
admin = db.query(AdminUser).filter(AdminUser.username == payload.username).first()
|
||||
if not admin or admin.status != "active" or not verify_password(payload.password, admin.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号或密码错误,请重试")
|
||||
return LoginResponse(
|
||||
access_token=create_access_token(str(admin.id), admin.role_code),
|
||||
role_code=admin.role_code,
|
||||
username=admin.username,
|
||||
)
|
||||
8
backend/app/api/routes/health.py
Normal file
8
backend/app/api/routes/health.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health_check() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
138
backend/app/api/routes/public.py
Normal file
138
backend/app/api/routes/public.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import hash_token
|
||||
from app.db.session import get_db
|
||||
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
|
||||
from app.services.captcha import make_captcha, verify_captcha
|
||||
from app.services.logs import log_action, mask_phone
|
||||
from app.services.pdf import render_certificate_pdf
|
||||
from app.services.rate_limit import hit_too_often
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DISCLAIMER = "本证书仅用于证明学员完成相关培训课程/阶段学习,不代表国家学历、学位、职业资格或职业技能等级认证。"
|
||||
|
||||
|
||||
class CertificateSearchRequest(BaseModel):
|
||||
certificate_no: str | None = Field(default=None, max_length=64)
|
||||
phone: str | None = Field(default=None, max_length=32)
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
captcha_id: str = Field(min_length=1, max_length=128)
|
||||
captcha_code: str = Field(min_length=1, max_length=16)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def certificate_no_or_phone_required(self) -> "CertificateSearchRequest":
|
||||
if not (self.certificate_no or "").strip() and not (self.phone or "").strip():
|
||||
raise ValueError("请填写证书编号或手机号其中一项")
|
||||
return self
|
||||
|
||||
|
||||
@router.get("/captcha")
|
||||
def get_captcha() -> dict[str, str]:
|
||||
return make_captcha()
|
||||
|
||||
|
||||
@router.post("/certificates/search")
|
||||
def search_certificate(
|
||||
payload: CertificateSearchRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, object]:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
if hit_too_often(f"search:{client_ip}", limit=30, window_seconds=60):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="访问过于频繁,请稍后再试")
|
||||
if not verify_captcha(payload.captcha_id, payload.captcha_code):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期,请重新输入")
|
||||
|
||||
name = payload.name.strip()
|
||||
certificate_no = (payload.certificate_no or "").strip().upper()
|
||||
phone = (payload.phone or "").strip()
|
||||
|
||||
query = db.query(Certificate, Learner).join(Learner, Learner.id == Certificate.learner_id)
|
||||
if certificate_no:
|
||||
result = query.filter(Certificate.certificate_no == certificate_no).filter(Learner.current_name == name).all()
|
||||
else:
|
||||
result = (
|
||||
query.filter(Learner.phone == phone)
|
||||
.filter(Learner.current_name == name)
|
||||
.order_by(Certificate.issue_date.desc(), Certificate.id.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
if not result:
|
||||
log_action(db, None, "public_search_certificate", "public_access", detail={"mode": "certificate_no" if certificate_no else "phone", "name": name, "certificate_no": certificate_no or None, "phone": mask_phone(phone), "matched_count": 0, "result": "not_found"})
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未查询到匹配的证书信息,请核对后重试")
|
||||
log_action(db, None, "public_search_certificate", "public_access", result[0][0].certificate_no, {"mode": "certificate_no" if certificate_no else "phone", "name": name, "certificate_no": certificate_no or None, "phone": mask_phone(phone), "matched_count": len(result), "result": "matched"})
|
||||
db.commit()
|
||||
return {"items": [public_certificate_payload(db, certificate, learner) for certificate, learner in result]}
|
||||
|
||||
|
||||
@router.get("/certificates/token/{token}")
|
||||
def get_certificate_by_token(token: str, db: Session = Depends(get_db)) -> dict[str, object]:
|
||||
access_token = get_active_token(db, token)
|
||||
certificate, learner = get_certificate_and_learner(db, access_token.certificate_id)
|
||||
return public_certificate_payload(db, certificate, learner)
|
||||
|
||||
|
||||
@router.get("/certificates/token/{token}/download")
|
||||
def download_certificate_pdf(token: str, db: Session = Depends(get_db)) -> FileResponse:
|
||||
access_token = get_active_token(db, token)
|
||||
certificate, learner = get_certificate_and_learner(db, access_token.certificate_id)
|
||||
if certificate.status != "valid":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="证书已作废,不支持下载正常 PDF")
|
||||
|
||||
project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first()
|
||||
pdf_path = render_certificate_pdf(certificate, learner, project, access_token)
|
||||
log_action(db, None, "public_download_certificate", "certificate", certificate.id, {"certificate_no": certificate.certificate_no, "learner_name": learner.current_name, "project_code": certificate.project_code})
|
||||
db.commit()
|
||||
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{certificate.certificate_no}.pdf")
|
||||
|
||||
|
||||
def public_certificate_payload(db: Session, certificate: Certificate, learner: Learner) -> dict[str, object]:
|
||||
token = db.get(CertificateAccessToken, certificate.public_token_id) if certificate.public_token_id else None
|
||||
token_value = token.token_value if token and token.status == "active" else None
|
||||
return {
|
||||
"certificate_no": certificate.certificate_no,
|
||||
"learner_name": learner.current_name,
|
||||
"certificate_name": certificate.certificate_name,
|
||||
"project_code": certificate.project_code,
|
||||
"course_name": certificate.course_name,
|
||||
"stage_name": certificate.stage_name,
|
||||
"issue_date": certificate.issue_date.isoformat(),
|
||||
"issuer_name": certificate.issuer_name,
|
||||
"status": certificate.status,
|
||||
"pdf_status": certificate.pdf_status,
|
||||
"public_token": token_value,
|
||||
"public_url": f"/cert/{token_value}" if token_value else None,
|
||||
"download_url": f"/api/public/certificates/token/{token_value}/download" if token_value and certificate.status == "valid" else None,
|
||||
"can_download_pdf": certificate.status == "valid" and bool(token_value),
|
||||
"disclaimer": DISCLAIMER,
|
||||
}
|
||||
|
||||
|
||||
def get_active_token(db: Session, token: str) -> CertificateAccessToken:
|
||||
access_token = (
|
||||
db.query(CertificateAccessToken)
|
||||
.filter(CertificateAccessToken.token_hash == hash_token(token))
|
||||
.filter(CertificateAccessToken.status == "active")
|
||||
.first()
|
||||
)
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="链接无效或已失效")
|
||||
return access_token
|
||||
|
||||
|
||||
def get_certificate_and_learner(db: Session, certificate_id: int) -> tuple[Certificate, Learner]:
|
||||
result = (
|
||||
db.query(Certificate, Learner)
|
||||
.join(Learner, Learner.id == Certificate.learner_id)
|
||||
.filter(Certificate.id == certificate_id)
|
||||
.first()
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="证书不存在")
|
||||
return result
|
||||
BIN
backend/app/assets/certificate-template.jpg
Normal file
BIN
backend/app/assets/certificate-template.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
30
backend/app/cli.py
Normal file
30
backend/app/cli.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import argparse
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
from app.db.init_db import create_tables, seed_defaults
|
||||
from app.db.session import SessionLocal
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Certificate system management commands")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
init_parser = subparsers.add_parser("init-db")
|
||||
init_parser.add_argument("--admin-username", default="admin")
|
||||
init_parser.add_argument("--admin-password", required=True)
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.command == "init-db":
|
||||
try:
|
||||
command.upgrade(Config("alembic.ini"), "head")
|
||||
except Exception:
|
||||
create_tables()
|
||||
with SessionLocal() as db:
|
||||
seed_defaults(db, args.admin_username, args.admin_password)
|
||||
print("Database initialized")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
33
backend/app/core/config.py
Normal file
33
backend/app/core/config.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
def _csv_env(name: str, default: list[str]) -> list[str]:
|
||||
raw = os.getenv(name)
|
||||
if not raw:
|
||||
return default
|
||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
app_name: str = os.getenv("APP_NAME", "Certificate Management System")
|
||||
app_env: str = os.getenv("APP_ENV", "development")
|
||||
database_url: str = os.getenv("DATABASE_URL", "sqlite:///./dev.db")
|
||||
secret_key: str = os.getenv("SECRET_KEY", "change-me")
|
||||
public_base_url: str = os.getenv("PUBLIC_BASE_URL", "http://localhost:8080")
|
||||
certificate_no_secret: str = os.getenv("CERTIFICATE_NO_SECRET", "change-me")
|
||||
pdf_cache_days: int = int(os.getenv("PDF_CACHE_DAYS", "30"))
|
||||
data_root: str = os.getenv("DATA_ROOT", "/data/certificate-system")
|
||||
cors_origins: list[str] = field(
|
||||
default_factory=lambda: _csv_env("CORS_ORIGINS", ["http://localhost:5173", "http://localhost:8080"])
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
15
backend/app/core/paths.py
Normal file
15
backend/app/core/paths.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
DATA_ROOT = Path(settings.data_root)
|
||||
|
||||
|
||||
def ensure_data_dirs() -> None:
|
||||
for name in ["uploads", "error-reports", "exports", "pdf-cache"]:
|
||||
(DATA_ROOT / name).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def data_path(name: str) -> Path:
|
||||
ensure_data_dirs()
|
||||
return DATA_ROOT / name
|
||||
71
backend/app/core/security.py
Normal file
71
backend/app/core/security.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def hash_password(password: str, salt: str | None = None) -> str:
|
||||
password_salt = salt or secrets.token_hex(16)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), password_salt.encode("utf-8"), 120_000)
|
||||
return f"pbkdf2_sha256${password_salt}${digest.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
scheme, salt, expected = password_hash.split("$", 2)
|
||||
except ValueError:
|
||||
return False
|
||||
if scheme != "pbkdf2_sha256":
|
||||
return False
|
||||
actual = hash_password(password, salt).split("$", 2)[2]
|
||||
return hmac.compare_digest(actual, expected)
|
||||
|
||||
|
||||
def _b64encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64decode(data: str) -> bytes:
|
||||
padding = "=" * (-len(data) % 4)
|
||||
return base64.urlsafe_b64decode((data + padding).encode("ascii"))
|
||||
|
||||
|
||||
def create_access_token(subject: str, role_code: str, expires_in: int = 60 * 60 * 8) -> str:
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
payload = {"sub": subject, "role": role_code, "exp": int(time.time()) + expires_in}
|
||||
signing_input = ".".join(
|
||||
[
|
||||
_b64encode(json.dumps(header, separators=(",", ":")).encode("utf-8")),
|
||||
_b64encode(json.dumps(payload, separators=(",", ":")).encode("utf-8")),
|
||||
]
|
||||
)
|
||||
signature = hmac.new(settings.secret_key.encode("utf-8"), signing_input.encode("ascii"), hashlib.sha256).digest()
|
||||
return f"{signing_input}.{_b64encode(signature)}"
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
header_b64, payload_b64, signature_b64 = token.split(".", 2)
|
||||
signing_input = f"{header_b64}.{payload_b64}"
|
||||
expected = hmac.new(settings.secret_key.encode("utf-8"), signing_input.encode("ascii"), hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(_b64encode(expected), signature_b64):
|
||||
return None
|
||||
payload = json.loads(_b64decode(payload_b64))
|
||||
if int(payload.get("exp", 0)) < int(time.time()):
|
||||
return None
|
||||
return payload
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(f"{settings.secret_key}:{token}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def generate_public_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
1
backend/app/db/__init__.py
Normal file
1
backend/app/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
5
backend/app/db/base.py
Normal file
5
backend/app/db/base.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
50
backend/app/db/init_db.py
Normal file
50
backend/app/db/init_db.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import hash_password
|
||||
from app.db.base import Base
|
||||
from app.db.session import engine
|
||||
from app.models import AdminUser, ProjectCourse, Role
|
||||
|
||||
|
||||
def create_tables() -> None:
|
||||
import app.models # noqa: F401
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def seed_defaults(db: Session, admin_username: str, admin_password: str) -> None:
|
||||
roles = [
|
||||
("system_admin", "系统管理员"),
|
||||
("certificate_admin", "证书管理员"),
|
||||
("readonly", "只读查看人员"),
|
||||
]
|
||||
for code, name in roles:
|
||||
if not db.query(Role).filter(Role.code == code).first():
|
||||
db.add(Role(code=code, name=name))
|
||||
|
||||
projects = [
|
||||
("DBY", "大本营"),
|
||||
("QSX", "七三线"),
|
||||
]
|
||||
for code, name in projects:
|
||||
if not db.query(ProjectCourse).filter(ProjectCourse.code == code).first():
|
||||
db.add(
|
||||
ProjectCourse(
|
||||
code=code,
|
||||
name=name,
|
||||
default_certificate_name="培训结业证书",
|
||||
default_course_name=name,
|
||||
default_stage_name=None,
|
||||
default_issuer_name="本公司",
|
||||
)
|
||||
)
|
||||
|
||||
if not db.query(AdminUser).filter(AdminUser.username == admin_username).first():
|
||||
db.add(
|
||||
AdminUser(
|
||||
username=admin_username,
|
||||
password_hash=hash_password(admin_password),
|
||||
role_code="system_admin",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
17
backend/app/db/session.py
Normal file
17
backend/app/db/session.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
21
backend/app/main.py
Normal file
21
backend/app/main.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title=settings.app_name)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(api_router)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
19
backend/app/models/__init__.py
Normal file
19
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from app.models.admin import AdminUser, Role
|
||||
from app.models.certificate import Certificate, CertificateAccessToken, ProjectCourse
|
||||
from app.models.import_batch import ImportBatch, ImportBatchRow
|
||||
from app.models.learner import Learner, LearnerNameHistory, LearnerPhoneHistory
|
||||
from app.models.log import OperationLog
|
||||
|
||||
__all__ = [
|
||||
"AdminUser",
|
||||
"Certificate",
|
||||
"CertificateAccessToken",
|
||||
"ImportBatch",
|
||||
"ImportBatchRow",
|
||||
"Learner",
|
||||
"LearnerNameHistory",
|
||||
"LearnerPhoneHistory",
|
||||
"OperationLog",
|
||||
"ProjectCourse",
|
||||
"Role",
|
||||
]
|
||||
27
backend/app/models/admin.py
Normal file
27
backend/app/models/admin.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(64))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class AdminUser(Base):
|
||||
__tablename__ = "admin_users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
role_code: Mapped[str] = mapped_column(String(64), index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
59
backend/app/models/certificate.py
Normal file
59
backend/app/models/certificate.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class ProjectCourse(Base):
|
||||
__tablename__ = "project_courses"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(16), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
default_certificate_name: Mapped[str] = mapped_column(String(128), default="培训结业证书")
|
||||
default_course_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
default_stage_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
default_issuer_name: Mapped[str] = mapped_column(String(128), default="本公司")
|
||||
status: Mapped[str] = mapped_column(String(32), default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class Certificate(Base):
|
||||
__tablename__ = "certificates"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
learner_id: Mapped[int] = mapped_column(index=True)
|
||||
project_code: Mapped[str] = mapped_column(String(16), index=True)
|
||||
certificate_no: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
certificate_name: Mapped[str] = mapped_column(String(128))
|
||||
class_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
course_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
stage_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
issue_date: Mapped[date] = mapped_column(Date)
|
||||
issuer_name: Mapped[str] = mapped_column(String(128))
|
||||
status: Mapped[str] = mapped_column(String(32), default="valid")
|
||||
pdf_status: Mapped[str] = mapped_column(String(32), default="not_generated")
|
||||
pdf_file_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
public_token_id: Mapped[int | None] = mapped_column(nullable=True)
|
||||
qr_token_id: Mapped[int | None] = mapped_column(nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class CertificateAccessToken(Base):
|
||||
__tablename__ = "certificate_access_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
certificate_id: Mapped[int] = mapped_column(index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
token_value: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
token_type: Mapped[str] = mapped_column(String(32))
|
||||
status: Mapped[str] = mapped_column(String(32), default="active")
|
||||
created_reason: Mapped[str] = mapped_column(String(128), default="initial")
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_access_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
35
backend/app/models/import_batch.py
Normal file
35
backend/app/models/import_batch.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class ImportBatch(Base):
|
||||
__tablename__ = "import_batches"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
file_path: Mapped[str] = mapped_column(String(512))
|
||||
status: Mapped[str] = mapped_column(String(32), default="uploaded")
|
||||
total_rows: Mapped[int] = mapped_column(Integer, default=0)
|
||||
valid_rows: Mapped[int] = mapped_column(Integer, default=0)
|
||||
failed_rows: Mapped[int] = mapped_column(Integer, default=0)
|
||||
conflict_rows: Mapped[int] = mapped_column(Integer, default=0)
|
||||
error_report_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
created_by: Mapped[int | None] = mapped_column(nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class ImportBatchRow(Base):
|
||||
__tablename__ = "import_batch_rows"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
batch_id: Mapped[int] = mapped_column(index=True)
|
||||
row_no: Mapped[int] = mapped_column(Integer)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
raw_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
44
backend/app/models/learner.py
Normal file
44
backend/app/models/learner.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Learner(Base):
|
||||
__tablename__ = "learners"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
phone: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
current_name: Mapped[str] = mapped_column(String(64), index=True)
|
||||
id_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
id_no_encrypted: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
id_no_masked: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
student_no: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="active")
|
||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class LearnerNameHistory(Base):
|
||||
__tablename__ = "learner_name_histories"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
learner_id: Mapped[int] = mapped_column(index=True)
|
||||
name: Mapped[str] = mapped_column(String(64))
|
||||
source: Mapped[str] = mapped_column(String(64), default="import")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class LearnerPhoneHistory(Base):
|
||||
__tablename__ = "learner_phone_histories"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
learner_id: Mapped[int] = mapped_column(index=True)
|
||||
old_phone: Mapped[str] = mapped_column(String(32))
|
||||
new_phone: Mapped[str] = mapped_column(String(32))
|
||||
reason: Mapped[str] = mapped_column(String(255))
|
||||
changed_by: Mapped[int | None] = mapped_column(nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
19
backend/app/models/log.py
Normal file
19
backend/app/models/log.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class OperationLog(Base):
|
||||
__tablename__ = "operation_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
admin_user_id: Mapped[int | None] = mapped_column(nullable=True, index=True)
|
||||
action: Mapped[str] = mapped_column(String(64), index=True)
|
||||
object_type: Mapped[str] = mapped_column(String(64), index=True)
|
||||
object_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
detail_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
1
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
13
backend/app/schemas/auth.py
Normal file
13
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=64)
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
role_code: str
|
||||
username: str
|
||||
39
backend/app/schemas/certificate.py
Normal file
39
backend/app/schemas/certificate.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class CertificateCreate(BaseModel):
|
||||
learner_id: int
|
||||
project_code: str = Field(min_length=2, max_length=16)
|
||||
certificate_name: str | None = Field(default=None, max_length=128)
|
||||
class_name: str | None = Field(default=None, max_length=128)
|
||||
course_name: str | None = Field(default=None, max_length=128)
|
||||
stage_name: str | None = Field(default=None, max_length=128)
|
||||
issue_date: date
|
||||
issuer_name: str = Field(min_length=1, max_length=128)
|
||||
remark: str | None = None
|
||||
|
||||
@field_validator("project_code")
|
||||
@classmethod
|
||||
def normalize_project_code(cls, value: str) -> str:
|
||||
return value.strip().upper()
|
||||
|
||||
|
||||
class CertificateOut(BaseModel):
|
||||
id: int
|
||||
learner_id: int
|
||||
project_code: str
|
||||
certificate_no: str
|
||||
certificate_name: str
|
||||
class_name: str | None
|
||||
course_name: str | None
|
||||
stage_name: str | None
|
||||
issue_date: date
|
||||
issuer_name: str
|
||||
status: str
|
||||
pdf_status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
18
backend/app/schemas/import_batch.py
Normal file
18
backend/app/schemas/import_batch.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ImportBatchOut(BaseModel):
|
||||
id: int
|
||||
filename: str
|
||||
status: str
|
||||
total_rows: int
|
||||
valid_rows: int
|
||||
failed_rows: int
|
||||
conflict_rows: int
|
||||
error_report_path: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
31
backend/app/schemas/learner.py
Normal file
31
backend/app/schemas/learner.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LearnerCreate(BaseModel):
|
||||
phone: str = Field(min_length=6, max_length=32)
|
||||
current_name: str = Field(min_length=1, max_length=64)
|
||||
student_no: str | None = Field(default=None, max_length=64)
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class LearnerUpdate(BaseModel):
|
||||
phone: str = Field(min_length=6, max_length=32)
|
||||
current_name: str = Field(min_length=1, max_length=64)
|
||||
student_no: str | None = Field(default=None, max_length=64)
|
||||
status: str = Field(default="active", pattern="^(active|disabled|deleted)$")
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class LearnerOut(BaseModel):
|
||||
id: int
|
||||
phone: str
|
||||
current_name: str
|
||||
student_no: str | None
|
||||
status: str
|
||||
remark: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
21
backend/app/schemas/log.py
Normal file
21
backend/app/schemas/log.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OperationLogOut(BaseModel):
|
||||
id: int
|
||||
admin_user_id: int | None
|
||||
action: str
|
||||
action_label: str | None = None
|
||||
object_type: str
|
||||
object_label: str | None = None
|
||||
object_id: str | None
|
||||
ip_address: str | None
|
||||
detail_json: str | None
|
||||
created_at: datetime
|
||||
risk: str | None = None
|
||||
risk_label: str | None = None
|
||||
summary: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
57
backend/app/schemas/project.py
Normal file
57
backend/app/schemas/project.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class ProjectCourseCreate(BaseModel):
|
||||
code: str = Field(min_length=2, max_length=16)
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
default_certificate_name: str | None = Field(default=None, max_length=128)
|
||||
default_course_name: str | None = Field(default=None, max_length=128)
|
||||
default_stage_name: str | None = Field(default=None, max_length=128)
|
||||
default_issuer_name: str = Field(default="本公司", min_length=1, max_length=128)
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
def normalize_code(cls, value: str) -> str:
|
||||
return value.strip().upper()
|
||||
|
||||
@field_validator("default_certificate_name", "default_course_name", "default_stage_name")
|
||||
@classmethod
|
||||
def empty_to_none(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = value.strip()
|
||||
return text or None
|
||||
|
||||
|
||||
class ProjectCourseUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
default_certificate_name: str | None = Field(default=None, max_length=128)
|
||||
default_course_name: str | None = Field(default=None, max_length=128)
|
||||
default_stage_name: str | None = Field(default=None, max_length=128)
|
||||
default_issuer_name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
status: str | None = Field(default=None, pattern="^(active|disabled)$")
|
||||
|
||||
@field_validator("default_certificate_name", "default_course_name", "default_stage_name")
|
||||
@classmethod
|
||||
def empty_to_none(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = value.strip()
|
||||
return text or None
|
||||
|
||||
|
||||
class ProjectCourseOut(BaseModel):
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
default_certificate_name: str
|
||||
default_course_name: str | None
|
||||
default_stage_name: str | None
|
||||
default_issuer_name: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
54
backend/app/services/captcha.py
Normal file
54
backend/app/services/captcha.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import base64
|
||||
import io
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
CAPTCHA_TTL_SECONDS = 300
|
||||
_captchas: dict[str, tuple[str, float]] = {}
|
||||
|
||||
|
||||
def make_captcha() -> dict[str, str]:
|
||||
clean_expired_captchas()
|
||||
captcha_id = secrets.token_urlsafe(16)
|
||||
code = "".join(random.choice(string.ascii_uppercase + string.digits) for _ in range(4))
|
||||
_captchas[captcha_id] = (code, time.time() + CAPTCHA_TTL_SECONDS)
|
||||
return {"captcha_id": captcha_id, "image": draw_captcha_image(code)}
|
||||
|
||||
|
||||
def verify_captcha(captcha_id: str, captcha_code: str) -> bool:
|
||||
clean_expired_captchas()
|
||||
record = _captchas.pop(captcha_id, None)
|
||||
if not record:
|
||||
return False
|
||||
expected, expires_at = record
|
||||
if expires_at < time.time():
|
||||
return False
|
||||
return expected.upper() == captcha_code.strip().upper()
|
||||
|
||||
|
||||
def clean_expired_captchas() -> None:
|
||||
now = time.time()
|
||||
expired = [captcha_id for captcha_id, (_, expires_at) in _captchas.items() if expires_at < now]
|
||||
for captcha_id in expired:
|
||||
_captchas.pop(captcha_id, None)
|
||||
|
||||
|
||||
def draw_captcha_image(code: str) -> str:
|
||||
width, height = 132, 44
|
||||
image = Image.new("RGB", (width, height), "#f8fafc")
|
||||
draw = ImageDraw.Draw(image)
|
||||
font = ImageFont.load_default()
|
||||
for i, char in enumerate(code):
|
||||
draw.text((18 + i * 26, 13 + random.randint(-2, 2)), char, fill="#1f2937", font=font)
|
||||
for _ in range(8):
|
||||
x1, y1 = random.randint(0, width), random.randint(0, height)
|
||||
x2, y2 = random.randint(0, width), random.randint(0, height)
|
||||
draw.line((x1, y1, x2, y2), fill="#cbd5e1", width=1)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
30
backend/app/services/certificate_number.py
Normal file
30
backend/app/services/certificate_number.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import hashlib
|
||||
from datetime import date
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
|
||||
|
||||
def _to_base32ish(value: int, length: int) -> str:
|
||||
chars: list[str] = []
|
||||
base = len(ALPHABET)
|
||||
while value:
|
||||
value, remainder = divmod(value, base)
|
||||
chars.append(ALPHABET[remainder])
|
||||
encoded = "".join(reversed(chars)) or ALPHABET[0]
|
||||
return encoded[-length:].rjust(length, ALPHABET[0])
|
||||
|
||||
|
||||
def _digest_int(payload: str) -> int:
|
||||
digest = hashlib.sha256(payload.encode("utf-8")).digest()
|
||||
return int.from_bytes(digest[:8], "big")
|
||||
|
||||
|
||||
def build_certificate_no(sequence_id: int, project_code: str, issue_date: date | None = None) -> str:
|
||||
year = (issue_date or date.today()).year
|
||||
normalized_project = project_code.strip().upper()
|
||||
seed = f"{settings.certificate_no_secret}:{year}:{normalized_project}:{sequence_id}"
|
||||
short_code = _to_base32ish(_digest_int(seed), 7)
|
||||
check_code = _to_base32ish(_digest_int(f"check:{seed}:{short_code}"), 2)
|
||||
return f"PX{year}-{normalized_project}-{short_code}-{check_code}"
|
||||
148
backend/app/services/logs.py
Normal file
148
backend/app/services/logs.py
Normal file
@@ -0,0 +1,148 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AdminUser, OperationLog
|
||||
|
||||
LOG_RETENTION_DAYS = 180
|
||||
HIGH_RISK_LOG_RETENTION_DAYS = 365
|
||||
|
||||
ACTION_LABELS = {
|
||||
"create_project": "\u65b0\u589e\u9879\u76ee",
|
||||
"update_project": "\u4fee\u6539\u9879\u76ee",
|
||||
"create_learner": "\u65b0\u589e\u5b66\u5458",
|
||||
"update_learner": "\u4fee\u6539\u5b66\u5458",
|
||||
"delete_learner": "\u5220\u9664\u5b66\u5458",
|
||||
"create_certificate": "\u65b0\u589e\u8bc1\u4e66",
|
||||
"void_certificate": "\u4f5c\u5e9f\u8bc1\u4e66",
|
||||
"regenerate_public_token": "\u91cd\u7f6e\u8bc1\u4e66\u94fe\u63a5",
|
||||
"upload_import_file": "\u4e0a\u4f20\u5bfc\u5165\u6587\u4ef6",
|
||||
"confirm_import_batch": "\u786e\u8ba4\u5bfc\u5165",
|
||||
"delete_import_batch": "\u5220\u9664\u5bfc\u5165\u6279\u6b21",
|
||||
"export_certificates": "\u5bfc\u51fa\u8bc1\u4e66",
|
||||
"public_search_certificate": "\u516c\u5f00\u67e5\u8be2\u8bc1\u4e66",
|
||||
"public_download_certificate": "\u516c\u5f00\u4e0b\u8f7d\u8bc1\u4e66",
|
||||
}
|
||||
OBJECT_LABELS = {
|
||||
"project": "\u9879\u76ee",
|
||||
"project_course": "\u9879\u76ee",
|
||||
"learner": "\u5b66\u5458",
|
||||
"certificate": "\u8bc1\u4e66",
|
||||
"import_batch": "\u5bfc\u5165\u6279\u6b21",
|
||||
"public_access": "\u516c\u5f00\u8bbf\u95ee",
|
||||
}
|
||||
HIGH_RISK_ACTIONS = {"void_certificate", "delete_import_batch", "regenerate_public_token", "delete_learner"}
|
||||
MEDIUM_RISK_ACTIONS = {"update_project", "update_learner", "confirm_import_batch", "create_certificate"}
|
||||
|
||||
|
||||
def log_action(
|
||||
db: Session,
|
||||
admin: AdminUser | None,
|
||||
action: str,
|
||||
object_type: str,
|
||||
object_id: int | str | None = None,
|
||||
detail: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
db.add(
|
||||
OperationLog(
|
||||
admin_user_id=admin.id if admin else None,
|
||||
action=action,
|
||||
object_type=object_type,
|
||||
object_id=str(object_id) if object_id is not None else None,
|
||||
detail_json=json.dumps(detail, ensure_ascii=False, default=str) if detail else None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def mask_phone(phone: object) -> str:
|
||||
raw = "".join(ch for ch in str(phone or "") if ch.isdigit())
|
||||
if len(raw) >= 7:
|
||||
return raw[:3] + "****" + raw[-4:]
|
||||
return raw or "-"
|
||||
|
||||
|
||||
def log_risk(action: str) -> str:
|
||||
if action in HIGH_RISK_ACTIONS:
|
||||
return "high"
|
||||
if action in MEDIUM_RISK_ACTIONS:
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
|
||||
def log_risk_text(risk: str) -> str:
|
||||
return {"high": "\u9ad8\u98ce\u9669", "medium": "\u4e2d\u98ce\u9669", "low": "\u4f4e\u98ce\u9669"}.get(risk, risk)
|
||||
|
||||
|
||||
def diff_values(before: dict[str, object], after: dict[str, object], fields: list[str]) -> dict[str, dict[str, object]]:
|
||||
changes: dict[str, dict[str, object]] = {}
|
||||
for field in fields:
|
||||
if before.get(field) != after.get(field):
|
||||
changes[field] = {"before": before.get(field), "after": after.get(field)}
|
||||
return changes
|
||||
|
||||
|
||||
def log_summary(action: str, object_type: str, object_id: object, detail: dict[str, object]) -> str:
|
||||
label = ACTION_LABELS.get(action, action)
|
||||
if action in {"create_project", "update_project"}:
|
||||
return f"{label}\uff1a{detail.get('name') or detail.get('code') or object_id}"
|
||||
if action in {"create_learner", "update_learner", "delete_learner"}:
|
||||
phone = detail.get("phone")
|
||||
return f"{label}\uff1a{detail.get('name') or object_id}" + (f"\uff08{phone}\uff09" if phone else "")
|
||||
if action in {"create_certificate", "void_certificate", "regenerate_public_token"}:
|
||||
learner_name = detail.get("learner_name")
|
||||
return f"{label}\uff1a{detail.get('certificate_no') or object_id}" + (f"\uff08{learner_name}\uff09" if learner_name else "")
|
||||
if action in {"upload_import_file", "delete_import_batch"}:
|
||||
return f"{label}\uff1a{detail.get('filename') or object_id}"
|
||||
if action == "confirm_import_batch":
|
||||
count = detail.get("imported_rows", detail.get("valid_rows", 0))
|
||||
return f"{label}\uff1a\u6279\u6b21 {object_id}\uff0c\u5bfc\u5165 {count} \u884c"
|
||||
if action == "public_search_certificate":
|
||||
mode = "\u8bc1\u4e66\u7f16\u53f7" if detail.get("mode") == "certificate_no" else "\u624b\u673a\u53f7"
|
||||
return f"{label}\uff1a{detail.get('name') or '-'} \u7528{mode}\u67e5\u8be2\uff0c\u5339\u914d {detail.get('matched_count', 0)} \u6761"
|
||||
if action == "public_download_certificate":
|
||||
return f"{label}\uff1a{detail.get('certificate_no') or object_id}\uff08{detail.get('learner_name') or '-'}\uff09"
|
||||
return f"{label}\uff1a{OBJECT_LABELS.get(object_type, object_type)} {object_id or ''}".strip()
|
||||
|
||||
|
||||
def format_log(log: OperationLog) -> dict[str, object]:
|
||||
detail: dict[str, object] = {}
|
||||
if log.detail_json:
|
||||
try:
|
||||
detail = json.loads(log.detail_json)
|
||||
except Exception:
|
||||
detail = {"raw": log.detail_json}
|
||||
risk = log_risk(log.action)
|
||||
return {
|
||||
"id": log.id,
|
||||
"admin_user_id": log.admin_user_id,
|
||||
"action": log.action,
|
||||
"action_label": ACTION_LABELS.get(log.action, log.action),
|
||||
"object_type": log.object_type,
|
||||
"object_label": OBJECT_LABELS.get(log.object_type, log.object_type),
|
||||
"object_id": log.object_id,
|
||||
"ip_address": log.ip_address,
|
||||
"detail_json": log.detail_json,
|
||||
"created_at": log.created_at,
|
||||
"risk": risk,
|
||||
"risk_label": log_risk_text(risk),
|
||||
"summary": log_summary(log.action, log.object_type, log.object_id, detail),
|
||||
}
|
||||
|
||||
|
||||
def cleanup_expired_logs(db: Session) -> int:
|
||||
normal_before = datetime.utcnow() - timedelta(days=LOG_RETENTION_DAYS)
|
||||
high_before = datetime.utcnow() - timedelta(days=HIGH_RISK_LOG_RETENTION_DAYS)
|
||||
normal_deleted = (
|
||||
db.query(OperationLog)
|
||||
.filter(OperationLog.created_at < normal_before)
|
||||
.filter(OperationLog.action.notin_(HIGH_RISK_ACTIONS))
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
high_deleted = (
|
||||
db.query(OperationLog)
|
||||
.filter(OperationLog.created_at < high_before)
|
||||
.filter(OperationLog.action.in_(HIGH_RISK_ACTIONS))
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
return int(normal_deleted or 0) + int(high_deleted or 0)
|
||||
192
backend/app/services/pdf.py
Normal file
192
backend/app/services/pdf.py
Normal file
@@ -0,0 +1,192 @@
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.paths import data_path
|
||||
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
|
||||
|
||||
|
||||
def pdf_cache_path(certificate_no: str) -> Path:
|
||||
safe_name = certificate_no.replace("/", "_")
|
||||
year = safe_name[2:6] if len(safe_name) >= 6 else "misc"
|
||||
folder = data_path("pdf-cache") / year
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
return folder / f"{safe_name}.pdf"
|
||||
|
||||
|
||||
def cached_pdf_is_fresh(path: Path) -> bool:
|
||||
if not path.exists():
|
||||
return False
|
||||
modified_at = datetime.fromtimestamp(path.stat().st_mtime)
|
||||
return modified_at >= datetime.now() - timedelta(days=settings.pdf_cache_days)
|
||||
|
||||
|
||||
def render_certificate_pdf(
|
||||
certificate: Certificate,
|
||||
learner: Learner,
|
||||
project: ProjectCourse | None,
|
||||
token: CertificateAccessToken,
|
||||
) -> Path:
|
||||
output_path = pdf_cache_path(certificate.certificate_no)
|
||||
template_path = Path(__file__).resolve().parents[1] / "assets" / "certificate-template.jpg"
|
||||
latest_renderer_mtime = max(template_path.stat().st_mtime, Path(__file__).stat().st_mtime)
|
||||
if cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_mtime:
|
||||
return output_path
|
||||
|
||||
image = render_certificate_image(certificate, learner, project)
|
||||
image.save(output_path, "PDF", resolution=150.0)
|
||||
|
||||
certificate.pdf_status = "generated"
|
||||
certificate.pdf_file_path = str(output_path)
|
||||
return output_path
|
||||
|
||||
|
||||
def render_certificate_image(certificate: Certificate, learner: Learner, project: ProjectCourse | None) -> Image.Image:
|
||||
template = Path(__file__).resolve().parents[1] / "assets" / "certificate-template.jpg"
|
||||
image = Image.open(template).convert("RGB")
|
||||
base_image = image.copy()
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
issue_year, issue_month, issue_day = date_parts(certificate.issue_date)
|
||||
course_name = certificate.course_name or certificate.certificate_name or (project.name if project else certificate.project_code)
|
||||
stage_name = certificate.stage_name or "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
|
||||
|
||||
for box in [(150, 442, 760, 132), (404, 675, 220, 38)]:
|
||||
paper_patch(image, box)
|
||||
|
||||
draw.text((512, 414), learner.current_name, fill="#111111", font=font(32, "kai", True), anchor="mm")
|
||||
|
||||
x = 185.0
|
||||
y = 494
|
||||
x = draw_inline(draw, x, y, "\u5728", 24, 18)
|
||||
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u6708", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u65e5\u81f3", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u6708", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8)
|
||||
draw_inline_flow(draw, [(f"\u201c{course_name}\u201d", "course", 0), (stage_name, "normal", 14)], x, y)
|
||||
|
||||
draw.text((512, 704), f"\u53d1\u8bc1\u65e5\u671f\uff1a{issue_year} \u5e74 {issue_month} \u6708 {issue_day} \u65e5", fill="#43382f", font=font(13, "song", True), anchor="mm")
|
||||
draw.text((105, 76), f"\u8bc1\u4e66\u7f16\u53f7\uff1a{certificate.certificate_no}", fill="#111827", font=font(14, "hei", True))
|
||||
image.paste(base_image.crop((720, 535, 950, 629)), (720, 535))
|
||||
return image
|
||||
|
||||
|
||||
def font(size: int, kind: str = "song", bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
choices = {
|
||||
"kai": [r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"],
|
||||
"hei": [r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc"],
|
||||
"song": [r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"],
|
||||
}
|
||||
paths = choices.get(kind, choices["song"])
|
||||
if bold and kind == "song":
|
||||
paths = [r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\simhei.ttf"] + paths
|
||||
for item in paths:
|
||||
if Path(item).exists():
|
||||
try:
|
||||
return ImageFont.truetype(item, size)
|
||||
except Exception:
|
||||
pass
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def date_parts(value: date | str | None) -> tuple[str, str, str]:
|
||||
if isinstance(value, date):
|
||||
return str(value.year), str(value.month), str(value.day)
|
||||
raw = str(value or "").strip()
|
||||
for fmt in ("%Y-%m-%d", "%Y/%m/%d"):
|
||||
try:
|
||||
d = datetime.strptime(raw[:10], fmt)
|
||||
return str(d.year), str(d.month), str(d.day)
|
||||
except ValueError:
|
||||
pass
|
||||
today = date.today()
|
||||
return str(today.year), str(today.month), str(today.day)
|
||||
|
||||
|
||||
def paper_patch(image: Image.Image, box: tuple[int, int, int, int]) -> None:
|
||||
x, y, w, h = box
|
||||
patch = Image.new("RGB", (w, h), "#fbf7ef")
|
||||
source = image.crop((72, max(0, y), 160, max(0, y) + h))
|
||||
for tx in range(0, w, source.width):
|
||||
patch.paste(source.crop((0, 0, min(source.width, w - tx), h)), (tx, 0))
|
||||
cover = Image.new("RGB", (w, h), "#fbf7ef")
|
||||
image.paste(Image.blend(patch, cover, 0.38), (x, y))
|
||||
|
||||
|
||||
def draw_inline(draw: ImageDraw.ImageDraw, x: float, y: int, value: str, size: int, gap: int, kind: str = "song", bold: bool = False) -> float:
|
||||
text_font = font(size, kind, bold)
|
||||
draw.text((x, y), value, fill="#111111", font=text_font, anchor="ls")
|
||||
if bold:
|
||||
draw.text((x + 0.45, y), value, fill="#111111", font=text_font, anchor="ls")
|
||||
return x + draw.textlength(value, font=text_font) + gap
|
||||
|
||||
|
||||
def draw_course_name(draw: ImageDraw.ImageDraw, value: str, x: float, y: int) -> int:
|
||||
max_width = 900 - x
|
||||
text_font = font(27, "kai", True)
|
||||
if draw.textlength(value, font=text_font) <= max_width:
|
||||
draw.text((x, y), value, fill="#111111", font=text_font)
|
||||
draw.text((x + 0.45, y), value, fill="#111111", font=text_font)
|
||||
return y
|
||||
|
||||
start_y = y + 34
|
||||
lines = split_by_width(draw, value, text_font, 690)[:2]
|
||||
for index, line in enumerate(lines):
|
||||
line_y = start_y + index * 34
|
||||
draw.text((185, line_y), line, fill="#111111", font=text_font)
|
||||
draw.text((185.45, line_y), line, fill="#111111", font=text_font)
|
||||
return start_y + (len(lines) - 1) * 34
|
||||
|
||||
|
||||
def draw_inline_flow(draw: ImageDraw.ImageDraw, segments: list[tuple[str, str, int]], start_x: float, start_y: int) -> None:
|
||||
x = start_x
|
||||
y = start_y
|
||||
line_start = 185
|
||||
line_height = 42
|
||||
for value, style, gap_before in segments:
|
||||
x += gap_before
|
||||
text_font = font(27, "kai", True) if style == "course" else font(24, "song", False)
|
||||
for char in value:
|
||||
max_right = 900 if y == start_y else 730
|
||||
width = draw.textlength(char, font=text_font)
|
||||
if x + width > max_right and x > line_start:
|
||||
x = line_start
|
||||
y += line_height
|
||||
draw.text((x, y), char, fill="#111111", font=text_font, anchor="ls")
|
||||
if style == "course":
|
||||
draw.text((x + 0.45, y), char, fill="#111111", font=text_font, anchor="ls")
|
||||
x += width
|
||||
|
||||
|
||||
def split_by_width(draw: ImageDraw.ImageDraw, value: str, text_font: ImageFont.ImageFont, max_width: int) -> list[str]:
|
||||
lines: list[str] = []
|
||||
line = ""
|
||||
for char in value:
|
||||
next_line = line + char
|
||||
if line and draw.textlength(next_line, font=text_font) > max_width:
|
||||
lines.append(line)
|
||||
line = char
|
||||
else:
|
||||
line = next_line
|
||||
if line:
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def draw_fit(draw: ImageDraw.ImageDraw, pos: tuple[int, int], value: str, text_font: ImageFont.ImageFont, fill: str, max_width: int) -> None:
|
||||
font_obj = text_font
|
||||
size = getattr(text_font, "size", 24)
|
||||
while draw.textlength(value, font=font_obj) > max_width and size > 12:
|
||||
size -= 1
|
||||
font_obj = font(size, "song", True)
|
||||
draw.text(pos, value, fill=fill, font=font_obj)
|
||||
12
backend/app/services/rate_limit.py
Normal file
12
backend/app/services/rate_limit.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import time
|
||||
|
||||
_hits: dict[str, list[float]] = {}
|
||||
|
||||
|
||||
def hit_too_often(key: str, limit: int, window_seconds: int) -> bool:
|
||||
now = time.time()
|
||||
start = now - window_seconds
|
||||
recent = [value for value in _hits.get(key, []) if value >= start]
|
||||
recent.append(now)
|
||||
_hits[key] = recent
|
||||
return len(recent) > limit
|
||||
36
backend/app/templates/certificate_placeholder.html
Normal file
36
backend/app/templates/certificate_placeholder.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Training Completion Certificate</title>
|
||||
<style>
|
||||
body { margin: 0; color: #1f2937; font-family: "Noto Sans CJK SC", "Microsoft YaHei", Arial, sans-serif; }
|
||||
.page { position: relative; width: 1123px; height: 794px; box-sizing: border-box; padding: 72px; border: 18px solid #c99a3b; }
|
||||
.title { margin-top: 18px; text-align: center; font-size: 52px; letter-spacing: 8px; font-weight: 700; }
|
||||
.subtitle { margin-top: 14px; text-align: center; font-size: 21px; color: #6b7280; }
|
||||
.content { margin-top: 72px; font-size: 28px; line-height: 2; }
|
||||
.name { display: inline-block; min-width: 180px; padding: 0 28px; border-bottom: 2px solid #111827; text-align: center; font-size: 36px; font-weight: 700; }
|
||||
.meta { margin-top: 54px; display: grid; grid-template-columns: 1fr 1fr; gap: 18px 32px; font-size: 20px; }
|
||||
.qr { position: absolute; right: 72px; bottom: 104px; width: 112px; height: 112px; object-fit: contain; }
|
||||
.disclaimer { position: absolute; left: 72px; right: 72px; bottom: 44px; font-size: 16px; line-height: 1.6; color: #6b7280; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="title">培训结业证书</div>
|
||||
<div class="subtitle">占位模板,正式 Logo、排版和公章后续替换</div>
|
||||
<div class="content">
|
||||
兹证明 <span class="name">{{ learner_name }}</span> 已完成 {{ project_name }} {{ course_name }} {{ stage_name }} 相关培训。
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div>证书编号:{{ certificate_no }}</div>
|
||||
<div>发证日期:{{ issue_date }}</div>
|
||||
<div>发证单位:{{ issuer_name }}</div>
|
||||
</div>
|
||||
{% if qr_code_data_url %}<img class="qr" src="{{ qr_code_data_url }}" alt="二维码" />{% endif %}
|
||||
<div class="disclaimer">
|
||||
本证书仅用于证明学员完成相关培训课程/阶段学习,不代表国家学历、学位、职业资格或职业技能等级认证。
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
11
backend/requirements.txt
Normal file
11
backend/requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
alembic==1.14.0
|
||||
pymysql==1.1.1
|
||||
python-multipart==0.0.20
|
||||
openpyxl==3.1.5
|
||||
qrcode[pil]==8.0
|
||||
playwright==1.49.1
|
||||
jinja2==3.1.5
|
||||
pytest==8.3.4
|
||||
9
backend/tests/test_captcha.py
Normal file
9
backend/tests/test_captcha.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from app.services.captcha import make_captcha, verify_captcha
|
||||
|
||||
|
||||
def test_captcha_can_only_be_used_once():
|
||||
captcha = make_captcha()
|
||||
captcha_id = captcha["captcha_id"]
|
||||
# The real code is intentionally not exposed. This test locks in one-time consumption behavior.
|
||||
assert not verify_captcha(captcha_id, "bad")
|
||||
assert not verify_captcha(captcha_id, "bad")
|
||||
15
backend/tests/test_certificate_number.py
Normal file
15
backend/tests/test_certificate_number.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
from app.services.certificate_number import build_certificate_no
|
||||
|
||||
|
||||
def test_certificate_number_shape():
|
||||
number = build_certificate_no(123, "dby", date(2026, 6, 1))
|
||||
assert re.fullmatch(r"PX2026-DBY-[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{7}-[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{2}", number)
|
||||
|
||||
|
||||
def test_certificate_number_changes_with_sequence():
|
||||
first = build_certificate_no(123, "DBY", date(2026, 6, 1))
|
||||
second = build_certificate_no(124, "DBY", date(2026, 6, 1))
|
||||
assert first != second
|
||||
34
backend/tests/test_import_rules.py
Normal file
34
backend/tests/test_import_rules.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from datetime import date
|
||||
|
||||
from app.api.routes.admin_imports import (
|
||||
COL_CERT_NAME,
|
||||
COL_ISSUE_DATE,
|
||||
COL_NAME,
|
||||
COL_PHONE,
|
||||
COL_PROJECT,
|
||||
COL_ISSUER,
|
||||
date_is_valid,
|
||||
parse_issue_date,
|
||||
row_errors,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_issue_date_accepts_common_formats():
|
||||
assert parse_issue_date("2026-06-01") == date(2026, 6, 1)
|
||||
assert parse_issue_date("2026/06/01") == date(2026, 6, 1)
|
||||
|
||||
|
||||
def test_row_errors_require_project_code_to_exist():
|
||||
row = {
|
||||
COL_NAME: "张三",
|
||||
COL_PHONE: "13800000000",
|
||||
COL_PROJECT: "BAD",
|
||||
COL_CERT_NAME: "结业证书",
|
||||
COL_ISSUE_DATE: "2026-06-01",
|
||||
COL_ISSUER: "测试公司",
|
||||
}
|
||||
assert "Project code is inactive or missing" in row_errors(row, {"DBY"})
|
||||
|
||||
|
||||
def test_date_is_valid_rejects_bad_text():
|
||||
assert not date_is_valid("not-a-date")
|
||||
15
backend/tests/test_security.py
Normal file
15
backend/tests/test_security.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from app.core.security import create_access_token, decode_access_token, hash_password, verify_password
|
||||
|
||||
|
||||
def test_password_hash_roundtrip():
|
||||
password_hash = hash_password("Secret123!")
|
||||
assert verify_password("Secret123!", password_hash)
|
||||
assert not verify_password("wrong", password_hash)
|
||||
|
||||
|
||||
def test_access_token_roundtrip():
|
||||
token = create_access_token("7", "system_admin", expires_in=60)
|
||||
payload = decode_access_token(token)
|
||||
assert payload is not None
|
||||
assert payload["sub"] == "7"
|
||||
assert payload["role"] == "system_admin"
|
||||
25
certificate-template-lab/README.md
Normal file
25
certificate-template-lab/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# 结业证书模板调试模块
|
||||
|
||||
这个目录是证书模板的独立验证模块,暂时不接主系统数据库。
|
||||
|
||||
## 启动
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```powershell
|
||||
$py = "C:\Users\nelso\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe"
|
||||
& $py -m http.server 8020 -d .\certificate-template-lab
|
||||
```
|
||||
|
||||
然后访问:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8020
|
||||
```
|
||||
|
||||
## 当前用途
|
||||
|
||||
- 以 `assets/original.jpg` 作为证书底图。
|
||||
- 擦除可变文字区域并重新绘制学员、课程、日期、单位、证书编号等信息。
|
||||
- 支持下载 PNG,用于先确认视觉效果。
|
||||
- 后续确认版式后,再把这套渲染参数迁移到主系统的 PDF/图片生成流程。
|
||||
465
certificate-template-lab/app.js
Normal file
465
certificate-template-lab/app.js
Normal file
@@ -0,0 +1,465 @@
|
||||
(function () {
|
||||
const canvas = document.getElementById("certificateCanvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const state = document.getElementById("renderState");
|
||||
const qrScratch = document.getElementById("qrScratch");
|
||||
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
|
||||
const text = {
|
||||
loading: "\u52a0\u8f7d\u4e2d",
|
||||
rendering: "\u6e32\u67d3\u4e2d",
|
||||
ready: "\u5df2\u66f4\u65b0",
|
||||
imageFailed: "\u539f\u56fe\u52a0\u8f7d\u5931\u8d25",
|
||||
title: "\u7ed3\u4e1a\u8bc1\u4e66",
|
||||
proves: "\u7279\u6b64\u8bc1\u660e",
|
||||
inWord: "\u5728",
|
||||
year: "\u5e74",
|
||||
month: "\u6708",
|
||||
day: "\u65e5",
|
||||
toWord: "\u81f3",
|
||||
finished: "\u65e5\u5b8c\u6210\u4e86",
|
||||
issueDate: "\u53d1\u8bc1\u65e5\u671f\uff1a",
|
||||
certNo: "\u8bc1\u4e66\u7f16\u53f7\uff1a",
|
||||
fallbackStudent: "\u5b66\u5458",
|
||||
filePrefix: "\u7ed3\u4e1a\u8bc1\u4e66"
|
||||
};
|
||||
|
||||
const defaults = {
|
||||
studentName: "\u5f20\u4e09",
|
||||
courseName: "\u667a\u6167\u8d4b\u80fd\u7597\u6108\u5e08",
|
||||
stageName: "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002",
|
||||
certificateNo: "HY20260602DYC001",
|
||||
startYear: "2026",
|
||||
startMonth: "6",
|
||||
startDay: "1",
|
||||
endYear: "2026",
|
||||
endMonth: "6",
|
||||
endDay: "2",
|
||||
issueDate: "2026 \u5e74 6 \u6708 2 \u65e5",
|
||||
mentorLabel: "\u5bfc\u5e08\uff1a",
|
||||
mentorName: "\u5362\u6167",
|
||||
issuerLeft: "\u6df1\u5733\u5e02\u6167\u6108\u6587\u5316\u79d1\u6280\u6709\u9650\u516c\u53f8",
|
||||
issuerRight: "\u56fd\u9645\u751f\u547d\u667a\u6167\u542f\u8499\u7814\u7a76\u9662",
|
||||
verifyUrl: "https://example.com/cert/HY20260602DYC001"
|
||||
};
|
||||
|
||||
const controls = {};
|
||||
Object.keys(defaults).forEach((key) => {
|
||||
controls[key] = document.getElementById(key);
|
||||
});
|
||||
controls.showOriginal = document.getElementById("showOriginal");
|
||||
controls.showDebug = document.getElementById("showDebug");
|
||||
|
||||
const original = new Image();
|
||||
original.src = "./assets/original.jpg";
|
||||
original.onload = render;
|
||||
original.onerror = () => {
|
||||
state.textContent = text.imageFailed;
|
||||
render();
|
||||
};
|
||||
|
||||
let renderTimer = null;
|
||||
let qrText = "";
|
||||
|
||||
bindEvents();
|
||||
render();
|
||||
|
||||
function bindEvents() {
|
||||
Object.values(controls).forEach((input) => {
|
||||
if (!input) return;
|
||||
input.addEventListener("input", scheduleRender);
|
||||
input.addEventListener("change", scheduleRender);
|
||||
});
|
||||
|
||||
document.getElementById("resetBtn").addEventListener("click", () => {
|
||||
Object.entries(defaults).forEach(([key, value]) => {
|
||||
controls[key].value = value;
|
||||
});
|
||||
controls.showOriginal.checked = true;
|
||||
controls.showDebug.checked = false;
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById("downloadBtn").addEventListener("click", () => {
|
||||
render();
|
||||
const link = document.createElement("a");
|
||||
const name = safeFileName(value("studentName") || text.fallbackStudent);
|
||||
link.download = `${text.filePrefix}_${name}.png`;
|
||||
link.href = canvas.toDataURL("image/png", 1);
|
||||
link.click();
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleRender() {
|
||||
window.clearTimeout(renderTimer);
|
||||
renderTimer = window.setTimeout(render, 80);
|
||||
}
|
||||
|
||||
function value(key) {
|
||||
return (controls[key] && controls[key].value || "").trim();
|
||||
}
|
||||
|
||||
function render() {
|
||||
try {
|
||||
state.textContent = text.rendering;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
const usingOriginal = controls.showOriginal.checked && original.complete && original.naturalWidth;
|
||||
|
||||
if (usingOriginal) {
|
||||
ctx.drawImage(original, 0, 0, W, H);
|
||||
eraseEditableAreas();
|
||||
} else {
|
||||
drawFallbackBase();
|
||||
}
|
||||
|
||||
drawStudentName();
|
||||
drawMainText();
|
||||
drawIssueDate();
|
||||
drawCertificateNo();
|
||||
|
||||
if (usingOriginal) {
|
||||
drawOriginalMentorLayer();
|
||||
} else {
|
||||
drawMentor();
|
||||
drawFooter();
|
||||
drawQr();
|
||||
}
|
||||
|
||||
if (controls.showDebug.checked) {
|
||||
drawDebugAreas();
|
||||
}
|
||||
|
||||
state.textContent = text.ready;
|
||||
} catch (err) {
|
||||
console.error("Certificate render failed:", err);
|
||||
state.textContent = "渲染异常";
|
||||
}
|
||||
}
|
||||
|
||||
const areas = [
|
||||
{ x: 150, y: 442, w: 760, h: 132, pad: 16 },
|
||||
{ x: 404, y: 675, w: 220, h: 38, pad: 10 }
|
||||
];
|
||||
|
||||
function eraseEditableAreas() {
|
||||
areas.forEach((area) => eraseWithPaper(area));
|
||||
}
|
||||
|
||||
function eraseWithPaper(area) {
|
||||
if (!original.complete || !original.naturalWidth) return;
|
||||
|
||||
const pad = area.pad || 8;
|
||||
const sourceX = 72;
|
||||
const sourceW = 88;
|
||||
const patch = document.createElement("canvas");
|
||||
patch.width = area.w + pad * 2;
|
||||
patch.height = area.h + pad * 2;
|
||||
const p = patch.getContext("2d");
|
||||
|
||||
for (let x = 0; x < patch.width; x += sourceW) {
|
||||
const tileW = Math.min(sourceW, patch.width - x);
|
||||
p.drawImage(
|
||||
original,
|
||||
sourceX,
|
||||
Math.max(0, area.y - pad),
|
||||
tileW,
|
||||
patch.height,
|
||||
x,
|
||||
0,
|
||||
tileW,
|
||||
patch.height
|
||||
);
|
||||
}
|
||||
|
||||
p.globalCompositeOperation = "destination-in";
|
||||
p.globalCompositeOperation = "source-over";
|
||||
p.globalAlpha = 0.38;
|
||||
p.fillStyle = "#fbf7ef";
|
||||
p.fillRect(0, 0, patch.width, patch.height);
|
||||
p.globalAlpha = 1;
|
||||
|
||||
p.globalCompositeOperation = "destination-in";
|
||||
const g = p.createLinearGradient(0, 0, 0, patch.height);
|
||||
g.addColorStop(0, "rgba(0,0,0,0)");
|
||||
g.addColorStop(0.04, "rgba(0,0,0,1)");
|
||||
g.addColorStop(0.96, "rgba(0,0,0,1)");
|
||||
g.addColorStop(1, "rgba(0,0,0,0)");
|
||||
p.fillStyle = g;
|
||||
p.fillRect(0, 0, patch.width, patch.height);
|
||||
|
||||
p.globalCompositeOperation = "destination-in";
|
||||
const gx = p.createLinearGradient(0, 0, patch.width, 0);
|
||||
gx.addColorStop(0, "rgba(0,0,0,0)");
|
||||
gx.addColorStop(0.035, "rgba(0,0,0,1)");
|
||||
gx.addColorStop(0.965, "rgba(0,0,0,1)");
|
||||
gx.addColorStop(1, "rgba(0,0,0,0)");
|
||||
p.fillStyle = gx;
|
||||
p.fillRect(0, 0, patch.width, patch.height);
|
||||
|
||||
ctx.drawImage(patch, area.x - pad, area.y - pad);
|
||||
}
|
||||
|
||||
function drawFallbackBase() {
|
||||
ctx.fillStyle = "#fbf7ef";
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.strokeStyle = "#25344f";
|
||||
ctx.lineWidth = 12;
|
||||
ctx.strokeRect(6, 6, W - 12, H - 12);
|
||||
ctx.strokeStyle = "#a98732";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(42, 38, W - 84, H - 76);
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = "#111";
|
||||
ctx.font = '700 52px "SimSun", serif';
|
||||
ctx.fillText(text.title, W / 2, 196);
|
||||
}
|
||||
|
||||
function drawSubTitle() {
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = "#49341f";
|
||||
ctx.font = '700 17px "SimSun", "Microsoft YaHei", serif';
|
||||
ctx.fillText(text.proves, W / 2, 329);
|
||||
ctx.fillStyle = "#555";
|
||||
ctx.font = '13px Georgia, serif';
|
||||
ctx.fillText("This Certifies That", W / 2, 351);
|
||||
}
|
||||
|
||||
function drawStudentName() {
|
||||
const name = value("studentName");
|
||||
if (!name) return;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = "#111";
|
||||
ctx.font = '700 32px "KaiTi", "STKaiti", "SimSun", serif';
|
||||
ctx.fillText(name, W / 2, 414);
|
||||
}
|
||||
|
||||
function drawMainText() {
|
||||
const line2 = value("stageName");
|
||||
|
||||
ctx.fillStyle = "#111";
|
||||
ctx.textAlign = "left";
|
||||
let x = 185;
|
||||
const y = 494;
|
||||
x = drawInlineText(text.inWord, x, y, 24, 400, 18);
|
||||
x = drawInlineText(value("startYear"), x, y, 24, 900, 8, "dateNumber");
|
||||
x = drawInlineText(text.year, x, y, 24, 400, 12);
|
||||
x = drawInlineText(value("startMonth"), x, y, 24, 900, 6, "dateNumber");
|
||||
x = drawInlineText(text.month, x, y, 24, 400, 12);
|
||||
x = drawInlineText(value("startDay"), x, y, 24, 900, 4, "dateNumber");
|
||||
x = drawInlineText(`${text.day}${text.toWord}`, x, y, 24, 400, 12);
|
||||
x = drawInlineText(value("endYear"), x, y, 24, 900, 8, "dateNumber");
|
||||
x = drawInlineText(text.year, x, y, 24, 400, 12);
|
||||
x = drawInlineText(value("endMonth"), x, y, 24, 900, 6, "dateNumber");
|
||||
x = drawInlineText(text.month, x, y, 24, 400, 12);
|
||||
x = drawInlineText(value("endDay"), x, y, 24, 900, 4, "dateNumber");
|
||||
x = drawInlineText(`${text.day}\u5b8c\u6210\u4e86`, x, y, 24, 400, 8);
|
||||
drawInlineFlow([
|
||||
{ text: `\u201c${value("courseName")}\u201d`, style: "course" },
|
||||
{ text: line2, style: "normal", gapBefore: 14 },
|
||||
], x, y);
|
||||
}
|
||||
|
||||
function drawInlineFlow(segments, startX, startY) {
|
||||
let x = startX;
|
||||
let y = startY;
|
||||
const lineStart = 185;
|
||||
const lineHeight = 42;
|
||||
|
||||
segments.forEach((segment) => {
|
||||
if (segment.gapBefore) x += segment.gapBefore;
|
||||
Array.from(segment.text || "").forEach((char) => {
|
||||
const style = inlineStyle(segment.style);
|
||||
ctx.font = style.font;
|
||||
const maxRight = y === startY ? 900 : 730;
|
||||
const width = ctx.measureText(char).width;
|
||||
if (x + width > maxRight && x > lineStart) {
|
||||
x = lineStart;
|
||||
y += lineHeight;
|
||||
}
|
||||
ctx.fillText(char, x, y);
|
||||
if (segment.style === "course") ctx.fillText(char, x + 0.45, y);
|
||||
x += width;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function inlineStyle(style) {
|
||||
if (style === "course") {
|
||||
return { font: '900 27px "KaiTi", "STKaiti", "Kaiti SC", "Noto Serif SC", serif' };
|
||||
}
|
||||
return { font: '400 24px "SimSun", "Noto Serif SC", serif' };
|
||||
}
|
||||
|
||||
function drawInlineText(raw, x, y, size, weight, gap, style) {
|
||||
const textValue = raw || "";
|
||||
const useKaiti = style === true || style === "dateNumber";
|
||||
const family = useKaiti
|
||||
? '"KaiTi", "STKaiti", "Kaiti SC", "Noto Serif SC", serif'
|
||||
: '"SimSun", "Noto Serif SC", serif';
|
||||
ctx.font = `${weight} ${size}px ${family}`;
|
||||
ctx.fillText(textValue, x, y);
|
||||
if (style === true || style === "dateNumber") {
|
||||
ctx.fillText(textValue, x + 0.45, y);
|
||||
}
|
||||
return x + ctx.measureText(textValue).width + gap;
|
||||
}
|
||||
|
||||
function drawMentor() {
|
||||
const label = value("mentorLabel");
|
||||
const name = value("mentorName");
|
||||
if (!label && !name) return;
|
||||
|
||||
ctx.fillStyle = "#111";
|
||||
ctx.textAlign = "left";
|
||||
ctx.font = '700 21px "SimSun", "Microsoft YaHei", serif';
|
||||
ctx.fillText(label, 740, 586);
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(812, 594);
|
||||
ctx.rotate(-0.05);
|
||||
ctx.fillStyle = "#1b1b1b";
|
||||
ctx.font = '700 34px "KaiTi", "STKaiti", "SimSun", serif';
|
||||
ctx.fillText(name, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawOriginalMentorLayer() {
|
||||
if (!original.complete || !original.naturalWidth) return;
|
||||
ctx.drawImage(original, 720, 535, 230, 94, 720, 535, 230, 94);
|
||||
}
|
||||
|
||||
function drawFooter() {
|
||||
ctx.fillStyle = "#111";
|
||||
ctx.font = '700 20px "SimSun", "Microsoft YaHei", serif';
|
||||
ctx.textAlign = "left";
|
||||
drawTextFit(value("issuerLeft"), 190, 643, 300, 20);
|
||||
|
||||
ctx.textAlign = "right";
|
||||
drawTextFit(value("issuerRight"), 835, 643, 320, 20);
|
||||
|
||||
ctx.textAlign = "center";
|
||||
drawIssueDate();
|
||||
}
|
||||
|
||||
function drawIssueDate() {
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = "#43382f";
|
||||
ctx.font = '700 13px "SimSun", serif';
|
||||
ctx.fillText(`${text.issueDate}${value("issueDate")}`, W / 2, 704);
|
||||
}
|
||||
|
||||
function drawCertificateNo() {
|
||||
const no = value("certificateNo");
|
||||
if (!no) return;
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillStyle = "#111827";
|
||||
ctx.font = '700 14px Arial, "Microsoft YaHei", sans-serif';
|
||||
ctx.fillText(`${text.certNo}${no}`, 105, 76);
|
||||
}
|
||||
|
||||
function drawQr() {
|
||||
const x = W / 2 - 33;
|
||||
const y = 602;
|
||||
const box = 66;
|
||||
|
||||
const qrNode = getQrNode(box);
|
||||
ctx.save();
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(x - 5, y - 5, box + 10, box + 10);
|
||||
ctx.strokeStyle = "#d3c6a4";
|
||||
ctx.strokeRect(x - 5, y - 5, box + 10, box + 10);
|
||||
|
||||
if (qrNode) {
|
||||
try {
|
||||
ctx.drawImage(qrNode, x, y, box, box);
|
||||
} catch (err) {
|
||||
console.warn("QR image is not ready yet, using fallback.", err);
|
||||
drawQrFallbackPixels(x, y);
|
||||
window.setTimeout(render, 120);
|
||||
}
|
||||
} else {
|
||||
drawQrFallbackPixels(x, y);
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#1f67ad";
|
||||
ctx.fillRect(x + 28, y + 28, 10, 10);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function getQrNode(size) {
|
||||
if (!window.QRCode || !qrScratch) return null;
|
||||
|
||||
const nextText = value("verifyUrl") || "https://example.com";
|
||||
if (nextText !== qrText || !qrScratch.firstChild) {
|
||||
qrText = nextText;
|
||||
qrScratch.innerHTML = "";
|
||||
new QRCode(qrScratch, {
|
||||
text: nextText,
|
||||
width: size,
|
||||
height: size,
|
||||
colorDark: "#111111",
|
||||
colorLight: "#ffffff",
|
||||
correctLevel: QRCode.CorrectLevel.H
|
||||
});
|
||||
}
|
||||
|
||||
const canvasNode = qrScratch.querySelector("canvas");
|
||||
if (canvasNode) return canvasNode;
|
||||
|
||||
const imgNode = qrScratch.querySelector("img");
|
||||
if (imgNode && imgNode.complete && imgNode.naturalWidth) return imgNode;
|
||||
return null;
|
||||
}
|
||||
|
||||
function drawQrFallbackPixels(x, y) {
|
||||
ctx.fillStyle = "#111";
|
||||
const seed = hash(value("verifyUrl"));
|
||||
const cell = 3;
|
||||
for (let row = 0; row < 22; row++) {
|
||||
for (let col = 0; col < 22; col++) {
|
||||
const fixed = finder(col, row) || finder(col - 15, row) || finder(col, row - 15);
|
||||
const on = fixed || ((seed + row * 17 + col * 31 + row * col) % 7 < 3);
|
||||
if (on) ctx.fillRect(x + col * cell, y + row * cell, cell, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finder(col, row) {
|
||||
if (col < 0 || row < 0 || col > 6 || row > 6) return false;
|
||||
return col === 0 || row === 0 || col === 6 || row === 6 || (col >= 2 && col <= 4 && row >= 2 && row <= 4);
|
||||
}
|
||||
|
||||
function hash(raw) {
|
||||
let n = 0;
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
n = (n * 31 + raw.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function drawTextFit(raw, x, y, maxWidth, size) {
|
||||
let fontSize = size;
|
||||
const textValue = raw || "";
|
||||
do {
|
||||
ctx.font = ctx.font.replace(/\d+px/, `${fontSize}px`);
|
||||
fontSize--;
|
||||
} while (ctx.measureText(textValue).width > maxWidth && fontSize > 12);
|
||||
ctx.fillText(textValue, x, y);
|
||||
}
|
||||
|
||||
function drawDebugAreas() {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "#f04438";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([6, 5]);
|
||||
areas.forEach((area) => ctx.strokeRect(area.x, area.y, area.w, area.h));
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function safeFileName(raw) {
|
||||
return raw.replace(/[\\/:*?"<>|]/g, "_");
|
||||
}
|
||||
})();
|
||||
BIN
certificate-template-lab/assets/original.jpg
Normal file
BIN
certificate-template-lab/assets/original.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
117
certificate-template-lab/index.html
Normal file
117
certificate-template-lab/index.html
Normal file
@@ -0,0 +1,117 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>结业证书模板调试</title>
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<aside class="panel">
|
||||
<div class="panel-head">
|
||||
<p class="eyebrow">Certificate Lab</p>
|
||||
<h1>结业证书模板调试</h1>
|
||||
<p class="hint">这里先单独调证书视觉,不接主系统数据。改字段后右侧会实时重绘。</p>
|
||||
</div>
|
||||
|
||||
<section class="group">
|
||||
<h2>学员与课程</h2>
|
||||
<label>学员姓名
|
||||
<input id="studentName" value="张三">
|
||||
</label>
|
||||
<label>课程名称
|
||||
<input id="courseName" value="智慧赋能疗愈师">
|
||||
</label>
|
||||
<label>课程阶段
|
||||
<input id="stageName" value="初级课程的专业学习。">
|
||||
</label>
|
||||
<label>证书编号
|
||||
<input id="certificateNo" value="HY20260602DYC001">
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2>日期</h2>
|
||||
<div class="grid">
|
||||
<label>开始年
|
||||
<input id="startYear" value="2026">
|
||||
</label>
|
||||
<label>开始月
|
||||
<input id="startMonth" value="6">
|
||||
</label>
|
||||
<label>开始日
|
||||
<input id="startDay" value="1">
|
||||
</label>
|
||||
<label>结束年
|
||||
<input id="endYear" value="2026">
|
||||
</label>
|
||||
<label>结束月
|
||||
<input id="endMonth" value="6">
|
||||
</label>
|
||||
<label>结束日
|
||||
<input id="endDay" value="2">
|
||||
</label>
|
||||
</div>
|
||||
<label>发证日期
|
||||
<input id="issueDate" value="2026 年 6 月 2 日">
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="group muted-group">
|
||||
<h2>底部图层</h2>
|
||||
<p class="group-note">导师、机构和二维码先使用原图。导师会作为最上层图层覆盖。</p>
|
||||
<label>导师称谓
|
||||
<input id="mentorLabel" value="导师:" disabled>
|
||||
</label>
|
||||
<label>导师姓名
|
||||
<input id="mentorName" value="卢慧" disabled>
|
||||
</label>
|
||||
<label>左侧单位
|
||||
<input id="issuerLeft" value="深圳市慧愈文化科技有限公司" disabled>
|
||||
</label>
|
||||
<label>右侧单位
|
||||
<input id="issuerRight" value="国际生命智慧启蒙研究院" disabled>
|
||||
</label>
|
||||
<label>查询链接
|
||||
<input id="verifyUrl" value="https://example.com/cert/HY20260602DYC001" disabled>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2>显示开关</h2>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="showOriginal" checked>
|
||||
使用原图作为底图
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="showDebug">
|
||||
显示擦除区域
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<button id="resetBtn" type="button">恢复示例</button>
|
||||
<button id="downloadBtn" type="button">下载 PNG</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="preview">
|
||||
<div class="preview-head">
|
||||
<div>
|
||||
<p class="eyebrow">1024 x 759</p>
|
||||
<h2>实时预览</h2>
|
||||
</div>
|
||||
<span id="renderState">加载中</span>
|
||||
</div>
|
||||
<div class="canvas-shell">
|
||||
<canvas id="certificateCanvas" width="1024" height="759"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="qrScratch" aria-hidden="true"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
246
certificate-template-lab/style.css
Normal file
246
certificate-template-lab/style.css
Normal file
@@ -0,0 +1,246 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f8fb;
|
||||
--panel: #ffffff;
|
||||
--line: #d9e2ec;
|
||||
--text: #172033;
|
||||
--muted: #657184;
|
||||
--blue: #2f6fbd;
|
||||
--blue-dark: #215493;
|
||||
--gold: #a98732;
|
||||
--shadow: 0 18px 45px rgba(30, 52, 82, .12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
linear-gradient(120deg, rgba(47, 111, 189, .08), transparent 38%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.page {
|
||||
display: grid;
|
||||
grid-template-columns: 360px minmax(0, 1fr);
|
||||
gap: 22px;
|
||||
min-height: 100vh;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.preview {
|
||||
background: rgba(255, 255, 255, .92);
|
||||
border: 1px solid rgba(217, 226, 236, .9);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
padding: 22px 22px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: var(--gold);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.group {
|
||||
padding: 16px 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.group h2 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.group-note {
|
||||
margin: -4px 0 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.muted-group input:disabled {
|
||||
color: #8b96a8;
|
||||
background: #f2f5f8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
color: #3a4658;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid #cdd8e6;
|
||||
border-radius: 6px;
|
||||
padding: 10px 11px;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--blue);
|
||||
box-shadow: 0 0 0 3px rgba(47, 111, 189, .14);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.check {
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.check input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
padding: 16px 22px 22px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 11px 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#resetBtn {
|
||||
color: var(--blue-dark);
|
||||
background: #e8f1fc;
|
||||
}
|
||||
|
||||
#downloadBtn {
|
||||
color: #fff;
|
||||
background: var(--blue);
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
#renderState {
|
||||
color: var(--blue-dark);
|
||||
background: #e8f1fc;
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.canvas-shell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 1;
|
||||
padding: 28px;
|
||||
overflow: auto;
|
||||
background:
|
||||
linear-gradient(45deg, #eef3f8 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #eef3f8 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #eef3f8 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #eef3f8 75%);
|
||||
background-size: 24px 24px;
|
||||
background-position: 0 0, 0 12px, 12px -12px, -12px 0;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: min(100%, 1024px);
|
||||
height: auto;
|
||||
background: #faf7ef;
|
||||
box-shadow: 0 18px 40px rgba(28, 42, 62, .18);
|
||||
}
|
||||
|
||||
#qrScratch {
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.page {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
43
deploy/nginx.prod.conf
Normal file
43
deploy/nginx.prod.conf
Normal file
@@ -0,0 +1,43 @@
|
||||
limit_req_zone $binary_remote_addr zone=public_query:10m rate=20r/m;
|
||||
limit_req_zone $binary_remote_addr zone=pdf_download:10m rate=30r/m;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name example.com;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/privkey.pem;
|
||||
|
||||
client_max_body_size 20m;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/public/certificates/search {
|
||||
limit_req zone=public_query burst=10 nodelay;
|
||||
proxy_pass http://backend:8000/public/certificates/search;
|
||||
include /etc/nginx/proxy_params;
|
||||
}
|
||||
|
||||
location ~ ^/api/public/certificates/token/.+/download$ {
|
||||
limit_req zone=pdf_download burst=10 nodelay;
|
||||
proxy_pass http://backend:8000$request_uri;
|
||||
rewrite ^/api/(.*)$ /$1 break;
|
||||
include /etc/nginx/proxy_params;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/;
|
||||
include /etc/nginx/proxy_params;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
7
deploy/proxy_params
Normal file
7
deploy/proxy_params
Normal file
@@ -0,0 +1,7 @@
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
42
docker-compose.yml
Normal file
42
docker-compose.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root-password
|
||||
MYSQL_DATABASE: certificate_system
|
||||
MYSQL_USER: certificate
|
||||
MYSQL_PASSWORD: certificate
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
ports:
|
||||
- "3306:3306"
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- certificate-data:/data/certificate-system
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "8080:80"
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
certificate-data:
|
||||
102
docs/decision-log.md
Normal file
102
docs/decision-log.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# 开发决策记录
|
||||
|
||||
本文档记录开发过程中由 Codex 先行拍板的实现决定。后续如果业务方有不同意见,可以按条目调整。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
### D001 PDF 生成策略
|
||||
|
||||
- 决定:PDF 由服务端生成,不在用户浏览器生成。
|
||||
- 策略:按需生成 + 30 天缓存。
|
||||
- 原因:证书属于正式凭证,服务端生成更利于统一版式、审计、作废状态控制和后续模板替换。
|
||||
|
||||
### D002 证书编号格式
|
||||
|
||||
- 决定:`EPX{YYYY}-{项目代码}-{7位短码}-{2位校验码}`。
|
||||
- 示例:`EPX2026-DBY-K7M9Q2R-83`。
|
||||
- 说明:内部数据库继续使用 `BIGINT` 主键,对外编号使用短码和校验码,降低枚举和手输错误风险。
|
||||
|
||||
### D003 公开查询方式
|
||||
|
||||
- 决定:一期公开查询支持两种方式:证书编号 + 姓名 + 图形验证码,或手机号 + 姓名 + 图形验证码。
|
||||
- 说明:手机号 + 姓名可能匹配多张证书,查询结果按列表展示。
|
||||
- 风控:继续保留图形验证码和基础频率限制,降低批量撞库风险。
|
||||
|
||||
### D004 作废证书 PDF
|
||||
|
||||
- 决定:一期作废证书不允许下载正常 PDF。
|
||||
- 展示:查询页、直达页、二维码核验页统一显示作废状态。
|
||||
|
||||
### D005 证书模板
|
||||
|
||||
- 决定:一期先使用占位模板。
|
||||
- 要求:模板文件与业务逻辑分离,后续替换 Logo、公章、排版、正式文案时不改核心业务代码。
|
||||
|
||||
### D006 更正和补发
|
||||
|
||||
- 决定:一期不实现复杂更正/补发流程。
|
||||
- 一期保留证书编辑、作废、重置链接等主链路操作。
|
||||
|
||||
## Codex 先行决定
|
||||
|
||||
### C001 项目代码维护方式
|
||||
|
||||
- 决定:后台提供项目/课程代码配置,导入 Excel 使用项目代码匹配,不允许随意生成编号中的项目代码。
|
||||
- 原因:避免同一项目出现多个拼写,确保证书编号稳定。
|
||||
|
||||
### C002 一期部署方式
|
||||
|
||||
- 决定:单机 Docker Compose 部署。
|
||||
- 组件:Nginx、前端静态资源、FastAPI 后端、MySQL。
|
||||
- 原因:符合低并发和快速上线目标,减少运维复杂度。
|
||||
|
||||
### C003 文件目录
|
||||
|
||||
- 决定:使用 `/data/certificate-system/uploads`、`error-reports`、`exports`、`pdf-cache`。
|
||||
- 原因:便于备份策略区分;PDF 缓存不进入长期备份。
|
||||
|
||||
### C004 初始化管理员
|
||||
|
||||
- 决定:通过初始化命令创建第一个系统管理员。
|
||||
- 原因:避免在公开页面暴露注册入口。
|
||||
|
||||
### C005 访问 token 一期存储
|
||||
|
||||
- 决定:一期在 `certificate_access_tokens` 中同时保存 token 哈希和 token 原值。
|
||||
- 原因:后台导出必须能生成证书对外直达链接;纯哈希无法反推出链接。
|
||||
- 风险:数据库泄露时 token 原值可被直接使用。
|
||||
- 后续优化:将 token 原值改为应用层加密存储,或仅在生成时写入导出文件并提供链接重置机制。
|
||||
|
||||
### C006 验证码和限流
|
||||
|
||||
- 决定:一期使用后端进程内存保存图形验证码和简单 IP 频率计数。
|
||||
- 原因:一期单机部署,不引入 Redis,满足快速上线和低并发目标。
|
||||
- 后续优化:访问量上来后迁移到 Redis,并把限流前移到 Nginx 或网关层。
|
||||
|
||||
### C007 后台 UI 风格
|
||||
|
||||
- 决定:后台采用浅色、清爽、偏办公工具的视觉风格。
|
||||
- 原因:这个系统是日常管理工具,用户需要快速查找和处理数据,不适合厚重的深色或营销化页面。
|
||||
|
||||
### C008 后台 PDF 下载方式
|
||||
|
||||
- 决定:后台证书 PDF 下载使用带 Authorization token 的 blob 请求。
|
||||
- 原因:受保护的后台接口不能依赖普通链接跳转,否则浏览器不会带前端保存的登录 token,容易导致下载失败。
|
||||
|
||||
### C009 Excel 导入字段
|
||||
|
||||
- 决定:导入模板只保留姓名、手机号、项目代码、发证日期。
|
||||
- 原因:证书名称、课程名称、阶段名称、发证单位属于项目配置,不应该在每一行 Excel 里重复填写。
|
||||
- 备注:导入备注字段一期移除;批量导入后的备注缺少明确展示和业务使用场景。
|
||||
|
||||
### C010 导入批次文件管理
|
||||
|
||||
- 决定:后台保留导入批次列表,并支持下载原始 Excel、下载错误报告、删除批次记录。
|
||||
- 删除范围:只删除上传文件、错误报告和批次记录,不删除已经确认导入后生成的学员和证书。
|
||||
- 原因:导入文件会占用服务器存储,但导入后的业务数据应作为正式数据继续保留。
|
||||
### C011 Certificate Rendering Template
|
||||
|
||||
- Decision: keep `certificate-template-lab` as a standalone visual tuning page.
|
||||
- Decision: production PDF rendering now uses the approved certificate image as the base layer, then renders learner/course/date/certificate number dynamically with Pillow.
|
||||
- Current limitation: the data model only has `issue_date`; until separate training start/end dates are added, the rendered course date range uses `issue_date` for both start and end.
|
||||
- Current limitation: mentor, issuer names, and QR area remain from the approved base image for visual consistency.
|
||||
56
docs/deployment-checklist.md
Normal file
56
docs/deployment-checklist.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 上线检查清单
|
||||
|
||||
## 服务器
|
||||
|
||||
- Docker 已安装。
|
||||
- Docker Compose 已安装。
|
||||
- 域名已解析到服务器。
|
||||
- HTTPS 证书已准备。
|
||||
- `/data/certificate-system` 已创建并挂载到数据盘。
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `SECRET_KEY` 已改成强随机值。
|
||||
- `CERTIFICATE_NO_SECRET` 已改成强随机值。
|
||||
- `PUBLIC_BASE_URL` 已改成正式域名。
|
||||
- `DATABASE_URL` 指向生产 MySQL。
|
||||
- `DATA_ROOT` 指向生产数据目录。
|
||||
|
||||
## 数据库
|
||||
|
||||
- 已执行迁移:
|
||||
|
||||
```bash
|
||||
docker compose exec backend alembic upgrade head
|
||||
```
|
||||
|
||||
- 已初始化管理员:
|
||||
|
||||
```bash
|
||||
docker compose exec backend python -m app.cli init-db --admin-username admin --admin-password "强密码"
|
||||
```
|
||||
|
||||
## 业务配置
|
||||
|
||||
- 项目代码已维护,例如 `DBY`、`QSX`。
|
||||
- 发证单位名称已确认。
|
||||
- Excel 导入模板已下载并试填。
|
||||
- 占位证书模板已确认可临时使用。
|
||||
|
||||
## 验收动作
|
||||
|
||||
- 后台可登录。
|
||||
- 可新增学员。
|
||||
- 可新增证书并生成编号。
|
||||
- 可上传 Excel 并确认导入。
|
||||
- 可导出证书和直达链接。
|
||||
- 公开查询必须输入验证码。
|
||||
- 作废证书不能下载正常 PDF。
|
||||
- 直达链接重置后旧链接失效。
|
||||
- PDF 可生成,二维码可打开核验页。
|
||||
|
||||
## 备份
|
||||
|
||||
- MySQL 每日备份已配置。
|
||||
- `uploads`、`error-reports`、`exports` 已纳入备份策略。
|
||||
- `pdf-cache` 不纳入长期备份。
|
||||
49
docs/mvp-scope.md
Normal file
49
docs/mvp-scope.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# MVP 范围说明
|
||||
|
||||
## 一期必须完成
|
||||
|
||||
1. 后台账号登录和基础角色权限。
|
||||
2. 项目/课程代码配置。
|
||||
3. 学员管理,手机号作为主标识,支持曾用名和手机号变更记录。
|
||||
4. 证书管理,支持一个学员多张证书。
|
||||
5. Excel 批量导入,导入前校验,导入后生成证书编号和公开访问 token。
|
||||
6. 证书编号系统自动生成,格式为 `PX{YYYY}-{项目代码}-{7位短码}-{2位校验码}`。
|
||||
7. 批量导出证书数据,包含证书编号和对外直达链接。
|
||||
8. 公开查询:证书编号 + 姓名 + 图形验证码。
|
||||
9. 证书直达页:通过不可枚举 token 查看单张证书。
|
||||
10. 二维码核验页:展示真实性、状态和必要证书信息。
|
||||
11. PDF 下载:服务端按需生成,占位模板,缓存 30 天。
|
||||
12. 操作日志和导出日志。
|
||||
|
||||
## 一期不做
|
||||
|
||||
1. 手机号公开查询。
|
||||
2. 复杂更正/补发流程。
|
||||
3. 证书审批流。
|
||||
4. 可视化证书模板编辑器。
|
||||
5. 正式 Logo、公章、电子签章。
|
||||
6. Redis、Celery、RabbitMQ、Kubernetes、多实例。
|
||||
7. 对接报名系统、学习系统或 CRM。
|
||||
|
||||
## 默认业务规则
|
||||
|
||||
- 证书作废后,公开页面展示作废状态,不允许下载正常 PDF。
|
||||
- 公开页面不展示完整手机号、证件号、后台备注、导入记录。
|
||||
- 证书直达链接使用随机 token,不由证书编号推导。
|
||||
- 导出完整手机号或证件号需要高权限,并记录日志。
|
||||
|
||||
## Excel 导入模板字段
|
||||
|
||||
一期模板字段:
|
||||
|
||||
1. 姓名
|
||||
2. 手机号
|
||||
3. 项目代码
|
||||
4. 证书名称
|
||||
5. 课程名称
|
||||
6. 阶段名称
|
||||
7. 发证日期
|
||||
8. 发证单位
|
||||
9. 备注
|
||||
|
||||
证书编号不在 Excel 中填写,由系统确认导入后自动生成。
|
||||
146
docs/usage-deployment-manual.md
Normal file
146
docs/usage-deployment-manual.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# 部署与使用手册
|
||||
|
||||
本文档用于一期本地验收和正式部署交接。
|
||||
|
||||
## 本地验收版
|
||||
|
||||
适合在没有 Docker、npm 环境时先完整跑通业务流程。
|
||||
|
||||
```bat
|
||||
cd /d C:\Users\nelso\Documents\projects\certificate-system
|
||||
start-local.bat
|
||||
```
|
||||
|
||||
访问地址:
|
||||
|
||||
```text
|
||||
后台:http://127.0.0.1:8000/admin/login
|
||||
公开查询:http://127.0.0.1:8000/query
|
||||
```
|
||||
|
||||
默认账号:
|
||||
|
||||
```text
|
||||
admin / Admin123!
|
||||
```
|
||||
|
||||
本地数据保存在:
|
||||
|
||||
```text
|
||||
local_app/data/local.db
|
||||
```
|
||||
|
||||
清空本地验收数据:
|
||||
|
||||
```bat
|
||||
reset-local-data.bat
|
||||
```
|
||||
|
||||
## 后台使用流程
|
||||
|
||||
1. 登录 `/admin/login`。
|
||||
2. 进入“项目管理”,维护 `DBY`、`QSX` 等项目代码。项目支持搜索、修改、启用和停用。
|
||||
3. 进入“学员管理”,手动新增、查询、修改或删除学员。
|
||||
4. 进入“证书管理”,手动新增证书,支持单张预览、PDF 下载、作废、重置直达链接和导出。
|
||||
5. 进入“Excel 导入”,下载模板、上传 Excel、查看校验结果,确认后正式导入。模板只需要填写姓名、手机号、项目代码、发证日期;证书名称、课程名称、阶段名称、发证单位从项目配置自动带出。
|
||||
“确认导入”表示把已校验通过的临时数据正式写入学员和证书表。导入记录支持下载原文件、下载错误报告和删除;删除导入记录只删除上传文件、错误报告和批次记录,不删除已经生成的学员和证书。
|
||||
6. 进入“操作日志”,查看关键后台操作。日志动作已转成中文显示。
|
||||
|
||||
## 公开查询流程
|
||||
|
||||
1. 用户进入 `/query`。
|
||||
2. 输入证书编号 + 姓名 + 图形验证码,或手机号 + 姓名 + 图形验证码。
|
||||
3. 查询成功后展示证书状态和证书信息;手机号查询可能返回多张证书。
|
||||
4. 有效证书可下载 PDF。
|
||||
5. 作废证书只展示作废状态,不提供正常 PDF 下载。
|
||||
|
||||
## PDF 下载说明
|
||||
|
||||
PDF 由服务端按需生成,并使用 30 天缓存。这样可以保证模板、二维码、作废状态和审计逻辑统一。
|
||||
|
||||
后台 PDF 下载不能用简单的 `window.location` 直接跳转,因为后台接口需要登录态。如果直接打开下载地址,浏览器不会自动带上前端保存的 Authorization token,容易出现下载失败。当前后台已改为通过带 token 的请求下载 blob 文件,再在浏览器里保存。
|
||||
|
||||
## 正式部署准备
|
||||
|
||||
- Linux 服务器,建议 2 核 4G 起步。
|
||||
- 域名和 HTTPS 证书。
|
||||
- Docker 和 Docker Compose。
|
||||
- MySQL 8,可以使用 Compose 内置 MySQL 或云数据库。
|
||||
|
||||
默认数据目录:
|
||||
|
||||
```text
|
||||
/data/certificate-system
|
||||
```
|
||||
|
||||
建议子目录:
|
||||
|
||||
```text
|
||||
uploads
|
||||
error-reports
|
||||
exports
|
||||
pdf-cache
|
||||
```
|
||||
|
||||
`pdf-cache` 是 30 天缓存,不需要作为长期档案备份。
|
||||
|
||||
## 环境变量
|
||||
|
||||
复制 `.env.example` 为 `.env`,至少修改:
|
||||
|
||||
```text
|
||||
APP_ENV=production
|
||||
DATABASE_URL=mysql+pymysql://certificate:password@mysql:3306/certificate_system
|
||||
SECRET_KEY=change-me
|
||||
PUBLIC_BASE_URL=https://your-domain.com
|
||||
CERTIFICATE_NO_SECRET=change-me
|
||||
PDF_CACHE_DAYS=30
|
||||
DATA_ROOT=/data/certificate-system
|
||||
```
|
||||
|
||||
## Docker 部署
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
首次初始化:
|
||||
|
||||
```bash
|
||||
docker compose exec backend python -m app.cli init-db --admin-username admin --admin-password "请替换为强密码"
|
||||
```
|
||||
|
||||
单独执行数据库迁移:
|
||||
|
||||
```bash
|
||||
docker compose exec backend alembic upgrade head
|
||||
```
|
||||
|
||||
后端测试:
|
||||
|
||||
```bash
|
||||
docker compose exec backend pytest
|
||||
```
|
||||
|
||||
## 当前已实现
|
||||
|
||||
- 后台登录。
|
||||
- 后台概览统计。
|
||||
- 项目新增、搜索、修改、启用、停用。
|
||||
- 学员手动新增、查询、修改、删除。
|
||||
- 证书新增、查询、后台预览、后台下载、作废、重置直达链接。
|
||||
- Excel 模板下载、上传校验、错误报告下载、确认导入。导入模板字段为姓名、手机号、项目代码、发证日期。
|
||||
- 导入批次原文件下载和导入批次删除。
|
||||
- 导入后自动生成证书编号、直达链接 token、二维码 token。
|
||||
- 证书导出,包含证书编号和对外直达链接。
|
||||
- 公开查询:证书编号 + 姓名 + 图形验证码,或手机号 + 姓名 + 图形验证码。
|
||||
- 证书直达页和二维码核验页。
|
||||
- PDF 服务端按需生成,缓存 30 天。
|
||||
- 关键操作日志写入和中文展示。
|
||||
|
||||
## 后续上线前确认
|
||||
|
||||
- 生产域名、HTTPS 和 Nginx 限流参数。
|
||||
- PDF 正式模板、Logo、公章和证书文案。
|
||||
- 生产管理员账号和强密码。
|
||||
- 服务器部署后的联调和验收数据导入演练。
|
||||
94
docs/产品视觉设计说明.md
Normal file
94
docs/产品视觉设计说明.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 产品视觉设计说明
|
||||
|
||||
## 设计目标
|
||||
|
||||
本系统是一套证书管理和公开查询产品,视觉上应当清爽、可信、便于长期办公使用。后台页面优先保证信息密度、扫描效率和操作稳定感;公开查询页优先保证用户能快速完成查询、预览和下载。
|
||||
|
||||
## 设计方向
|
||||
|
||||
- 不做营销式首页。
|
||||
- 不做重装饰背景。
|
||||
- 不使用沉重深色大面积背景。
|
||||
- 不使用老气的灰褐色或暗沉配色。
|
||||
- 使用浅色背景、白色工作区、克制青绿色主色。
|
||||
- 通过边框、间距、字体粗细和轻微阴影建立层级。
|
||||
|
||||
## 颜色
|
||||
|
||||
主色:
|
||||
|
||||
```text
|
||||
#16817e
|
||||
```
|
||||
|
||||
用于:
|
||||
|
||||
- 主按钮。
|
||||
- 激活导航。
|
||||
- 有效状态。
|
||||
- 链接。
|
||||
|
||||
背景:
|
||||
|
||||
```text
|
||||
#f7fafb -> #eef4f6
|
||||
```
|
||||
|
||||
用于页面底色,保持清爽但不纯白刺眼。
|
||||
|
||||
边框:
|
||||
|
||||
```text
|
||||
#dbe6ea
|
||||
#c8d7dd
|
||||
```
|
||||
|
||||
用于卡片、输入框、表格边界。
|
||||
|
||||
文字:
|
||||
|
||||
```text
|
||||
#172033 主文字
|
||||
#64748b 次级文字
|
||||
```
|
||||
|
||||
风险色:
|
||||
|
||||
```text
|
||||
#c9413a 高风险/危险操作
|
||||
#b7791f 中风险/警告
|
||||
```
|
||||
|
||||
## 后台布局
|
||||
|
||||
- 左侧导航固定 248px。
|
||||
- 工作区使用 28px 页面内边距。
|
||||
- 页面标题区采用左标题、右主操作按钮布局。
|
||||
- 表单区域和列表区域分成独立白色面板。
|
||||
- 卡片圆角控制在 10px 左右,不使用过大的圆角。
|
||||
- 分页右对齐,符合后台数据列表习惯。
|
||||
|
||||
## 公开查询页
|
||||
|
||||
- 查询卡片是第一屏核心。
|
||||
- 不放多余介绍文字。
|
||||
- 查询方式使用分段选择。
|
||||
- 查询结果使用证书卡片展示。
|
||||
- 结果卡保留证书编号、姓名、项目、课程/阶段、发证日期、发证单位。
|
||||
- 预览和下载按钮放在卡片右下方。
|
||||
|
||||
## 表格和控件
|
||||
|
||||
- 表头使用浅灰蓝背景。
|
||||
- 表格 hover 只做轻微背景变化。
|
||||
- 输入框和下拉框高度统一。
|
||||
- 下拉框箭头不得贴边。
|
||||
- 主要按钮使用青绿色,危险按钮使用红色。
|
||||
- 状态标签使用圆角胶囊,但尺寸克制。
|
||||
|
||||
## 自行决策记录
|
||||
|
||||
- 选择青绿色作为主色,因为它比蓝色更接近当前证书产品的清爽和可信气质,也不会显得像通用后台模板。
|
||||
- 没有加入大面积插画、渐变装饰或复杂动效,避免影响后台扫描效率。
|
||||
- 正式版新增全局 `design.css`,统一 Element Plus 控件和通用布局。
|
||||
- 本地验证版同步更新内联 CSS,保证本地验收看到的视觉方向与正式版一致。
|
||||
59
docs/操作日志分页需求.md
Normal file
59
docs/操作日志分页需求.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 操作日志分页需求
|
||||
|
||||
## 背景
|
||||
|
||||
操作日志会持续增长,如果一次性展示全部日志,页面会变长,查找和点击都不方便。日志列表需要分页展示,并且页码数量较多时不能把所有页码全部铺开。
|
||||
|
||||
## 功能目标
|
||||
|
||||
1. 操作日志列表每页显示 20 条。
|
||||
2. 日志列表上方和下方都显示分页控件。
|
||||
3. 支持上一页、下一页和指定页码跳转。
|
||||
4. 页码超过 10 页时,远离当前页的页码用省略号折叠。
|
||||
5. 分页需要和现有筛选条件联动:
|
||||
- 操作类型。
|
||||
- 对象类型。
|
||||
- 风险等级。
|
||||
- 关键词。
|
||||
6. 切换筛选条件后,从第 1 页重新查询。
|
||||
7. 导出日志不受当前页限制,仍按当前筛选条件导出全部匹配日志。
|
||||
|
||||
## 页码展示规则
|
||||
|
||||
分页控件始终显示:
|
||||
|
||||
- 第 1 页。
|
||||
- 最后一页。
|
||||
- 当前页前后 2 页。
|
||||
- 中间缺失页码用 `...` 表示。
|
||||
|
||||
示例:
|
||||
|
||||
- 当前第 1 页,共 3 页:`1 2 3`
|
||||
- 当前第 6 页,共 20 页:`1 ... 4 5 6 7 8 ... 20`
|
||||
- 当前第 19 页,共 20 页:`1 ... 17 18 19 20`
|
||||
|
||||
## 接口约定
|
||||
|
||||
日志列表接口支持:
|
||||
|
||||
- `page`:页码,从 1 开始,默认 1。
|
||||
- `page_size`:每页数量,默认 20。
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [],
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total_pages": 1
|
||||
}
|
||||
```
|
||||
|
||||
## 自行决策记录
|
||||
|
||||
- 默认每页 20 条,兼顾浏览效率和页面密度。
|
||||
- 页码折叠规则采用“首页 + 尾页 + 当前页前后 2 页”,避免页码过多时挤压布局。
|
||||
- 上下各放一组分页控件,避免用户滚动到底部后还要回到顶部翻页。
|
||||
100
docs/操作日志强化需求.md
Normal file
100
docs/操作日志强化需求.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 操作日志强化需求
|
||||
|
||||
## 背景
|
||||
|
||||
当前后台已经具备基础操作日志能力,可以记录部分后台动作并在页面查看。随着证书、学员、项目、导入文件等管理动作增多,日志需要更方便排查问题、追踪责任和留档审计。
|
||||
|
||||
## 一期目标
|
||||
|
||||
快速增强后台操作日志的可用性,不做复杂审计系统,但要满足日常运营排查和关键动作追踪。
|
||||
|
||||
## 功能需求
|
||||
|
||||
1. 日志列表支持筛选和搜索:
|
||||
- 按操作类型筛选。
|
||||
- 按对象类型筛选。
|
||||
- 按风险等级筛选。
|
||||
- 按关键词搜索对象 ID、摘要、详情内容。
|
||||
|
||||
2. 日志显示更易读:
|
||||
- 操作名显示中文。
|
||||
- 对象类型显示中文。
|
||||
- 增加操作摘要,例如“作废证书:PX2026-xxx”“删除导入文件:test.xlsx”。
|
||||
- 高风险动作增加醒目标记。
|
||||
|
||||
3. 日志详情弹窗:
|
||||
- 展示操作时间、操作人、操作、对象、对象 ID、风险等级。
|
||||
- 展示摘要。
|
||||
- 展示完整详情 JSON,便于排查。
|
||||
|
||||
4. 关键动作记录更多上下文:
|
||||
- 新增/修改/停用项目记录项目代码、名称、状态变化。
|
||||
- 新增/修改/删除学员记录姓名、脱敏手机号、状态变化。
|
||||
- 新增/作废/重置链接证书记录证书编号和学员信息。
|
||||
- 上传/确认/删除导入批次记录文件名、导入统计。
|
||||
|
||||
5. 日志导出:
|
||||
- 支持导出 Excel。
|
||||
- 导出内容包含时间、操作人、操作、对象、对象 ID、风险等级、摘要、详情。
|
||||
|
||||
## 风险等级规则
|
||||
|
||||
高风险:
|
||||
- 作废证书。
|
||||
- 删除导入批次/导入原文件。
|
||||
- 重置证书公开链接。
|
||||
- 停用项目。
|
||||
- 删除学员。
|
||||
|
||||
中风险:
|
||||
- 修改项目。
|
||||
- 修改学员。
|
||||
- 确认导入。
|
||||
- 新增证书。
|
||||
|
||||
低风险:
|
||||
- 新增项目。
|
||||
- 新增学员。
|
||||
- 上传导入文件。
|
||||
- 导出数据。
|
||||
|
||||
## 暂不做
|
||||
|
||||
- 日志删除功能。
|
||||
- 日志归档策略。
|
||||
- 复杂审计报表。
|
||||
- 登录失败风控和异常 IP 告警。
|
||||
|
||||
## 日志保留与清理策略
|
||||
|
||||
一期采用自动清理过期日志,不开放任意删除单条日志,避免审计证据被误删。
|
||||
|
||||
- 普通后台操作日志保留 180 天。
|
||||
- 学员公开查询、公开下载日志保留 180 天。
|
||||
- 高风险后台操作日志保留 365 天,包括作废证书、删除导入批次、重置证书公开链接、删除学员。
|
||||
- 系统启动和进入日志管理时可以触发过期清理。
|
||||
- 后台日志页提供“清理过期日志”按钮,只清理超过保留周期的日志,不删除未过期日志。
|
||||
|
||||
## 公开访问日志
|
||||
|
||||
需要记录学员/外部用户主动查询和下载电子证书的行为,用于排查“用户是否查过/下载过”的问题。
|
||||
|
||||
- 公开查询证书:
|
||||
- 记录查询方式:证书编号或手机号。
|
||||
- 记录姓名。
|
||||
- 手机号脱敏。
|
||||
- 记录匹配数量。
|
||||
- 记录失败查询,但不记录验证码。
|
||||
|
||||
- 公开下载证书:
|
||||
- 记录证书编号。
|
||||
- 记录学员姓名。
|
||||
- 记录项目代码。
|
||||
- 不记录公开 token 原文。
|
||||
|
||||
## 自行决策记录
|
||||
|
||||
- 一期优先增强后台日常可用性,不引入新的日志表结构。
|
||||
- 风险等级先由操作类型在服务端/前端映射计算,避免为当前版本增加迁移复杂度。
|
||||
- 敏感信息在摘要里脱敏,完整详情尽量不写入明文手机号或 token。
|
||||
- 公开查询/下载先进入同一张操作日志表,操作人 ID 为空,用“公开访问”对象类型区分。
|
||||
792
docs/阿里云正式部署详细操作手册.md
Normal file
792
docs/阿里云正式部署详细操作手册.md
Normal file
@@ -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 也可以,但一定要配置每日备份。
|
||||
14
frontend/Dockerfile
Normal file
14
frontend/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>培训结业证书管理与查询系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
frontend/nginx.conf
Normal file
19
frontend/nginx.conf
Normal file
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "certificate-system-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"axios": "^1.7.9",
|
||||
"element-plus": "^2.9.1",
|
||||
"pinia": "^2.3.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
3
frontend/src/App.vue
Normal file
3
frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
106
frontend/src/api.ts
Normal file
106
frontend/src/api.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import axios from "axios";
|
||||
|
||||
export const http = axios.create({
|
||||
baseURL: "/api"
|
||||
});
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("admin_token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
export interface PublicCertificate {
|
||||
certificate_no: string;
|
||||
learner_name: string;
|
||||
certificate_name: string;
|
||||
project_code: string;
|
||||
course_name: string | null;
|
||||
stage_name: string | null;
|
||||
issue_date: string;
|
||||
issuer_name: string;
|
||||
status: string;
|
||||
pdf_status: string;
|
||||
public_token: string | null;
|
||||
public_url: string | null;
|
||||
download_url: string | null;
|
||||
can_download_pdf: boolean;
|
||||
disclaimer: string;
|
||||
}
|
||||
|
||||
export interface Captcha {
|
||||
captcha_id: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
export interface ProjectCourse {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
default_certificate_name: string;
|
||||
default_course_name: string | null;
|
||||
default_stage_name: string | null;
|
||||
default_issuer_name: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Learner {
|
||||
id: number;
|
||||
phone: string;
|
||||
current_name: string;
|
||||
student_no: string | null;
|
||||
status: string;
|
||||
remark: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AdminCertificate {
|
||||
id: number;
|
||||
learner_id: number;
|
||||
project_code: string;
|
||||
certificate_no: string;
|
||||
certificate_name: string;
|
||||
class_name: string | null;
|
||||
course_name: string | null;
|
||||
stage_name: string | null;
|
||||
issue_date: string;
|
||||
issuer_name: string;
|
||||
status: string;
|
||||
pdf_status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ImportBatch {
|
||||
id: number;
|
||||
filename: string;
|
||||
status: string;
|
||||
total_rows: number;
|
||||
valid_rows: number;
|
||||
failed_rows: number;
|
||||
conflict_rows: number;
|
||||
error_report_path: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OperationLog {
|
||||
id: number;
|
||||
admin_user_id: number | null;
|
||||
action: string;
|
||||
action_label: string;
|
||||
object_type: string;
|
||||
object_label: string;
|
||||
object_id: string | null;
|
||||
ip_address: string | null;
|
||||
detail_json: string | null;
|
||||
risk: string;
|
||||
risk_label: string;
|
||||
summary: string;
|
||||
created_at: string;
|
||||
}
|
||||
217
frontend/src/design.css
Normal file
217
frontend/src/design.css
Normal file
@@ -0,0 +1,217 @@
|
||||
:root {
|
||||
--app-bg: #f5f8fa;
|
||||
--app-surface: #ffffff;
|
||||
--app-surface-soft: #f9fbfc;
|
||||
--app-border: #dbe6ea;
|
||||
--app-border-strong: #c8d7dd;
|
||||
--app-text: #172033;
|
||||
--app-muted: #64748b;
|
||||
--app-primary: #16817e;
|
||||
--app-primary-strong: #0f6f6b;
|
||||
--app-primary-soft: #e6f5f3;
|
||||
--app-blue-soft: #eef5fb;
|
||||
--app-gold: #b88a2d;
|
||||
--app-danger: #c9413a;
|
||||
--app-warning: #b7791f;
|
||||
--app-success: #16817e;
|
||||
--app-shadow: 0 14px 36px rgba(42, 64, 78, 0.08);
|
||||
--app-shadow-soft: 0 8px 22px rgba(42, 64, 78, 0.055);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--app-text);
|
||||
background:
|
||||
linear-gradient(180deg, #f7fafb 0%, #eef4f6 100%);
|
||||
font-family:
|
||||
Inter, "Microsoft YaHei", "PingFang SC", "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.page-head h2 {
|
||||
margin: 0;
|
||||
color: var(--app-text);
|
||||
font-size: 24px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.page-head p {
|
||||
margin: 7px 0 0;
|
||||
color: var(--app-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.el-card.panel,
|
||||
.el-card.stat {
|
||||
border: 1px solid var(--app-border);
|
||||
border-radius: 10px;
|
||||
background: var(--app-surface);
|
||||
box-shadow: var(--app-shadow-soft);
|
||||
}
|
||||
|
||||
.el-card {
|
||||
--el-card-border-color: var(--app-border);
|
||||
--el-card-border-radius: 10px;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
--el-button-border-radius: 7px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: var(--app-primary);
|
||||
--el-button-border-color: var(--app-primary);
|
||||
--el-button-hover-bg-color: var(--app-primary-strong);
|
||||
--el-button-hover-border-color: var(--app-primary-strong);
|
||||
--el-button-active-bg-color: var(--app-primary-strong);
|
||||
--el-button-active-border-color: var(--app-primary-strong);
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-select__wrapper,
|
||||
.el-textarea__inner {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px var(--app-border) inset;
|
||||
}
|
||||
|
||||
.el-input__wrapper:hover,
|
||||
.el-select__wrapper:hover,
|
||||
.el-textarea__inner:hover {
|
||||
box-shadow: 0 0 0 1px var(--app-border-strong) inset;
|
||||
}
|
||||
|
||||
.el-input__wrapper.is-focus,
|
||||
.el-select__wrapper.is-focused,
|
||||
.el-textarea__inner:focus {
|
||||
box-shadow: 0 0 0 1px var(--app-primary) inset, 0 0 0 3px rgba(22, 129, 126, 0.12);
|
||||
}
|
||||
|
||||
.el-form-item__label {
|
||||
color: #415466;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-border-color: #e5edf0;
|
||||
--el-table-header-bg-color: #f6fafb;
|
||||
--el-table-header-text-color: #52677a;
|
||||
--el-table-row-hover-bg-color: #f8fcfc;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
border-radius: 999px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
padding: 22px 24px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 14px 24px 22px;
|
||||
}
|
||||
|
||||
.el-dialog__title {
|
||||
color: var(--app-text);
|
||||
font-size: 20px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.el-segmented {
|
||||
--el-segmented-item-selected-bg-color: var(--app-primary);
|
||||
--el-segmented-item-selected-color: #ffffff;
|
||||
border: 1px solid var(--app-border);
|
||||
background: #f4f8fa;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-grid .el-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.filters {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pager {
|
||||
border-top: 1px solid #edf2f4;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.stat {
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.stat:hover {
|
||||
border-color: var(--app-border-strong);
|
||||
box-shadow: var(--app-shadow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--app-primary-strong);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page-head {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
10
frontend/src/download.ts
Normal file
10
frontend/src/download.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { http } from "./api";
|
||||
|
||||
export async function downloadFile(url: string, filename: string) {
|
||||
const { data } = await http.get(url, { responseType: "blob" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(data);
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
11
frontend/src/main.ts
Normal file
11
frontend/src/main.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import "element-plus/dist/index.css";
|
||||
import "./design.css";
|
||||
|
||||
import ElementPlus from "element-plus";
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from "vue";
|
||||
|
||||
import App from "./App.vue";
|
||||
import { router } from "./router";
|
||||
|
||||
createApp(App).use(createPinia()).use(router).use(ElementPlus).mount("#app");
|
||||
42
frontend/src/router/index.ts
Normal file
42
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
import AdminCertificates from "../views/AdminCertificates.vue";
|
||||
import AdminHome from "../views/AdminHome.vue";
|
||||
import AdminImports from "../views/AdminImports.vue";
|
||||
import AdminLogin from "../views/AdminLogin.vue";
|
||||
import AdminLogs from "../views/AdminLogs.vue";
|
||||
import AdminLearners from "../views/AdminLearners.vue";
|
||||
import AdminProjects from "../views/AdminProjects.vue";
|
||||
import AdminShell from "../views/AdminShell.vue";
|
||||
import CertificateQuery from "../views/CertificateQuery.vue";
|
||||
import CertificateView from "../views/CertificateView.vue";
|
||||
import VerifyView from "../views/VerifyView.vue";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: "/", redirect: "/query" },
|
||||
{ path: "/admin/login", component: AdminLogin },
|
||||
{
|
||||
path: "/admin",
|
||||
component: AdminShell,
|
||||
children: [
|
||||
{ path: "", component: AdminHome },
|
||||
{ path: "projects", component: AdminProjects },
|
||||
{ path: "learners", component: AdminLearners },
|
||||
{ path: "certificates", component: AdminCertificates },
|
||||
{ path: "imports", component: AdminImports },
|
||||
{ path: "logs", component: AdminLogs }
|
||||
]
|
||||
},
|
||||
{ path: "/query", component: CertificateQuery },
|
||||
{ path: "/cert/:token", component: CertificateView },
|
||||
{ path: "/verify/:token", component: VerifyView }
|
||||
]
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.path.startsWith("/admin") && to.path !== "/admin/login" && !localStorage.getItem("admin_token")) {
|
||||
return "/admin/login";
|
||||
}
|
||||
});
|
||||
216
frontend/src/views/AdminCertificates.vue
Normal file
216
frontend/src/views/AdminCertificates.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<section>
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h2>证书管理</h2>
|
||||
<p>支持单张新增、预览、下载、作废和链接重置。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="createCertificate">新增证书</el-button>
|
||||
</header>
|
||||
|
||||
<el-card class="panel" shadow="never">
|
||||
<el-form class="form-grid" label-position="top">
|
||||
<el-form-item label="学员ID">
|
||||
<el-input v-model.number="form.learner_id" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目代码">
|
||||
<el-select v-model="form.project_code" filterable>
|
||||
<el-option v-for="item in activeProjects" :key="item.code" :label="`${item.code} ${item.name}`" :value="item.code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="发证日期">
|
||||
<el-date-picker v-model="form.issue_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="课程名称">
|
||||
<el-input v-model.trim="form.course_name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="阶段名称">
|
||||
<el-input v-model.trim="form.stage_name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="发证单位">
|
||||
<el-input v-model.trim="form.issuer_name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model.trim="form.remark" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="panel" shadow="never">
|
||||
<div class="toolbar">
|
||||
<el-input v-model.trim="keyword" placeholder="按证书编号、名称、项目代码搜索" clearable @keyup.enter="loadCertificates" />
|
||||
<el-select v-model="statusValue" clearable placeholder="状态">
|
||||
<el-option label="有效" value="valid" />
|
||||
<el-option label="已作废" value="voided" />
|
||||
</el-select>
|
||||
<el-button @click="loadCertificates">搜索</el-button>
|
||||
<el-button @click="exportCertificates">导出</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="certificates" border>
|
||||
<el-table-column prop="certificate_no" label="证书编号" width="220" />
|
||||
<el-table-column prop="learner_id" label="学员ID" width="90" />
|
||||
<el-table-column prop="project_code" label="项目" width="90" />
|
||||
<el-table-column prop="certificate_name" label="证书名称" min-width="180" />
|
||||
<el-table-column prop="issue_date" label="发证日期" width="120" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'valid' ? 'success' : 'danger'">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="330">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="previewCertificate(row)">预览</el-button>
|
||||
<el-button size="small" @click="downloadCertificate(row)">下载</el-button>
|
||||
<el-button size="small" @click="regenerateToken(row)">重置链接</el-button>
|
||||
<el-button v-if="row.status === 'valid'" size="small" type="danger" @click="voidCertificate(row)">作废</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="previewVisible" title="证书预览" width="680px">
|
||||
<el-descriptions v-if="preview" :column="1" border>
|
||||
<el-descriptions-item label="证书编号">{{ preview.certificate_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学员姓名">{{ preview.learner_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="证书名称">{{ preview.certificate_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目/课程/阶段">
|
||||
{{ preview.project_code }} {{ preview.course_name || "" }} {{ preview.stage_name || "" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="发证日期">{{ preview.issue_date }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发证单位">{{ preview.issuer_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="公开链接">
|
||||
<a :href="preview.public_url" target="_blank" rel="noreferrer">{{ preview.public_url }}</a>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="核验链接">
|
||||
<a :href="preview.verify_url" target="_blank" rel="noreferrer">{{ preview.verify_url }}</a>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { http, type AdminCertificate, type ProjectCourse } from "../api";
|
||||
import { downloadFile } from "../download";
|
||||
|
||||
const certificates = ref<AdminCertificate[]>([]);
|
||||
const projects = ref<ProjectCourse[]>([]);
|
||||
const keyword = ref("");
|
||||
const statusValue = ref("");
|
||||
const previewVisible = ref(false);
|
||||
const preview = ref<any>(null);
|
||||
const form = reactive({
|
||||
learner_id: undefined as number | undefined,
|
||||
project_code: "",
|
||||
course_name: "",
|
||||
stage_name: "",
|
||||
issue_date: "",
|
||||
issuer_name: "",
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const activeProjects = computed(() => projects.value.filter((item) => item.status === "active"));
|
||||
|
||||
function statusText(status: string) {
|
||||
return status === "valid" ? "有效" : "已作废";
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
const { data } = await http.get<ProjectCourse[]>("/admin/projects");
|
||||
projects.value = data;
|
||||
}
|
||||
|
||||
async function loadCertificates() {
|
||||
const { data } = await http.get<AdminCertificate[]>("/admin/certificates", {
|
||||
params: { keyword: keyword.value || undefined, status: statusValue.value || undefined },
|
||||
});
|
||||
certificates.value = data;
|
||||
}
|
||||
|
||||
async function createCertificate() {
|
||||
if (!form.learner_id || !form.project_code || !form.issue_date || !form.issuer_name) {
|
||||
ElMessage.warning("请填写学员ID、项目、发证日期和发证单位");
|
||||
return;
|
||||
}
|
||||
await http.post("/admin/certificates", {
|
||||
learner_id: form.learner_id,
|
||||
project_code: form.project_code,
|
||||
course_name: form.course_name || null,
|
||||
stage_name: form.stage_name || null,
|
||||
issue_date: form.issue_date,
|
||||
issuer_name: form.issuer_name,
|
||||
remark: form.remark || null,
|
||||
});
|
||||
ElMessage.success("证书已创建");
|
||||
await loadCertificates();
|
||||
}
|
||||
|
||||
async function previewCertificate(row: AdminCertificate) {
|
||||
const { data } = await http.get(`/admin/certificates/${row.id}/preview`);
|
||||
preview.value = data;
|
||||
previewVisible.value = true;
|
||||
}
|
||||
|
||||
async function downloadCertificate(row: AdminCertificate) {
|
||||
await downloadFile(`/admin/certificates/${row.id}/download`, `${row.certificate_no}.pdf`);
|
||||
}
|
||||
|
||||
async function voidCertificate(row: AdminCertificate) {
|
||||
await ElMessageBox.confirm(`确认作废证书 ${row.certificate_no}?`, "作废证书", { type: "warning" });
|
||||
await http.post(`/admin/certificates/${row.id}/void`);
|
||||
await loadCertificates();
|
||||
}
|
||||
|
||||
async function regenerateToken(row: AdminCertificate) {
|
||||
await ElMessageBox.confirm(`重新生成 ${row.certificate_no} 的直达链接?旧链接会失效。`, "重置链接", { type: "warning" });
|
||||
await http.post(`/admin/certificates/${row.id}/token/regenerate`);
|
||||
ElMessage.success("直达链接已重置");
|
||||
}
|
||||
|
||||
function exportCertificates() {
|
||||
downloadFile("/admin/exports/certificates", "certificates-export.xlsx");
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadProjects(), loadCertificates()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-head,
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-head p {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.toolbar .el-input {
|
||||
max-width: 340px;
|
||||
}
|
||||
</style>
|
||||
106
frontend/src/views/AdminHome.vue
Normal file
106
frontend/src/views/AdminHome.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<section>
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h2>一期工作台</h2>
|
||||
<p>快速查看证书、导入和状态数据。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="downloadTemplate">下载导入模板</el-button>
|
||||
</header>
|
||||
|
||||
<div class="stats">
|
||||
<el-card v-for="item in cards" :key="item.label" class="stat" shadow="never">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</el-card>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import { http } from "../api";
|
||||
import { downloadFile } from "../download";
|
||||
|
||||
const router = useRouter();
|
||||
const summary = reactive({
|
||||
learner_count: 0,
|
||||
certificate_count: 0,
|
||||
valid_certificate_count: 0,
|
||||
voided_certificate_count: 0,
|
||||
recent_import_count: 0,
|
||||
});
|
||||
|
||||
const cards = computed(() => [
|
||||
{ label: "学员总数", value: summary.learner_count },
|
||||
{ label: "证书总数", value: summary.certificate_count },
|
||||
{ label: "有效证书", value: summary.valid_certificate_count },
|
||||
{ label: "作废证书", value: summary.voided_certificate_count },
|
||||
{ label: "近期导入", value: summary.recent_import_count },
|
||||
]);
|
||||
|
||||
async function loadSummary() {
|
||||
try {
|
||||
const { data } = await http.get("/admin/dashboard/summary");
|
||||
Object.assign(summary, data);
|
||||
} catch {
|
||||
localStorage.removeItem("admin_token");
|
||||
await router.push("/admin/login");
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTemplate() {
|
||||
downloadFile("/admin/import-batches/template", "certificate-import-template.xlsx");
|
||||
}
|
||||
|
||||
onMounted(loadSummary);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.page-head p {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
border: 1px solid #dbe6ea;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, #ffffff, #f8fbfc);
|
||||
box-shadow: 0 8px 22px rgba(42, 64, 78, 0.055);
|
||||
}
|
||||
|
||||
.stat span {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
166
frontend/src/views/AdminImports.vue
Normal file
166
frontend/src/views/AdminImports.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<section>
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h2>Excel 导入</h2>
|
||||
<p>上传后先校验,确认导入后才会正式写入学员和证书数据。</p>
|
||||
</div>
|
||||
<el-button @click="downloadTemplate">下载模板</el-button>
|
||||
</header>
|
||||
|
||||
<el-upload
|
||||
class="upload"
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:show-file-list="true"
|
||||
:limit="1"
|
||||
accept=".xlsx"
|
||||
@change="pickFile"
|
||||
@remove="selectedFile = null"
|
||||
>
|
||||
<el-icon class="upload-icon"><UploadFilled /></el-icon>
|
||||
<div>把 Excel 文件拖到这里,或点击选择文件</div>
|
||||
</el-upload>
|
||||
<el-button type="primary" :disabled="!selectedFile" :loading="uploading" @click="uploadFile">上传并校验</el-button>
|
||||
|
||||
<el-card class="panel" shadow="never">
|
||||
<el-table :data="batches" border>
|
||||
<el-table-column prop="id" label="批次ID" width="90" />
|
||||
<el-table-column prop="filename" label="文件名" min-width="220" />
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="{ row }">{{ statusText(row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_rows" label="总行数" width="100" />
|
||||
<el-table-column prop="valid_rows" label="可导入" width="100" />
|
||||
<el-table-column prop="failed_rows" label="失败" width="100" />
|
||||
<el-table-column prop="created_at" label="上传时间" width="190" />
|
||||
<el-table-column label="操作" width="380">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="downloadSource(row)">下载原文件</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'validated'"
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="confirmingId === row.id"
|
||||
@click="confirmBatch(row)"
|
||||
>
|
||||
确认导入
|
||||
</el-button>
|
||||
<el-button v-if="row.error_report_path" size="small" @click="downloadErrorReport(row)">错误报告</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteBatch(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UploadFilled } from "@element-plus/icons-vue";
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from "element-plus";
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
import { http, type ImportBatch } from "../api";
|
||||
import { downloadFile } from "../download";
|
||||
|
||||
const batches = ref<ImportBatch[]>([]);
|
||||
const selectedFile = ref<File | null>(null);
|
||||
const uploading = ref(false);
|
||||
const confirmingId = ref<number | null>(null);
|
||||
|
||||
function statusText(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
uploaded: "已上传",
|
||||
validated: "已校验",
|
||||
imported: "已导入",
|
||||
failed: "校验失败",
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
function pickFile(file: UploadFile) {
|
||||
selectedFile.value = file.raw || null;
|
||||
}
|
||||
|
||||
function downloadTemplate() {
|
||||
downloadFile("/admin/import-batches/template", "certificate-import-template.xlsx");
|
||||
}
|
||||
|
||||
async function loadBatches() {
|
||||
const { data } = await http.get<ImportBatch[]>("/admin/import-batches");
|
||||
batches.value = data;
|
||||
}
|
||||
|
||||
async function uploadFile() {
|
||||
if (!selectedFile.value) return;
|
||||
uploading.value = true;
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append("file", selectedFile.value);
|
||||
await http.post("/admin/import-batches", body);
|
||||
ElMessage.success("文件已上传并完成校验,请检查结果后点击确认导入");
|
||||
selectedFile.value = null;
|
||||
await loadBatches();
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBatch(row: ImportBatch) {
|
||||
await ElMessageBox.confirm(`确认把批次 ${row.id} 中 ${row.valid_rows} 行校验通过的数据写入系统?`, "确认导入", { type: "warning" });
|
||||
confirmingId.value = row.id;
|
||||
try {
|
||||
const { data } = await http.post<ImportBatch>(`/admin/import-batches/${row.id}/confirm`);
|
||||
row.status = data.status;
|
||||
ElMessage.success(`导入完成,当前状态:${statusText(data.status)}`);
|
||||
await loadBatches();
|
||||
} finally {
|
||||
confirmingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadSource(row: ImportBatch) {
|
||||
downloadFile(`/admin/import-batches/${row.id}/file`, row.filename);
|
||||
}
|
||||
|
||||
function downloadErrorReport(row: ImportBatch) {
|
||||
downloadFile(`/admin/import-batches/${row.id}/error-report`, `import-errors-${row.id}.xlsx`);
|
||||
}
|
||||
|
||||
async function deleteBatch(row: ImportBatch) {
|
||||
await ElMessageBox.confirm(`确认删除导入批次 ${row.id}?这只删除上传文件和导入记录,不会删除已经生成的学员和证书。`, "删除导入记录", { type: "warning" });
|
||||
await http.delete(`/admin/import-batches/${row.id}`);
|
||||
ElMessage.success("导入记录已删除");
|
||||
await loadBatches();
|
||||
}
|
||||
|
||||
onMounted(loadBatches);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-head p {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.upload {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 36px;
|
||||
color: #208a87;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-top: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
163
frontend/src/views/AdminLearners.vue
Normal file
163
frontend/src/views/AdminLearners.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<section>
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h2>学员管理</h2>
|
||||
<p>支持手动新增、修改、删除和查询学员,也可以继续通过 Excel 批量导入。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="saveLearner">{{ editingId ? "保存修改" : "新增学员" }}</el-button>
|
||||
</header>
|
||||
|
||||
<el-card class="panel" shadow="never">
|
||||
<el-form class="form-grid" label-position="top">
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model.trim="form.current_name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model.trim="form.phone" />
|
||||
</el-form-item>
|
||||
<el-form-item label="学员编号">
|
||||
<el-input v-model.trim="form.student_no" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status">
|
||||
<el-option label="启用" value="active" />
|
||||
<el-option label="停用" value="disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="wide">
|
||||
<el-input v-model.trim="form.remark" />
|
||||
</el-form-item>
|
||||
<el-form-item label=" ">
|
||||
<el-button @click="resetForm">清空</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="panel" shadow="never">
|
||||
<div class="toolbar">
|
||||
<el-input v-model.trim="keyword" placeholder="按姓名、手机号、学员编号搜索" clearable @keyup.enter="loadLearners" />
|
||||
<el-button @click="loadLearners">搜索</el-button>
|
||||
</div>
|
||||
<el-table :data="learners" border>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="current_name" label="姓名" width="140" />
|
||||
<el-table-column prop="phone" label="手机号" width="160" />
|
||||
<el-table-column prop="student_no" label="学员编号" width="150" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" />
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editLearner(row)">修改</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteLearner(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { http, type Learner } from "../api";
|
||||
|
||||
const learners = ref<Learner[]>([]);
|
||||
const keyword = ref("");
|
||||
const editingId = ref<number | null>(null);
|
||||
const form = reactive({ current_name: "", phone: "", student_no: "", status: "active", remark: "" });
|
||||
|
||||
function statusText(status: string) {
|
||||
return status === "active" ? "启用" : "停用";
|
||||
}
|
||||
|
||||
async function loadLearners() {
|
||||
const { data } = await http.get<Learner[]>("/admin/learners", { params: { keyword: keyword.value || undefined } });
|
||||
learners.value = data;
|
||||
}
|
||||
|
||||
function editLearner(row: Learner) {
|
||||
editingId.value = row.id;
|
||||
Object.assign(form, {
|
||||
current_name: row.current_name,
|
||||
phone: row.phone,
|
||||
student_no: row.student_no || "",
|
||||
status: row.status,
|
||||
remark: row.remark || "",
|
||||
});
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null;
|
||||
Object.assign(form, { current_name: "", phone: "", student_no: "", status: "active", remark: "" });
|
||||
}
|
||||
|
||||
async function saveLearner() {
|
||||
if (!form.current_name || !form.phone) {
|
||||
ElMessage.warning("姓名和手机号必填");
|
||||
return;
|
||||
}
|
||||
const payload = { ...form, student_no: form.student_no || null, remark: form.remark || null };
|
||||
if (editingId.value) {
|
||||
await http.put(`/admin/learners/${editingId.value}`, payload);
|
||||
} else {
|
||||
await http.post("/admin/learners", payload);
|
||||
}
|
||||
ElMessage.success("学员已保存");
|
||||
resetForm();
|
||||
await loadLearners();
|
||||
}
|
||||
|
||||
async function deleteLearner(row: Learner) {
|
||||
await ElMessageBox.confirm(`确认删除学员 ${row.current_name}?`, "删除学员", { type: "warning" });
|
||||
await http.delete(`/admin/learners/${row.id}`);
|
||||
ElMessage.success("学员已删除");
|
||||
await loadLearners();
|
||||
}
|
||||
|
||||
onMounted(loadLearners);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-head,
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-head p {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wide {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.toolbar .el-input {
|
||||
max-width: 380px;
|
||||
}
|
||||
</style>
|
||||
82
frontend/src/views/AdminLogin.vue
Normal file
82
frontend/src/views/AdminLogin.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<section class="login-box">
|
||||
<h1>后台登录</h1>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="账号">
|
||||
<el-input v-model.trim="form.username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" type="password" show-password @keyup.enter="login" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" class="submit" :loading="loading" @click="login">登录</el-button>
|
||||
</el-form>
|
||||
<el-alert v-if="message" class="message" :title="message" type="error" :closable="false" />
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import { http } from "../api";
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const message = ref("");
|
||||
const form = reactive({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
async function login() {
|
||||
loading.value = true;
|
||||
message.value = "";
|
||||
try {
|
||||
const { data } = await http.post("/admin/auth/login", form);
|
||||
localStorage.setItem("admin_token", data.access_token);
|
||||
await router.push("/admin");
|
||||
} catch (error: any) {
|
||||
message.value = error?.response?.data?.detail || "登录失败,请重试";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(180deg, #f7fafb 0%, #eef4f6 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: min(440px, 100%);
|
||||
padding: 32px;
|
||||
border: 1px solid #dbe6ea;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 48px rgba(42, 64, 78, 0.09);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 22px;
|
||||
color: #172033;
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
290
frontend/src/views/AdminLogs.vue
Normal file
290
frontend/src/views/AdminLogs.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<section>
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h2>操作日志</h2>
|
||||
<p>记录后台关键动作,支持筛选、导出和查看完整详情。</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<el-button @click="loadLogs">刷新</el-button>
|
||||
<el-button @click="cleanupLogs">清理过期日志</el-button>
|
||||
<el-button type="primary" @click="exportLogs">导出日志</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-card class="panel" shadow="never">
|
||||
<p class="retention-note">日志保留策略:普通后台日志、公开查询和公开下载日志保留 180 天;高风险后台操作保留 365 天。</p>
|
||||
<div class="filters">
|
||||
<el-input v-model.trim="filters.keyword" clearable placeholder="搜索摘要、对象ID、详情" @keyup.enter="loadLogs" />
|
||||
<el-select v-model="filters.action" clearable placeholder="操作类型">
|
||||
<el-option v-for="item in actionOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.object_type" clearable placeholder="对象类型">
|
||||
<el-option label="项目" value="project_course" />
|
||||
<el-option label="学员" value="learner" />
|
||||
<el-option label="证书" value="certificate" />
|
||||
<el-option label="导入批次" value="import_batch" />
|
||||
<el-option label="公开访问" value="public_access" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.risk" clearable placeholder="风险等级">
|
||||
<el-option label="高风险" value="high" />
|
||||
<el-option label="中风险" value="medium" />
|
||||
<el-option label="低风险" value="low" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadLogs">搜索</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="pager">
|
||||
<el-button :disabled="page <= 1" @click="loadLogs(page - 1)">上一页</el-button>
|
||||
<template v-for="item in pageItems" :key="item">
|
||||
<span v-if="item === '...'" class="ellipsis">...</span>
|
||||
<el-button v-else :type="item === page ? 'primary' : 'default'" @click="loadLogs(Number(item))">{{ item }}</el-button>
|
||||
</template>
|
||||
<el-button :disabled="page >= totalPages" @click="loadLogs(page + 1)">下一页</el-button>
|
||||
<span class="total">共 {{ total }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="logs" border>
|
||||
<el-table-column prop="created_at" label="时间" width="190" />
|
||||
<el-table-column prop="admin_user_id" label="操作人ID" width="100" />
|
||||
<el-table-column prop="action_label" label="操作" width="150" />
|
||||
<el-table-column prop="object_label" label="对象" width="100" />
|
||||
<el-table-column prop="object_id" label="对象ID" width="110" />
|
||||
<el-table-column label="风险" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="riskTag(row.risk)">{{ row.risk_label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="summary" label="摘要" min-width="260" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="totalPages > 1" class="pager bottom">
|
||||
<el-button :disabled="page <= 1" @click="loadLogs(page - 1)">上一页</el-button>
|
||||
<template v-for="item in pageItems" :key="`bottom-${item}`">
|
||||
<span v-if="item === '...'" class="ellipsis">...</span>
|
||||
<el-button v-else :type="item === page ? 'primary' : 'default'" @click="loadLogs(Number(item))">{{ item }}</el-button>
|
||||
</template>
|
||||
<el-button :disabled="page >= totalPages" @click="loadLogs(page + 1)">下一页</el-button>
|
||||
<span class="total">共 {{ total }} 条</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="操作日志详情" width="720px">
|
||||
<el-descriptions v-if="current" :column="1" border>
|
||||
<el-descriptions-item label="时间">{{ current.created_at }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作人ID">{{ current.admin_user_id || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作">{{ current.action_label }}</el-descriptions-item>
|
||||
<el-descriptions-item label="对象">{{ current.object_label }} #{{ current.object_id || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="风险等级">
|
||||
<el-tag :type="riskTag(current.risk)">{{ current.risk_label }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="摘要">{{ current.summary }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<pre class="detail-json">{{ prettyDetail }}</pre>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { http, type OperationLog } from "../api";
|
||||
import { downloadFile } from "../download";
|
||||
|
||||
const logs = ref<OperationLog[]>([]);
|
||||
const current = ref<OperationLog | null>(null);
|
||||
const detailVisible = ref(false);
|
||||
const filters = reactive({ keyword: "", action: "", object_type: "", risk: "" });
|
||||
const page = ref(1);
|
||||
const pageSize = 20;
|
||||
const total = ref(0);
|
||||
const totalPages = ref(1);
|
||||
|
||||
const actionOptions = [
|
||||
{ value: "create_project", label: "新增项目" },
|
||||
{ value: "update_project", label: "修改项目" },
|
||||
{ value: "create_learner", label: "新增学员" },
|
||||
{ value: "update_learner", label: "修改学员" },
|
||||
{ value: "delete_learner", label: "删除学员" },
|
||||
{ value: "create_certificate", label: "新增证书" },
|
||||
{ value: "void_certificate", label: "作废证书" },
|
||||
{ value: "regenerate_public_token", label: "重置证书链接" },
|
||||
{ value: "upload_import_file", label: "上传导入文件" },
|
||||
{ value: "confirm_import_batch", label: "确认导入" },
|
||||
{ value: "delete_import_batch", label: "删除导入批次" },
|
||||
{ value: "export_certificates", label: "导出证书" },
|
||||
{ value: "public_search_certificate", label: "公开查询证书" },
|
||||
{ value: "public_download_certificate", label: "公开下载证书" },
|
||||
];
|
||||
|
||||
const prettyDetail = computed(() => {
|
||||
if (!current.value?.detail_json) return "{}";
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(current.value.detail_json), null, 2);
|
||||
} catch {
|
||||
return current.value.detail_json;
|
||||
}
|
||||
});
|
||||
|
||||
const pageItems = computed<Array<number | string>>(() => compactPages(page.value, totalPages.value));
|
||||
|
||||
function queryParams() {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) params.set(key, value);
|
||||
});
|
||||
params.set("page", String(page.value));
|
||||
params.set("page_size", String(pageSize));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function compactPages(current: number, totalPageCount: number) {
|
||||
const pages = new Set<number>();
|
||||
pages.add(1);
|
||||
for (let item = current - 2; item <= current + 2; item += 1) {
|
||||
if (item > 1 && item < totalPageCount) pages.add(item);
|
||||
}
|
||||
if (totalPageCount > 1) pages.add(totalPageCount);
|
||||
const sorted = Array.from(pages).sort((a, b) => a - b);
|
||||
const result: Array<number | string> = [];
|
||||
sorted.forEach((item, index) => {
|
||||
if (index > 0 && item - sorted[index - 1] > 1) result.push("...");
|
||||
result.push(item);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function riskTag(risk?: string | null) {
|
||||
if (risk === "high") return "danger";
|
||||
if (risk === "medium") return "warning";
|
||||
return "success";
|
||||
}
|
||||
|
||||
function openDetail(row: OperationLog) {
|
||||
current.value = row;
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = "";
|
||||
filters.action = "";
|
||||
filters.object_type = "";
|
||||
filters.risk = "";
|
||||
loadLogs(1);
|
||||
}
|
||||
|
||||
async function loadLogs(nextPage = 1) {
|
||||
page.value = nextPage;
|
||||
const query = queryParams();
|
||||
const { data } = await http.get<{ items: OperationLog[]; total: number; page: number; page_size: number; total_pages: number }>(`/admin/logs${query ? `?${query}` : ""}`);
|
||||
logs.value = data.items;
|
||||
total.value = data.total;
|
||||
page.value = data.page;
|
||||
totalPages.value = data.total_pages;
|
||||
}
|
||||
|
||||
async function exportLogs() {
|
||||
const query = queryParams();
|
||||
await downloadFile(`/admin/logs/export${query ? `?${query}` : ""}`, "operation-logs.xlsx");
|
||||
}
|
||||
|
||||
async function cleanupLogs() {
|
||||
await http.post("/admin/logs/cleanup");
|
||||
await loadLogs();
|
||||
}
|
||||
|
||||
onMounted(loadLogs);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-head p {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.retention-note {
|
||||
margin: 0 0 12px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) repeat(3, 160px) auto auto;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.filters :deep(.el-select__wrapper) {
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
.filters :deep(.el-select__suffix) {
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.pager.bottom {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ellipsis,
|
||||
.total {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.detail-json {
|
||||
margin: 14px 0 0;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
border: 1px solid #dfe9ec;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.filters {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
199
frontend/src/views/AdminProjects.vue
Normal file
199
frontend/src/views/AdminProjects.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<section>
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h2>项目管理</h2>
|
||||
<p>维护项目代码和导入时使用的证书默认信息。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="saveProject">{{ editingId ? "保存修改" : "新增项目" }}</el-button>
|
||||
</header>
|
||||
|
||||
<el-card class="panel" shadow="never">
|
||||
<el-form class="form-grid" label-position="top">
|
||||
<el-form-item label="项目代码">
|
||||
<el-input v-model.trim="form.code" :disabled="!!editingId" maxlength="16" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目名称">
|
||||
<el-input v-model.trim="form.name" maxlength="128" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认发证单位">
|
||||
<el-input v-model.trim="form.default_issuer_name" maxlength="128" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认课程名称">
|
||||
<el-input v-model.trim="form.default_course_name" maxlength="128" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认阶段名称">
|
||||
<el-input v-model.trim="form.default_stage_name" maxlength="128" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status">
|
||||
<el-option label="启用" value="active" />
|
||||
<el-option label="停用" value="disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label=" ">
|
||||
<el-button @click="resetForm">清空</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="panel" shadow="never">
|
||||
<div class="toolbar">
|
||||
<el-input v-model.trim="keyword" placeholder="搜索项目代码、名称、课程、阶段或状态" clearable />
|
||||
</div>
|
||||
<el-table :data="projects" border>
|
||||
<el-table-column prop="code" label="项目代码" width="120" />
|
||||
<el-table-column prop="name" label="项目名称" width="150" />
|
||||
<el-table-column prop="default_course_name" label="课程名称" min-width="140" />
|
||||
<el-table-column prop="default_stage_name" label="阶段名称" min-width="120" />
|
||||
<el-table-column prop="default_issuer_name" label="发证单位" min-width="140" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'">
|
||||
{{ statusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editProject(row)">修改</el-button>
|
||||
<el-button size="small" @click="toggleStatus(row)">
|
||||
{{ row.status === "active" ? "停用" : "启用" }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from "element-plus";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { http, type ProjectCourse } from "../api";
|
||||
|
||||
const allProjects = ref<ProjectCourse[]>([]);
|
||||
const keyword = ref("");
|
||||
const editingId = ref<number | null>(null);
|
||||
const form = reactive({
|
||||
code: "",
|
||||
name: "",
|
||||
default_course_name: "",
|
||||
default_stage_name: "",
|
||||
default_issuer_name: "本公司",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const projects = computed(() => {
|
||||
const value = keyword.value.toLowerCase();
|
||||
if (!value) return allProjects.value;
|
||||
return allProjects.value.filter((item) => {
|
||||
const text = [
|
||||
item.code,
|
||||
item.name,
|
||||
item.default_course_name || "",
|
||||
item.default_stage_name || "",
|
||||
item.default_issuer_name,
|
||||
statusText(item.status),
|
||||
].join(" ").toLowerCase();
|
||||
return text.includes(value);
|
||||
});
|
||||
});
|
||||
|
||||
function statusText(status: string) {
|
||||
return status === "active" ? "启用" : "停用";
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
const { data } = await http.get<ProjectCourse[]>("/admin/projects");
|
||||
allProjects.value = data;
|
||||
}
|
||||
|
||||
function editProject(row: ProjectCourse) {
|
||||
editingId.value = row.id;
|
||||
Object.assign(form, {
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
default_course_name: row.default_course_name || "",
|
||||
default_stage_name: row.default_stage_name || "",
|
||||
default_issuer_name: row.default_issuer_name,
|
||||
status: row.status,
|
||||
});
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null;
|
||||
Object.assign(form, {
|
||||
code: "",
|
||||
name: "",
|
||||
default_course_name: "",
|
||||
default_stage_name: "",
|
||||
default_issuer_name: "本公司",
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
|
||||
function projectPayload() {
|
||||
return {
|
||||
code: form.code,
|
||||
name: form.name,
|
||||
default_course_name: form.default_course_name || null,
|
||||
default_stage_name: form.default_stage_name || null,
|
||||
default_issuer_name: form.default_issuer_name,
|
||||
status: form.status,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveProject() {
|
||||
if (!form.code || !form.name || !form.default_issuer_name) {
|
||||
ElMessage.warning("项目代码、项目名称和发证单位必填");
|
||||
return;
|
||||
}
|
||||
if (editingId.value) {
|
||||
await http.put(`/admin/projects/${editingId.value}`, projectPayload());
|
||||
} else {
|
||||
await http.post("/admin/projects", projectPayload());
|
||||
}
|
||||
ElMessage.success("项目已保存");
|
||||
resetForm();
|
||||
await loadProjects();
|
||||
}
|
||||
|
||||
async function toggleStatus(row: ProjectCourse) {
|
||||
await http.put(`/admin/projects/${row.id}`, { status: row.status === "active" ? "disabled" : "active" });
|
||||
await loadProjects();
|
||||
}
|
||||
|
||||
onMounted(loadProjects);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-head,
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-head p {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar .el-input {
|
||||
max-width: 420px;
|
||||
}
|
||||
</style>
|
||||
113
frontend/src/views/AdminShell.vue
Normal file
113
frontend/src/views/AdminShell.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<main class="admin-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<h1>证书管理</h1>
|
||||
<p>培训结业证书后台</p>
|
||||
</div>
|
||||
<el-menu :default-active="route.path" router>
|
||||
<el-menu-item index="/admin">数据概览</el-menu-item>
|
||||
<el-menu-item index="/admin/projects">项目管理</el-menu-item>
|
||||
<el-menu-item index="/admin/learners">学员管理</el-menu-item>
|
||||
<el-menu-item index="/admin/certificates">证书管理</el-menu-item>
|
||||
<el-menu-item index="/admin/imports">Excel 导入</el-menu-item>
|
||||
<el-menu-item index="/admin/logs">操作日志</el-menu-item>
|
||||
</el-menu>
|
||||
<el-button class="logout" @click="logout">退出登录</el-button>
|
||||
</aside>
|
||||
<section class="workspace">
|
||||
<router-view />
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem("admin_token");
|
||||
router.push("/admin/login");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 248px 1fr;
|
||||
background: linear-gradient(180deg, #f7fafb 0%, #eef4f6 100%);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-right: 1px solid #e1ebef;
|
||||
padding: 24px 14px;
|
||||
box-shadow: 8px 0 26px rgba(42, 64, 78, 0.045);
|
||||
}
|
||||
|
||||
.brand {
|
||||
padding: 0 12px 20px;
|
||||
border-bottom: 1px solid #e1ebef;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
font-size: 21px;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.brand p {
|
||||
margin: 7px 0 0;
|
||||
color: #6b8795;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.el-menu) {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item) {
|
||||
height: 42px;
|
||||
margin: 4px 0;
|
||||
border-radius: 8px;
|
||||
color: #42576a;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-active),
|
||||
:deep(.el-menu-item:hover) {
|
||||
background: #e6f5f3;
|
||||
color: #116d6b;
|
||||
}
|
||||
|
||||
.logout {
|
||||
width: calc(100% - 16px);
|
||||
margin: 20px 8px 0;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
padding: 28px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
450
frontend/src/views/CertificateQuery.vue
Normal file
450
frontend/src/views/CertificateQuery.vue
Normal file
@@ -0,0 +1,450 @@
|
||||
<template>
|
||||
<main class="public-page">
|
||||
<section class="query-panel">
|
||||
<div class="heading">
|
||||
<p>电子证书查询</p>
|
||||
<h1>培训结业证书查询</h1>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="查询方式">
|
||||
<el-segmented v-model="queryMode" :options="queryOptions" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="queryMode === 'certificate'" label="证书编号">
|
||||
<el-input v-model.trim="form.certificateNo" placeholder="例如 PX2026-DBY-K7M9Q2R-83" />
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="手机号">
|
||||
<el-input v-model.trim="form.phone" placeholder="请输入证书对应手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model.trim="form.name" placeholder="请输入证书对应姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图形验证码">
|
||||
<div class="captcha-row">
|
||||
<el-input v-model.trim="form.captcha" placeholder="请输入验证码" />
|
||||
<button class="captcha-button" type="button" @click="loadCaptcha">
|
||||
<img v-if="captchaImage" :src="captchaImage" alt="验证码" />
|
||||
<span v-else>刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-button type="primary" class="submit" :loading="loading" @click="submitQuery">查询</el-button>
|
||||
</el-form>
|
||||
|
||||
<el-alert v-if="message" class="notice" :title="message" :type="messageType" :closable="false" />
|
||||
|
||||
<section v-if="results.length" class="results">
|
||||
<div class="results-head">
|
||||
<div>
|
||||
<h2>查询结果</h2>
|
||||
<p>共找到 {{ results.length }} 张证书</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article v-for="item in results" :key="item.certificate_no" class="certificate-card">
|
||||
<div class="card-top">
|
||||
<div>
|
||||
<span :class="['status', item.status === 'valid' ? 'ok' : 'bad']">{{ statusText(item.status) }}</span>
|
||||
<h3>{{ item.certificate_name }}</h3>
|
||||
</div>
|
||||
<p class="number">{{ item.certificate_no }}</p>
|
||||
</div>
|
||||
|
||||
<dl class="meta">
|
||||
<div>
|
||||
<dt>学员姓名</dt>
|
||||
<dd>{{ item.learner_name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>项目代码</dt>
|
||||
<dd>{{ item.project_code }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>课程/阶段</dt>
|
||||
<dd>{{ courseText(item) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>发证日期</dt>
|
||||
<dd>{{ item.issue_date }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>发证单位</dt>
|
||||
<dd>{{ item.issuer_name }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="card-actions">
|
||||
<el-button @click="preview(item)">预览</el-button>
|
||||
<el-button type="primary" :disabled="!item.can_download_pdf" @click="downloadPdf(item)">下载 PDF</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<p class="disclaimer">
|
||||
本证书仅用于证明学员完成相关培训课程/阶段学习,不代表国家学历、学位、职业资格或职业技能等级认证。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="previewVisible" title="证书预览" width="720px" @closed="clearPreviewUrl">
|
||||
<div v-if="previewPdfUrl" class="pdf-frame">
|
||||
<embed :src="previewPdfUrl" type="application/pdf" />
|
||||
</div>
|
||||
<div v-else-if="current" class="preview-card">
|
||||
<p class="preview-kicker">Training Completion Certificate</p>
|
||||
<h2>{{ current.certificate_name }}</h2>
|
||||
<p class="preview-name">{{ current.learner_name }}</p>
|
||||
<p class="preview-copy">
|
||||
已完成 {{ current.course_name || current.project_code }} {{ current.stage_name || "" }} 相关培训学习。
|
||||
</p>
|
||||
<div class="preview-meta">
|
||||
<span>证书编号:{{ current.certificate_no }}</span>
|
||||
<span>发证日期:{{ current.issue_date }}</span>
|
||||
<span>发证单位:{{ current.issuer_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="closePreview">关闭</el-button>
|
||||
<el-button v-if="current?.can_download_pdf" type="primary" @click="downloadPdf(current)">下载 PDF</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from "vue";
|
||||
|
||||
import { http, type Captcha, type PublicCertificate } from "../api";
|
||||
|
||||
interface SearchResponse {
|
||||
items: PublicCertificate[];
|
||||
}
|
||||
|
||||
const queryOptions = [
|
||||
{ label: "证书编号查询", value: "certificate" },
|
||||
{ label: "手机号查询", value: "phone" },
|
||||
];
|
||||
|
||||
const queryMode = ref<"certificate" | "phone">("certificate");
|
||||
const form = reactive({ certificateNo: "", phone: "", name: "", captcha: "" });
|
||||
const captchaId = ref("");
|
||||
const captchaImage = ref("");
|
||||
const loading = ref(false);
|
||||
const message = ref("");
|
||||
const messageType = ref<"success" | "warning" | "error">("warning");
|
||||
const results = ref<PublicCertificate[]>([]);
|
||||
const previewVisible = ref(false);
|
||||
const current = ref<PublicCertificate | null>(null);
|
||||
const previewPdfUrl = ref("");
|
||||
|
||||
function statusText(status: string) {
|
||||
return status === "valid" ? "有效" : "已作废";
|
||||
}
|
||||
|
||||
function courseText(item: PublicCertificate) {
|
||||
return [item.course_name, item.stage_name].filter(Boolean).join(" / ") || "-";
|
||||
}
|
||||
|
||||
watch(queryMode, () => {
|
||||
message.value = "";
|
||||
results.value = [];
|
||||
});
|
||||
|
||||
async function loadCaptcha() {
|
||||
const { data } = await http.get<Captcha>("/public/captcha");
|
||||
captchaId.value = data.captcha_id;
|
||||
captchaImage.value = data.image;
|
||||
form.captcha = "";
|
||||
}
|
||||
|
||||
async function submitQuery() {
|
||||
loading.value = true;
|
||||
message.value = "";
|
||||
results.value = [];
|
||||
try {
|
||||
const { data } = await http.post<SearchResponse>("/public/certificates/search", {
|
||||
certificate_no: queryMode.value === "certificate" ? form.certificateNo : null,
|
||||
phone: queryMode.value === "phone" ? form.phone : null,
|
||||
name: form.name,
|
||||
captcha_id: captchaId.value,
|
||||
captcha_code: form.captcha,
|
||||
});
|
||||
results.value = data.items;
|
||||
const hasVoided = data.items.some((item) => item.status !== "valid");
|
||||
message.value = hasVoided ? "已查询到证书,其中包含作废证书,请核对状态。" : "已查询到匹配证书。";
|
||||
messageType.value = hasVoided ? "warning" : "success";
|
||||
} catch (error: any) {
|
||||
message.value = error?.response?.data?.detail || "查询失败,请稍后再试";
|
||||
messageType.value = "error";
|
||||
await loadCaptcha();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function preview(item: PublicCertificate) {
|
||||
current.value = item;
|
||||
clearPreviewUrl();
|
||||
if (item.can_download_pdf && item.download_url) {
|
||||
const url = item.download_url.replace(/^\/api/, "");
|
||||
const { data } = await http.get(url, { responseType: "blob" });
|
||||
previewPdfUrl.value = URL.createObjectURL(data);
|
||||
}
|
||||
previewVisible.value = true;
|
||||
}
|
||||
|
||||
function clearPreviewUrl() {
|
||||
if (previewPdfUrl.value) {
|
||||
URL.revokeObjectURL(previewPdfUrl.value);
|
||||
previewPdfUrl.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
previewVisible.value = false;
|
||||
}
|
||||
|
||||
function downloadPdf(item: PublicCertificate) {
|
||||
if (item.download_url) window.location.href = item.download_url;
|
||||
}
|
||||
|
||||
onMounted(loadCaptcha);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.public-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: start center;
|
||||
background: linear-gradient(180deg, #f7fafb 0%, #eef4f6 100%);
|
||||
padding: 42px 20px;
|
||||
}
|
||||
|
||||
.query-panel {
|
||||
width: min(820px, 100%);
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe6ea;
|
||||
border-radius: 14px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 18px 48px rgba(42, 64, 78, 0.09);
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.heading p {
|
||||
margin: 0 0 6px;
|
||||
color: #16817e;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 30px;
|
||||
color: #172033;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 132px;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.captcha-button {
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 1px solid #dbe6ea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.captcha-button img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.notice,
|
||||
.results {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.results-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.results-head p {
|
||||
margin: 4px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.certificate-card {
|
||||
border: 1px solid #dbe6ea;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 14px;
|
||||
background: linear-gradient(180deg, #ffffff, #f8fbfc);
|
||||
box-shadow: 0 8px 22px rgba(42, 64, 78, 0.05);
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid #e8eef1;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 0 9px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status.ok {
|
||||
color: #0f6f6b;
|
||||
background: #e6f5f3;
|
||||
}
|
||||
|
||||
.status.bad {
|
||||
color: #991b1b;
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.number {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 22px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.meta div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta dt {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.meta dd {
|
||||
margin: 0;
|
||||
color: #172033;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin: 20px 0 0;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e8eef1;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
border: 1px solid #dfe9ec;
|
||||
border-radius: 10px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
background: linear-gradient(180deg, #ffffff, #f8fbfc);
|
||||
}
|
||||
|
||||
.pdf-frame {
|
||||
height: 70vh;
|
||||
border: 1px solid #dfe9ec;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.pdf-frame embed {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-kicker {
|
||||
margin: 0 0 10px;
|
||||
color: #16817e;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.preview-name {
|
||||
margin: 22px 0 10px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preview-copy {
|
||||
margin: 0 auto 24px;
|
||||
max-width: 520px;
|
||||
color: #475569;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.query-panel {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.card-top,
|
||||
.card-actions {
|
||||
display: grid;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
168
frontend/src/views/CertificateView.vue
Normal file
168
frontend/src/views/CertificateView.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<main class="public-page">
|
||||
<section class="certificate-page">
|
||||
<p class="kicker">电子证书</p>
|
||||
<h1>证书公开展示</h1>
|
||||
<el-alert v-if="message" class="notice" :title="message" :type="messageType" :closable="false" />
|
||||
|
||||
<article v-if="certificate" class="certificate-card">
|
||||
<span :class="['status', certificate.status === 'valid' ? 'ok' : 'bad']">{{ statusText(certificate.status) }}</span>
|
||||
<h2>{{ certificate.certificate_name }}</h2>
|
||||
<p class="name">{{ certificate.learner_name }}</p>
|
||||
<p class="copy">
|
||||
已完成 {{ certificate.course_name || certificate.project_code }} {{ certificate.stage_name || "" }} 相关培训学习。
|
||||
</p>
|
||||
<dl class="meta">
|
||||
<div>
|
||||
<dt>证书编号</dt>
|
||||
<dd>{{ certificate.certificate_no }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>发证日期</dt>
|
||||
<dd>{{ certificate.issue_date }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>发证单位</dt>
|
||||
<dd>{{ certificate.issuer_name }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<el-button v-if="certificate.can_download_pdf" type="primary" class="download" @click="downloadPdf">下载 PDF 证书</el-button>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { http, type PublicCertificate } from "../api";
|
||||
|
||||
const route = useRoute();
|
||||
const token = String(route.params.token);
|
||||
const certificate = ref<PublicCertificate | null>(null);
|
||||
const message = ref("");
|
||||
const messageType = ref<"success" | "warning" | "error">("success");
|
||||
|
||||
function statusText(status: string) {
|
||||
return status === "valid" ? "有效" : "已作废";
|
||||
}
|
||||
|
||||
async function loadCertificate() {
|
||||
try {
|
||||
const { data } = await http.get<PublicCertificate>(`/public/certificates/token/${token}`);
|
||||
certificate.value = data;
|
||||
message.value = data.status === "valid" ? "该证书真实有效。" : "该证书已作废,请联系发证单位核实。";
|
||||
messageType.value = data.status === "valid" ? "success" : "warning";
|
||||
} catch (error: any) {
|
||||
message.value = error?.response?.data?.detail || "链接无效或已失效";
|
||||
messageType.value = "error";
|
||||
}
|
||||
}
|
||||
|
||||
function downloadPdf() {
|
||||
window.location.href = `/api/public/certificates/token/${token}/download`;
|
||||
}
|
||||
|
||||
onMounted(loadCertificate);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.public-page {
|
||||
min-height: 100vh;
|
||||
background: #f7fafc;
|
||||
padding: 36px 20px;
|
||||
}
|
||||
|
||||
.certificate-page {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e1ebef;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 16px 46px rgba(76, 102, 118, 0.08);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0 0 6px;
|
||||
color: #208a87;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.certificate-card {
|
||||
margin-top: 20px;
|
||||
border: 1px solid #dfe9ec;
|
||||
border-radius: 10px;
|
||||
padding: 34px;
|
||||
text-align: center;
|
||||
background: #fbfefe;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 0 9px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status.ok {
|
||||
color: #116d6b;
|
||||
background: #dff5f3;
|
||||
}
|
||||
|
||||
.status.bad {
|
||||
color: #991b1b;
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 24px 0 10px;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.copy {
|
||||
max-width: 560px;
|
||||
margin: 0 auto 24px;
|
||||
color: #475569;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.meta dt {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.meta dd {
|
||||
margin: 3px 0 0;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.download {
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
69
frontend/src/views/VerifyView.vue
Normal file
69
frontend/src/views/VerifyView.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<main class="public-page">
|
||||
<section class="verify-page">
|
||||
<h1>证书核验</h1>
|
||||
<el-alert :title="message" :type="messageType" :closable="false" />
|
||||
<el-descriptions v-if="certificate" :column="1" border class="details">
|
||||
<el-descriptions-item label="核验结果">{{ certificate.status === "valid" ? "该证书真实有效" : "该证书已作废" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="证书状态">{{ certificate.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="证书编号">{{ certificate.certificate_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学员姓名">{{ certificate.learner_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="证书名称">{{ certificate.certificate_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发证单位">{{ certificate.issuer_name }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { http, type PublicCertificate } from "../api";
|
||||
|
||||
const route = useRoute();
|
||||
const token = String(route.params.token);
|
||||
const certificate = ref<PublicCertificate | null>(null);
|
||||
const message = ref("正在核验证书信息");
|
||||
const messageType = ref<"success" | "warning" | "error" | "info">("info");
|
||||
|
||||
async function loadCertificate() {
|
||||
try {
|
||||
const { data } = await http.get<PublicCertificate>(`/public/certificates/token/${token}`);
|
||||
certificate.value = data;
|
||||
message.value = data.status === "valid" ? "该证书真实有效。" : "该证书已作废,请联系发证单位核实。";
|
||||
messageType.value = data.status === "valid" ? "success" : "warning";
|
||||
} catch (error: any) {
|
||||
message.value = error?.response?.data?.detail || "链接无效或已失效";
|
||||
messageType.value = "error";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCertificate);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.public-page {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fb;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.verify-page {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 20px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.details {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
16
frontend/tsconfig.json
Normal file
16
frontend/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
16
frontend/vite.config.ts
Normal file
16
frontend/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
980
local_app/server.py
Normal file
980
local_app/server.py
Normal file
@@ -0,0 +1,980 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import json
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
import traceback
|
||||
from datetime import date, datetime
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from reportlab.lib.pagesizes import A4, landscape
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
DATA = ROOT / "data"
|
||||
DB_PATH = DATA / "local.db"
|
||||
SECRET = "local-dev-secret"
|
||||
ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
CAPTCHAS: dict[str, tuple[str, float]] = {}
|
||||
LOG_RETENTION_DAYS = 180
|
||||
HIGH_RISK_LOG_RETENTION_DAYS = 365
|
||||
|
||||
COL_NAME = "\u59d3\u540d"
|
||||
COL_PHONE = "\u624b\u673a\u53f7"
|
||||
COL_PROJECT = "\u9879\u76ee\u4ee3\u7801"
|
||||
COL_ISSUE_DATE = "\u53d1\u8bc1\u65e5\u671f"
|
||||
COLS = [COL_NAME, COL_PHONE, COL_PROJECT, COL_ISSUE_DATE]
|
||||
DISCLAIMER = "\u672c\u8bc1\u4e66\u4ec5\u7528\u4e8e\u8bc1\u660e\u5b66\u5458\u5b8c\u6210\u76f8\u5173\u57f9\u8bad\u8bfe\u7a0b/\u9636\u6bb5\u5b66\u4e60\uff0c\u4e0d\u4ee3\u8868\u56fd\u5bb6\u5b66\u5386\u3001\u5b66\u4f4d\u3001\u804c\u4e1a\u8d44\u683c\u6216\u804c\u4e1a\u6280\u80fd\u7b49\u7ea7\u8ba4\u8bc1\u3002"
|
||||
|
||||
ACTION_LABELS = {
|
||||
"create_project": "\u65b0\u589e\u9879\u76ee",
|
||||
"update_project": "\u4fee\u6539\u9879\u76ee",
|
||||
"create_learner": "\u65b0\u589e\u5b66\u5458",
|
||||
"update_learner": "\u4fee\u6539\u5b66\u5458",
|
||||
"delete_learner": "\u5220\u9664\u5b66\u5458",
|
||||
"create_certificate": "\u65b0\u589e\u8bc1\u4e66",
|
||||
"void_certificate": "\u4f5c\u5e9f\u8bc1\u4e66",
|
||||
"regenerate_public_token": "\u91cd\u7f6e\u8bc1\u4e66\u94fe\u63a5",
|
||||
"upload_import_file": "\u4e0a\u4f20\u5bfc\u5165\u6587\u4ef6",
|
||||
"confirm_import_batch": "\u786e\u8ba4\u5bfc\u5165",
|
||||
"delete_import_batch": "\u5220\u9664\u5bfc\u5165\u6279\u6b21",
|
||||
"public_search_certificate": "\u516c\u5f00\u67e5\u8be2\u8bc1\u4e66",
|
||||
"public_download_certificate": "\u516c\u5f00\u4e0b\u8f7d\u8bc1\u4e66",
|
||||
}
|
||||
OBJECT_LABELS = {
|
||||
"project": "\u9879\u76ee",
|
||||
"project_course": "\u9879\u76ee",
|
||||
"learner": "\u5b66\u5458",
|
||||
"certificate": "\u8bc1\u4e66",
|
||||
"import_batch": "\u5bfc\u5165\u6279\u6b21",
|
||||
"public_access": "\u516c\u5f00\u8bbf\u95ee",
|
||||
}
|
||||
HIGH_RISK_ACTIONS = {"void_certificate", "delete_import_batch", "regenerate_public_token", "delete_learner"}
|
||||
MEDIUM_RISK_ACTIONS = {"update_project", "update_learner", "confirm_import_batch", "create_certificate"}
|
||||
|
||||
|
||||
def db() -> sqlite3.Connection:
|
||||
DATA.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with db() as conn:
|
||||
conn.executescript(
|
||||
"""
|
||||
create table if not exists admins(id integer primary key, username text unique, password_hash text);
|
||||
create table if not exists projects(
|
||||
id integer primary key, code text unique, name text,
|
||||
default_certificate_name text, default_course_name text, default_stage_name text, default_issuer_name text,
|
||||
status text
|
||||
);
|
||||
create table if not exists learners(id integer primary key, phone text unique, current_name text, student_no text, status text, remark text, created_at text, updated_at text);
|
||||
create table if not exists certificates(
|
||||
id integer primary key, learner_id integer, project_code text, certificate_no text unique, certificate_name text,
|
||||
course_name text, stage_name text, issue_date text, issuer_name text, status text, pdf_status text,
|
||||
public_token text, qr_token text, remark text, created_at text, updated_at text
|
||||
);
|
||||
create table if not exists import_batches(id integer primary key, filename text, status text, total_rows integer, valid_rows integer, failed_rows integer, error_report_path text, created_at text, updated_at text);
|
||||
create table if not exists logs(id integer primary key, admin_user_id integer, action text, object_type text, object_id text, detail_json text, created_at text);
|
||||
"""
|
||||
)
|
||||
if not conn.execute("select 1 from admins where username='admin'").fetchone():
|
||||
conn.execute("insert into admins(username,password_hash) values(?,?)", ("admin", password_hash("Admin123!")))
|
||||
ensure_project_columns(conn)
|
||||
for code, name in [("DBY", "\u5927\u672c\u8425"), ("QSX", "\u4e03\u4e09\u7ebf")]:
|
||||
if not conn.execute("select 1 from projects where code=?", (code,)).fetchone():
|
||||
conn.execute(
|
||||
"insert into projects(code,name,default_certificate_name,default_course_name,default_stage_name,default_issuer_name,status) values(?,?,?,?,?,?,?)",
|
||||
(code, name, name, name, None, "\u672c\u516c\u53f8", "active"),
|
||||
)
|
||||
conn.execute("update projects set default_certificate_name=name, default_course_name=coalesce(default_course_name,name), default_issuer_name=coalesce(default_issuer_name,?)", ("\u672c\u516c\u53f8",))
|
||||
cleanup_expired_logs(conn)
|
||||
|
||||
|
||||
def ensure_project_columns(conn: sqlite3.Connection) -> None:
|
||||
columns = {row["name"] for row in conn.execute("pragma table_info(projects)").fetchall()}
|
||||
for name in ["default_certificate_name", "default_course_name", "default_stage_name", "default_issuer_name"]:
|
||||
if name not in columns:
|
||||
conn.execute(f"alter table projects add column {name} text")
|
||||
|
||||
|
||||
def password_hash(password: str) -> str:
|
||||
salt = secrets.token_hex(8)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 120_000).hex()
|
||||
return f"{salt}${digest}"
|
||||
|
||||
|
||||
def check_password(password: str, stored: str) -> bool:
|
||||
salt, digest = stored.split("$", 1)
|
||||
actual = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 120_000).hex()
|
||||
return hmac.compare_digest(actual, digest)
|
||||
|
||||
|
||||
def make_token(admin_id: int) -> str:
|
||||
payload = f"{admin_id}:{int(time.time()) + 86400}:{secrets.token_hex(8)}"
|
||||
sig = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||
return base64.urlsafe_b64encode(f"{payload}:{sig}".encode()).decode()
|
||||
|
||||
|
||||
def token_admin_id(token: str) -> int | None:
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(token.encode()).decode()
|
||||
admin_id, exp, nonce, sig = raw.split(":", 3)
|
||||
payload = f"{admin_id}:{exp}:{nonce}"
|
||||
expected = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(expected, sig) and int(exp) > time.time():
|
||||
return int(admin_id)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def log(conn: sqlite3.Connection, admin_id: int | None, action: str, object_type: str, object_id: object = None, detail: dict | None = None) -> None:
|
||||
conn.execute(
|
||||
"insert into logs(admin_user_id,action,object_type,object_id,detail_json,created_at) values(?,?,?,?,?,?)",
|
||||
(admin_id, action, object_type, str(object_id) if object_id is not None else None, json.dumps(detail or {}, ensure_ascii=False), now()),
|
||||
)
|
||||
|
||||
|
||||
def mask_phone(phone: object) -> str:
|
||||
raw = "".join(ch for ch in str(phone or "") if ch.isdigit())
|
||||
if len(raw) >= 7:
|
||||
return raw[:3] + "****" + raw[-4:]
|
||||
return raw or "-"
|
||||
|
||||
|
||||
def log_risk(action: str) -> str:
|
||||
if action in HIGH_RISK_ACTIONS:
|
||||
return "high"
|
||||
if action in MEDIUM_RISK_ACTIONS:
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
|
||||
def log_risk_text(risk: str) -> str:
|
||||
return {"high": "\u9ad8\u98ce\u9669", "medium": "\u4e2d\u98ce\u9669", "low": "\u4f4e\u98ce\u9669"}.get(risk, risk)
|
||||
|
||||
|
||||
def diff_detail(before: sqlite3.Row | None, after: dict, fields: list[str]) -> dict[str, dict[str, object]]:
|
||||
changes: dict[str, dict[str, object]] = {}
|
||||
for field in fields:
|
||||
old_value = before[field] if before else None
|
||||
new_value = after.get(field, old_value)
|
||||
if old_value != new_value:
|
||||
changes[field] = {"before": old_value, "after": new_value}
|
||||
return changes
|
||||
|
||||
|
||||
def log_summary(action: str, object_type: str, object_id: object, detail: dict[str, object]) -> str:
|
||||
label = ACTION_LABELS.get(action, action)
|
||||
if action in {"create_project", "update_project"}:
|
||||
return f"{label}\uff1a{detail.get('name') or detail.get('code') or object_id}"
|
||||
if action in {"create_learner", "update_learner", "delete_learner"}:
|
||||
phone = detail.get("phone")
|
||||
return f"{label}\uff1a{detail.get('name') or object_id}" + (f"\uff08{phone}\uff09" if phone else "")
|
||||
if action in {"create_certificate", "void_certificate", "regenerate_public_token"}:
|
||||
learner_name = detail.get("learner_name")
|
||||
return f"{label}\uff1a{detail.get('certificate_no') or object_id}" + (f"\uff08{learner_name}\uff09" if learner_name else "")
|
||||
if action in {"upload_import_file", "delete_import_batch"}:
|
||||
return f"{label}\uff1a{detail.get('filename') or object_id}"
|
||||
if action == "confirm_import_batch":
|
||||
count = detail.get("imported_rows", detail.get("valid_rows", 0))
|
||||
return f"{label}\uff1a\u6279\u6b21 {object_id}\uff0c\u5bfc\u5165 {count} \u884c"
|
||||
if action == "public_search_certificate":
|
||||
mode = "\u8bc1\u4e66\u7f16\u53f7" if detail.get("mode") == "certificate_no" else "\u624b\u673a\u53f7"
|
||||
return f"{label}\uff1a{detail.get('name') or '-'} \u7528{mode}\u67e5\u8be2\uff0c\u5339\u914d {detail.get('matched_count', 0)} \u6761"
|
||||
if action == "public_download_certificate":
|
||||
return f"{label}\uff1a{detail.get('certificate_no') or object_id}\uff08{detail.get('learner_name') or '-'}\uff09"
|
||||
return f"{label}\uff1a{OBJECT_LABELS.get(object_type, object_type)} {object_id or ''}".strip()
|
||||
|
||||
|
||||
def format_log_row(row: sqlite3.Row) -> dict:
|
||||
detail: dict[str, object] = {}
|
||||
if row["detail_json"]:
|
||||
try:
|
||||
detail = json.loads(row["detail_json"])
|
||||
except Exception:
|
||||
detail = {"raw": row["detail_json"]}
|
||||
risk = log_risk(row["action"])
|
||||
payload = row_dict(row)
|
||||
payload.update({
|
||||
"action_label": ACTION_LABELS.get(row["action"], row["action"]),
|
||||
"object_label": OBJECT_LABELS.get(row["object_type"], row["object_type"]),
|
||||
"risk": risk,
|
||||
"risk_label": log_risk_text(risk),
|
||||
"summary": log_summary(row["action"], row["object_type"], row["object_id"], detail),
|
||||
})
|
||||
return payload
|
||||
|
||||
|
||||
def filtered_operation_logs(conn: sqlite3.Connection, query: dict[str, list[str]]) -> list[dict]:
|
||||
action = (query.get("action") or [""])[0]
|
||||
object_type = (query.get("object_type") or [""])[0]
|
||||
risk = (query.get("risk") or [""])[0]
|
||||
keyword = (query.get("keyword") or [""])[0].strip().lower()
|
||||
items = [format_log_row(row) for row in conn.execute("select * from logs order by id desc limit 500").fetchall()]
|
||||
if action:
|
||||
items = [item for item in items if item["action"] == action]
|
||||
if object_type:
|
||||
items = [item for item in items if item["object_type"] == object_type]
|
||||
if risk:
|
||||
items = [item for item in items if item["risk"] == risk]
|
||||
if keyword:
|
||||
items = [
|
||||
item for item in items
|
||||
if keyword in " ".join(str(item.get(key, "")) for key in ["object_id", "summary", "detail_json", "action_label", "object_label"]).lower()
|
||||
]
|
||||
return items
|
||||
|
||||
|
||||
def list_operation_logs(conn: sqlite3.Connection, query: dict[str, list[str]]) -> dict[str, object]:
|
||||
items = filtered_operation_logs(conn, query)
|
||||
page = max(1, int((query.get("page") or ["1"])[0] or "1"))
|
||||
page_size = max(1, min(100, int((query.get("page_size") or ["20"])[0] or "20")))
|
||||
total = len(items)
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
page = min(page, total_pages)
|
||||
start = (page - 1) * page_size
|
||||
return {
|
||||
"items": items[start:start + page_size],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
|
||||
|
||||
def cleanup_expired_logs(conn: sqlite3.Connection) -> int:
|
||||
normal_before = (datetime.now() - timedelta(days=LOG_RETENTION_DAYS)).isoformat(timespec="seconds")
|
||||
high_before = (datetime.now() - timedelta(days=HIGH_RISK_LOG_RETENTION_DAYS)).isoformat(timespec="seconds")
|
||||
high_actions = tuple(HIGH_RISK_ACTIONS)
|
||||
normal_sql = f"delete from logs where created_at < ? and action not in ({','.join('?' for _ in high_actions)})"
|
||||
cur = conn.execute(normal_sql, (normal_before, *high_actions))
|
||||
removed = cur.rowcount if cur.rowcount != -1 else 0
|
||||
high_sql = f"delete from logs where created_at < ? and action in ({','.join('?' for _ in high_actions)})"
|
||||
cur = conn.execute(high_sql, (high_before, *high_actions))
|
||||
removed += cur.rowcount if cur.rowcount != -1 else 0
|
||||
return removed
|
||||
|
||||
|
||||
def row_dict(row: sqlite3.Row | None) -> dict | None:
|
||||
if row is None:
|
||||
return None
|
||||
return {key: row[key] for key in row.keys()}
|
||||
|
||||
|
||||
def rows(conn: sqlite3.Connection, sql: str, args: tuple = ()) -> list[dict]:
|
||||
return [row_dict(row) for row in conn.execute(sql, args).fetchall()]
|
||||
|
||||
|
||||
def digest_int(text: str) -> int:
|
||||
return int.from_bytes(hashlib.sha256(text.encode()).digest()[:8], "big")
|
||||
|
||||
|
||||
def base_code(value: int, length: int) -> str:
|
||||
chars = []
|
||||
while value:
|
||||
value, rem = divmod(value, len(ALPHABET))
|
||||
chars.append(ALPHABET[rem])
|
||||
return ("".join(reversed(chars)) or ALPHABET[0])[-length:].rjust(length, ALPHABET[0])
|
||||
|
||||
|
||||
def certificate_no(cert_id: int, project: str, issue_date: str) -> str:
|
||||
year = issue_date[:4] if issue_date else str(date.today().year)
|
||||
seed = f"{SECRET}:{year}:{project}:{cert_id}"
|
||||
short = base_code(digest_int(seed), 7)
|
||||
check = base_code(digest_int(f"check:{seed}:{short}"), 2)
|
||||
return f"PX{year}-{project}-{short}-{check}"
|
||||
|
||||
|
||||
def json_bytes(value) -> bytes:
|
||||
return json.dumps(value, ensure_ascii=False, default=str).encode("utf-8")
|
||||
|
||||
|
||||
def make_captcha() -> dict:
|
||||
code = "".join(secrets.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(4))
|
||||
captcha_id = secrets.token_urlsafe(12)
|
||||
CAPTCHAS[captcha_id] = (code, time.time() + 300)
|
||||
image = Image.new("RGB", (132, 44), "#f8fafc")
|
||||
draw = ImageDraw.Draw(image)
|
||||
font = ImageFont.load_default()
|
||||
for i, ch in enumerate(code):
|
||||
draw.text((18 + i * 26, 14), ch, fill="#111827", font=font)
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, "PNG")
|
||||
return {"captcha_id": captcha_id, "image": "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()}
|
||||
|
||||
|
||||
def verify_captcha(captcha_id: str, code: str) -> bool:
|
||||
record = CAPTCHAS.pop(captcha_id, None)
|
||||
return bool(record and record[1] > time.time() and record[0].upper() == code.strip().upper())
|
||||
|
||||
|
||||
def parse_multipart(body: bytes, content_type: str) -> tuple[str, bytes]:
|
||||
boundary = content_type.split("boundary=", 1)[1].encode()
|
||||
parts = body.split(b"--" + boundary)
|
||||
for part in parts:
|
||||
if b"filename=" not in part:
|
||||
continue
|
||||
head, data = part.split(b"\r\n\r\n", 1)
|
||||
filename = head.split(b'filename="', 1)[1].split(b'"', 1)[0].decode(errors="ignore")
|
||||
return filename, data.rsplit(b"\r\n", 1)[0]
|
||||
raise ValueError("file not found")
|
||||
|
||||
|
||||
def public_payload(conn: sqlite3.Connection, cert: sqlite3.Row) -> dict:
|
||||
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
|
||||
return {
|
||||
"certificate_no": cert["certificate_no"],
|
||||
"learner_name": learner["current_name"] if learner else "",
|
||||
"certificate_name": cert["certificate_name"],
|
||||
"project_code": cert["project_code"],
|
||||
"course_name": cert["course_name"],
|
||||
"stage_name": cert["stage_name"],
|
||||
"issue_date": cert["issue_date"],
|
||||
"issuer_name": cert["issuer_name"],
|
||||
"status": cert["status"],
|
||||
"pdf_status": cert["pdf_status"],
|
||||
"public_token": cert["public_token"],
|
||||
"public_url": f"/cert/{cert['public_token']}",
|
||||
"download_url": f"/api/public/certificates/token/{cert['public_token']}/download" if cert["status"] == "valid" else None,
|
||||
"can_download_pdf": cert["status"] == "valid",
|
||||
"disclaimer": DISCLAIMER,
|
||||
}
|
||||
|
||||
|
||||
def font_name() -> str:
|
||||
for path in [r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"]:
|
||||
if Path(path).exists():
|
||||
try:
|
||||
pdfmetrics.registerFont(TTFont("LocalCJK", path))
|
||||
return "LocalCJK"
|
||||
except Exception:
|
||||
pass
|
||||
return "Helvetica"
|
||||
|
||||
|
||||
def pil_font(size: int, kind: str = "song", bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
choices = {
|
||||
"kai": [r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"],
|
||||
"hei": [r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc"],
|
||||
"song": [r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"],
|
||||
}
|
||||
paths = choices.get(kind, choices["song"])
|
||||
if bold and kind == "song":
|
||||
paths = [r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\simhei.ttf"] + paths
|
||||
for item in paths:
|
||||
if Path(item).exists():
|
||||
try:
|
||||
return ImageFont.truetype(item, size)
|
||||
except Exception:
|
||||
pass
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def date_parts(value: str) -> tuple[str, str, str]:
|
||||
raw = str(value or "").strip()
|
||||
for fmt in ("%Y-%m-%d", "%Y/%m/%d"):
|
||||
try:
|
||||
d = datetime.strptime(raw[:10], fmt)
|
||||
return str(d.year), str(d.month), str(d.day)
|
||||
except ValueError:
|
||||
pass
|
||||
if len(raw) >= 10 and raw[0:4].isdigit():
|
||||
return raw[0:4], raw[5:7].lstrip("0") or raw[5:7], raw[8:10].lstrip("0") or raw[8:10]
|
||||
today = date.today()
|
||||
return str(today.year), str(today.month), str(today.day)
|
||||
|
||||
|
||||
def draw_fit(draw: ImageDraw.ImageDraw, pos: tuple[int, int], text: str, font: ImageFont.ImageFont, fill: str, max_width: int) -> None:
|
||||
font_obj = font
|
||||
size = getattr(font, "size", 24)
|
||||
while draw.textlength(text, font=font_obj) > max_width and size > 12:
|
||||
size -= 1
|
||||
font_obj = pil_font(size, "song", True)
|
||||
draw.text(pos, text, fill=fill, font=font_obj)
|
||||
|
||||
|
||||
def draw_inline(draw: ImageDraw.ImageDraw, x: float, y: int, text: str, size: int, gap: int, kind: str = "song", bold: bool = False) -> float:
|
||||
font = pil_font(size, kind, bold)
|
||||
draw.text((x, y), text, fill="#111111", font=font, anchor="ls")
|
||||
if bold:
|
||||
draw.text((x + 0.45, y), text, fill="#111111", font=font, anchor="ls")
|
||||
return x + draw.textlength(text, font=font) + gap
|
||||
|
||||
|
||||
def draw_course_name(draw: ImageDraw.ImageDraw, text: str, x: float, y: int) -> int:
|
||||
max_width = 900 - x
|
||||
text_font = pil_font(27, "kai", True)
|
||||
if draw.textlength(text, font=text_font) <= max_width:
|
||||
draw.text((x, y), text, fill="#111111", font=text_font)
|
||||
draw.text((x + 0.45, y), text, fill="#111111", font=text_font)
|
||||
return y
|
||||
|
||||
start_y = y + 34
|
||||
lines = split_by_width(draw, text, text_font, 690)[:2]
|
||||
for index, line in enumerate(lines):
|
||||
line_y = start_y + index * 34
|
||||
draw.text((185, line_y), line, fill="#111111", font=text_font)
|
||||
draw.text((185.45, line_y), line, fill="#111111", font=text_font)
|
||||
return start_y + (len(lines) - 1) * 34
|
||||
|
||||
|
||||
def draw_inline_flow(draw: ImageDraw.ImageDraw, segments: list[tuple[str, str, int]], start_x: float, start_y: int) -> None:
|
||||
x = start_x
|
||||
y = start_y
|
||||
line_start = 185
|
||||
line_height = 42
|
||||
for text, style, gap_before in segments:
|
||||
x += gap_before
|
||||
text_font = pil_font(27, "kai", True) if style == "course" else pil_font(24, "song", False)
|
||||
for char in text:
|
||||
max_right = 900 if y == start_y else 730
|
||||
width = draw.textlength(char, font=text_font)
|
||||
if x + width > max_right and x > line_start:
|
||||
x = line_start
|
||||
y += line_height
|
||||
draw.text((x, y), char, fill="#111111", font=text_font, anchor="ls")
|
||||
if style == "course":
|
||||
draw.text((x + 0.45, y), char, fill="#111111", font=text_font, anchor="ls")
|
||||
x += width
|
||||
|
||||
|
||||
def split_by_width(draw: ImageDraw.ImageDraw, text: str, text_font: ImageFont.ImageFont, max_width: int) -> list[str]:
|
||||
lines: list[str] = []
|
||||
line = ""
|
||||
for char in text:
|
||||
next_line = line + char
|
||||
if line and draw.textlength(next_line, font=text_font) > max_width:
|
||||
lines.append(line)
|
||||
line = char
|
||||
else:
|
||||
line = next_line
|
||||
if line:
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def paper_patch(image: Image.Image, box: tuple[int, int, int, int]) -> None:
|
||||
x, y, w, h = box
|
||||
patch = Image.new("RGB", (w, h), "#fbf7ef")
|
||||
source = image.crop((72, max(0, y), 160, max(0, y) + h))
|
||||
for tx in range(0, w, source.width):
|
||||
patch.paste(source.crop((0, 0, min(source.width, w - tx), h)), (tx, 0))
|
||||
cover = Image.new("RGB", (w, h), "#fbf7ef")
|
||||
patch = Image.blend(patch, cover, 0.38)
|
||||
image.paste(patch, (x, y))
|
||||
|
||||
|
||||
def render_certificate_image(conn: sqlite3.Connection, cert: sqlite3.Row) -> Image.Image:
|
||||
template = ROOT / "static" / "assets" / "certificate-template.jpg"
|
||||
image = Image.open(template).convert("RGB")
|
||||
base_image = image.copy()
|
||||
draw = ImageDraw.Draw(image)
|
||||
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
|
||||
learner_name = (learner["current_name"] if learner else "") or ""
|
||||
issue_year, issue_month, issue_day = date_parts(cert["issue_date"])
|
||||
|
||||
for box in [(150, 442, 760, 132), (404, 675, 220, 38)]:
|
||||
paper_patch(image, box)
|
||||
|
||||
draw.text((512, 414), learner_name, fill="#111111", font=pil_font(32, "kai", True), anchor="mm")
|
||||
|
||||
x = 185.0
|
||||
y = 494
|
||||
x = draw_inline(draw, x, y, "\u5728", 24, 18)
|
||||
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u6708", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u65e5\u81f3", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u6708", 24, 12)
|
||||
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
|
||||
x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8)
|
||||
course_text = f"\u201c{cert['course_name'] or cert['certificate_name'] or cert['project_code']}\u201d"
|
||||
stage = cert["stage_name"] or "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
|
||||
draw_inline_flow(draw, [(course_text, "course", 0), (stage, "normal", 14)], x, y)
|
||||
|
||||
issue_label = f"\u53d1\u8bc1\u65e5\u671f\uff1a{issue_year} \u5e74 {issue_month} \u6708 {issue_day} \u65e5"
|
||||
draw.text((512, 704), issue_label, fill="#43382f", font=pil_font(13, "song", True), anchor="mm")
|
||||
draw.text((105, 76), f"\u8bc1\u4e66\u7f16\u53f7\uff1a{cert['certificate_no']}", fill="#111827", font=pil_font(14, "hei", True))
|
||||
image.paste(base_image.crop((720, 535, 950, 629)), (720, 535))
|
||||
return image
|
||||
|
||||
|
||||
def build_pdf(conn: sqlite3.Connection, cert: sqlite3.Row) -> Path:
|
||||
pdf_dir = DATA / "pdf-cache"
|
||||
pdf_dir.mkdir(exist_ok=True)
|
||||
path = pdf_dir / f"{cert['certificate_no']}.pdf"
|
||||
template = ROOT / "static" / "assets" / "certificate-template.jpg"
|
||||
renderer_mtime = Path(__file__).stat().st_mtime
|
||||
if path.exists() and path.stat().st_mtime >= max(template.stat().st_mtime, renderer_mtime):
|
||||
return path
|
||||
image = render_certificate_image(conn, cert)
|
||||
image.save(path, "PDF", resolution=150.0)
|
||||
conn.execute("update certificates set pdf_status=? where id=?", ("generated", cert["id"]))
|
||||
return path
|
||||
|
||||
|
||||
def validate_import(conn: sqlite3.Connection, file_path: Path, filename: str) -> dict:
|
||||
wb = load_workbook(file_path, data_only=True)
|
||||
ws = wb.active
|
||||
header = [cell.value for cell in ws[1]]
|
||||
valid_rows = []
|
||||
failed = 0
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
if not any(row):
|
||||
continue
|
||||
item = dict(zip(header, row))
|
||||
missing = [c for c in COLS if not item.get(c)]
|
||||
project = conn.execute("select 1 from projects where code=? and status='active'", (str(item.get(COL_PROJECT, "")).upper(),)).fetchone()
|
||||
if missing or not project:
|
||||
failed += 1
|
||||
else:
|
||||
valid_rows.append(item)
|
||||
cur = conn.execute(
|
||||
"insert into import_batches(filename,status,total_rows,valid_rows,failed_rows,error_report_path,created_at,updated_at) values(?,?,?,?,?,?,?,?)",
|
||||
(filename, "validated", len(valid_rows) + failed, len(valid_rows), failed, None, now(), now()),
|
||||
)
|
||||
batch_id = cur.lastrowid
|
||||
(DATA / f"batch-{batch_id}.json").write_text(json.dumps(valid_rows, ensure_ascii=False, default=str), encoding="utf-8")
|
||||
return row_dict(conn.execute("select * from import_batches where id=?", (batch_id,)).fetchone())
|
||||
|
||||
|
||||
def import_batch(conn: sqlite3.Connection, batch_id: int, admin_id: int):
|
||||
batch = conn.execute("select * from import_batches where id=?", (batch_id,)).fetchone()
|
||||
if not batch:
|
||||
raise ValueError("Import batch not found")
|
||||
if batch["status"] == "imported":
|
||||
return row_dict(batch)
|
||||
if batch["status"] != "validated":
|
||||
raise ValueError("Import batch is not ready")
|
||||
|
||||
rows_data = json.loads((DATA / f"batch-{batch_id}.json").read_text(encoding="utf-8"))
|
||||
ok_rows = 0
|
||||
failed_rows = 0
|
||||
for item in rows_data:
|
||||
phone = str(item[COL_PHONE])
|
||||
learner = conn.execute("select * from learners where phone=?", (phone,)).fetchone()
|
||||
if learner:
|
||||
learner_id = learner["id"]
|
||||
conn.execute("update learners set current_name=?,updated_at=? where id=?", (item[COL_NAME], now(), learner_id))
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"insert into learners(phone,current_name,status,created_at,updated_at) values(?,?,?,?,?)",
|
||||
(phone, item[COL_NAME], "active", now(), now()),
|
||||
)
|
||||
learner_id = cur.lastrowid
|
||||
issue = str(item[COL_ISSUE_DATE])[:10].replace("/", "-")
|
||||
project = conn.execute("select * from projects where code=? and status='active'", (str(item[COL_PROJECT]).upper(),)).fetchone()
|
||||
if not project:
|
||||
failed_rows += 1
|
||||
continue
|
||||
duplicate = conn.execute(
|
||||
"""
|
||||
select 1 from certificates
|
||||
where learner_id=?
|
||||
and project_code=?
|
||||
and certificate_name=?
|
||||
and coalesce(course_name,'')=?
|
||||
and coalesce(stage_name,'')=?
|
||||
and issue_date=?
|
||||
""",
|
||||
(
|
||||
learner_id,
|
||||
project["code"],
|
||||
project["name"],
|
||||
project["default_course_name"] or "",
|
||||
project["default_stage_name"] or "",
|
||||
issue,
|
||||
),
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
ok_rows += 1
|
||||
continue
|
||||
cur = conn.execute(
|
||||
"insert into certificates(learner_id,project_code,certificate_no,certificate_name,course_name,stage_name,issue_date,issuer_name,status,pdf_status,public_token,qr_token,remark,created_at,updated_at) values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(learner_id, project["code"], "PENDING", project["name"], project["default_course_name"], project["default_stage_name"], issue, project["default_issuer_name"], "valid", "not_generated", secrets.token_urlsafe(24), secrets.token_urlsafe(24), None, now(), now()),
|
||||
)
|
||||
cert_id = cur.lastrowid
|
||||
conn.execute("update certificates set certificate_no=? where id=?", (certificate_no(cert_id, str(item[COL_PROJECT]).upper(), issue), cert_id))
|
||||
ok_rows += 1
|
||||
next_status = "imported" if ok_rows else "failed"
|
||||
conn.execute("update import_batches set status=?,failed_rows=?,updated_at=? where id=?", (next_status, failed_rows or batch["failed_rows"], now(), batch_id))
|
||||
log(conn, admin_id, "confirm_import_batch", "import_batch", batch_id, {"filename": batch["filename"], "imported_rows": ok_rows, "failed_rows": failed_rows})
|
||||
return row_dict(conn.execute("select * from import_batches where id=?", (batch_id,)).fetchone())
|
||||
|
||||
|
||||
def export_certs(conn: sqlite3.Connection) -> bytes:
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.append([COL_NAME, COL_PHONE, "\u8bc1\u4e66\u7f16\u53f7", "\u8bc1\u4e66\u5bf9\u5916\u76f4\u8fbe\u94fe\u63a5", COL_PROJECT, "\u8bc1\u4e66\u540d\u79f0", COL_ISSUE_DATE, "\u8bc1\u4e66\u72b6\u6001"])
|
||||
certs = conn.execute("select c.*,l.current_name,l.phone from certificates c join learners l on l.id=c.learner_id order by c.id desc").fetchall()
|
||||
for row in certs:
|
||||
phone = row["phone"] or ""
|
||||
ws.append([row["current_name"], phone[:3] + "****" + phone[-4:] if len(phone) >= 7 else phone, row["certificate_no"], f"http://localhost:8000/cert/{row['public_token']}", row["project_code"], row["certificate_name"], row["issue_date"], row["status"]])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def export_logs(items: list[dict]) -> bytes:
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "\u64cd\u4f5c\u65e5\u5fd7"
|
||||
ws.append(["\u65f6\u95f4", "\u64cd\u4f5c\u4ebaID", "\u64cd\u4f5c", "\u5bf9\u8c61", "\u5bf9\u8c61ID", "\u98ce\u9669\u7b49\u7ea7", "\u6458\u8981", "\u8be6\u60c5"])
|
||||
for item in items:
|
||||
ws.append([
|
||||
item.get("created_at"),
|
||||
item.get("admin_user_id"),
|
||||
item.get("action_label"),
|
||||
item.get("object_label"),
|
||||
item.get("object_id"),
|
||||
item.get("risk_label"),
|
||||
item.get("summary"),
|
||||
item.get("detail_json"),
|
||||
])
|
||||
for column in "ABCDEFGH":
|
||||
ws.column_dimensions[column].width = 22 if column != "H" else 60
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.route()
|
||||
|
||||
def do_POST(self):
|
||||
self.route()
|
||||
|
||||
def do_PUT(self):
|
||||
self.route()
|
||||
|
||||
def do_DELETE(self):
|
||||
self.route()
|
||||
|
||||
def route(self):
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path
|
||||
try:
|
||||
if path.startswith("/api/"):
|
||||
return self.handle_api(path, parse_qs(parsed.query))
|
||||
if path in ["/", "/admin", "/admin/login", "/query"] or path.startswith("/cert/") or path.startswith("/verify/"):
|
||||
return self.send_file(ROOT / "static" / "index.html", "text/html; charset=utf-8")
|
||||
target = ROOT / "static" / path.lstrip("/")
|
||||
if target.exists():
|
||||
return self.send_file(target, "application/octet-stream")
|
||||
return self.error(HTTPStatus.NOT_FOUND, "Not found")
|
||||
except Exception as exc:
|
||||
(DATA / "last-error.log").write_text(traceback.format_exc(), encoding="utf-8")
|
||||
return self.error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
||||
|
||||
def handle_api(self, path: str, query: dict):
|
||||
body = self.read_body()
|
||||
admin_id = self.admin_id()
|
||||
with db() as conn:
|
||||
if path == "/api/health":
|
||||
return self.json({"status": "ok"})
|
||||
if path == "/api/admin/auth/login":
|
||||
data = json.loads(body or b"{}")
|
||||
admin = conn.execute("select * from admins where username=?", (data.get("username"),)).fetchone()
|
||||
if not admin or not check_password(data.get("password", ""), admin["password_hash"]):
|
||||
return self.error(401, "\u8d26\u53f7\u6216\u5bc6\u7801\u9519\u8bef\uff0c\u8bf7\u91cd\u8bd5")
|
||||
return self.json({"access_token": make_token(admin["id"]), "token_type": "bearer", "username": admin["username"], "role_code": "system_admin"})
|
||||
if path == "/api/public/captcha":
|
||||
return self.json(make_captcha())
|
||||
if path == "/api/public/certificates/search":
|
||||
data = json.loads(body or b"{}")
|
||||
if not verify_captcha(data.get("captcha_id", ""), data.get("captcha_code", "")):
|
||||
return self.error(400, "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f\uff0c\u8bf7\u91cd\u65b0\u8f93\u5165")
|
||||
name = data.get("name", "").strip()
|
||||
query_certificate_no = data.get("certificate_no", "").strip().upper()
|
||||
phone = data.get("phone", "").strip()
|
||||
if not name or (not query_certificate_no and not phone):
|
||||
return self.error(400, "\u8bf7\u8f93\u5165\u59d3\u540d\uff0c\u5e76\u586b\u5199\u8bc1\u4e66\u7f16\u53f7\u6216\u624b\u673a\u53f7\u5176\u4e2d\u4e00\u9879")
|
||||
if query_certificate_no:
|
||||
result = rows(conn,
|
||||
"select c.* from certificates c join learners l on l.id=c.learner_id where c.certificate_no=? and l.current_name=?",
|
||||
(query_certificate_no, name),
|
||||
)
|
||||
else:
|
||||
result = rows(conn,
|
||||
"select c.* from certificates c join learners l on l.id=c.learner_id where l.phone=? and l.current_name=? order by c.issue_date desc,c.id desc",
|
||||
(phone, name),
|
||||
)
|
||||
if not result:
|
||||
log(conn, None, "public_search_certificate", "public_access", None, {"mode": "certificate_no" if query_certificate_no else "phone", "name": name, "certificate_no": query_certificate_no or None, "phone": mask_phone(phone), "matched_count": 0, "result": "not_found"})
|
||||
return self.error(404, "\u672a\u67e5\u8be2\u5230\u5339\u914d\u7684\u6709\u6548\u8bc1\u4e66\u4fe1\u606f\uff0c\u8bf7\u6838\u5bf9\u540e\u91cd\u8bd5")
|
||||
log(conn, None, "public_search_certificate", "public_access", result[0]["certificate_no"], {"mode": "certificate_no" if query_certificate_no else "phone", "name": name, "certificate_no": query_certificate_no or None, "phone": mask_phone(phone), "matched_count": len(result), "result": "matched"})
|
||||
return self.json({"items": [public_payload(conn, item) for item in result]})
|
||||
if path.startswith("/api/public/certificates/token/"):
|
||||
token = unquote(path.split("/")[5])
|
||||
cert = conn.execute("select * from certificates where public_token=? or qr_token=?", (token, token)).fetchone()
|
||||
if not cert:
|
||||
return self.error(404, "\u94fe\u63a5\u65e0\u6548\u6216\u5df2\u5931\u6548")
|
||||
if path.endswith("/download"):
|
||||
if cert["status"] != "valid":
|
||||
return self.error(403, "\u8bc1\u4e66\u5df2\u4f5c\u5e9f\uff0c\u4e0d\u652f\u6301\u4e0b\u8f7d\u6b63\u5e38 PDF")
|
||||
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
|
||||
log(conn, None, "public_download_certificate", "certificate", cert["id"], {"certificate_no": cert["certificate_no"], "learner_name": learner["current_name"] if learner else "", "project_code": cert["project_code"]})
|
||||
return self.send_file(build_pdf(conn, cert), "application/pdf", f"{cert['certificate_no']}.pdf")
|
||||
return self.json(public_payload(conn, cert))
|
||||
|
||||
if not admin_id:
|
||||
return self.error(401, "Not authenticated")
|
||||
|
||||
if path == "/api/admin/dashboard/summary":
|
||||
return self.json({
|
||||
"learner_count": conn.execute("select count(*) from learners where status!='deleted'").fetchone()[0],
|
||||
"certificate_count": conn.execute("select count(*) from certificates").fetchone()[0],
|
||||
"valid_certificate_count": conn.execute("select count(*) from certificates where status='valid'").fetchone()[0],
|
||||
"voided_certificate_count": conn.execute("select count(*) from certificates where status='voided'").fetchone()[0],
|
||||
"recent_import_count": conn.execute("select count(*) from import_batches").fetchone()[0],
|
||||
})
|
||||
|
||||
if path == "/api/admin/projects" and self.command == "GET":
|
||||
return self.json(rows(conn, "select * from projects order by code"))
|
||||
if path == "/api/admin/projects" and self.command == "POST":
|
||||
data = json.loads(body)
|
||||
conn.execute(
|
||||
"insert into projects(code,name,default_certificate_name,default_course_name,default_stage_name,default_issuer_name,status) values(?,?,?,?,?,?,?)",
|
||||
(
|
||||
data["code"].strip().upper(),
|
||||
data["name"].strip(),
|
||||
data["name"].strip(),
|
||||
data.get("default_course_name"),
|
||||
data.get("default_stage_name"),
|
||||
data.get("default_issuer_name") or "\u672c\u516c\u53f8",
|
||||
"active",
|
||||
),
|
||||
)
|
||||
log(conn, admin_id, "create_project", "project", data["code"], {"code": data["code"].strip().upper(), "name": data["name"].strip(), "status": "active"})
|
||||
return self.json({"ok": True}, 201)
|
||||
if path.startswith("/api/admin/projects/") and self.command == "PUT":
|
||||
project_id = int(path.rsplit("/", 1)[1])
|
||||
data = json.loads(body)
|
||||
project = conn.execute("select * from projects where id=?", (project_id,)).fetchone()
|
||||
if not project:
|
||||
return self.error(404, "Project not found")
|
||||
next_data = {
|
||||
"name": data.get("name", project["name"]),
|
||||
"default_course_name": data.get("default_course_name", project["default_course_name"]),
|
||||
"default_stage_name": data.get("default_stage_name", project["default_stage_name"]),
|
||||
"default_issuer_name": data.get("default_issuer_name", project["default_issuer_name"]),
|
||||
"status": data.get("status", project["status"]),
|
||||
}
|
||||
conn.execute(
|
||||
"update projects set name=?,default_certificate_name=?,default_course_name=?,default_stage_name=?,default_issuer_name=?,status=? where id=?",
|
||||
(
|
||||
next_data["name"],
|
||||
next_data["name"],
|
||||
next_data["default_course_name"],
|
||||
next_data["default_stage_name"],
|
||||
next_data["default_issuer_name"],
|
||||
next_data["status"],
|
||||
project_id,
|
||||
),
|
||||
)
|
||||
log(conn, admin_id, "update_project", "project", project_id, {"code": project["code"], "name": next_data["name"], "changes": diff_detail(project, next_data, ["name", "default_course_name", "default_stage_name", "default_issuer_name", "status"])})
|
||||
return self.json(row_dict(conn.execute("select * from projects where id=?", (project_id,)).fetchone()))
|
||||
|
||||
if path == "/api/admin/learners" and self.command == "GET":
|
||||
keyword = (query.get("keyword") or [""])[0]
|
||||
if keyword:
|
||||
return self.json(rows(conn, "select * from learners where status!='deleted' and (current_name like ? or phone like ? or student_no like ?) order by id desc", (f"%{keyword}%", f"%{keyword}%", f"%{keyword}%")))
|
||||
return self.json(rows(conn, "select * from learners where status!='deleted' order by id desc limit 200"))
|
||||
if path == "/api/admin/learners" and self.command == "POST":
|
||||
data = json.loads(body)
|
||||
cur = conn.execute(
|
||||
"insert into learners(phone,current_name,student_no,status,remark,created_at,updated_at) values(?,?,?,?,?,?,?)",
|
||||
(data["phone"].strip(), data["current_name"].strip(), data.get("student_no"), "active", data.get("remark"), now(), now()),
|
||||
)
|
||||
log(conn, admin_id, "create_learner", "learner", cur.lastrowid, {"name": data["current_name"].strip(), "phone": mask_phone(data["phone"]), "status": "active"})
|
||||
return self.json(row_dict(conn.execute("select * from learners where id=?", (cur.lastrowid,)).fetchone()), 201)
|
||||
if path.startswith("/api/admin/learners/"):
|
||||
learner_id = int(path.rsplit("/", 1)[1])
|
||||
if self.command == "GET":
|
||||
learner = row_dict(conn.execute("select * from learners where id=?", (learner_id,)).fetchone())
|
||||
return self.json(learner) if learner else self.error(404, "Learner not found")
|
||||
if self.command == "PUT":
|
||||
data = json.loads(body)
|
||||
learner = conn.execute("select * from learners where id=?", (learner_id,)).fetchone()
|
||||
if not learner:
|
||||
return self.error(404, "Learner not found")
|
||||
next_data = {
|
||||
"phone": data.get("phone", learner["phone"]),
|
||||
"current_name": data.get("current_name", learner["current_name"]),
|
||||
"student_no": data.get("student_no", learner["student_no"]),
|
||||
"status": data.get("status", learner["status"]),
|
||||
"remark": data.get("remark", learner["remark"]),
|
||||
}
|
||||
conn.execute(
|
||||
"update learners set phone=?,current_name=?,student_no=?,status=?,remark=?,updated_at=? where id=?",
|
||||
(next_data["phone"], next_data["current_name"], next_data["student_no"], next_data["status"], next_data["remark"], now(), learner_id),
|
||||
)
|
||||
changes = diff_detail(learner, next_data, ["phone", "current_name", "student_no", "status", "remark"])
|
||||
if "phone" in changes:
|
||||
changes["phone"] = {"before": mask_phone(changes["phone"]["before"]), "after": mask_phone(changes["phone"]["after"])}
|
||||
log(conn, admin_id, "update_learner", "learner", learner_id, {"name": next_data["current_name"], "phone": mask_phone(next_data["phone"]), "changes": changes})
|
||||
return self.json(row_dict(conn.execute("select * from learners where id=?", (learner_id,)).fetchone()))
|
||||
if self.command == "DELETE":
|
||||
learner = conn.execute("select * from learners where id=?", (learner_id,)).fetchone()
|
||||
conn.execute("update learners set status='deleted',updated_at=? where id=?", (now(), learner_id))
|
||||
log(conn, admin_id, "delete_learner", "learner", learner_id, {"name": learner["current_name"] if learner else learner_id, "phone": mask_phone(learner["phone"] if learner else "")})
|
||||
return self.json({"ok": True})
|
||||
|
||||
if path == "/api/admin/certificates" and self.command == "GET":
|
||||
return self.json(rows(conn, "select c.*,l.current_name learner_name from certificates c left join learners l on l.id=c.learner_id order by c.id desc limit 200"))
|
||||
if path == "/api/admin/certificates" and self.command == "POST":
|
||||
data = json.loads(body)
|
||||
project = conn.execute("select * from projects where code=? and status='active'", (data["project_code"].strip().upper(),)).fetchone()
|
||||
if not project:
|
||||
return self.error(400, "Project code is inactive or missing")
|
||||
cur = conn.execute(
|
||||
"insert into certificates(learner_id,project_code,certificate_no,certificate_name,course_name,stage_name,issue_date,issuer_name,status,pdf_status,public_token,qr_token,remark,created_at,updated_at) values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(data["learner_id"], project["code"], "PENDING", project["name"], data.get("course_name") or project["default_course_name"], data.get("stage_name") or project["default_stage_name"], data["issue_date"], data.get("issuer_name") or project["default_issuer_name"], "valid", "not_generated", secrets.token_urlsafe(24), secrets.token_urlsafe(24), data.get("remark"), now(), now()),
|
||||
)
|
||||
no = certificate_no(cur.lastrowid, project["code"], data["issue_date"])
|
||||
conn.execute("update certificates set certificate_no=? where id=?", (no, cur.lastrowid))
|
||||
learner = conn.execute("select * from learners where id=?", (data["learner_id"],)).fetchone()
|
||||
log(conn, admin_id, "create_certificate", "certificate", cur.lastrowid, {"certificate_no": no, "learner_name": learner["current_name"] if learner else "", "project_code": project["code"], "issue_date": data["issue_date"]})
|
||||
return self.json(row_dict(conn.execute("select * from certificates where id=?", (cur.lastrowid,)).fetchone()), 201)
|
||||
if path.startswith("/api/admin/certificates/"):
|
||||
parts = path.split("/")
|
||||
cert_id = int(parts[4])
|
||||
cert = conn.execute("select * from certificates where id=?", (cert_id,)).fetchone()
|
||||
if not cert:
|
||||
return self.error(404, "Certificate not found")
|
||||
if self.command == "GET" and len(parts) == 5:
|
||||
payload = row_dict(cert)
|
||||
payload["public_url"] = f"http://127.0.0.1:8000/cert/{cert['public_token']}"
|
||||
payload["verify_url"] = f"http://127.0.0.1:8000/verify/{cert['qr_token']}"
|
||||
return self.json(payload)
|
||||
if path.endswith("/preview"):
|
||||
return self.json(public_payload(conn, cert))
|
||||
if path.endswith("/download"):
|
||||
if cert["status"] != "valid":
|
||||
return self.error(403, "\u8bc1\u4e66\u5df2\u4f5c\u5e9f\uff0c\u4e0d\u652f\u6301\u4e0b\u8f7d\u6b63\u5e38 PDF")
|
||||
return self.send_file(build_pdf(conn, cert), "application/pdf", f"{cert['certificate_no']}.pdf")
|
||||
if path.endswith("/void"):
|
||||
conn.execute("update certificates set status='voided',pdf_status='not_generated' where id=?", (cert_id,))
|
||||
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
|
||||
log(conn, admin_id, "void_certificate", "certificate", cert_id, {"certificate_no": cert["certificate_no"], "learner_name": learner["current_name"] if learner else "", "previous_status": cert["status"], "status": "voided"})
|
||||
return self.json(row_dict(conn.execute("select * from certificates where id=?", (cert_id,)).fetchone()))
|
||||
if path.endswith("/token/regenerate"):
|
||||
conn.execute("update certificates set public_token=? where id=?", (secrets.token_urlsafe(24), cert_id))
|
||||
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
|
||||
log(conn, admin_id, "regenerate_public_token", "certificate", cert_id, {"certificate_no": cert["certificate_no"], "learner_name": learner["current_name"] if learner else ""})
|
||||
return self.json(row_dict(conn.execute("select * from certificates where id=?", (cert_id,)).fetchone()))
|
||||
|
||||
if path == "/api/admin/import-batches/template":
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.append(COLS)
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return self.bytes(buf.getvalue(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "certificate-import-template.xlsx")
|
||||
if path == "/api/admin/import-batches" and self.command == "GET":
|
||||
return self.json(rows(conn, "select * from import_batches order by id desc"))
|
||||
if path == "/api/admin/import-batches" and self.command == "POST":
|
||||
filename, content = parse_multipart(body, self.headers.get("Content-Type", ""))
|
||||
uploads = DATA / "uploads"
|
||||
uploads.mkdir(exist_ok=True)
|
||||
file_path = uploads / filename
|
||||
file_path.write_bytes(content)
|
||||
batch = validate_import(conn, file_path, filename)
|
||||
log(conn, admin_id, "upload_import_file", "import_batch", batch["id"], {"filename": filename, "total_rows": batch["total_rows"], "valid_rows": batch["valid_rows"], "failed_rows": batch["failed_rows"], "status": batch["status"]})
|
||||
return self.json(batch, 201)
|
||||
if path.endswith("/confirm") and path.startswith("/api/admin/import-batches/"):
|
||||
batch_id = int(path.split("/")[4])
|
||||
return self.json(import_batch(conn, batch_id, admin_id))
|
||||
if path.endswith("/file") and path.startswith("/api/admin/import-batches/"):
|
||||
batch_id = int(path.split("/")[4])
|
||||
batch = conn.execute("select * from import_batches where id=?", (batch_id,)).fetchone()
|
||||
if not batch:
|
||||
return self.error(404, "Import batch not found")
|
||||
file_path = DATA / "uploads" / batch["filename"]
|
||||
if not file_path.exists():
|
||||
return self.error(404, "\u539f\u59cb\u6587\u4ef6\u4e0d\u5b58\u5728")
|
||||
return self.send_file(file_path, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", batch["filename"])
|
||||
if path.startswith("/api/admin/import-batches/") and self.command == "DELETE":
|
||||
batch_id = int(path.rsplit("/", 1)[1])
|
||||
batch = conn.execute("select * from import_batches where id=?", (batch_id,)).fetchone()
|
||||
if not batch:
|
||||
return self.error(404, "Import batch not found")
|
||||
for file_path in [DATA / "uploads" / batch["filename"], DATA / f"batch-{batch_id}.json"]:
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
conn.execute("delete from import_batches where id=?", (batch_id,))
|
||||
log(conn, admin_id, "delete_import_batch", "import_batch", batch_id, {"filename": batch["filename"], "status": batch["status"], "total_rows": batch["total_rows"]})
|
||||
return self.json({"ok": True})
|
||||
if path == "/api/admin/exports/certificates":
|
||||
return self.bytes(export_certs(conn), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "certificates-export.xlsx")
|
||||
if path == "/api/admin/logs/export":
|
||||
return self.bytes(export_logs(filtered_operation_logs(conn, query)), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "operation-logs.xlsx")
|
||||
if path == "/api/admin/logs/cleanup" and self.command == "POST":
|
||||
removed = cleanup_expired_logs(conn)
|
||||
return self.json({"removed": removed, "normal_retention_days": LOG_RETENTION_DAYS, "high_risk_retention_days": HIGH_RISK_LOG_RETENTION_DAYS})
|
||||
if path == "/api/admin/logs":
|
||||
cleanup_expired_logs(conn)
|
||||
return self.json(list_operation_logs(conn, query))
|
||||
return self.error(404, "Not found")
|
||||
|
||||
def admin_id(self) -> int | None:
|
||||
auth = self.headers.get("Authorization", "")
|
||||
return token_admin_id(auth[7:]) if auth.startswith("Bearer ") else None
|
||||
|
||||
def read_body(self) -> bytes:
|
||||
return self.rfile.read(int(self.headers.get("Content-Length", "0") or "0"))
|
||||
|
||||
def json(self, value, status_code=200):
|
||||
self.bytes(json_bytes(value), "application/json; charset=utf-8", status_code=status_code)
|
||||
|
||||
def bytes(self, data: bytes, content_type: str, filename: str | None = None, status_code=200):
|
||||
self.send_response(status_code)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
if filename:
|
||||
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def send_file(self, path: Path, content_type: str, filename: str | None = None):
|
||||
self.bytes(path.read_bytes(), content_type, filename)
|
||||
|
||||
def error(self, status_code, message):
|
||||
self.bytes(json_bytes({"detail": str(message)}), "application/json; charset=utf-8", status_code=status_code)
|
||||
|
||||
|
||||
def main():
|
||||
init_db()
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 8000), Handler)
|
||||
print("Local app running at http://127.0.0.1:8000")
|
||||
print("Admin: admin / Admin123!")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user