feat: KPL v1.5.0 功能迭代
Some checks failed
continuous-integration/drone/push Build is failing

1. 奖章条件优化
- 修复统计查询 SQL 语法
- 添加按类别检查奖章方法

2. 移动端适配
- 登录页、课程中心、课程详情
- 考试页面、成长路径、排行榜

3. 证书系统
- 数据库模型和迁移脚本
- 证书颁发/列表/下载/验证 API
- 前端证书列表页面

4. 数据大屏
- 企业级/团队级数据 API
- ECharts 可视化大屏页面
This commit is contained in:
yuliang_guo
2026-01-29 16:51:17 +08:00
parent 813ba2c295
commit 6f0f2e6363
21 changed files with 4907 additions and 80 deletions

View File

@@ -110,5 +110,11 @@ api_router.include_router(system_settings_router, prefix="/settings", tags=["sys
# level_router 等级与奖章路由
from .endpoints.level import router as level_router
api_router.include_router(level_router, prefix="/level", tags=["level"])
# certificate_router 证书管理路由
from .endpoints.certificate import router as certificate_router
api_router.include_router(certificate_router, prefix="/certificates", tags=["certificates"])
# dashboard_router 数据大屏路由
from .endpoints.dashboard import router as dashboard_router
api_router.include_router(dashboard_router, prefix="/dashboard", tags=["dashboard"])
__all__ = ["api_router"]

View File

@@ -0,0 +1,305 @@
"""
证书管理 API 端点
提供证书相关的 RESTful API
- 获取证书列表
- 获取证书详情
- 下载证书
- 验证证书
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Response, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
import io
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.services.certificate_service import CertificateService
router = APIRouter()
@router.get("/templates")
async def get_certificate_templates(
cert_type: Optional[str] = Query(None, description="证书类型: course/exam/achievement"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取证书模板列表"""
service = CertificateService(db)
templates = await service.get_templates(cert_type)
return {
"code": 200,
"message": "success",
"data": templates
}
@router.get("/me")
async def get_my_certificates(
cert_type: Optional[str] = Query(None, description="证书类型过滤"),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取当前用户的证书列表"""
service = CertificateService(db)
result = await service.get_user_certificates(
user_id=current_user.id,
cert_type=cert_type,
offset=offset,
limit=limit
)
return {
"code": 200,
"message": "success",
"data": result
}
@router.get("/user/{user_id}")
async def get_user_certificates(
user_id: int,
cert_type: Optional[str] = Query(None),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取指定用户的证书列表(需要管理员权限)"""
# 只允许查看自己的证书或管理员查看
if current_user.id != user_id and current_user.role not in ["admin", "enterprise_admin"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权查看其他用户的证书"
)
service = CertificateService(db)
result = await service.get_user_certificates(
user_id=user_id,
cert_type=cert_type,
offset=offset,
limit=limit
)
return {
"code": 200,
"message": "success",
"data": result
}
@router.get("/{cert_id}")
async def get_certificate_detail(
cert_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取证书详情"""
service = CertificateService(db)
cert = await service.get_certificate_by_id(cert_id)
if not cert:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="证书不存在"
)
return {
"code": 200,
"message": "success",
"data": cert
}
@router.get("/{cert_id}/image")
async def get_certificate_image(
cert_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取证书分享图片"""
service = CertificateService(db)
try:
# 获取基础URL
base_url = "https://kpl.example.com/certificates" # 可从配置读取
image_bytes = await service.generate_certificate_image(cert_id, base_url)
return StreamingResponse(
io.BytesIO(image_bytes),
media_type="image/png",
headers={
"Content-Disposition": f"inline; filename=certificate_{cert_id}.png"
}
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"生成证书图片失败: {str(e)}"
)
@router.get("/{cert_id}/download")
async def download_certificate_pdf(
cert_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""下载证书PDF"""
service = CertificateService(db)
cert = await service.get_certificate_by_id(cert_id)
if not cert:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="证书不存在"
)
# 如果已有PDF URL则重定向
if cert.get("pdf_url"):
return {
"code": 200,
"message": "success",
"data": {
"download_url": cert["pdf_url"]
}
}
# 否则返回图片作为替代
try:
base_url = "https://kpl.example.com/certificates"
image_bytes = await service.generate_certificate_image(cert_id, base_url)
return StreamingResponse(
io.BytesIO(image_bytes),
media_type="image/png",
headers={
"Content-Disposition": f"attachment; filename=certificate_{cert['certificate_no']}.png"
}
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"下载失败: {str(e)}"
)
@router.get("/verify/{cert_no}")
async def verify_certificate(
cert_no: str,
db: AsyncSession = Depends(get_db)
):
"""
验证证书真伪
此接口无需登录,可用于公开验证证书
"""
service = CertificateService(db)
cert = await service.get_certificate_by_no(cert_no)
if not cert:
return {
"code": 404,
"message": "证书不存在或编号错误",
"data": {
"valid": False,
"certificate_no": cert_no
}
}
return {
"code": 200,
"message": "证书验证通过",
"data": {
"valid": True,
"certificate_no": cert_no,
"title": cert.get("title"),
"type_name": cert.get("type_name"),
"issued_at": cert.get("issued_at"),
"user": cert.get("user", {}),
}
}
@router.post("/issue/course")
async def issue_course_certificate(
course_id: int,
course_name: str,
completion_rate: float = 100.0,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
颁发课程结业证书
通常由系统在用户完成课程时自动调用
"""
service = CertificateService(db)
try:
cert = await service.issue_course_certificate(
user_id=current_user.id,
course_id=course_id,
course_name=course_name,
completion_rate=completion_rate,
user_name=current_user.full_name or current_user.username
)
await db.commit()
return {
"code": 200,
"message": "证书颁发成功",
"data": cert
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/issue/exam")
async def issue_exam_certificate(
exam_id: int,
exam_name: str,
score: float,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
颁发考试合格证书
通常由系统在用户考试通过时自动调用
"""
service = CertificateService(db)
try:
cert = await service.issue_exam_certificate(
user_id=current_user.id,
exam_id=exam_id,
exam_name=exam_name,
score=score,
user_name=current_user.full_name or current_user.username
)
await db.commit()
return {
"code": 200,
"message": "证书颁发成功",
"data": cert
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)

View File

@@ -0,0 +1,231 @@
"""
数据大屏 API 端点
提供企业级和团队级数据大屏接口
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.services.dashboard_service import DashboardService
router = APIRouter()
@router.get("/enterprise/overview")
async def get_enterprise_overview(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取企业级数据概览
需要管理员或企业管理员权限
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_enterprise_overview()
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/departments")
async def get_department_comparison(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取部门/团队学习对比数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_department_comparison()
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/trend")
async def get_learning_trend(
days: int = Query(7, ge=1, le=30),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取学习趋势数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_learning_trend(days)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/level-distribution")
async def get_level_distribution(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取等级分布数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_level_distribution()
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/activities")
async def get_realtime_activities(
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取实时动态
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_realtime_activities(limit)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/course-ranking")
async def get_course_ranking(
limit: int = Query(10, ge=1, le=50),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取课程热度排行
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_course_ranking(limit)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/team")
async def get_team_dashboard(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取团队级数据大屏
面向团队负责人,显示其管理团队的数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要团队负责人权限"
)
service = DashboardService(db)
data = await service.get_team_dashboard(current_user.id)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/all")
async def get_all_dashboard_data(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取完整的大屏数据(一次性获取所有数据)
用于大屏初始化加载
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
# 并行获取所有数据
overview = await service.get_enterprise_overview()
departments = await service.get_department_comparison()
trend = await service.get_learning_trend(7)
level_dist = await service.get_level_distribution()
activities = await service.get_realtime_activities(20)
course_ranking = await service.get_course_ranking(10)
return {
"code": 200,
"message": "success",
"data": {
"overview": overview,
"departments": departments,
"trend": trend,
"level_distribution": level_dist,
"activities": activities,
"course_ranking": course_ranking,
}
}

View File

@@ -27,6 +27,11 @@ from app.models.level import (
BadgeCategory,
ConditionType,
)
from app.models.certificate import (
CertificateTemplate,
UserCertificate,
CertificateType,
)
__all__ = [
"Base",
@@ -64,4 +69,7 @@ __all__ = [
"ExpType",
"BadgeCategory",
"ConditionType",
"CertificateTemplate",
"UserCertificate",
"CertificateType",
]

View File

@@ -0,0 +1,76 @@
"""
证书系统数据模型
定义证书模板和用户证书的数据结构
"""
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Enum as SQLEnum, DECIMAL, JSON
from sqlalchemy.orm import relationship
from app.core.database import Base
class CertificateType(str, Enum):
"""证书类型枚举"""
COURSE = "course" # 课程结业证书
EXAM = "exam" # 考试合格证书
ACHIEVEMENT = "achievement" # 成就证书
class CertificateTemplate(Base):
"""证书模板表"""
__tablename__ = "certificate_templates"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False, comment="模板名称")
type = Column(SQLEnum(CertificateType), nullable=False, comment="证书类型")
background_url = Column(String(500), comment="证书背景图URL")
template_html = Column(Text, comment="HTML模板内容")
template_style = Column(Text, comment="CSS样式")
is_active = Column(Boolean, default=True, comment="是否启用")
sort_order = Column(Integer, default=0, comment="排序顺序")
created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
# 关联
certificates = relationship("UserCertificate", back_populates="template")
class UserCertificate(Base):
"""用户证书表"""
__tablename__ = "user_certificates"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="用户ID")
template_id = Column(Integer, ForeignKey("certificate_templates.id"), nullable=False, comment="模板ID")
certificate_no = Column(String(50), unique=True, nullable=False, comment="证书编号")
title = Column(String(200), nullable=False, comment="证书标题")
description = Column(Text, comment="证书描述")
issued_at = Column(DateTime, default=datetime.now, comment="颁发时间")
valid_until = Column(DateTime, comment="有效期至")
# 关联信息
course_id = Column(Integer, comment="关联课程ID")
exam_id = Column(Integer, comment="关联考试ID")
badge_id = Column(Integer, comment="关联奖章ID")
# 成绩信息
score = Column(DECIMAL(5, 2), comment="考试分数")
completion_rate = Column(DECIMAL(5, 2), comment="完成率")
# 生成的文件
pdf_url = Column(String(500), comment="PDF文件URL")
image_url = Column(String(500), comment="分享图片URL")
# 元数据
meta_data = Column(JSON, comment="扩展元数据")
created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
# 关联
template = relationship("CertificateTemplate", back_populates="certificates")
user = relationship("User", backref="certificates")

View File

@@ -10,7 +10,7 @@
from datetime import datetime
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy import select, func, and_, or_
from sqlalchemy import select, func, and_, or_, case
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logger import get_logger
@@ -162,80 +162,102 @@ class BadgeService:
"user_level": 1,
}
# 获取用户等级信息
result = await self.db.execute(
select(UserLevel).where(UserLevel.user_id == user_id)
)
user_level = result.scalar_one_or_none()
if user_level:
stats["login_streak"] = user_level.login_streak
stats["user_level"] = user_level.level
# 获取登录次数(从经验值历史)
result = await self.db.execute(
select(func.count(ExpHistory.id))
.where(
ExpHistory.user_id == user_id,
ExpHistory.exp_type == ExpType.LOGIN
try:
# 获取用户等级信息
result = await self.db.execute(
select(UserLevel).where(UserLevel.user_id == user_id)
)
)
stats["login_count"] = result.scalar() or 0
# 获取考试统计
result = await self.db.execute(
select(
func.count(Exam.id),
func.sum(func.if_(Exam.score >= 100, 1, 0)),
func.sum(func.if_(Exam.score >= 90, 1, 0))
user_level = result.scalar_one_or_none()
if user_level:
stats["login_streak"] = user_level.login_streak or 0
stats["user_level"] = user_level.level or 1
# 获取登录/签到次数(从经验值历史)
result = await self.db.execute(
select(func.count(ExpHistory.id))
.where(
ExpHistory.user_id == user_id,
ExpHistory.exp_type == ExpType.LOGIN
)
)
.where(
Exam.user_id == user_id,
Exam.is_passed == True,
Exam.status == "submitted"
stats["login_count"] = result.scalar() or 0
# 获取考试统计 - 使用 case 语句
# 通过考试数量
result = await self.db.execute(
select(func.count(Exam.id))
.where(
Exam.user_id == user_id,
Exam.is_passed == True,
Exam.status == "submitted"
)
)
)
row = result.first()
if row:
stats["exam_passed"] = row[0] or 0
stats["exam_perfect_count"] = int(row[1] or 0)
stats["exam_excellent"] = int(row[2] or 0)
# 获取练习统计
result = await self.db.execute(
select(
func.count(PracticeSession.id),
func.sum(PracticeSession.duration_seconds)
stats["exam_passed"] = result.scalar() or 0
# 满分考试数量score >= 总分,通常是 100
result = await self.db.execute(
select(func.count(Exam.id))
.where(
Exam.user_id == user_id,
Exam.status == "submitted",
Exam.score >= Exam.total_score
)
)
.where(
PracticeSession.user_id == user_id,
PracticeSession.status == "completed"
stats["exam_perfect_count"] = result.scalar() or 0
# 优秀考试数量90分以上
result = await self.db.execute(
select(func.count(Exam.id))
.where(
Exam.user_id == user_id,
Exam.status == "submitted",
Exam.score >= 90
)
)
)
row = result.first()
if row:
stats["practice_count"] = row[0] or 0
total_seconds = row[1] or 0
stats["practice_hours"] = total_seconds / 3600
# 获取陪练统计
result = await self.db.execute(
select(func.count(TrainingSession.id))
.where(
TrainingSession.user_id == user_id,
TrainingSession.status == "COMPLETED"
stats["exam_excellent"] = result.scalar() or 0
# 获取练习统计PracticeSession - AI 陪练)
result = await self.db.execute(
select(
func.count(PracticeSession.id),
func.coalesce(func.sum(PracticeSession.duration_seconds), 0)
)
.where(
PracticeSession.user_id == user_id,
PracticeSession.status == "completed"
)
)
)
stats["training_count"] = result.scalar() or 0
# 检查首次高分陪练
result = await self.db.execute(
select(func.count(TrainingReport.id))
.where(
TrainingReport.user_id == user_id,
TrainingReport.overall_score >= 90
row = result.first()
if row:
stats["practice_count"] = row[0] or 0
total_seconds = row[1] or 0
stats["practice_hours"] = float(total_seconds) / 3600.0
# 获取培训/陪练统计TrainingSession
result = await self.db.execute(
select(func.count(TrainingSession.id))
.where(
TrainingSession.user_id == user_id,
TrainingSession.status == "COMPLETED"
)
)
)
stats["first_practice_90"] = 1 if (result.scalar() or 0) > 0 else 0
stats["training_count"] = result.scalar() or 0
# 检查是否有高分陪练90分以上
result = await self.db.execute(
select(func.count(TrainingReport.id))
.where(
TrainingReport.user_id == user_id,
TrainingReport.overall_score >= 90
)
)
high_score_count = result.scalar() or 0
stats["first_practice_90"] = 1 if high_score_count > 0 else 0
logger.debug(f"用户 {user_id} 奖章统计数据: {stats}")
except Exception as e:
logger.error(f"获取用户统计数据失败: {e}")
return stats
@@ -465,3 +487,100 @@ class BadgeService:
await self.db.execute(query)
await self.db.flush()
async def check_badges_by_category(
self,
user_id: int,
categories: List[str]
) -> List[Dict[str, Any]]:
"""
按类别检查并授予奖章(优化触发时机)
Args:
user_id: 用户ID
categories: 要检查的奖章类别列表
Returns:
新获得的奖章列表
"""
# 获取用户统计数据
stats = await self._get_user_stats(user_id)
# 获取指定类别的奖章定义
result = await self.db.execute(
select(BadgeDefinition)
.where(
BadgeDefinition.is_active == True,
BadgeDefinition.category.in_(categories)
)
.order_by(BadgeDefinition.sort_order)
)
category_badges = list(result.scalars().all())
# 获取用户已有的奖章
result = await self.db.execute(
select(UserBadge.badge_id).where(UserBadge.user_id == user_id)
)
owned_badge_ids = {row[0] for row in result.all()}
# 检查每个奖章的解锁条件
newly_awarded = []
for badge in category_badges:
if badge.id in owned_badge_ids:
continue
# 检查条件
condition_met = self._check_badge_condition(badge, stats)
if condition_met:
# 授予奖章
user_badge = UserBadge(
user_id=user_id,
badge_id=badge.id,
unlocked_at=datetime.now(),
is_notified=False
)
self.db.add(user_badge)
# 如果有经验奖励,添加经验值
if badge.exp_reward > 0:
from app.services.level_service import LevelService
level_service = LevelService(self.db)
await level_service.add_exp(
user_id=user_id,
exp_amount=badge.exp_reward,
exp_type=ExpType.BADGE,
description=f"解锁奖章「{badge.name}"
)
newly_awarded.append({
"badge_id": badge.id,
"code": badge.code,
"name": badge.name,
"description": badge.description,
"icon": badge.icon,
"exp_reward": badge.exp_reward,
})
logger.info(f"用户 {user_id} 解锁奖章: {badge.name}")
if newly_awarded:
await self.db.flush()
return newly_awarded
async def check_exam_badges(self, user_id: int) -> List[Dict[str, Any]]:
"""考试后检查考试类奖章"""
return await self.check_badges_by_category(user_id, [BadgeCategory.EXAM])
async def check_practice_badges(self, user_id: int) -> List[Dict[str, Any]]:
"""练习后检查练习类奖章"""
return await self.check_badges_by_category(user_id, [BadgeCategory.PRACTICE])
async def check_streak_badges(self, user_id: int) -> List[Dict[str, Any]]:
"""签到后检查连续打卡类奖章"""
return await self.check_badges_by_category(user_id, [BadgeCategory.STREAK, BadgeCategory.LEARNING])
async def check_level_badges(self, user_id: int) -> List[Dict[str, Any]]:
"""等级变化后检查等级类奖章"""
return await self.check_badges_by_category(user_id, [BadgeCategory.SPECIAL])

View File

@@ -0,0 +1,516 @@
"""
证书服务
提供证书管理功能:
- 颁发证书
- 获取证书列表
- 生成证书PDF/图片
- 验证证书
"""
import os
import io
import uuid
from datetime import datetime
from typing import Optional, List, Dict, Any
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from PIL import Image, ImageDraw, ImageFont
import qrcode
from app.core.logger import get_logger
from app.core.config import settings
from app.models.certificate import CertificateTemplate, UserCertificate, CertificateType
logger = get_logger(__name__)
class CertificateService:
"""证书服务"""
# 证书编号前缀
CERT_NO_PREFIX = "KPL"
def __init__(self, db: AsyncSession):
self.db = db
async def get_templates(self, cert_type: Optional[str] = None) -> List[Dict[str, Any]]:
"""
获取证书模板列表
Args:
cert_type: 证书类型过滤
Returns:
模板列表
"""
query = select(CertificateTemplate).where(CertificateTemplate.is_active == True)
if cert_type:
query = query.where(CertificateTemplate.type == cert_type)
query = query.order_by(CertificateTemplate.sort_order)
result = await self.db.execute(query)
templates = result.scalars().all()
return [
{
"id": t.id,
"name": t.name,
"type": t.type.value if isinstance(t.type, CertificateType) else t.type,
"background_url": t.background_url,
"is_active": t.is_active,
}
for t in templates
]
async def _generate_certificate_no(self) -> str:
"""生成唯一证书编号"""
year = datetime.now().year
# 获取当年的证书数量
result = await self.db.execute(
select(func.count(UserCertificate.id))
.where(UserCertificate.certificate_no.like(f"{self.CERT_NO_PREFIX}-{year}-%"))
)
count = result.scalar() or 0
# 生成编号KPL-年份-6位序号
cert_no = f"{self.CERT_NO_PREFIX}-{year}-{str(count + 1).zfill(6)}"
return cert_no
async def issue_course_certificate(
self,
user_id: int,
course_id: int,
course_name: str,
completion_rate: float,
user_name: str
) -> Dict[str, Any]:
"""
颁发课程结业证书
Args:
user_id: 用户ID
course_id: 课程ID
course_name: 课程名称
completion_rate: 完成率
user_name: 用户姓名
Returns:
证书信息
"""
# 检查是否已颁发
existing = await self.db.execute(
select(UserCertificate).where(
UserCertificate.user_id == user_id,
UserCertificate.course_id == course_id
)
)
if existing.scalar_one_or_none():
raise ValueError("该课程证书已颁发")
# 获取课程证书模板
result = await self.db.execute(
select(CertificateTemplate).where(
CertificateTemplate.type == CertificateType.COURSE,
CertificateTemplate.is_active == True
)
)
template = result.scalar_one_or_none()
if not template:
raise ValueError("证书模板不存在")
# 生成证书编号
cert_no = await self._generate_certificate_no()
# 创建证书
certificate = UserCertificate(
user_id=user_id,
template_id=template.id,
certificate_no=cert_no,
title=f"{course_name}》课程结业证书",
description=f"完成课程《{course_name}》的全部学习内容",
course_id=course_id,
completion_rate=completion_rate,
meta_data={
"course_name": course_name,
"user_name": user_name,
"completion_rate": completion_rate
}
)
self.db.add(certificate)
await self.db.flush()
logger.info(f"颁发课程证书: user_id={user_id}, course_id={course_id}, cert_no={cert_no}")
return await self._format_certificate(certificate, template)
async def issue_exam_certificate(
self,
user_id: int,
exam_id: int,
exam_name: str,
score: float,
user_name: str
) -> Dict[str, Any]:
"""
颁发考试合格证书
Args:
user_id: 用户ID
exam_id: 考试ID
exam_name: 考试名称
score: 分数
user_name: 用户姓名
Returns:
证书信息
"""
# 检查是否已颁发
existing = await self.db.execute(
select(UserCertificate).where(
UserCertificate.user_id == user_id,
UserCertificate.exam_id == exam_id
)
)
if existing.scalar_one_or_none():
raise ValueError("该考试证书已颁发")
# 获取考试证书模板
result = await self.db.execute(
select(CertificateTemplate).where(
CertificateTemplate.type == CertificateType.EXAM,
CertificateTemplate.is_active == True
)
)
template = result.scalar_one_or_none()
if not template:
raise ValueError("证书模板不存在")
# 生成证书编号
cert_no = await self._generate_certificate_no()
# 创建证书
certificate = UserCertificate(
user_id=user_id,
template_id=template.id,
certificate_no=cert_no,
title=f"{exam_name}》考试合格证书",
description=f"在《{exam_name}》考试中成绩合格",
exam_id=exam_id,
score=score,
meta_data={
"exam_name": exam_name,
"user_name": user_name,
"score": score
}
)
self.db.add(certificate)
await self.db.flush()
logger.info(f"颁发考试证书: user_id={user_id}, exam_id={exam_id}, cert_no={cert_no}")
return await self._format_certificate(certificate, template)
async def issue_achievement_certificate(
self,
user_id: int,
badge_id: int,
badge_name: str,
badge_description: str,
user_name: str
) -> Dict[str, Any]:
"""
颁发成就证书
Args:
user_id: 用户ID
badge_id: 奖章ID
badge_name: 奖章名称
badge_description: 奖章描述
user_name: 用户姓名
Returns:
证书信息
"""
# 检查是否已颁发
existing = await self.db.execute(
select(UserCertificate).where(
UserCertificate.user_id == user_id,
UserCertificate.badge_id == badge_id
)
)
if existing.scalar_one_or_none():
raise ValueError("该成就证书已颁发")
# 获取成就证书模板
result = await self.db.execute(
select(CertificateTemplate).where(
CertificateTemplate.type == CertificateType.ACHIEVEMENT,
CertificateTemplate.is_active == True
)
)
template = result.scalar_one_or_none()
if not template:
raise ValueError("证书模板不存在")
# 生成证书编号
cert_no = await self._generate_certificate_no()
# 创建证书
certificate = UserCertificate(
user_id=user_id,
template_id=template.id,
certificate_no=cert_no,
title=f"{badge_name}」成就证书",
description=badge_description,
badge_id=badge_id,
meta_data={
"badge_name": badge_name,
"badge_description": badge_description,
"user_name": user_name
}
)
self.db.add(certificate)
await self.db.flush()
logger.info(f"颁发成就证书: user_id={user_id}, badge_id={badge_id}, cert_no={cert_no}")
return await self._format_certificate(certificate, template)
async def get_user_certificates(
self,
user_id: int,
cert_type: Optional[str] = None,
offset: int = 0,
limit: int = 20
) -> Dict[str, Any]:
"""
获取用户证书列表
Args:
user_id: 用户ID
cert_type: 证书类型过滤
offset: 偏移量
limit: 数量限制
Returns:
证书列表和分页信息
"""
query = (
select(UserCertificate, CertificateTemplate)
.join(CertificateTemplate, UserCertificate.template_id == CertificateTemplate.id)
.where(UserCertificate.user_id == user_id)
)
if cert_type:
query = query.where(CertificateTemplate.type == cert_type)
# 获取总数
count_query = select(func.count()).select_from(query.subquery())
total_result = await self.db.execute(count_query)
total = total_result.scalar() or 0
# 分页查询
query = query.order_by(UserCertificate.issued_at.desc()).offset(offset).limit(limit)
result = await self.db.execute(query)
rows = result.all()
certificates = [
await self._format_certificate(cert, template)
for cert, template in rows
]
return {
"items": certificates,
"total": total,
"offset": offset,
"limit": limit
}
async def get_certificate_by_id(self, cert_id: int) -> Optional[Dict[str, Any]]:
"""根据ID获取证书"""
result = await self.db.execute(
select(UserCertificate, CertificateTemplate)
.join(CertificateTemplate, UserCertificate.template_id == CertificateTemplate.id)
.where(UserCertificate.id == cert_id)
)
row = result.first()
if not row:
return None
cert, template = row
return await self._format_certificate(cert, template)
async def get_certificate_by_no(self, cert_no: str) -> Optional[Dict[str, Any]]:
"""根据编号获取证书(用于验证)"""
result = await self.db.execute(
select(UserCertificate, CertificateTemplate)
.join(CertificateTemplate, UserCertificate.template_id == CertificateTemplate.id)
.where(UserCertificate.certificate_no == cert_no)
)
row = result.first()
if not row:
return None
cert, template = row
return await self._format_certificate(cert, template, include_user=True)
async def _format_certificate(
self,
cert: UserCertificate,
template: CertificateTemplate,
include_user: bool = False
) -> Dict[str, Any]:
"""格式化证书数据"""
data = {
"id": cert.id,
"certificate_no": cert.certificate_no,
"title": cert.title,
"description": cert.description,
"type": template.type.value if isinstance(template.type, CertificateType) else template.type,
"type_name": self._get_type_name(template.type),
"issued_at": cert.issued_at.isoformat() if cert.issued_at else None,
"valid_until": cert.valid_until.isoformat() if cert.valid_until else None,
"score": float(cert.score) if cert.score else None,
"completion_rate": float(cert.completion_rate) if cert.completion_rate else None,
"pdf_url": cert.pdf_url,
"image_url": cert.image_url,
"course_id": cert.course_id,
"exam_id": cert.exam_id,
"badge_id": cert.badge_id,
"meta_data": cert.meta_data,
"template": {
"id": template.id,
"name": template.name,
"background_url": template.background_url,
}
}
if include_user and cert.user:
data["user"] = {
"id": cert.user.id,
"username": cert.user.username,
"full_name": cert.user.full_name,
}
return data
def _get_type_name(self, cert_type) -> str:
"""获取证书类型名称"""
type_names = {
CertificateType.COURSE: "课程结业证书",
CertificateType.EXAM: "考试合格证书",
CertificateType.ACHIEVEMENT: "成就证书",
"course": "课程结业证书",
"exam": "考试合格证书",
"achievement": "成就证书",
}
return type_names.get(cert_type, "证书")
async def generate_certificate_image(
self,
cert_id: int,
base_url: str = ""
) -> bytes:
"""
生成证书分享图片
Args:
cert_id: 证书ID
base_url: 基础URL用于生成二维码链接
Returns:
图片二进制数据
"""
# 获取证书信息
cert_data = await self.get_certificate_by_id(cert_id)
if not cert_data:
raise ValueError("证书不存在")
# 创建图片
width, height = 800, 600
img = Image.new('RGB', (width, height), color='#f5f7fa')
draw = ImageDraw.Draw(img)
# 尝试加载字体,如果失败则使用默认字体
try:
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 36)
text_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
except:
title_font = ImageFont.load_default()
text_font = ImageFont.load_default()
small_font = ImageFont.load_default()
# 绘制标题
title = cert_data.get("type_name", "证书")
draw.text((width // 2, 60), title, font=title_font, fill='#333333', anchor='mm')
# 绘制证书标题
cert_title = cert_data.get("title", "")
draw.text((width // 2, 140), cert_title, font=text_font, fill='#666666', anchor='mm')
# 绘制描述
description = cert_data.get("description", "")
draw.text((width // 2, 200), description, font=text_font, fill='#666666', anchor='mm')
# 绘制分数/完成率(如果有)
if cert_data.get("score"):
score_text = f"成绩:{cert_data['score']}"
draw.text((width // 2, 280), score_text, font=text_font, fill='#667eea', anchor='mm')
elif cert_data.get("completion_rate"):
rate_text = f"完成率:{cert_data['completion_rate']}%"
draw.text((width // 2, 280), rate_text, font=text_font, fill='#667eea', anchor='mm')
# 绘制颁发日期
if cert_data.get("issued_at"):
date_text = f"颁发日期:{cert_data['issued_at'][:10]}"
draw.text((width // 2, 360), date_text, font=small_font, fill='#999999', anchor='mm')
# 绘制证书编号
cert_no = cert_data.get("certificate_no", "")
draw.text((width // 2, 520), f"证书编号:{cert_no}", font=small_font, fill='#999999', anchor='mm')
# 生成验证二维码
if base_url and cert_no:
verify_url = f"{base_url}/verify/{cert_no}"
qr = qrcode.QRCode(version=1, box_size=3, border=2)
qr.add_data(verify_url)
qr.make(fit=True)
qr_img = qr.make_image(fill_color="black", back_color="white")
qr_img = qr_img.resize((80, 80))
img.paste(qr_img, (width - 100, height - 100))
# 转换为字节
img_bytes = io.BytesIO()
img.save(img_bytes, format='PNG')
img_bytes.seek(0)
return img_bytes.getvalue()
async def update_certificate_files(
self,
cert_id: int,
pdf_url: Optional[str] = None,
image_url: Optional[str] = None
):
"""更新证书文件URL"""
result = await self.db.execute(
select(UserCertificate).where(UserCertificate.id == cert_id)
)
cert = result.scalar_one_or_none()
if cert:
if pdf_url:
cert.pdf_url = pdf_url
if image_url:
cert.image_url = image_url
await self.db.flush()

View File

@@ -0,0 +1,489 @@
"""
数据大屏服务
提供企业级和团队级数据大屏功能:
- 学习数据概览
- 部门/团队对比
- 趋势分析
- 实时动态
"""
from datetime import datetime, timedelta, date
from typing import Optional, List, Dict, Any
from sqlalchemy import select, func, and_, or_, desc, case
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logger import get_logger
from app.models.user import User
from app.models.course import Course, CourseMaterial
from app.models.exam import Exam
from app.models.practice import PracticeSession
from app.models.training import TrainingSession, TrainingReport
from app.models.level import UserLevel, ExpHistory, UserBadge
from app.models.position import Position
from app.models.position_member import PositionMember
logger = get_logger(__name__)
class DashboardService:
"""数据大屏服务"""
def __init__(self, db: AsyncSession):
self.db = db
async def get_enterprise_overview(self, enterprise_id: Optional[int] = None) -> Dict[str, Any]:
"""
获取企业级数据概览
Args:
enterprise_id: 企业ID可选用于多租户
Returns:
企业级数据概览
"""
today = date.today()
week_ago = today - timedelta(days=7)
month_ago = today - timedelta(days=30)
# 基础统计
# 1. 总学员数
result = await self.db.execute(
select(func.count(User.id))
.where(User.is_deleted == False, User.role == 'trainee')
)
total_users = result.scalar() or 0
# 2. 今日活跃用户(有经验值记录)
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(func.date(ExpHistory.created_at) == today)
)
today_active = result.scalar() or 0
# 3. 本周活跃用户
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(ExpHistory.created_at >= datetime.combine(week_ago, datetime.min.time()))
)
week_active = result.scalar() or 0
# 4. 本月活跃用户
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(ExpHistory.created_at >= datetime.combine(month_ago, datetime.min.time()))
)
month_active = result.scalar() or 0
# 5. 总学习时长(小时)
result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(PracticeSession.status == 'completed')
)
practice_hours = (result.scalar() or 0) / 3600
result = await self.db.execute(
select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0))
.where(TrainingSession.status == 'COMPLETED')
)
training_hours = (result.scalar() or 0) / 3600
total_hours = round(practice_hours + training_hours, 1)
# 6. 考试统计
result = await self.db.execute(
select(
func.count(Exam.id),
func.count(case((Exam.is_passed == True, 1))),
func.avg(Exam.score)
)
.where(Exam.status == 'submitted')
)
exam_row = result.first()
exam_count = exam_row[0] or 0
exam_passed = exam_row[1] or 0
exam_avg_score = round(exam_row[2] or 0, 1)
exam_pass_rate = round(exam_passed / exam_count * 100, 1) if exam_count > 0 else 0
# 7. 满分人数
result = await self.db.execute(
select(func.count(func.distinct(Exam.user_id)))
.where(Exam.status == 'submitted', Exam.score >= Exam.total_score)
)
perfect_users = result.scalar() or 0
# 8. 签到率(今日签到人数/总用户数)
result = await self.db.execute(
select(func.count(UserLevel.id))
.where(func.date(UserLevel.last_login_date) == today)
)
today_checkin = result.scalar() or 0
checkin_rate = round(today_checkin / total_users * 100, 1) if total_users > 0 else 0
return {
"overview": {
"total_users": total_users,
"today_active": today_active,
"week_active": week_active,
"month_active": month_active,
"total_hours": total_hours,
"checkin_rate": checkin_rate,
},
"exam": {
"total_count": exam_count,
"pass_rate": exam_pass_rate,
"avg_score": exam_avg_score,
"perfect_users": perfect_users,
},
"updated_at": datetime.now().isoformat()
}
async def get_department_comparison(self) -> List[Dict[str, Any]]:
"""
获取部门/团队学习对比数据
Returns:
部门对比列表
"""
# 获取所有岗位及其成员的学习数据
result = await self.db.execute(
select(Position)
.where(Position.is_deleted == False)
.order_by(Position.name)
)
positions = result.scalars().all()
departments = []
for pos in positions:
# 获取该岗位的成员数
result = await self.db.execute(
select(func.count(PositionMember.id))
.where(PositionMember.position_id == pos.id)
)
member_count = result.scalar() or 0
if member_count == 0:
continue
# 获取成员ID列表
result = await self.db.execute(
select(PositionMember.user_id)
.where(PositionMember.position_id == pos.id)
)
member_ids = [row[0] for row in result.all()]
# 统计该岗位成员的学习数据
# 考试通过率
result = await self.db.execute(
select(
func.count(Exam.id),
func.count(case((Exam.is_passed == True, 1)))
)
.where(
Exam.user_id.in_(member_ids),
Exam.status == 'submitted'
)
)
exam_row = result.first()
exam_total = exam_row[0] or 0
exam_passed = exam_row[1] or 0
pass_rate = round(exam_passed / exam_total * 100, 1) if exam_total > 0 else 0
# 平均学习时长
result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(
PracticeSession.user_id.in_(member_ids),
PracticeSession.status == 'completed'
)
)
total_seconds = result.scalar() or 0
avg_hours = round(total_seconds / 3600 / member_count, 1) if member_count > 0 else 0
# 平均等级
result = await self.db.execute(
select(func.avg(UserLevel.level))
.where(UserLevel.user_id.in_(member_ids))
)
avg_level = round(result.scalar() or 1, 1)
departments.append({
"id": pos.id,
"name": pos.name,
"member_count": member_count,
"pass_rate": pass_rate,
"avg_hours": avg_hours,
"avg_level": avg_level,
})
# 按通过率排序
departments.sort(key=lambda x: x["pass_rate"], reverse=True)
return departments
async def get_learning_trend(self, days: int = 7) -> Dict[str, Any]:
"""
获取学习趋势数据
Args:
days: 统计天数
Returns:
趋势数据
"""
today = date.today()
dates = [(today - timedelta(days=i)) for i in range(days-1, -1, -1)]
trend_data = []
for d in dates:
# 当日活跃用户
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(func.date(ExpHistory.created_at) == d)
)
active_users = result.scalar() or 0
# 当日新增学习时长
result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(
func.date(PracticeSession.created_at) == d,
PracticeSession.status == 'completed'
)
)
hours = round((result.scalar() or 0) / 3600, 1)
# 当日考试次数
result = await self.db.execute(
select(func.count(Exam.id))
.where(
func.date(Exam.created_at) == d,
Exam.status == 'submitted'
)
)
exams = result.scalar() or 0
trend_data.append({
"date": d.isoformat(),
"active_users": active_users,
"learning_hours": hours,
"exam_count": exams,
})
return {
"dates": [d.isoformat() for d in dates],
"trend": trend_data
}
async def get_level_distribution(self) -> Dict[str, Any]:
"""
获取等级分布数据
Returns:
等级分布
"""
result = await self.db.execute(
select(UserLevel.level, func.count(UserLevel.id))
.group_by(UserLevel.level)
.order_by(UserLevel.level)
)
rows = result.all()
distribution = {row[0]: row[1] for row in rows}
# 补全1-10级
for i in range(1, 11):
if i not in distribution:
distribution[i] = 0
return {
"levels": list(range(1, 11)),
"counts": [distribution.get(i, 0) for i in range(1, 11)]
}
async def get_realtime_activities(self, limit: int = 20) -> List[Dict[str, Any]]:
"""
获取实时动态
Args:
limit: 数量限制
Returns:
实时动态列表
"""
activities = []
# 获取最近的经验值记录
result = await self.db.execute(
select(ExpHistory, User)
.join(User, ExpHistory.user_id == User.id)
.order_by(ExpHistory.created_at.desc())
.limit(limit)
)
rows = result.all()
for exp, user in rows:
activity_type = "学习"
if "考试" in (exp.description or ""):
activity_type = "考试"
elif "签到" in (exp.description or ""):
activity_type = "签到"
elif "陪练" in (exp.description or ""):
activity_type = "陪练"
elif "奖章" in (exp.description or ""):
activity_type = "奖章"
activities.append({
"id": exp.id,
"user_id": user.id,
"user_name": user.full_name or user.username,
"type": activity_type,
"description": exp.description,
"exp_amount": exp.exp_amount,
"created_at": exp.created_at.isoformat() if exp.created_at else None,
})
return activities
async def get_team_dashboard(self, team_leader_id: int) -> Dict[str, Any]:
"""
获取团队级数据大屏
Args:
team_leader_id: 团队负责人ID
Returns:
团队数据
"""
# 获取团队负责人管理的岗位
result = await self.db.execute(
select(Position)
.where(
Position.is_deleted == False,
or_(
Position.manager_id == team_leader_id,
Position.created_by == team_leader_id
)
)
)
positions = result.scalars().all()
position_ids = [p.id for p in positions]
if not position_ids:
return {
"members": [],
"overview": {
"total_members": 0,
"avg_level": 0,
"avg_exp": 0,
"total_badges": 0,
},
"pending_tasks": []
}
# 获取团队成员
result = await self.db.execute(
select(PositionMember.user_id)
.where(PositionMember.position_id.in_(position_ids))
)
member_ids = [row[0] for row in result.all()]
if not member_ids:
return {
"members": [],
"overview": {
"total_members": 0,
"avg_level": 0,
"avg_exp": 0,
"total_badges": 0,
},
"pending_tasks": []
}
# 获取成员详细信息
result = await self.db.execute(
select(User, UserLevel)
.outerjoin(UserLevel, User.id == UserLevel.user_id)
.where(User.id.in_(member_ids))
.order_by(UserLevel.total_exp.desc().nullslast())
)
rows = result.all()
members = []
total_exp = 0
total_level = 0
for user, level in rows:
user_level = level.level if level else 1
user_exp = level.total_exp if level else 0
total_level += user_level
total_exp += user_exp
# 获取用户奖章数
result = await self.db.execute(
select(func.count(UserBadge.id))
.where(UserBadge.user_id == user.id)
)
badge_count = result.scalar() or 0
members.append({
"id": user.id,
"username": user.username,
"full_name": user.full_name,
"avatar_url": user.avatar_url,
"level": user_level,
"total_exp": user_exp,
"badge_count": badge_count,
})
total_members = len(members)
# 获取团队总奖章数
result = await self.db.execute(
select(func.count(UserBadge.id))
.where(UserBadge.user_id.in_(member_ids))
)
total_badges = result.scalar() or 0
return {
"members": members,
"overview": {
"total_members": total_members,
"avg_level": round(total_level / total_members, 1) if total_members > 0 else 0,
"avg_exp": round(total_exp / total_members) if total_members > 0 else 0,
"total_badges": total_badges,
},
"positions": [{"id": p.id, "name": p.name} for p in positions]
}
async def get_course_ranking(self, limit: int = 10) -> List[Dict[str, Any]]:
"""
获取课程热度排行
Args:
limit: 数量限制
Returns:
课程排行列表
"""
# 这里简化实现,实际应该统计课程学习次数
result = await self.db.execute(
select(Course)
.where(Course.is_deleted == False, Course.is_published == True)
.order_by(Course.created_at.desc())
.limit(limit)
)
courses = result.scalars().all()
ranking = []
for i, course in enumerate(courses, 1):
ranking.append({
"rank": i,
"id": course.id,
"name": course.name,
"description": course.description,
# 这里可以添加实际的学习人数统计
"learners": 0,
})
return ranking

View File

@@ -0,0 +1,166 @@
-- ================================================================
-- 证书系统数据库迁移脚本
-- 创建日期: 2026-01-29
-- 功能: 添加证书模板表和用户证书表
-- ================================================================
-- 事务开始
START TRANSACTION;
-- ================================================================
-- 1. 创建证书模板表
-- ================================================================
CREATE TABLE IF NOT EXISTS certificate_templates (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL COMMENT '模板名称',
type ENUM('course', 'exam', 'achievement') NOT NULL COMMENT '证书类型: course=课程结业, exam=考试合格, achievement=成就证书',
background_url VARCHAR(500) COMMENT '证书背景图URL',
template_html TEXT COMMENT 'HTML模板内容',
template_style TEXT COMMENT 'CSS样式',
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
sort_order INT DEFAULT 0 COMMENT '排序顺序',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_type (type),
INDEX idx_is_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='证书模板表';
-- ================================================================
-- 2. 创建用户证书表
-- ================================================================
CREATE TABLE IF NOT EXISTS user_certificates (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL COMMENT '用户ID',
template_id INT NOT NULL COMMENT '模板ID',
certificate_no VARCHAR(50) UNIQUE NOT NULL COMMENT '证书编号 KPL-年份-序号',
title VARCHAR(200) NOT NULL COMMENT '证书标题',
description TEXT COMMENT '证书描述/成就说明',
issued_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '颁发时间',
valid_until DATETIME COMMENT '有效期至NULL表示永久',
-- 关联信息
course_id INT COMMENT '关联课程ID',
exam_id INT COMMENT '关联考试ID',
badge_id INT COMMENT '关联奖章ID',
-- 成绩信息
score DECIMAL(5,2) COMMENT '考试分数',
completion_rate DECIMAL(5,2) COMMENT '完成率',
-- 生成的文件
pdf_url VARCHAR(500) COMMENT 'PDF文件URL',
image_url VARCHAR(500) COMMENT '分享图片URL',
-- 元数据
meta_data JSON COMMENT '扩展元数据',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (template_id) REFERENCES certificate_templates(id),
INDEX idx_user_id (user_id),
INDEX idx_certificate_no (certificate_no),
INDEX idx_course_id (course_id),
INDEX idx_exam_id (exam_id),
INDEX idx_issued_at (issued_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户证书表';
-- ================================================================
-- 3. 插入默认证书模板
-- ================================================================
INSERT INTO certificate_templates (name, type, template_html, template_style, is_active, sort_order) VALUES
-- 课程结业证书模板
('课程结业证书', 'course',
'<div class="certificate">
<div class="header">
<div class="logo">考培练系统</div>
<h1>结业证书</h1>
</div>
<div class="body">
<p class="recipient">兹证明 <strong>{{user_name}}</strong></p>
<p class="content">已完成课程《{{course_name}}》的全部学习内容</p>
<p class="completion">完成率:{{completion_rate}}%</p>
<p class="date">颁发日期:{{issue_date}}</p>
</div>
<div class="footer">
<div class="cert-no">证书编号:{{certificate_no}}</div>
<div class="qrcode">{{qrcode}}</div>
</div>
</div>',
'.certificate { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #f5f7fa 0%, #e4e9f2 100%); font-family: "Microsoft YaHei", sans-serif; }
.header { text-align: center; margin-bottom: 30px; }
.header .logo { font-size: 24px; color: #667eea; font-weight: bold; }
.header h1 { font-size: 36px; color: #333; margin: 20px 0; }
.body { text-align: center; padding: 30px 60px; }
.body .recipient { font-size: 20px; margin-bottom: 20px; }
.body .content { font-size: 18px; color: #555; margin-bottom: 15px; }
.body .completion { font-size: 16px; color: #667eea; }
.body .date { font-size: 14px; color: #888; margin-top: 30px; }
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: 40px; }
.cert-no { font-size: 12px; color: #999; }
.qrcode { width: 80px; height: 80px; }',
TRUE, 1),
-- 考试合格证书模板
('考试合格证书', 'exam',
'<div class="certificate exam-cert">
<div class="header">
<div class="logo">考培练系统</div>
<h1>考试合格证书</h1>
</div>
<div class="body">
<p class="recipient">兹证明 <strong>{{user_name}}</strong></p>
<p class="content">在《{{exam_name}}》考试中成绩合格</p>
<div class="score-badge">
<span class="score">{{score}}</span>
<span class="unit">分</span>
</div>
<p class="date">考试日期:{{exam_date}}</p>
</div>
<div class="footer">
<div class="cert-no">证书编号:{{certificate_no}}</div>
<div class="qrcode">{{qrcode}}</div>
</div>
</div>',
'.certificate.exam-cert { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); font-family: "Microsoft YaHei", sans-serif; }
.exam-cert .header h1 { color: #2e7d32; }
.score-badge { display: inline-block; padding: 20px 40px; background: #fff; border-radius: 50%; box-shadow: 0 4px 20px rgba(0,0,0,0.1); margin: 20px 0; }
.score-badge .score { font-size: 48px; font-weight: bold; color: #2e7d32; }
.score-badge .unit { font-size: 18px; color: #666; }',
TRUE, 2),
-- 成就证书模板
('成就证书', 'achievement',
'<div class="certificate achievement-cert">
<div class="header">
<div class="logo">考培练系统</div>
<h1>成就证书</h1>
</div>
<div class="body">
<p class="recipient">兹证明 <strong>{{user_name}}</strong></p>
<div class="achievement-icon">{{badge_icon}}</div>
<p class="achievement-name">{{badge_name}}</p>
<p class="achievement-desc">{{badge_description}}</p>
<p class="date">获得日期:{{achieve_date}}</p>
</div>
<div class="footer">
<div class="cert-no">证书编号:{{certificate_no}}</div>
<div class="qrcode">{{qrcode}}</div>
</div>
</div>',
'.certificate.achievement-cert { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%); font-family: "Microsoft YaHei", sans-serif; }
.achievement-cert .header h1 { color: #e65100; }
.achievement-icon { font-size: 64px; margin: 20px 0; }
.achievement-name { font-size: 24px; font-weight: bold; color: #e65100; margin: 10px 0; }
.achievement-desc { font-size: 16px; color: #666; }',
TRUE, 3);
-- 提交事务
COMMIT;
-- ================================================================
-- 验证脚本
-- ================================================================
-- SELECT * FROM certificate_templates;
-- SELECT COUNT(*) AS template_count FROM certificate_templates;