Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,234 +1,234 @@
|
||||
"""
|
||||
成长路径 API 端点
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.growth_path_service import growth_path_service
|
||||
from app.schemas.growth_path import (
|
||||
GrowthPathCreate,
|
||||
GrowthPathUpdate,
|
||||
GrowthPathResponse,
|
||||
GrowthPathListResponse,
|
||||
TraineeGrowthPathResponse,
|
||||
UserGrowthPathProgressResponse,
|
||||
StartGrowthPathRequest,
|
||||
CompleteNodeRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# =====================================================
|
||||
# 学员端 API
|
||||
# =====================================================
|
||||
|
||||
@router.get("/trainee/growth-path", response_model=Optional[TraineeGrowthPathResponse])
|
||||
async def get_trainee_growth_path(
|
||||
position_id: Optional[int] = Query(None, description="岗位ID,不传则自动匹配"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取学员的成长路径(含进度)
|
||||
|
||||
返回数据包含:
|
||||
- 成长路径基本信息
|
||||
- 各阶段及节点信息
|
||||
- 每个节点的学习状态(locked/unlocked/in_progress/completed)
|
||||
- 每个节点的课程学习进度
|
||||
"""
|
||||
try:
|
||||
result = await growth_path_service.get_trainee_growth_path(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
position_id=position_id
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"获取成长路径失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/trainee/growth-path/start")
|
||||
async def start_growth_path(
|
||||
request: StartGrowthPathRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
开始学习成长路径
|
||||
"""
|
||||
try:
|
||||
progress = await growth_path_service.start_growth_path(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
growth_path_id=request.growth_path_id
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "已开始学习成长路径",
|
||||
"progress_id": progress.id,
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"开始成长路径失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/trainee/growth-path/node/complete")
|
||||
async def complete_growth_path_node(
|
||||
request: CompleteNodeRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
完成成长路径节点
|
||||
"""
|
||||
try:
|
||||
result = await growth_path_service.complete_node(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
node_id=request.node_id
|
||||
)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"完成节点失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# =====================================================
|
||||
# 管理端 API
|
||||
# =====================================================
|
||||
|
||||
@router.get("/manager/growth-paths")
|
||||
async def list_growth_paths(
|
||||
position_id: Optional[int] = Query(None, description="岗位ID筛选"),
|
||||
is_active: Optional[bool] = Query(None, description="是否启用"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取成长路径列表(管理端)
|
||||
"""
|
||||
try:
|
||||
result = await growth_path_service.list_growth_paths(
|
||||
db=db,
|
||||
position_id=position_id,
|
||||
is_active=is_active,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"获取成长路径列表失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/manager/growth-paths")
|
||||
async def create_growth_path(
|
||||
data: GrowthPathCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
创建成长路径(管理端)
|
||||
"""
|
||||
try:
|
||||
growth_path = await growth_path_service.create_growth_path(
|
||||
db=db,
|
||||
data=data,
|
||||
created_by=current_user.id
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "创建成功",
|
||||
"id": growth_path.id,
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"创建成长路径失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/manager/growth-paths/{path_id}")
|
||||
async def get_growth_path(
|
||||
path_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取成长路径详情(管理端)
|
||||
"""
|
||||
try:
|
||||
result = await growth_path_service.get_growth_path(db=db, path_id=path_id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="成长路径不存在")
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取成长路径详情失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/manager/growth-paths/{path_id}")
|
||||
async def update_growth_path(
|
||||
path_id: int,
|
||||
data: GrowthPathUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
更新成长路径(管理端)
|
||||
"""
|
||||
try:
|
||||
await growth_path_service.update_growth_path(
|
||||
db=db,
|
||||
path_id=path_id,
|
||||
data=data
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "更新成功",
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"更新成长路径失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/manager/growth-paths/{path_id}")
|
||||
async def delete_growth_path(
|
||||
path_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
删除成长路径(管理端)
|
||||
"""
|
||||
try:
|
||||
await growth_path_service.delete_growth_path(db=db, path_id=path_id)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "删除成功",
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"删除成长路径失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
"""
|
||||
成长路径 API 端点
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.growth_path_service import growth_path_service
|
||||
from app.schemas.growth_path import (
|
||||
GrowthPathCreate,
|
||||
GrowthPathUpdate,
|
||||
GrowthPathResponse,
|
||||
GrowthPathListResponse,
|
||||
TraineeGrowthPathResponse,
|
||||
UserGrowthPathProgressResponse,
|
||||
StartGrowthPathRequest,
|
||||
CompleteNodeRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# =====================================================
|
||||
# 学员端 API
|
||||
# =====================================================
|
||||
|
||||
@router.get("/trainee/growth-path", response_model=Optional[TraineeGrowthPathResponse])
|
||||
async def get_trainee_growth_path(
|
||||
position_id: Optional[int] = Query(None, description="岗位ID,不传则自动匹配"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取学员的成长路径(含进度)
|
||||
|
||||
返回数据包含:
|
||||
- 成长路径基本信息
|
||||
- 各阶段及节点信息
|
||||
- 每个节点的学习状态(locked/unlocked/in_progress/completed)
|
||||
- 每个节点的课程学习进度
|
||||
"""
|
||||
try:
|
||||
result = await growth_path_service.get_trainee_growth_path(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
position_id=position_id
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"获取成长路径失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/trainee/growth-path/start")
|
||||
async def start_growth_path(
|
||||
request: StartGrowthPathRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
开始学习成长路径
|
||||
"""
|
||||
try:
|
||||
progress = await growth_path_service.start_growth_path(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
growth_path_id=request.growth_path_id
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "已开始学习成长路径",
|
||||
"progress_id": progress.id,
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"开始成长路径失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/trainee/growth-path/node/complete")
|
||||
async def complete_growth_path_node(
|
||||
request: CompleteNodeRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
完成成长路径节点
|
||||
"""
|
||||
try:
|
||||
result = await growth_path_service.complete_node(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
node_id=request.node_id
|
||||
)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"完成节点失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# =====================================================
|
||||
# 管理端 API
|
||||
# =====================================================
|
||||
|
||||
@router.get("/manager/growth-paths")
|
||||
async def list_growth_paths(
|
||||
position_id: Optional[int] = Query(None, description="岗位ID筛选"),
|
||||
is_active: Optional[bool] = Query(None, description="是否启用"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取成长路径列表(管理端)
|
||||
"""
|
||||
try:
|
||||
result = await growth_path_service.list_growth_paths(
|
||||
db=db,
|
||||
position_id=position_id,
|
||||
is_active=is_active,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"获取成长路径列表失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/manager/growth-paths")
|
||||
async def create_growth_path(
|
||||
data: GrowthPathCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
创建成长路径(管理端)
|
||||
"""
|
||||
try:
|
||||
growth_path = await growth_path_service.create_growth_path(
|
||||
db=db,
|
||||
data=data,
|
||||
created_by=current_user.id
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "创建成功",
|
||||
"id": growth_path.id,
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"创建成长路径失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/manager/growth-paths/{path_id}")
|
||||
async def get_growth_path(
|
||||
path_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取成长路径详情(管理端)
|
||||
"""
|
||||
try:
|
||||
result = await growth_path_service.get_growth_path(db=db, path_id=path_id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="成长路径不存在")
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取成长路径详情失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/manager/growth-paths/{path_id}")
|
||||
async def update_growth_path(
|
||||
path_id: int,
|
||||
data: GrowthPathUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
更新成长路径(管理端)
|
||||
"""
|
||||
try:
|
||||
await growth_path_service.update_growth_path(
|
||||
db=db,
|
||||
path_id=path_id,
|
||||
data=data
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "更新成功",
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"更新成长路径失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/manager/growth-paths/{path_id}")
|
||||
async def delete_growth_path(
|
||||
path_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
删除成长路径(管理端)
|
||||
"""
|
||||
try:
|
||||
await growth_path_service.delete_growth_path(db=db, path_id=path_id)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "删除成功",
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"删除成长路径失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -131,6 +132,20 @@ os.makedirs(upload_path, exist_ok=True)
|
||||
app.mount("/static/uploads", StaticFiles(directory=upload_path), name="uploads")
|
||||
|
||||
|
||||
# 请求验证错误处理 (422)
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""处理请求验证错误,记录详细日志"""
|
||||
logger.error(f"请求验证错误 [{request.method} {request.url.path}]: {exc.errors()}")
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={
|
||||
"detail": exc.errors(),
|
||||
"body": exc.body if hasattr(exc, 'body') else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# 全局异常处理
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request, exc):
|
||||
|
||||
@@ -1,118 +1,118 @@
|
||||
-- 成长路径功能数据库迁移脚本
|
||||
-- 创建时间: 2026-01-30
|
||||
|
||||
-- =====================================================
|
||||
-- 1. 修改 growth_paths 表,添加岗位关联
|
||||
-- =====================================================
|
||||
ALTER TABLE growth_paths
|
||||
ADD COLUMN IF NOT EXISTS position_id INT NULL COMMENT '关联岗位ID' AFTER target_role,
|
||||
ADD COLUMN IF NOT EXISTS stages JSON NULL COMMENT '阶段配置[{name, description, order}]' AFTER courses,
|
||||
ADD INDEX idx_position_id (position_id);
|
||||
|
||||
-- =====================================================
|
||||
-- 2. 创建成长路径节点表
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS growth_path_nodes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
growth_path_id INT NOT NULL COMMENT '成长路径ID',
|
||||
course_id INT NOT NULL COMMENT '课程ID',
|
||||
stage_name VARCHAR(100) NULL COMMENT '所属阶段名称',
|
||||
title VARCHAR(200) NOT NULL COMMENT '节点标题',
|
||||
description TEXT NULL COMMENT '节点描述',
|
||||
order_num INT DEFAULT 0 NOT NULL COMMENT '排序顺序',
|
||||
is_required BOOLEAN DEFAULT TRUE NOT NULL COMMENT '是否必修',
|
||||
prerequisites JSON NULL COMMENT '前置节点IDs [1, 2, 3]',
|
||||
estimated_days INT DEFAULT 7 COMMENT '预计学习天数',
|
||||
|
||||
-- 软删除
|
||||
is_deleted BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
deleted_at DATETIME NULL,
|
||||
|
||||
-- 时间戳
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
||||
|
||||
-- 索引
|
||||
INDEX idx_growth_path_id (growth_path_id),
|
||||
INDEX idx_course_id (course_id),
|
||||
INDEX idx_stage_name (stage_name),
|
||||
INDEX idx_order_num (order_num),
|
||||
INDEX idx_is_deleted (is_deleted),
|
||||
|
||||
-- 外键
|
||||
CONSTRAINT fk_gpn_growth_path FOREIGN KEY (growth_path_id)
|
||||
REFERENCES growth_paths(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_gpn_course FOREIGN KEY (course_id)
|
||||
REFERENCES courses(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='成长路径节点表';
|
||||
|
||||
-- =====================================================
|
||||
-- 3. 创建用户成长路径进度表
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS user_growth_path_progress (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
growth_path_id INT NOT NULL COMMENT '成长路径ID',
|
||||
current_node_id INT NULL COMMENT '当前学习节点ID',
|
||||
completed_node_ids JSON NULL COMMENT '已完成节点IDs [1, 2, 3]',
|
||||
total_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '总进度百分比',
|
||||
status VARCHAR(20) DEFAULT 'not_started' NOT NULL COMMENT '状态: not_started/in_progress/completed',
|
||||
|
||||
-- 时间记录
|
||||
started_at DATETIME NULL COMMENT '开始时间',
|
||||
completed_at DATETIME NULL COMMENT '完成时间',
|
||||
last_activity_at DATETIME NULL COMMENT '最后活动时间',
|
||||
|
||||
-- 时间戳
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
||||
|
||||
-- 索引
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_growth_path_id (growth_path_id),
|
||||
INDEX idx_status (status),
|
||||
UNIQUE KEY uk_user_growth_path (user_id, growth_path_id),
|
||||
|
||||
-- 外键
|
||||
CONSTRAINT fk_ugpp_user FOREIGN KEY (user_id)
|
||||
REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_ugpp_growth_path FOREIGN KEY (growth_path_id)
|
||||
REFERENCES growth_paths(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='用户成长路径进度表';
|
||||
|
||||
-- =====================================================
|
||||
-- 4. 创建用户节点完成记录表(详细记录每个节点的完成情况)
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS user_node_completions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
growth_path_id INT NOT NULL COMMENT '成长路径ID',
|
||||
node_id INT NOT NULL COMMENT '节点ID',
|
||||
course_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '课程学习进度',
|
||||
status VARCHAR(20) DEFAULT 'locked' NOT NULL COMMENT '状态: locked/unlocked/in_progress/completed',
|
||||
|
||||
-- 时间记录
|
||||
unlocked_at DATETIME NULL COMMENT '解锁时间',
|
||||
started_at DATETIME NULL COMMENT '开始学习时间',
|
||||
completed_at DATETIME NULL COMMENT '完成时间',
|
||||
|
||||
-- 时间戳
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
||||
|
||||
-- 索引
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_growth_path_id (growth_path_id),
|
||||
INDEX idx_node_id (node_id),
|
||||
INDEX idx_status (status),
|
||||
UNIQUE KEY uk_user_node (user_id, node_id),
|
||||
|
||||
-- 外键
|
||||
CONSTRAINT fk_unc_user FOREIGN KEY (user_id)
|
||||
REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_unc_node FOREIGN KEY (node_id)
|
||||
REFERENCES growth_path_nodes(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='用户节点完成记录表';
|
||||
-- 成长路径功能数据库迁移脚本
|
||||
-- 创建时间: 2026-01-30
|
||||
|
||||
-- =====================================================
|
||||
-- 1. 修改 growth_paths 表,添加岗位关联
|
||||
-- =====================================================
|
||||
ALTER TABLE growth_paths
|
||||
ADD COLUMN IF NOT EXISTS position_id INT NULL COMMENT '关联岗位ID' AFTER target_role,
|
||||
ADD COLUMN IF NOT EXISTS stages JSON NULL COMMENT '阶段配置[{name, description, order}]' AFTER courses,
|
||||
ADD INDEX idx_position_id (position_id);
|
||||
|
||||
-- =====================================================
|
||||
-- 2. 创建成长路径节点表
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS growth_path_nodes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
growth_path_id INT NOT NULL COMMENT '成长路径ID',
|
||||
course_id INT NOT NULL COMMENT '课程ID',
|
||||
stage_name VARCHAR(100) NULL COMMENT '所属阶段名称',
|
||||
title VARCHAR(200) NOT NULL COMMENT '节点标题',
|
||||
description TEXT NULL COMMENT '节点描述',
|
||||
order_num INT DEFAULT 0 NOT NULL COMMENT '排序顺序',
|
||||
is_required BOOLEAN DEFAULT TRUE NOT NULL COMMENT '是否必修',
|
||||
prerequisites JSON NULL COMMENT '前置节点IDs [1, 2, 3]',
|
||||
estimated_days INT DEFAULT 7 COMMENT '预计学习天数',
|
||||
|
||||
-- 软删除
|
||||
is_deleted BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
deleted_at DATETIME NULL,
|
||||
|
||||
-- 时间戳
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
||||
|
||||
-- 索引
|
||||
INDEX idx_growth_path_id (growth_path_id),
|
||||
INDEX idx_course_id (course_id),
|
||||
INDEX idx_stage_name (stage_name),
|
||||
INDEX idx_order_num (order_num),
|
||||
INDEX idx_is_deleted (is_deleted),
|
||||
|
||||
-- 外键
|
||||
CONSTRAINT fk_gpn_growth_path FOREIGN KEY (growth_path_id)
|
||||
REFERENCES growth_paths(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_gpn_course FOREIGN KEY (course_id)
|
||||
REFERENCES courses(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='成长路径节点表';
|
||||
|
||||
-- =====================================================
|
||||
-- 3. 创建用户成长路径进度表
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS user_growth_path_progress (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
growth_path_id INT NOT NULL COMMENT '成长路径ID',
|
||||
current_node_id INT NULL COMMENT '当前学习节点ID',
|
||||
completed_node_ids JSON NULL COMMENT '已完成节点IDs [1, 2, 3]',
|
||||
total_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '总进度百分比',
|
||||
status VARCHAR(20) DEFAULT 'not_started' NOT NULL COMMENT '状态: not_started/in_progress/completed',
|
||||
|
||||
-- 时间记录
|
||||
started_at DATETIME NULL COMMENT '开始时间',
|
||||
completed_at DATETIME NULL COMMENT '完成时间',
|
||||
last_activity_at DATETIME NULL COMMENT '最后活动时间',
|
||||
|
||||
-- 时间戳
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
||||
|
||||
-- 索引
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_growth_path_id (growth_path_id),
|
||||
INDEX idx_status (status),
|
||||
UNIQUE KEY uk_user_growth_path (user_id, growth_path_id),
|
||||
|
||||
-- 外键
|
||||
CONSTRAINT fk_ugpp_user FOREIGN KEY (user_id)
|
||||
REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_ugpp_growth_path FOREIGN KEY (growth_path_id)
|
||||
REFERENCES growth_paths(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='用户成长路径进度表';
|
||||
|
||||
-- =====================================================
|
||||
-- 4. 创建用户节点完成记录表(详细记录每个节点的完成情况)
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS user_node_completions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
growth_path_id INT NOT NULL COMMENT '成长路径ID',
|
||||
node_id INT NOT NULL COMMENT '节点ID',
|
||||
course_progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '课程学习进度',
|
||||
status VARCHAR(20) DEFAULT 'locked' NOT NULL COMMENT '状态: locked/unlocked/in_progress/completed',
|
||||
|
||||
-- 时间记录
|
||||
unlocked_at DATETIME NULL COMMENT '解锁时间',
|
||||
started_at DATETIME NULL COMMENT '开始学习时间',
|
||||
completed_at DATETIME NULL COMMENT '完成时间',
|
||||
|
||||
-- 时间戳
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
||||
|
||||
-- 索引
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_growth_path_id (growth_path_id),
|
||||
INDEX idx_node_id (node_id),
|
||||
INDEX idx_status (status),
|
||||
UNIQUE KEY uk_user_node (user_id, node_id),
|
||||
|
||||
-- 外键
|
||||
CONSTRAINT fk_unc_user FOREIGN KEY (user_id)
|
||||
REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_unc_node FOREIGN KEY (node_id)
|
||||
REFERENCES growth_path_nodes(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='用户节点完成记录表';
|
||||
|
||||
@@ -1,206 +1,206 @@
|
||||
"""
|
||||
成长路径相关数据库模型
|
||||
"""
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
String,
|
||||
Text,
|
||||
Integer,
|
||||
Boolean,
|
||||
ForeignKey,
|
||||
Enum as SQLEnum,
|
||||
JSON,
|
||||
DateTime,
|
||||
DECIMAL,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import BaseModel, SoftDeleteMixin
|
||||
|
||||
|
||||
class GrowthPathStatus(str, Enum):
|
||||
"""成长路径学习状态"""
|
||||
NOT_STARTED = "not_started" # 未开始
|
||||
IN_PROGRESS = "in_progress" # 进行中
|
||||
COMPLETED = "completed" # 已完成
|
||||
|
||||
|
||||
class NodeStatus(str, Enum):
|
||||
"""节点状态"""
|
||||
LOCKED = "locked" # 锁定(前置未完成)
|
||||
UNLOCKED = "unlocked" # 已解锁(可以开始)
|
||||
IN_PROGRESS = "in_progress" # 学习中
|
||||
COMPLETED = "completed" # 已完成
|
||||
|
||||
|
||||
class GrowthPathNode(BaseModel, SoftDeleteMixin):
|
||||
"""
|
||||
成长路径节点表
|
||||
每个节点对应一门课程
|
||||
"""
|
||||
__tablename__ = "growth_path_nodes"
|
||||
|
||||
# 关联
|
||||
growth_path_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("growth_paths.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="成长路径ID"
|
||||
)
|
||||
course_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("courses.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="课程ID"
|
||||
)
|
||||
|
||||
# 节点信息
|
||||
stage_name: Mapped[Optional[str]] = mapped_column(
|
||||
String(100), nullable=True, comment="所属阶段名称"
|
||||
)
|
||||
title: Mapped[str] = mapped_column(
|
||||
String(200), nullable=False, comment="节点标题"
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True, comment="节点描述"
|
||||
)
|
||||
|
||||
# 配置
|
||||
order_num: Mapped[int] = mapped_column(
|
||||
Integer, default=0, nullable=False, comment="排序顺序"
|
||||
)
|
||||
is_required: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, nullable=False, comment="是否必修"
|
||||
)
|
||||
prerequisites: Mapped[Optional[List[int]]] = mapped_column(
|
||||
JSON, nullable=True, comment="前置节点IDs"
|
||||
)
|
||||
estimated_days: Mapped[int] = mapped_column(
|
||||
Integer, default=7, nullable=False, comment="预计学习天数"
|
||||
)
|
||||
|
||||
# 关联关系
|
||||
growth_path: Mapped["GrowthPath"] = relationship(
|
||||
"GrowthPath", back_populates="nodes"
|
||||
)
|
||||
course: Mapped["Course"] = relationship("Course")
|
||||
user_completions: Mapped[List["UserNodeCompletion"]] = relationship(
|
||||
"UserNodeCompletion", back_populates="node"
|
||||
)
|
||||
|
||||
|
||||
class UserGrowthPathProgress(BaseModel):
|
||||
"""
|
||||
用户成长路径进度表
|
||||
记录用户在某条成长路径上的整体进度
|
||||
"""
|
||||
__tablename__ = "user_growth_path_progress"
|
||||
|
||||
# 关联
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="用户ID"
|
||||
)
|
||||
growth_path_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("growth_paths.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="成长路径ID"
|
||||
)
|
||||
|
||||
# 进度信息
|
||||
current_node_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, nullable=True, comment="当前学习节点ID"
|
||||
)
|
||||
completed_node_ids: Mapped[Optional[List[int]]] = mapped_column(
|
||||
JSON, nullable=True, comment="已完成节点IDs"
|
||||
)
|
||||
total_progress: Mapped[Decimal] = mapped_column(
|
||||
DECIMAL(5, 2), default=0.00, nullable=False, comment="总进度百分比"
|
||||
)
|
||||
|
||||
# 状态
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default=GrowthPathStatus.NOT_STARTED.value,
|
||||
nullable=False,
|
||||
comment="状态"
|
||||
)
|
||||
|
||||
# 时间记录
|
||||
started_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="开始时间"
|
||||
)
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="完成时间"
|
||||
)
|
||||
last_activity_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="最后活动时间"
|
||||
)
|
||||
|
||||
# 关联关系
|
||||
user: Mapped["User"] = relationship("User")
|
||||
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath")
|
||||
|
||||
|
||||
class UserNodeCompletion(BaseModel):
|
||||
"""
|
||||
用户节点完成记录表
|
||||
详细记录用户在每个节点上的学习状态
|
||||
"""
|
||||
__tablename__ = "user_node_completions"
|
||||
|
||||
# 关联
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="用户ID"
|
||||
)
|
||||
growth_path_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("growth_paths.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="成长路径ID"
|
||||
)
|
||||
node_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("growth_path_nodes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="节点ID"
|
||||
)
|
||||
|
||||
# 进度信息
|
||||
course_progress: Mapped[Decimal] = mapped_column(
|
||||
DECIMAL(5, 2), default=0.00, nullable=False, comment="课程学习进度"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default=NodeStatus.LOCKED.value,
|
||||
nullable=False,
|
||||
comment="状态"
|
||||
)
|
||||
|
||||
# 时间记录
|
||||
unlocked_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="解锁时间"
|
||||
)
|
||||
started_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="开始学习时间"
|
||||
)
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="完成时间"
|
||||
)
|
||||
|
||||
# 关联关系
|
||||
user: Mapped["User"] = relationship("User")
|
||||
node: Mapped["GrowthPathNode"] = relationship(
|
||||
"GrowthPathNode", back_populates="user_completions"
|
||||
)
|
||||
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath")
|
||||
"""
|
||||
成长路径相关数据库模型
|
||||
"""
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
String,
|
||||
Text,
|
||||
Integer,
|
||||
Boolean,
|
||||
ForeignKey,
|
||||
Enum as SQLEnum,
|
||||
JSON,
|
||||
DateTime,
|
||||
DECIMAL,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import BaseModel, SoftDeleteMixin
|
||||
|
||||
|
||||
class GrowthPathStatus(str, Enum):
|
||||
"""成长路径学习状态"""
|
||||
NOT_STARTED = "not_started" # 未开始
|
||||
IN_PROGRESS = "in_progress" # 进行中
|
||||
COMPLETED = "completed" # 已完成
|
||||
|
||||
|
||||
class NodeStatus(str, Enum):
|
||||
"""节点状态"""
|
||||
LOCKED = "locked" # 锁定(前置未完成)
|
||||
UNLOCKED = "unlocked" # 已解锁(可以开始)
|
||||
IN_PROGRESS = "in_progress" # 学习中
|
||||
COMPLETED = "completed" # 已完成
|
||||
|
||||
|
||||
class GrowthPathNode(BaseModel, SoftDeleteMixin):
|
||||
"""
|
||||
成长路径节点表
|
||||
每个节点对应一门课程
|
||||
"""
|
||||
__tablename__ = "growth_path_nodes"
|
||||
|
||||
# 关联
|
||||
growth_path_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("growth_paths.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="成长路径ID"
|
||||
)
|
||||
course_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("courses.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="课程ID"
|
||||
)
|
||||
|
||||
# 节点信息
|
||||
stage_name: Mapped[Optional[str]] = mapped_column(
|
||||
String(100), nullable=True, comment="所属阶段名称"
|
||||
)
|
||||
title: Mapped[str] = mapped_column(
|
||||
String(200), nullable=False, comment="节点标题"
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True, comment="节点描述"
|
||||
)
|
||||
|
||||
# 配置
|
||||
order_num: Mapped[int] = mapped_column(
|
||||
Integer, default=0, nullable=False, comment="排序顺序"
|
||||
)
|
||||
is_required: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, nullable=False, comment="是否必修"
|
||||
)
|
||||
prerequisites: Mapped[Optional[List[int]]] = mapped_column(
|
||||
JSON, nullable=True, comment="前置节点IDs"
|
||||
)
|
||||
estimated_days: Mapped[int] = mapped_column(
|
||||
Integer, default=7, nullable=False, comment="预计学习天数"
|
||||
)
|
||||
|
||||
# 关联关系
|
||||
growth_path: Mapped["GrowthPath"] = relationship(
|
||||
"GrowthPath", back_populates="nodes"
|
||||
)
|
||||
course: Mapped["Course"] = relationship("Course")
|
||||
user_completions: Mapped[List["UserNodeCompletion"]] = relationship(
|
||||
"UserNodeCompletion", back_populates="node"
|
||||
)
|
||||
|
||||
|
||||
class UserGrowthPathProgress(BaseModel):
|
||||
"""
|
||||
用户成长路径进度表
|
||||
记录用户在某条成长路径上的整体进度
|
||||
"""
|
||||
__tablename__ = "user_growth_path_progress"
|
||||
|
||||
# 关联
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="用户ID"
|
||||
)
|
||||
growth_path_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("growth_paths.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="成长路径ID"
|
||||
)
|
||||
|
||||
# 进度信息
|
||||
current_node_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, nullable=True, comment="当前学习节点ID"
|
||||
)
|
||||
completed_node_ids: Mapped[Optional[List[int]]] = mapped_column(
|
||||
JSON, nullable=True, comment="已完成节点IDs"
|
||||
)
|
||||
total_progress: Mapped[Decimal] = mapped_column(
|
||||
DECIMAL(5, 2), default=0.00, nullable=False, comment="总进度百分比"
|
||||
)
|
||||
|
||||
# 状态
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default=GrowthPathStatus.NOT_STARTED.value,
|
||||
nullable=False,
|
||||
comment="状态"
|
||||
)
|
||||
|
||||
# 时间记录
|
||||
started_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="开始时间"
|
||||
)
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="完成时间"
|
||||
)
|
||||
last_activity_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="最后活动时间"
|
||||
)
|
||||
|
||||
# 关联关系
|
||||
user: Mapped["User"] = relationship("User")
|
||||
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath")
|
||||
|
||||
|
||||
class UserNodeCompletion(BaseModel):
|
||||
"""
|
||||
用户节点完成记录表
|
||||
详细记录用户在每个节点上的学习状态
|
||||
"""
|
||||
__tablename__ = "user_node_completions"
|
||||
|
||||
# 关联
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="用户ID"
|
||||
)
|
||||
growth_path_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("growth_paths.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="成长路径ID"
|
||||
)
|
||||
node_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("growth_path_nodes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="节点ID"
|
||||
)
|
||||
|
||||
# 进度信息
|
||||
course_progress: Mapped[Decimal] = mapped_column(
|
||||
DECIMAL(5, 2), default=0.00, nullable=False, comment="课程学习进度"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default=NodeStatus.LOCKED.value,
|
||||
nullable=False,
|
||||
comment="状态"
|
||||
)
|
||||
|
||||
# 时间记录
|
||||
unlocked_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="解锁时间"
|
||||
)
|
||||
started_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="开始学习时间"
|
||||
)
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="完成时间"
|
||||
)
|
||||
|
||||
# 关联关系
|
||||
user: Mapped["User"] = relationship("User")
|
||||
node: Mapped["GrowthPathNode"] = relationship(
|
||||
"GrowthPathNode", back_populates="user_completions"
|
||||
)
|
||||
growth_path: Mapped["GrowthPath"] = relationship("GrowthPath")
|
||||
|
||||
@@ -1,226 +1,226 @@
|
||||
"""
|
||||
成长路径相关 Schema
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# =====================================================
|
||||
# 基础数据结构
|
||||
# =====================================================
|
||||
|
||||
class StageConfig(BaseModel):
|
||||
"""阶段配置"""
|
||||
name: str = Field(..., description="阶段名称")
|
||||
description: Optional[str] = Field(None, description="阶段描述")
|
||||
order: int = Field(0, description="排序")
|
||||
|
||||
|
||||
class NodeBase(BaseModel):
|
||||
"""节点基础信息"""
|
||||
course_id: int = Field(..., description="课程ID")
|
||||
stage_name: Optional[str] = Field(None, description="所属阶段名称")
|
||||
title: str = Field(..., description="节点标题")
|
||||
description: Optional[str] = Field(None, description="节点描述")
|
||||
order_num: int = Field(0, description="排序顺序")
|
||||
is_required: bool = Field(True, description="是否必修")
|
||||
prerequisites: Optional[List[int]] = Field(None, description="前置节点IDs")
|
||||
estimated_days: int = Field(7, description="预计学习天数")
|
||||
|
||||
|
||||
# =====================================================
|
||||
# 管理端 - 创建/更新
|
||||
# =====================================================
|
||||
|
||||
class GrowthPathNodeCreate(NodeBase):
|
||||
"""创建节点"""
|
||||
pass
|
||||
|
||||
|
||||
class GrowthPathNodeUpdate(BaseModel):
|
||||
"""更新节点"""
|
||||
course_id: Optional[int] = None
|
||||
stage_name: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
order_num: Optional[int] = None
|
||||
is_required: Optional[bool] = None
|
||||
prerequisites: Optional[List[int]] = None
|
||||
estimated_days: Optional[int] = None
|
||||
|
||||
|
||||
class GrowthPathCreate(BaseModel):
|
||||
"""创建成长路径"""
|
||||
name: str = Field(..., description="路径名称")
|
||||
description: Optional[str] = Field(None, description="路径描述")
|
||||
target_role: Optional[str] = Field(None, description="目标角色")
|
||||
position_id: Optional[int] = Field(None, description="关联岗位ID(兼容旧版)")
|
||||
position_ids: Optional[List[int]] = Field(None, description="关联岗位ID列表(支持多选)")
|
||||
stages: Optional[List[StageConfig]] = Field(None, description="阶段配置")
|
||||
estimated_duration_days: Optional[int] = Field(None, description="预计完成天数")
|
||||
is_active: bool = Field(True, description="是否启用")
|
||||
sort_order: int = Field(0, description="排序")
|
||||
nodes: Optional[List[GrowthPathNodeCreate]] = Field(None, description="节点列表")
|
||||
|
||||
|
||||
class GrowthPathUpdate(BaseModel):
|
||||
"""更新成长路径"""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
target_role: Optional[str] = None
|
||||
position_id: Optional[int] = None
|
||||
position_ids: Optional[List[int]] = None
|
||||
stages: Optional[List[StageConfig]] = None
|
||||
estimated_duration_days: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
sort_order: Optional[int] = None
|
||||
nodes: Optional[List[GrowthPathNodeCreate]] = None # 整体替换节点
|
||||
|
||||
|
||||
# =====================================================
|
||||
# 管理端 - 响应
|
||||
# =====================================================
|
||||
|
||||
class GrowthPathNodeResponse(NodeBase):
|
||||
"""节点响应"""
|
||||
id: int
|
||||
growth_path_id: int
|
||||
course_name: Optional[str] = None # 课程名称(关联查询)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GrowthPathResponse(BaseModel):
|
||||
"""成长路径响应(管理端)"""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
target_role: Optional[str] = None
|
||||
position_id: Optional[int] = None
|
||||
position_name: Optional[str] = None # 岗位名称(关联查询)
|
||||
stages: Optional[List[StageConfig]] = None
|
||||
estimated_duration_days: Optional[int] = None
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
nodes: List[GrowthPathNodeResponse] = []
|
||||
node_count: int = 0 # 节点数量
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GrowthPathListResponse(BaseModel):
|
||||
"""成长路径列表响应"""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
position_id: Optional[int] = None
|
||||
position_name: Optional[str] = None
|
||||
is_active: bool
|
||||
node_count: int = 0
|
||||
estimated_duration_days: Optional[int] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# =====================================================
|
||||
# 学员端 - 响应
|
||||
# =====================================================
|
||||
|
||||
class TraineeNodeResponse(BaseModel):
|
||||
"""学员端节点响应(含进度状态)"""
|
||||
id: int
|
||||
course_id: int
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
stage_name: Optional[str] = None
|
||||
is_required: bool
|
||||
estimated_days: int
|
||||
order_num: int
|
||||
|
||||
# 学员特有
|
||||
status: str = Field(..., description="状态: locked/unlocked/in_progress/completed")
|
||||
progress: float = Field(0, description="课程学习进度 0-100")
|
||||
|
||||
# 课程信息
|
||||
course_name: Optional[str] = None
|
||||
course_cover: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TraineeStageResponse(BaseModel):
|
||||
"""学员端阶段响应"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
completed: int = Field(0, description="已完成节点数")
|
||||
total: int = Field(0, description="总节点数")
|
||||
nodes: List[TraineeNodeResponse] = []
|
||||
|
||||
|
||||
class TraineeGrowthPathResponse(BaseModel):
|
||||
"""学员端成长路径响应"""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
position_id: Optional[int] = None
|
||||
position_name: Optional[str] = None
|
||||
|
||||
# 进度信息
|
||||
total_progress: float = Field(0, description="总进度百分比")
|
||||
completed_nodes: int = Field(0, description="已完成节点数")
|
||||
total_nodes: int = Field(0, description="总节点数")
|
||||
status: str = Field("not_started", description="状态: not_started/in_progress/completed")
|
||||
|
||||
# 时间信息
|
||||
started_at: Optional[datetime] = None
|
||||
estimated_completion_days: Optional[int] = None
|
||||
|
||||
# 阶段和节点
|
||||
stages: List[TraineeStageResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# =====================================================
|
||||
# 用户进度
|
||||
# =====================================================
|
||||
|
||||
class UserGrowthPathProgressResponse(BaseModel):
|
||||
"""用户成长路径进度响应"""
|
||||
id: int
|
||||
user_id: int
|
||||
growth_path_id: int
|
||||
growth_path_name: str
|
||||
current_node_id: Optional[int] = None
|
||||
current_node_title: Optional[str] = None
|
||||
completed_node_ids: List[int] = []
|
||||
total_progress: float
|
||||
status: str
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
last_activity_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class StartGrowthPathRequest(BaseModel):
|
||||
"""开始学习成长路径请求"""
|
||||
growth_path_id: int = Field(..., description="成长路径ID")
|
||||
|
||||
|
||||
class CompleteNodeRequest(BaseModel):
|
||||
"""完成节点请求"""
|
||||
node_id: int = Field(..., description="节点ID")
|
||||
"""
|
||||
成长路径相关 Schema
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# =====================================================
|
||||
# 基础数据结构
|
||||
# =====================================================
|
||||
|
||||
class StageConfig(BaseModel):
|
||||
"""阶段配置"""
|
||||
name: str = Field(..., description="阶段名称")
|
||||
description: Optional[str] = Field(None, description="阶段描述")
|
||||
order: int = Field(0, description="排序")
|
||||
|
||||
|
||||
class NodeBase(BaseModel):
|
||||
"""节点基础信息"""
|
||||
course_id: int = Field(..., description="课程ID")
|
||||
stage_name: Optional[str] = Field(None, description="所属阶段名称")
|
||||
title: str = Field(..., description="节点标题")
|
||||
description: Optional[str] = Field(None, description="节点描述")
|
||||
order_num: int = Field(0, description="排序顺序")
|
||||
is_required: bool = Field(True, description="是否必修")
|
||||
prerequisites: Optional[List[int]] = Field(None, description="前置节点IDs")
|
||||
estimated_days: int = Field(7, description="预计学习天数")
|
||||
|
||||
|
||||
# =====================================================
|
||||
# 管理端 - 创建/更新
|
||||
# =====================================================
|
||||
|
||||
class GrowthPathNodeCreate(NodeBase):
|
||||
"""创建节点"""
|
||||
pass
|
||||
|
||||
|
||||
class GrowthPathNodeUpdate(BaseModel):
|
||||
"""更新节点"""
|
||||
course_id: Optional[int] = None
|
||||
stage_name: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
order_num: Optional[int] = None
|
||||
is_required: Optional[bool] = None
|
||||
prerequisites: Optional[List[int]] = None
|
||||
estimated_days: Optional[int] = None
|
||||
|
||||
|
||||
class GrowthPathCreate(BaseModel):
|
||||
"""创建成长路径"""
|
||||
name: str = Field(..., description="路径名称")
|
||||
description: Optional[str] = Field(None, description="路径描述")
|
||||
target_role: Optional[str] = Field(None, description="目标角色")
|
||||
position_id: Optional[int] = Field(None, description="关联岗位ID(兼容旧版)")
|
||||
position_ids: Optional[List[int]] = Field(None, description="关联岗位ID列表(支持多选)")
|
||||
stages: Optional[List[StageConfig]] = Field(None, description="阶段配置")
|
||||
estimated_duration_days: Optional[int] = Field(None, description="预计完成天数")
|
||||
is_active: bool = Field(True, description="是否启用")
|
||||
sort_order: int = Field(0, description="排序")
|
||||
nodes: Optional[List[GrowthPathNodeCreate]] = Field(None, description="节点列表")
|
||||
|
||||
|
||||
class GrowthPathUpdate(BaseModel):
|
||||
"""更新成长路径"""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
target_role: Optional[str] = None
|
||||
position_id: Optional[int] = None
|
||||
position_ids: Optional[List[int]] = None
|
||||
stages: Optional[List[StageConfig]] = None
|
||||
estimated_duration_days: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
sort_order: Optional[int] = None
|
||||
nodes: Optional[List[GrowthPathNodeCreate]] = None # 整体替换节点
|
||||
|
||||
|
||||
# =====================================================
|
||||
# 管理端 - 响应
|
||||
# =====================================================
|
||||
|
||||
class GrowthPathNodeResponse(NodeBase):
|
||||
"""节点响应"""
|
||||
id: int
|
||||
growth_path_id: int
|
||||
course_name: Optional[str] = None # 课程名称(关联查询)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GrowthPathResponse(BaseModel):
|
||||
"""成长路径响应(管理端)"""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
target_role: Optional[str] = None
|
||||
position_id: Optional[int] = None
|
||||
position_name: Optional[str] = None # 岗位名称(关联查询)
|
||||
stages: Optional[List[StageConfig]] = None
|
||||
estimated_duration_days: Optional[int] = None
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
nodes: List[GrowthPathNodeResponse] = []
|
||||
node_count: int = 0 # 节点数量
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GrowthPathListResponse(BaseModel):
|
||||
"""成长路径列表响应"""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
position_id: Optional[int] = None
|
||||
position_name: Optional[str] = None
|
||||
is_active: bool
|
||||
node_count: int = 0
|
||||
estimated_duration_days: Optional[int] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# =====================================================
|
||||
# 学员端 - 响应
|
||||
# =====================================================
|
||||
|
||||
class TraineeNodeResponse(BaseModel):
|
||||
"""学员端节点响应(含进度状态)"""
|
||||
id: int
|
||||
course_id: int
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
stage_name: Optional[str] = None
|
||||
is_required: bool
|
||||
estimated_days: int
|
||||
order_num: int
|
||||
|
||||
# 学员特有
|
||||
status: str = Field(..., description="状态: locked/unlocked/in_progress/completed")
|
||||
progress: float = Field(0, description="课程学习进度 0-100")
|
||||
|
||||
# 课程信息
|
||||
course_name: Optional[str] = None
|
||||
course_cover: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TraineeStageResponse(BaseModel):
|
||||
"""学员端阶段响应"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
completed: int = Field(0, description="已完成节点数")
|
||||
total: int = Field(0, description="总节点数")
|
||||
nodes: List[TraineeNodeResponse] = []
|
||||
|
||||
|
||||
class TraineeGrowthPathResponse(BaseModel):
|
||||
"""学员端成长路径响应"""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
position_id: Optional[int] = None
|
||||
position_name: Optional[str] = None
|
||||
|
||||
# 进度信息
|
||||
total_progress: float = Field(0, description="总进度百分比")
|
||||
completed_nodes: int = Field(0, description="已完成节点数")
|
||||
total_nodes: int = Field(0, description="总节点数")
|
||||
status: str = Field("not_started", description="状态: not_started/in_progress/completed")
|
||||
|
||||
# 时间信息
|
||||
started_at: Optional[datetime] = None
|
||||
estimated_completion_days: Optional[int] = None
|
||||
|
||||
# 阶段和节点
|
||||
stages: List[TraineeStageResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# =====================================================
|
||||
# 用户进度
|
||||
# =====================================================
|
||||
|
||||
class UserGrowthPathProgressResponse(BaseModel):
|
||||
"""用户成长路径进度响应"""
|
||||
id: int
|
||||
user_id: int
|
||||
growth_path_id: int
|
||||
growth_path_name: str
|
||||
current_node_id: Optional[int] = None
|
||||
current_node_title: Optional[str] = None
|
||||
completed_node_ids: List[int] = []
|
||||
total_progress: float
|
||||
status: str
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
last_activity_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class StartGrowthPathRequest(BaseModel):
|
||||
"""开始学习成长路径请求"""
|
||||
growth_path_id: int = Field(..., description="成长路径ID")
|
||||
|
||||
|
||||
class CompleteNodeRequest(BaseModel):
|
||||
"""完成节点请求"""
|
||||
node_id: int = Field(..., description="节点ID")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,14 @@
|
||||
"""
|
||||
工具模块
|
||||
"""
|
||||
from app.utils.score_distributor import (
|
||||
ScoreDistributor,
|
||||
distribute_scores,
|
||||
get_question_score,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ScoreDistributor",
|
||||
"distribute_scores",
|
||||
"get_question_score",
|
||||
]
|
||||
"""
|
||||
工具模块
|
||||
"""
|
||||
from app.utils.score_distributor import (
|
||||
ScoreDistributor,
|
||||
distribute_scores,
|
||||
get_question_score,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ScoreDistributor",
|
||||
"distribute_scores",
|
||||
"get_question_score",
|
||||
]
|
||||
|
||||
@@ -1,218 +1,218 @@
|
||||
"""
|
||||
分数分配工具
|
||||
|
||||
解决题目分数无法整除的问题,确保:
|
||||
1. 所有题目分数之和精确等于总分
|
||||
2. 题目分数差异最小化(最多相差1分)
|
||||
3. 支持整数分配和小数分配两种模式
|
||||
"""
|
||||
from typing import List, Tuple
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
import math
|
||||
|
||||
|
||||
class ScoreDistributor:
|
||||
"""
|
||||
智能分数分配器
|
||||
|
||||
使用示例:
|
||||
distributor = ScoreDistributor(total_score=100, question_count=6)
|
||||
scores = distributor.distribute()
|
||||
# 结果: [17, 17, 17, 17, 16, 16] 总和=100
|
||||
"""
|
||||
|
||||
def __init__(self, total_score: float, question_count: int):
|
||||
"""
|
||||
初始化分配器
|
||||
|
||||
Args:
|
||||
total_score: 总分(如 100)
|
||||
question_count: 题目数量(如 6)
|
||||
"""
|
||||
if question_count <= 0:
|
||||
raise ValueError("题目数量必须大于0")
|
||||
if total_score <= 0:
|
||||
raise ValueError("总分必须大于0")
|
||||
|
||||
self.total_score = total_score
|
||||
self.question_count = question_count
|
||||
|
||||
def distribute_integer(self) -> List[int]:
|
||||
"""
|
||||
整数分配模式
|
||||
|
||||
将总分分配为整数,前面的题目分数可能比后面的多1分
|
||||
|
||||
Returns:
|
||||
分数列表,如 [17, 17, 17, 17, 16, 16]
|
||||
|
||||
示例:
|
||||
100分 / 6题 = [17, 17, 17, 17, 16, 16]
|
||||
100分 / 7题 = [15, 15, 14, 14, 14, 14, 14]
|
||||
"""
|
||||
total = int(self.total_score)
|
||||
count = self.question_count
|
||||
|
||||
# 基础分数(向下取整)
|
||||
base_score = total // count
|
||||
# 需要额外加1分的题目数量
|
||||
extra_count = total % count
|
||||
|
||||
# 生成分数列表
|
||||
scores = []
|
||||
for i in range(count):
|
||||
if i < extra_count:
|
||||
scores.append(base_score + 1)
|
||||
else:
|
||||
scores.append(base_score)
|
||||
|
||||
return scores
|
||||
|
||||
def distribute_decimal(self, decimal_places: int = 1) -> List[float]:
|
||||
"""
|
||||
小数分配模式
|
||||
|
||||
将总分分配为小数,最后一题用于补齐差额
|
||||
|
||||
Args:
|
||||
decimal_places: 小数位数,默认1位
|
||||
|
||||
Returns:
|
||||
分数列表,如 [16.7, 16.7, 16.7, 16.7, 16.7, 16.5]
|
||||
"""
|
||||
count = self.question_count
|
||||
|
||||
# 计算每题分数并四舍五入
|
||||
per_score = self.total_score / count
|
||||
rounded_score = round(per_score, decimal_places)
|
||||
|
||||
# 前 n-1 题使用四舍五入的分数
|
||||
scores = [rounded_score] * (count - 1)
|
||||
|
||||
# 最后一题用总分减去前面的和,确保总分精确
|
||||
last_score = round(self.total_score - sum(scores), decimal_places)
|
||||
scores.append(last_score)
|
||||
|
||||
return scores
|
||||
|
||||
def distribute(self, mode: str = "integer") -> List[float]:
|
||||
"""
|
||||
分配分数
|
||||
|
||||
Args:
|
||||
mode: 分配模式
|
||||
- "integer": 整数分配(推荐)
|
||||
- "decimal": 小数分配
|
||||
- "decimal_1": 保留1位小数
|
||||
- "decimal_2": 保留2位小数
|
||||
|
||||
Returns:
|
||||
分数列表
|
||||
"""
|
||||
if mode == "integer":
|
||||
return [float(s) for s in self.distribute_integer()]
|
||||
elif mode == "decimal" or mode == "decimal_1":
|
||||
return self.distribute_decimal(1)
|
||||
elif mode == "decimal_2":
|
||||
return self.distribute_decimal(2)
|
||||
else:
|
||||
return [float(s) for s in self.distribute_integer()]
|
||||
|
||||
def get_score_for_question(self, question_index: int, mode: str = "integer") -> float:
|
||||
"""
|
||||
获取指定题目的分数
|
||||
|
||||
Args:
|
||||
question_index: 题目索引(从0开始)
|
||||
mode: 分配模式
|
||||
|
||||
Returns:
|
||||
该题目的分数
|
||||
"""
|
||||
scores = self.distribute(mode)
|
||||
if 0 <= question_index < len(scores):
|
||||
return scores[question_index]
|
||||
raise IndexError(f"题目索引 {question_index} 超出范围 [0, {self.question_count})")
|
||||
|
||||
def validate(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
验证分配结果
|
||||
|
||||
Returns:
|
||||
(是否有效, 信息)
|
||||
"""
|
||||
scores = self.distribute()
|
||||
total = sum(scores)
|
||||
|
||||
if abs(total - self.total_score) < 0.01:
|
||||
return True, f"分配有效:{scores},总分={total}"
|
||||
else:
|
||||
return False, f"分配无效:{scores},总分={total},期望={self.total_score}"
|
||||
|
||||
@staticmethod
|
||||
def format_score(score: float, decimal_places: int = 1) -> str:
|
||||
"""
|
||||
格式化分数显示
|
||||
|
||||
Args:
|
||||
score: 分数
|
||||
decimal_places: 小数位数
|
||||
|
||||
Returns:
|
||||
格式化的分数字符串
|
||||
"""
|
||||
if score == int(score):
|
||||
return str(int(score))
|
||||
return f"{score:.{decimal_places}f}"
|
||||
|
||||
@staticmethod
|
||||
def calculate_pass_score(total_score: float, pass_rate: float = 0.6) -> float:
|
||||
"""
|
||||
计算及格分数
|
||||
|
||||
Args:
|
||||
total_score: 总分
|
||||
pass_rate: 及格率,默认60%
|
||||
|
||||
Returns:
|
||||
及格分数(整数)
|
||||
"""
|
||||
return math.ceil(total_score * pass_rate)
|
||||
|
||||
|
||||
def distribute_scores(total_score: float, question_count: int, mode: str = "integer") -> List[float]:
|
||||
"""
|
||||
便捷函数:分配分数
|
||||
|
||||
Args:
|
||||
total_score: 总分
|
||||
question_count: 题目数量
|
||||
mode: 分配模式(integer/decimal)
|
||||
|
||||
Returns:
|
||||
分数列表
|
||||
"""
|
||||
distributor = ScoreDistributor(total_score, question_count)
|
||||
return distributor.distribute(mode)
|
||||
|
||||
|
||||
def get_question_score(
|
||||
total_score: float,
|
||||
question_count: int,
|
||||
question_index: int,
|
||||
mode: str = "integer"
|
||||
) -> float:
|
||||
"""
|
||||
便捷函数:获取指定题目的分数
|
||||
|
||||
Args:
|
||||
total_score: 总分
|
||||
question_count: 题目数量
|
||||
question_index: 题目索引(从0开始)
|
||||
mode: 分配模式
|
||||
|
||||
Returns:
|
||||
该题目的分数
|
||||
"""
|
||||
distributor = ScoreDistributor(total_score, question_count)
|
||||
return distributor.get_score_for_question(question_index, mode)
|
||||
"""
|
||||
分数分配工具
|
||||
|
||||
解决题目分数无法整除的问题,确保:
|
||||
1. 所有题目分数之和精确等于总分
|
||||
2. 题目分数差异最小化(最多相差1分)
|
||||
3. 支持整数分配和小数分配两种模式
|
||||
"""
|
||||
from typing import List, Tuple
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
import math
|
||||
|
||||
|
||||
class ScoreDistributor:
|
||||
"""
|
||||
智能分数分配器
|
||||
|
||||
使用示例:
|
||||
distributor = ScoreDistributor(total_score=100, question_count=6)
|
||||
scores = distributor.distribute()
|
||||
# 结果: [17, 17, 17, 17, 16, 16] 总和=100
|
||||
"""
|
||||
|
||||
def __init__(self, total_score: float, question_count: int):
|
||||
"""
|
||||
初始化分配器
|
||||
|
||||
Args:
|
||||
total_score: 总分(如 100)
|
||||
question_count: 题目数量(如 6)
|
||||
"""
|
||||
if question_count <= 0:
|
||||
raise ValueError("题目数量必须大于0")
|
||||
if total_score <= 0:
|
||||
raise ValueError("总分必须大于0")
|
||||
|
||||
self.total_score = total_score
|
||||
self.question_count = question_count
|
||||
|
||||
def distribute_integer(self) -> List[int]:
|
||||
"""
|
||||
整数分配模式
|
||||
|
||||
将总分分配为整数,前面的题目分数可能比后面的多1分
|
||||
|
||||
Returns:
|
||||
分数列表,如 [17, 17, 17, 17, 16, 16]
|
||||
|
||||
示例:
|
||||
100分 / 6题 = [17, 17, 17, 17, 16, 16]
|
||||
100分 / 7题 = [15, 15, 14, 14, 14, 14, 14]
|
||||
"""
|
||||
total = int(self.total_score)
|
||||
count = self.question_count
|
||||
|
||||
# 基础分数(向下取整)
|
||||
base_score = total // count
|
||||
# 需要额外加1分的题目数量
|
||||
extra_count = total % count
|
||||
|
||||
# 生成分数列表
|
||||
scores = []
|
||||
for i in range(count):
|
||||
if i < extra_count:
|
||||
scores.append(base_score + 1)
|
||||
else:
|
||||
scores.append(base_score)
|
||||
|
||||
return scores
|
||||
|
||||
def distribute_decimal(self, decimal_places: int = 1) -> List[float]:
|
||||
"""
|
||||
小数分配模式
|
||||
|
||||
将总分分配为小数,最后一题用于补齐差额
|
||||
|
||||
Args:
|
||||
decimal_places: 小数位数,默认1位
|
||||
|
||||
Returns:
|
||||
分数列表,如 [16.7, 16.7, 16.7, 16.7, 16.7, 16.5]
|
||||
"""
|
||||
count = self.question_count
|
||||
|
||||
# 计算每题分数并四舍五入
|
||||
per_score = self.total_score / count
|
||||
rounded_score = round(per_score, decimal_places)
|
||||
|
||||
# 前 n-1 题使用四舍五入的分数
|
||||
scores = [rounded_score] * (count - 1)
|
||||
|
||||
# 最后一题用总分减去前面的和,确保总分精确
|
||||
last_score = round(self.total_score - sum(scores), decimal_places)
|
||||
scores.append(last_score)
|
||||
|
||||
return scores
|
||||
|
||||
def distribute(self, mode: str = "integer") -> List[float]:
|
||||
"""
|
||||
分配分数
|
||||
|
||||
Args:
|
||||
mode: 分配模式
|
||||
- "integer": 整数分配(推荐)
|
||||
- "decimal": 小数分配
|
||||
- "decimal_1": 保留1位小数
|
||||
- "decimal_2": 保留2位小数
|
||||
|
||||
Returns:
|
||||
分数列表
|
||||
"""
|
||||
if mode == "integer":
|
||||
return [float(s) for s in self.distribute_integer()]
|
||||
elif mode == "decimal" or mode == "decimal_1":
|
||||
return self.distribute_decimal(1)
|
||||
elif mode == "decimal_2":
|
||||
return self.distribute_decimal(2)
|
||||
else:
|
||||
return [float(s) for s in self.distribute_integer()]
|
||||
|
||||
def get_score_for_question(self, question_index: int, mode: str = "integer") -> float:
|
||||
"""
|
||||
获取指定题目的分数
|
||||
|
||||
Args:
|
||||
question_index: 题目索引(从0开始)
|
||||
mode: 分配模式
|
||||
|
||||
Returns:
|
||||
该题目的分数
|
||||
"""
|
||||
scores = self.distribute(mode)
|
||||
if 0 <= question_index < len(scores):
|
||||
return scores[question_index]
|
||||
raise IndexError(f"题目索引 {question_index} 超出范围 [0, {self.question_count})")
|
||||
|
||||
def validate(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
验证分配结果
|
||||
|
||||
Returns:
|
||||
(是否有效, 信息)
|
||||
"""
|
||||
scores = self.distribute()
|
||||
total = sum(scores)
|
||||
|
||||
if abs(total - self.total_score) < 0.01:
|
||||
return True, f"分配有效:{scores},总分={total}"
|
||||
else:
|
||||
return False, f"分配无效:{scores},总分={total},期望={self.total_score}"
|
||||
|
||||
@staticmethod
|
||||
def format_score(score: float, decimal_places: int = 1) -> str:
|
||||
"""
|
||||
格式化分数显示
|
||||
|
||||
Args:
|
||||
score: 分数
|
||||
decimal_places: 小数位数
|
||||
|
||||
Returns:
|
||||
格式化的分数字符串
|
||||
"""
|
||||
if score == int(score):
|
||||
return str(int(score))
|
||||
return f"{score:.{decimal_places}f}"
|
||||
|
||||
@staticmethod
|
||||
def calculate_pass_score(total_score: float, pass_rate: float = 0.6) -> float:
|
||||
"""
|
||||
计算及格分数
|
||||
|
||||
Args:
|
||||
total_score: 总分
|
||||
pass_rate: 及格率,默认60%
|
||||
|
||||
Returns:
|
||||
及格分数(整数)
|
||||
"""
|
||||
return math.ceil(total_score * pass_rate)
|
||||
|
||||
|
||||
def distribute_scores(total_score: float, question_count: int, mode: str = "integer") -> List[float]:
|
||||
"""
|
||||
便捷函数:分配分数
|
||||
|
||||
Args:
|
||||
total_score: 总分
|
||||
question_count: 题目数量
|
||||
mode: 分配模式(integer/decimal)
|
||||
|
||||
Returns:
|
||||
分数列表
|
||||
"""
|
||||
distributor = ScoreDistributor(total_score, question_count)
|
||||
return distributor.distribute(mode)
|
||||
|
||||
|
||||
def get_question_score(
|
||||
total_score: float,
|
||||
question_count: int,
|
||||
question_index: int,
|
||||
mode: str = "integer"
|
||||
) -> float:
|
||||
"""
|
||||
便捷函数:获取指定题目的分数
|
||||
|
||||
Args:
|
||||
total_score: 总分
|
||||
question_count: 题目数量
|
||||
question_index: 题目索引(从0开始)
|
||||
mode: 分配模式
|
||||
|
||||
Returns:
|
||||
该题目的分数
|
||||
"""
|
||||
distributor = ScoreDistributor(total_score, question_count)
|
||||
return distributor.get_score_for_question(question_index, mode)
|
||||
|
||||
@@ -1,201 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
修复历史考试的小数分数问题
|
||||
|
||||
将历史考试中的小数分数(如 0.9090909090909091)转换为整数分数
|
||||
使用智能整数分配算法,确保所有题目分数之和等于总分
|
||||
|
||||
使用方法:
|
||||
# 在后端容器中执行
|
||||
cd /app
|
||||
python scripts/fix_exam_scores.py --dry-run # 预览模式,不实际修改
|
||||
python scripts/fix_exam_scores.py # 实际执行修复
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from decimal import Decimal
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
def distribute_integer_scores(total_score: float, question_count: int) -> list:
|
||||
"""
|
||||
整数分配分数
|
||||
|
||||
将总分分配为整数,前面的题目分数可能比后面的多1分
|
||||
|
||||
示例:
|
||||
100分 / 6题 = [17, 17, 17, 17, 16, 16]
|
||||
100分 / 11题 = [10, 10, 10, 10, 10, 10, 10, 10, 10, 5, 5]
|
||||
"""
|
||||
total = int(total_score)
|
||||
count = question_count
|
||||
|
||||
# 基础分数(向下取整)
|
||||
base_score = total // count
|
||||
# 需要额外加1分的题目数量
|
||||
extra_count = total % count
|
||||
|
||||
# 生成分数列表
|
||||
scores = []
|
||||
for i in range(count):
|
||||
if i < extra_count:
|
||||
scores.append(base_score + 1)
|
||||
else:
|
||||
scores.append(base_score)
|
||||
|
||||
return scores
|
||||
|
||||
|
||||
def is_decimal_score(score) -> bool:
|
||||
"""检查分数是否是小数(非整数)"""
|
||||
if score is None:
|
||||
return False
|
||||
try:
|
||||
score_float = float(score)
|
||||
return score_float != int(score_float)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def fix_exam_scores(dry_run: bool = True, db_url: str = None):
|
||||
"""
|
||||
修复考试分数
|
||||
|
||||
Args:
|
||||
dry_run: 如果为 True,只预览不实际修改
|
||||
db_url: 数据库连接字符串,如果为 None 则从环境变量读取
|
||||
"""
|
||||
import os
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# 获取数据库连接
|
||||
if db_url is None:
|
||||
db_url = os.environ.get('DATABASE_URL')
|
||||
if not db_url:
|
||||
# 尝试从配置文件读取
|
||||
try:
|
||||
from app.core.config import settings
|
||||
db_url = settings.DATABASE_URL
|
||||
except:
|
||||
print("错误:无法获取数据库连接字符串")
|
||||
print("请设置 DATABASE_URL 环境变量或确保在正确的目录下运行")
|
||||
sys.exit(1)
|
||||
|
||||
# 将 mysql+aiomysql:// 转换为 mysql+pymysql://
|
||||
if 'aiomysql' in db_url:
|
||||
db_url = db_url.replace('aiomysql', 'pymysql')
|
||||
|
||||
print(f"连接数据库...")
|
||||
engine = create_engine(db_url, echo=False)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
try:
|
||||
# 查询所有考试记录
|
||||
result = session.execute(text("""
|
||||
SELECT id, user_id, course_id, exam_name, question_count, total_score, questions
|
||||
FROM exams
|
||||
WHERE questions IS NOT NULL
|
||||
ORDER BY id DESC
|
||||
"""))
|
||||
|
||||
exams = result.fetchall()
|
||||
print(f"找到 {len(exams)} 条考试记录")
|
||||
|
||||
fixed_count = 0
|
||||
skipped_count = 0
|
||||
error_count = 0
|
||||
|
||||
for exam in exams:
|
||||
exam_id, user_id, course_id, exam_name, question_count, total_score, questions_json = exam
|
||||
|
||||
try:
|
||||
# 解析 questions JSON
|
||||
if isinstance(questions_json, str):
|
||||
questions = json.loads(questions_json)
|
||||
else:
|
||||
questions = questions_json
|
||||
|
||||
if not questions or not isinstance(questions, list):
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# 检查是否有小数分数
|
||||
has_decimal = False
|
||||
for q in questions:
|
||||
if 'score' in q and is_decimal_score(q['score']):
|
||||
has_decimal = True
|
||||
break
|
||||
|
||||
if not has_decimal:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# 计算新的整数分数
|
||||
actual_count = len(questions)
|
||||
actual_total = total_score or 100
|
||||
new_scores = distribute_integer_scores(actual_total, actual_count)
|
||||
|
||||
# 更新每道题的分数
|
||||
old_scores = [q.get('score', 0) for q in questions]
|
||||
for i, q in enumerate(questions):
|
||||
q['score'] = new_scores[i]
|
||||
|
||||
# 验证总分
|
||||
new_total = sum(new_scores)
|
||||
if abs(new_total - actual_total) > 0.01:
|
||||
print(f" 警告:exam_id={exam_id} 分数总和不匹配: {new_total} != {actual_total}")
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
print(f"[预览] exam_id={exam_id}, 课程={course_id}, 题数={actual_count}, 总分={actual_total}")
|
||||
print(f" 旧分数: {old_scores[:5]}..." if len(old_scores) > 5 else f" 旧分数: {old_scores}")
|
||||
print(f" 新分数: {new_scores[:5]}..." if len(new_scores) > 5 else f" 新分数: {new_scores}")
|
||||
else:
|
||||
# 实际更新数据库
|
||||
new_json = json.dumps(questions, ensure_ascii=False)
|
||||
session.execute(text("""
|
||||
UPDATE exams SET questions = :questions WHERE id = :exam_id
|
||||
"""), {"questions": new_json, "exam_id": exam_id})
|
||||
print(f"[已修复] exam_id={exam_id}, 题数={actual_count}, 分数: {new_scores}")
|
||||
|
||||
fixed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" 错误:处理 exam_id={exam_id} 时出错: {e}")
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
if not dry_run:
|
||||
session.commit()
|
||||
print("\n已提交数据库更改")
|
||||
|
||||
print(f"\n=== 统计 ===")
|
||||
print(f"需要修复: {fixed_count}")
|
||||
print(f"已跳过(无小数): {skipped_count}")
|
||||
print(f"错误: {error_count}")
|
||||
|
||||
if dry_run:
|
||||
print("\n这是预览模式,未实际修改数据库。")
|
||||
print("如需实际执行,请去掉 --dry-run 参数重新运行。")
|
||||
|
||||
except Exception as e:
|
||||
print(f"执行失败: {e}")
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="修复历史考试的小数分数问题")
|
||||
parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际修改数据库")
|
||||
parser.add_argument("--db-url", type=str, help="数据库连接字符串")
|
||||
args = parser.parse_args()
|
||||
|
||||
fix_exam_scores(dry_run=args.dry_run, db_url=args.db_url)
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
修复历史考试的小数分数问题
|
||||
|
||||
将历史考试中的小数分数(如 0.9090909090909091)转换为整数分数
|
||||
使用智能整数分配算法,确保所有题目分数之和等于总分
|
||||
|
||||
使用方法:
|
||||
# 在后端容器中执行
|
||||
cd /app
|
||||
python scripts/fix_exam_scores.py --dry-run # 预览模式,不实际修改
|
||||
python scripts/fix_exam_scores.py # 实际执行修复
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from decimal import Decimal
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
def distribute_integer_scores(total_score: float, question_count: int) -> list:
|
||||
"""
|
||||
整数分配分数
|
||||
|
||||
将总分分配为整数,前面的题目分数可能比后面的多1分
|
||||
|
||||
示例:
|
||||
100分 / 6题 = [17, 17, 17, 17, 16, 16]
|
||||
100分 / 11题 = [10, 10, 10, 10, 10, 10, 10, 10, 10, 5, 5]
|
||||
"""
|
||||
total = int(total_score)
|
||||
count = question_count
|
||||
|
||||
# 基础分数(向下取整)
|
||||
base_score = total // count
|
||||
# 需要额外加1分的题目数量
|
||||
extra_count = total % count
|
||||
|
||||
# 生成分数列表
|
||||
scores = []
|
||||
for i in range(count):
|
||||
if i < extra_count:
|
||||
scores.append(base_score + 1)
|
||||
else:
|
||||
scores.append(base_score)
|
||||
|
||||
return scores
|
||||
|
||||
|
||||
def is_decimal_score(score) -> bool:
|
||||
"""检查分数是否是小数(非整数)"""
|
||||
if score is None:
|
||||
return False
|
||||
try:
|
||||
score_float = float(score)
|
||||
return score_float != int(score_float)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def fix_exam_scores(dry_run: bool = True, db_url: str = None):
|
||||
"""
|
||||
修复考试分数
|
||||
|
||||
Args:
|
||||
dry_run: 如果为 True,只预览不实际修改
|
||||
db_url: 数据库连接字符串,如果为 None 则从环境变量读取
|
||||
"""
|
||||
import os
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# 获取数据库连接
|
||||
if db_url is None:
|
||||
db_url = os.environ.get('DATABASE_URL')
|
||||
if not db_url:
|
||||
# 尝试从配置文件读取
|
||||
try:
|
||||
from app.core.config import settings
|
||||
db_url = settings.DATABASE_URL
|
||||
except:
|
||||
print("错误:无法获取数据库连接字符串")
|
||||
print("请设置 DATABASE_URL 环境变量或确保在正确的目录下运行")
|
||||
sys.exit(1)
|
||||
|
||||
# 将 mysql+aiomysql:// 转换为 mysql+pymysql://
|
||||
if 'aiomysql' in db_url:
|
||||
db_url = db_url.replace('aiomysql', 'pymysql')
|
||||
|
||||
print(f"连接数据库...")
|
||||
engine = create_engine(db_url, echo=False)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
try:
|
||||
# 查询所有考试记录
|
||||
result = session.execute(text("""
|
||||
SELECT id, user_id, course_id, exam_name, question_count, total_score, questions
|
||||
FROM exams
|
||||
WHERE questions IS NOT NULL
|
||||
ORDER BY id DESC
|
||||
"""))
|
||||
|
||||
exams = result.fetchall()
|
||||
print(f"找到 {len(exams)} 条考试记录")
|
||||
|
||||
fixed_count = 0
|
||||
skipped_count = 0
|
||||
error_count = 0
|
||||
|
||||
for exam in exams:
|
||||
exam_id, user_id, course_id, exam_name, question_count, total_score, questions_json = exam
|
||||
|
||||
try:
|
||||
# 解析 questions JSON
|
||||
if isinstance(questions_json, str):
|
||||
questions = json.loads(questions_json)
|
||||
else:
|
||||
questions = questions_json
|
||||
|
||||
if not questions or not isinstance(questions, list):
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# 检查是否有小数分数
|
||||
has_decimal = False
|
||||
for q in questions:
|
||||
if 'score' in q and is_decimal_score(q['score']):
|
||||
has_decimal = True
|
||||
break
|
||||
|
||||
if not has_decimal:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# 计算新的整数分数
|
||||
actual_count = len(questions)
|
||||
actual_total = total_score or 100
|
||||
new_scores = distribute_integer_scores(actual_total, actual_count)
|
||||
|
||||
# 更新每道题的分数
|
||||
old_scores = [q.get('score', 0) for q in questions]
|
||||
for i, q in enumerate(questions):
|
||||
q['score'] = new_scores[i]
|
||||
|
||||
# 验证总分
|
||||
new_total = sum(new_scores)
|
||||
if abs(new_total - actual_total) > 0.01:
|
||||
print(f" 警告:exam_id={exam_id} 分数总和不匹配: {new_total} != {actual_total}")
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
print(f"[预览] exam_id={exam_id}, 课程={course_id}, 题数={actual_count}, 总分={actual_total}")
|
||||
print(f" 旧分数: {old_scores[:5]}..." if len(old_scores) > 5 else f" 旧分数: {old_scores}")
|
||||
print(f" 新分数: {new_scores[:5]}..." if len(new_scores) > 5 else f" 新分数: {new_scores}")
|
||||
else:
|
||||
# 实际更新数据库
|
||||
new_json = json.dumps(questions, ensure_ascii=False)
|
||||
session.execute(text("""
|
||||
UPDATE exams SET questions = :questions WHERE id = :exam_id
|
||||
"""), {"questions": new_json, "exam_id": exam_id})
|
||||
print(f"[已修复] exam_id={exam_id}, 题数={actual_count}, 分数: {new_scores}")
|
||||
|
||||
fixed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" 错误:处理 exam_id={exam_id} 时出错: {e}")
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
if not dry_run:
|
||||
session.commit()
|
||||
print("\n已提交数据库更改")
|
||||
|
||||
print(f"\n=== 统计 ===")
|
||||
print(f"需要修复: {fixed_count}")
|
||||
print(f"已跳过(无小数): {skipped_count}")
|
||||
print(f"错误: {error_count}")
|
||||
|
||||
if dry_run:
|
||||
print("\n这是预览模式,未实际修改数据库。")
|
||||
print("如需实际执行,请去掉 --dry-run 参数重新运行。")
|
||||
|
||||
except Exception as e:
|
||||
print(f"执行失败: {e}")
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="修复历史考试的小数分数问题")
|
||||
parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际修改数据库")
|
||||
parser.add_argument("--db-url", type=str, help="数据库连接字符串")
|
||||
args = parser.parse_args()
|
||||
|
||||
fix_exam_scores(dry_run=args.dry_run, db_url=args.db_url)
|
||||
|
||||
Reference in New Issue
Block a user