提交
This commit is contained in:
1 parent
48f15745be
commit
e421030ada
72 files changed
+17064
-35
No files matched your search
+556
@@ -0,0 +1,556 @@
|
||||
package org.dromara.xg.controller;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
import java.math.BigDecimal;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.constraints.*;
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.dromara.common.idempotent.annotation.RepeatSubmit;
|
||||
import org.dromara.common.log.annotation.Log;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.log.enums.BusinessType;
|
||||
import org.dromara.common.excel.utils.ExcelUtil;
|
||||
import org.dromara.xg.domain.vo.XgConductScoreVo;
|
||||
import org.dromara.xg.domain.bo.XgConductScoreBo;
|
||||
import org.dromara.xg.domain.bo.XgConductScoreQueryBo;
|
||||
import org.dromara.xg.domain.bo.XgConductScoreBatchBo;
|
||||
import org.dromara.xg.domain.vo.XgConductScoreBatchResultVo;
|
||||
import org.dromara.xg.domain.vo.XgConductScoreClassSummaryVo;
|
||||
import org.dromara.xg.domain.vo.XgConductScoreSemesterAverageVo;
|
||||
import org.dromara.xg.service.IXgConductScoreService;
|
||||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* 学生操行分
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/xg/conduct-score")
|
||||
public class XgConductScoreController extends BaseController {
|
||||
|
||||
private final IXgConductScoreService xgConductScoreService;
|
||||
|
||||
/**
|
||||
* 获取班主任负责的班级学生列表(用于录入)
|
||||
*/
|
||||
// @SaCheckPermission("xg:conductScore:classStudents")
|
||||
@GetMapping("/class-students")
|
||||
public R<XgConductScoreClassSummaryVo> classStudents(
|
||||
@NotNull(message = "学年不能为空") Integer academicYear,
|
||||
@NotNull(message = "学期不能为空") Integer semester,
|
||||
@NotNull(message = "月份不能为空") Integer month) {
|
||||
return R.ok(xgConductScoreService.getClassStudentsByTeacher(academicYear, semester, month));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量录入操行分
|
||||
*/
|
||||
// @SaCheckPermission("xg:conductScore:batchAdd")
|
||||
@RepeatSubmit()
|
||||
@Log(title = "学生操行分", businessType = BusinessType.INSERT)
|
||||
@PostMapping("/batch")
|
||||
public R<XgConductScoreBatchResultVo> batchAdd(@Validated @RequestBody XgConductScoreBatchBo bo) {
|
||||
return R.ok(xgConductScoreService.batchInsertConductScores(bo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询学生操行分列表
|
||||
*/
|
||||
// @SaCheckPermission("xg:conductScore:list")
|
||||
@GetMapping("/list")
|
||||
public TableDataInfo<XgConductScoreVo> list(XgConductScoreQueryBo bo, PageQuery pageQuery) {
|
||||
return xgConductScoreService.queryPageList(bo, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据组织ID查询学生操行分列表
|
||||
*/
|
||||
// @SaCheckPermission("xg:conductScore:list")
|
||||
@PostMapping("/listByDeptId")
|
||||
public TableDataInfo<XgConductScoreVo> listByDeptId(@RequestBody Map<String, Object> requestBody,
|
||||
PageQuery pageQuery) {
|
||||
// 从请求体中解析排序参数
|
||||
if (requestBody.containsKey("orderByColumn")) {
|
||||
pageQuery.setOrderByColumn((String) requestBody.get("orderByColumn"));
|
||||
}
|
||||
if (requestBody.containsKey("isAsc")) {
|
||||
pageQuery.setIsAsc((String) requestBody.get("isAsc"));
|
||||
}
|
||||
|
||||
// 从请求体中解析查询条件和部门ID
|
||||
XgConductScoreQueryBo bo = new XgConductScoreQueryBo();
|
||||
|
||||
// 设置基础查询条件
|
||||
if (requestBody.containsKey("academicYear") && requestBody.get("academicYear") != null
|
||||
&& !"".equals(requestBody.get("academicYear").toString())) {
|
||||
bo.setAcademicYear(Integer.valueOf(requestBody.get("academicYear").toString()));
|
||||
}
|
||||
if (requestBody.containsKey("semester") && requestBody.get("semester") != null
|
||||
&& !"".equals(requestBody.get("semester").toString())) {
|
||||
bo.setSemester(Integer.valueOf(requestBody.get("semester").toString()));
|
||||
}
|
||||
if (requestBody.containsKey("month") && requestBody.get("month") != null
|
||||
&& !"".equals(requestBody.get("month").toString())) {
|
||||
bo.setMonth(Integer.valueOf(requestBody.get("month").toString()));
|
||||
}
|
||||
if (requestBody.containsKey("studentName")) {
|
||||
bo.setStudentName((String) requestBody.get("studentName"));
|
||||
}
|
||||
if (requestBody.containsKey("studentNo")) {
|
||||
bo.setStudentNo((String) requestBody.get("studentNo"));
|
||||
}
|
||||
if (requestBody.containsKey("scoreMin")) {
|
||||
bo.setScoreMin(requestBody.get("scoreMin") != null
|
||||
? new java.math.BigDecimal(requestBody.get("scoreMin").toString())
|
||||
: null);
|
||||
}
|
||||
if (requestBody.containsKey("scoreMax")) {
|
||||
bo.setScoreMax(requestBody.get("scoreMax") != null
|
||||
? new java.math.BigDecimal(requestBody.get("scoreMax").toString())
|
||||
: null);
|
||||
}
|
||||
if (requestBody.containsKey("className")) {
|
||||
bo.setClassName((String) requestBody.get("className"));
|
||||
}
|
||||
if (requestBody.containsKey("collegeName")) {
|
||||
bo.setCollegeName((String) requestBody.get("collegeName"));
|
||||
}
|
||||
if (requestBody.containsKey("majorName")) {
|
||||
bo.setMajorName((String) requestBody.get("majorName"));
|
||||
}
|
||||
|
||||
// 设置时间范围查询条件
|
||||
Map<String, Object> params = bo.getParams();
|
||||
if (requestBody.containsKey("beginTime")) {
|
||||
params.put("beginTime", requestBody.get("beginTime"));
|
||||
}
|
||||
if (requestBody.containsKey("endTime")) {
|
||||
params.put("endTime", requestBody.get("endTime"));
|
||||
}
|
||||
|
||||
// 提取部门ID参数
|
||||
Map<String, Object> deptParams = new HashMap<>();
|
||||
// 支持多种参数名称:deptId(驼峰)和 dept_id(下划线)
|
||||
Object deptIdValue = null;
|
||||
if (requestBody.containsKey("deptId") && requestBody.get("deptId") != null) {
|
||||
deptIdValue = requestBody.get("deptId");
|
||||
} else if (requestBody.containsKey("dept_id") && requestBody.get("dept_id") != null) {
|
||||
deptIdValue = requestBody.get("dept_id");
|
||||
}
|
||||
|
||||
// 如果请求体中显式包含 dept_id 且值为 null,则按需求返回空数据
|
||||
if (requestBody.containsKey("dept_id") && requestBody.get("dept_id") == null) {
|
||||
log.info("listByDeptId - 请求中 dept_id 为 null,返回空分页数据");
|
||||
return TableDataInfo.build(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>());
|
||||
}
|
||||
|
||||
if (deptIdValue != null) {
|
||||
deptParams.put("dept_id", deptIdValue);
|
||||
}
|
||||
|
||||
return xgConductScoreService.queryPageListByDeptId(bo, deptParams, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出学生操行分列表
|
||||
*/
|
||||
// @SaCheckPermission("xg:conductScore:export")
|
||||
@Log(title = "学生操行分", businessType = BusinessType.EXPORT)
|
||||
@PostMapping("/export")
|
||||
public void export(@RequestParam Map<String, Object> requestBody, HttpServletResponse response) {
|
||||
// 从请求体中解析查询条件
|
||||
XgConductScoreQueryBo bo = new XgConductScoreQueryBo();
|
||||
|
||||
// 设置基础查询条件
|
||||
if (requestBody.containsKey("academicYear") && requestBody.get("academicYear") != null
|
||||
&& !"".equals(requestBody.get("academicYear").toString())) {
|
||||
bo.setAcademicYear(Integer.valueOf(requestBody.get("academicYear").toString()));
|
||||
}
|
||||
if (requestBody.containsKey("semester") && requestBody.get("semester") != null
|
||||
&& !"".equals(requestBody.get("semester").toString())) {
|
||||
bo.setSemester(Integer.valueOf(requestBody.get("semester").toString()));
|
||||
}
|
||||
if (requestBody.containsKey("month") && requestBody.get("month") != null
|
||||
&& !"".equals(requestBody.get("month").toString())) {
|
||||
bo.setMonth(Integer.valueOf(requestBody.get("month").toString()));
|
||||
}
|
||||
if (requestBody.containsKey("studentName")) {
|
||||
bo.setStudentName((String) requestBody.get("studentName"));
|
||||
}
|
||||
if (requestBody.containsKey("studentNo")) {
|
||||
bo.setStudentNo((String) requestBody.get("studentNo"));
|
||||
}
|
||||
if (requestBody.containsKey("scoreMin")) {
|
||||
bo.setScoreMin(requestBody.get("scoreMin") != null
|
||||
? new java.math.BigDecimal(requestBody.get("scoreMin").toString())
|
||||
: null);
|
||||
}
|
||||
if (requestBody.containsKey("scoreMax")) {
|
||||
bo.setScoreMax(requestBody.get("scoreMax") != null
|
||||
? new java.math.BigDecimal(requestBody.get("scoreMax").toString())
|
||||
: null);
|
||||
}
|
||||
if (requestBody.containsKey("className")) {
|
||||
bo.setClassName((String) requestBody.get("className"));
|
||||
}
|
||||
if (requestBody.containsKey("collegeName")) {
|
||||
bo.setCollegeName((String) requestBody.get("collegeName"));
|
||||
}
|
||||
if (requestBody.containsKey("majorName")) {
|
||||
bo.setMajorName((String) requestBody.get("majorName"));
|
||||
}
|
||||
|
||||
// 设置时间范围查询条件
|
||||
Map<String, Object> params = bo.getParams();
|
||||
if (requestBody.containsKey("beginTime")) {
|
||||
params.put("beginTime", requestBody.get("beginTime"));
|
||||
}
|
||||
if (requestBody.containsKey("endTime")) {
|
||||
params.put("endTime", requestBody.get("endTime"));
|
||||
}
|
||||
|
||||
// 提取部门ID参数
|
||||
Map<String, Object> deptParams = new HashMap<>();
|
||||
// 支持多种参数名称:deptId(驼峰)和 dept_id(下划线)
|
||||
Object deptIdValue = null;
|
||||
if (requestBody.containsKey("deptId") && requestBody.get("deptId") != null) {
|
||||
deptIdValue = requestBody.get("deptId");
|
||||
} else if (requestBody.containsKey("dept_id") && requestBody.get("dept_id") != null) {
|
||||
deptIdValue = requestBody.get("dept_id");
|
||||
}
|
||||
|
||||
if (deptIdValue != null) {
|
||||
deptParams.put("dept_id", deptIdValue);
|
||||
}
|
||||
|
||||
// 使用权限过滤的查询方法
|
||||
List<XgConductScoreVo> list = xgConductScoreService.queryListByDeptId(bo, deptParams);
|
||||
|
||||
// 如果需要包含评判结果,进行处理
|
||||
if (requestBody.containsKey("includeEvaluation") &&
|
||||
"true".equals(requestBody.get("includeEvaluation").toString())) {
|
||||
for (XgConductScoreVo vo : list) {
|
||||
if (vo.getScore() != null) {
|
||||
vo.setEvaluationResult(calculateEvaluationResult(vo.getScore()));
|
||||
} else {
|
||||
vo.setEvaluationResult("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExcelUtil.exportExcel(list, "学生操行分", XgConductScoreVo.class, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算评判结果
|
||||
*/
|
||||
private String calculateEvaluationResult(BigDecimal score) {
|
||||
if (score == null) {
|
||||
return "";
|
||||
}
|
||||
double scoreValue = score.doubleValue();
|
||||
if (scoreValue >= 85) {
|
||||
return "优秀";
|
||||
} else if (scoreValue >= 75) {
|
||||
return "良好";
|
||||
} else if (scoreValue >= 60) {
|
||||
return "合格";
|
||||
} else {
|
||||
return "不合格";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询学期平均分统计
|
||||
*/
|
||||
// @SaCheckPermission("xg:conductScore:semesterAverage")
|
||||
@GetMapping("/semester-average")
|
||||
public TableDataInfo<XgConductScoreSemesterAverageVo> semesterAverage(
|
||||
@NotNull(message = "学年不能为空") Integer academicYear,
|
||||
@NotNull(message = "学期不能为空") Integer semester,
|
||||
XgConductScoreQueryBo bo, PageQuery pageQuery) {
|
||||
return xgConductScoreService.getSemesterAverage(academicYear, semester, bo, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据部门ID查询学期平均分统计(带权限过滤)
|
||||
*/
|
||||
// @SaCheckPermission("xg:conductScore:semesterAverage")
|
||||
@PostMapping("/semester-average-by-dept-id")
|
||||
public TableDataInfo<XgConductScoreSemesterAverageVo> semesterAverageByDeptId(
|
||||
@RequestBody Map<String, Object> requestBody) {
|
||||
// 从请求体中解析分页参数
|
||||
PageQuery pageQuery = new PageQuery();
|
||||
if (requestBody.containsKey("pageNum") && requestBody.get("pageNum") != null) {
|
||||
pageQuery.setPageNum(Integer.valueOf(requestBody.get("pageNum").toString()));
|
||||
} else {
|
||||
pageQuery.setPageNum(1);
|
||||
}
|
||||
if (requestBody.containsKey("pageSize") && requestBody.get("pageSize") != null) {
|
||||
pageQuery.setPageSize(Integer.valueOf(requestBody.get("pageSize").toString()));
|
||||
} else {
|
||||
pageQuery.setPageSize(10);
|
||||
}
|
||||
|
||||
// 从请求体中解析查询条件和部门ID
|
||||
XgConductScoreQueryBo bo = new XgConductScoreQueryBo();
|
||||
|
||||
// 设置基础查询条件
|
||||
if (requestBody.containsKey("academicYear") && requestBody.get("academicYear") != null
|
||||
&& !"".equals(requestBody.get("academicYear").toString())) {
|
||||
bo.setAcademicYear(Integer.valueOf(requestBody.get("academicYear").toString()));
|
||||
}
|
||||
if (requestBody.containsKey("semester") && requestBody.get("semester") != null
|
||||
&& !"".equals(requestBody.get("semester").toString())) {
|
||||
bo.setSemester(Integer.valueOf(requestBody.get("semester").toString()));
|
||||
}
|
||||
if (requestBody.containsKey("month") && requestBody.get("month") != null
|
||||
&& !"".equals(requestBody.get("month").toString())) {
|
||||
bo.setMonth(Integer.valueOf(requestBody.get("month").toString()));
|
||||
}
|
||||
if (requestBody.containsKey("studentName")) {
|
||||
bo.setStudentName((String) requestBody.get("studentName"));
|
||||
}
|
||||
if (requestBody.containsKey("studentNo")) {
|
||||
bo.setStudentNo((String) requestBody.get("studentNo"));
|
||||
}
|
||||
if (requestBody.containsKey("collegeName")) {
|
||||
bo.setCollegeName((String) requestBody.get("collegeName"));
|
||||
}
|
||||
if (requestBody.containsKey("majorName")) {
|
||||
bo.setMajorName((String) requestBody.get("majorName"));
|
||||
}
|
||||
if (requestBody.containsKey("className")) {
|
||||
bo.setClassName((String) requestBody.get("className"));
|
||||
}
|
||||
if (requestBody.containsKey("scoreMin")) {
|
||||
bo.setScoreMin(requestBody.get("scoreMin") != null
|
||||
? new java.math.BigDecimal(requestBody.get("scoreMin").toString())
|
||||
: null);
|
||||
}
|
||||
if (requestBody.containsKey("scoreMax")) {
|
||||
bo.setScoreMax(requestBody.get("scoreMax") != null
|
||||
? new java.math.BigDecimal(requestBody.get("scoreMax").toString())
|
||||
: null);
|
||||
}
|
||||
|
||||
// 解析学年和学期(必需参数,用于业务逻辑)
|
||||
Integer academicYear = null;
|
||||
Integer semester = null;
|
||||
if (bo.getAcademicYear() != null) {
|
||||
academicYear = bo.getAcademicYear();
|
||||
} else {
|
||||
throw new IllegalArgumentException("学年不能为空");
|
||||
}
|
||||
if (bo.getSemester() != null) {
|
||||
semester = bo.getSemester();
|
||||
} else {
|
||||
throw new IllegalArgumentException("学期不能为空");
|
||||
}
|
||||
|
||||
// 设置时间范围查询条件
|
||||
Map<String, Object> params = bo.getParams();
|
||||
if (requestBody.containsKey("beginTime")) {
|
||||
params.put("beginTime", requestBody.get("beginTime"));
|
||||
}
|
||||
if (requestBody.containsKey("endTime")) {
|
||||
params.put("endTime", requestBody.get("endTime"));
|
||||
}
|
||||
|
||||
// 提取部门ID参数
|
||||
Map<String, Object> deptParams = new HashMap<>();
|
||||
// 支持多种参数名称:deptId(驼峰)和 dept_id(下划线)
|
||||
Object deptIdValue = null;
|
||||
if (requestBody.containsKey("deptId") && requestBody.get("deptId") != null) {
|
||||
deptIdValue = requestBody.get("deptId");
|
||||
} else if (requestBody.containsKey("dept_id") && requestBody.get("dept_id") != null) {
|
||||
deptIdValue = requestBody.get("dept_id");
|
||||
}
|
||||
|
||||
// 如果请求体中显式包含 dept_id 且值为 null,则按需求返回空数据
|
||||
if (requestBody.containsKey("dept_id") && requestBody.get("dept_id") == null) {
|
||||
log.info("semesterAverageByDeptId - 请求中 dept_id 为 null,返回空分页数据");
|
||||
return TableDataInfo.build(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>());
|
||||
}
|
||||
|
||||
if (deptIdValue != null) {
|
||||
deptParams.put("dept_id", deptIdValue);
|
||||
}
|
||||
|
||||
return xgConductScoreService.getSemesterAverageByDeptId(academicYear, semester, bo, deptParams, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出学期平均分统计
|
||||
*/
|
||||
// @SaCheckPermission("xg:conductScore:exportSemester")
|
||||
@Log(title = "学期平均分统计", businessType = BusinessType.EXPORT)
|
||||
@PostMapping("/export-semester")
|
||||
public void exportSemester(@RequestParam Map<String, Object> requestBody, HttpServletResponse response) {
|
||||
// 从请求体中解析查询条件
|
||||
XgConductScoreQueryBo bo = new XgConductScoreQueryBo();
|
||||
|
||||
// 设置基础查询条件
|
||||
if (requestBody.containsKey("academicYear") && requestBody.get("academicYear") != null
|
||||
&& !"".equals(requestBody.get("academicYear").toString())) {
|
||||
bo.setAcademicYear(Integer.valueOf(requestBody.get("academicYear").toString()));
|
||||
}
|
||||
if (requestBody.containsKey("semester") && requestBody.get("semester") != null
|
||||
&& !"".equals(requestBody.get("semester").toString())) {
|
||||
bo.setSemester(Integer.valueOf(requestBody.get("semester").toString()));
|
||||
}
|
||||
if (requestBody.containsKey("month") && requestBody.get("month") != null
|
||||
&& !"".equals(requestBody.get("month").toString())) {
|
||||
bo.setMonth(Integer.valueOf(requestBody.get("month").toString()));
|
||||
}
|
||||
if (requestBody.containsKey("studentName")) {
|
||||
bo.setStudentName((String) requestBody.get("studentName"));
|
||||
}
|
||||
if (requestBody.containsKey("studentNo")) {
|
||||
bo.setStudentNo((String) requestBody.get("studentNo"));
|
||||
}
|
||||
if (requestBody.containsKey("collegeName")) {
|
||||
bo.setCollegeName((String) requestBody.get("collegeName"));
|
||||
}
|
||||
if (requestBody.containsKey("majorName")) {
|
||||
bo.setMajorName((String) requestBody.get("majorName"));
|
||||
}
|
||||
if (requestBody.containsKey("className")) {
|
||||
bo.setClassName((String) requestBody.get("className"));
|
||||
}
|
||||
if (requestBody.containsKey("scoreMin")) {
|
||||
bo.setScoreMin(requestBody.get("scoreMin") != null
|
||||
? new java.math.BigDecimal(requestBody.get("scoreMin").toString())
|
||||
: null);
|
||||
}
|
||||
if (requestBody.containsKey("scoreMax")) {
|
||||
bo.setScoreMax(requestBody.get("scoreMax") != null
|
||||
? new java.math.BigDecimal(requestBody.get("scoreMax").toString())
|
||||
: null);
|
||||
}
|
||||
|
||||
// 解析学年和学期(必需参数,用于业务逻辑)
|
||||
Integer academicYear = null;
|
||||
Integer semester = null;
|
||||
if (bo.getAcademicYear() != null) {
|
||||
academicYear = bo.getAcademicYear();
|
||||
} else {
|
||||
throw new IllegalArgumentException("学年不能为空");
|
||||
}
|
||||
if (bo.getSemester() != null) {
|
||||
semester = bo.getSemester();
|
||||
} else {
|
||||
throw new IllegalArgumentException("学期不能为空");
|
||||
}
|
||||
|
||||
// 设置时间范围查询条件
|
||||
Map<String, Object> params = bo.getParams();
|
||||
if (requestBody.containsKey("beginTime")) {
|
||||
params.put("beginTime", requestBody.get("beginTime"));
|
||||
}
|
||||
if (requestBody.containsKey("endTime")) {
|
||||
params.put("endTime", requestBody.get("endTime"));
|
||||
}
|
||||
|
||||
// 提取部门ID参数
|
||||
Map<String, Object> deptParams = new HashMap<>();
|
||||
// 支持多种参数名称:deptId(驼峰)和 dept_id(下划线)
|
||||
Object deptIdValue = null;
|
||||
if (requestBody.containsKey("deptId") && requestBody.get("deptId") != null) {
|
||||
deptIdValue = requestBody.get("deptId");
|
||||
} else if (requestBody.containsKey("dept_id") && requestBody.get("dept_id") != null) {
|
||||
deptIdValue = requestBody.get("dept_id");
|
||||
}
|
||||
|
||||
if (deptIdValue != null) {
|
||||
deptParams.put("dept_id", deptIdValue);
|
||||
}
|
||||
|
||||
// 使用权限过滤的查询方法获取所有数据(设置大分页以获取全部数据)
|
||||
PageQuery pageQuery = new PageQuery();
|
||||
pageQuery.setPageNum(1);
|
||||
pageQuery.setPageSize(999999); // 设置足够大的页面大小以获取所有数据
|
||||
|
||||
List<XgConductScoreSemesterAverageVo> list = xgConductScoreService
|
||||
.getSemesterAverageByDeptId(academicYear, semester, bo, deptParams, pageQuery).getRows();
|
||||
|
||||
// 为每条数据计算评判结果
|
||||
for (XgConductScoreSemesterAverageVo vo : list) {
|
||||
if (vo.getAvgScore() != null && vo.getRecordCount() != null && vo.getRecordCount() > 0) {
|
||||
vo.setEvaluationResult(calculateEvaluationResult(vo.getAvgScore()));
|
||||
} else {
|
||||
vo.setEvaluationResult("");
|
||||
}
|
||||
}
|
||||
|
||||
ExcelUtil.exportExcel(list, "学期平均分统计", XgConductScoreSemesterAverageVo.class, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个学生操行分记录详情
|
||||
*/
|
||||
// @SaCheckPermission("xg:conductScore:studentRecords")
|
||||
@GetMapping("/student-records/{studentId}")
|
||||
public R<org.dromara.xg.domain.vo.XgConductScoreStudentRecordsVo> getStudentRecords(
|
||||
@NotNull(message = "学生ID不能为空") @PathVariable Long studentId,
|
||||
Integer academicYear, Integer semester, String tenantId) {
|
||||
return R.ok(xgConductScoreService.getStudentRecords(studentId, academicYear, semester, tenantId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取班级详细统计数据
|
||||
*/
|
||||
// @SaCheckPermission("xg:conductScore:classDetail")
|
||||
@GetMapping("/class-detail/{classId}")
|
||||
public R<org.dromara.xg.domain.vo.XgConductScoreClassDetailVo> getClassDetail(
|
||||
@NotNull(message = "班级ID不能为空") @PathVariable Long classId,
|
||||
@NotNull(message = "学年不能为空") Integer academicYear,
|
||||
@NotNull(message = "学期不能为空") Integer semester) {
|
||||
return R.ok(xgConductScoreService.getClassDetailStatistics(classId, academicYear, semester));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改单个操行分记录
|
||||
*/
|
||||
// @SaCheckPermission("xg:conductScore:update")
|
||||
@RepeatSubmit()
|
||||
@Log(title = "学生操行分", businessType = BusinessType.UPDATE)
|
||||
@PutMapping("/{id}")
|
||||
public R<Void> updateRecord(@NotNull(message = "记录ID不能为空") @PathVariable Long id,
|
||||
@Validated @RequestBody XgConductScoreBo bo) {
|
||||
return toAjax(xgConductScoreService.updateRecordById(id, bo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除学生操行分
|
||||
*/
|
||||
// @SaCheckPermission("xg:conductScore:remove")
|
||||
@Log(title = "学生操行分", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{ids}")
|
||||
public R<Void> remove(@NotEmpty(message = "主键不能为空") @PathVariable Long[] ids) {
|
||||
return toAjax(xgConductScoreService.deleteWithValidByIds(List.of(ids), true));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证录入权限
|
||||
*/
|
||||
// @SaCheckPermission("xg:conductScore:checkPermission")
|
||||
@GetMapping("/check-permission")
|
||||
public R<Boolean> checkPermission() {
|
||||
return R.ok(xgConductScoreService.checkInputPermission());
|
||||
}
|
||||
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package org.dromara.xg.domain;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 学生操行分对象 xg_conduct_score
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class XgConductScore extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** ID */
|
||||
private Long id;
|
||||
|
||||
/** 租户ID */
|
||||
private String tenantId;
|
||||
|
||||
/** 学年 */
|
||||
private Integer academicYear;
|
||||
|
||||
/** 学期 */
|
||||
private Integer semester;
|
||||
|
||||
/** 月份 */
|
||||
private Integer month;
|
||||
|
||||
/** 学生ID */
|
||||
private Long studentId;
|
||||
|
||||
/** 班级ID */
|
||||
private Long classId;
|
||||
|
||||
/** 班主任ID */
|
||||
private Long classTeacherId;
|
||||
|
||||
/** 分数 */
|
||||
private BigDecimal score;
|
||||
|
||||
/** 备注 */
|
||||
private String remark;
|
||||
|
||||
/** 删除标志 */
|
||||
private Integer deleted;
|
||||
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package org.dromara.xg.domain.bo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 学生操行分批量录入业务对象
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Data
|
||||
public class XgConductScoreBatchBo {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 学年
|
||||
*/
|
||||
private Integer academicYear;
|
||||
|
||||
/**
|
||||
* 学期
|
||||
*/
|
||||
private Integer semester;
|
||||
|
||||
/**
|
||||
* 月份
|
||||
*/
|
||||
private Integer month;
|
||||
|
||||
/**
|
||||
* 班级ID(前端传递字符串避免精度问题)
|
||||
*/
|
||||
private String classId;
|
||||
|
||||
/**
|
||||
* 录入分数列表
|
||||
*/
|
||||
private List<XgConductScoreBatchItemBo> scores;
|
||||
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package org.dromara.xg.domain.bo;
|
||||
|
||||
import lombok.Data;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 学生操行分批量录入项业务对象
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Data
|
||||
public class XgConductScoreBatchItemBo {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 学生ID
|
||||
*/
|
||||
@NotNull(message = "学生ID不能为空")
|
||||
private Long studentId;
|
||||
|
||||
/**
|
||||
* 分数
|
||||
*/
|
||||
@NotNull(message = "分数不能为空")
|
||||
@DecimalMin(value = "0", message = "分数不能小于0")
|
||||
@DecimalMax(value = "100", message = "分数不能大于100")
|
||||
private BigDecimal score;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package org.dromara.xg.domain.bo;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.dromara.common.core.validate.AddGroup;
|
||||
import org.dromara.common.core.validate.EditGroup;
|
||||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 学生操行分业务对象
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class XgConductScoreBo extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* ID
|
||||
*/
|
||||
@NotNull(message = "ID不能为空", groups = { EditGroup.class })
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 租户ID
|
||||
*/
|
||||
private String tenantId;
|
||||
|
||||
/**
|
||||
* 学年
|
||||
*/
|
||||
@NotNull(message = "学年不能为空", groups = { AddGroup.class, EditGroup.class })
|
||||
private Integer academicYear;
|
||||
|
||||
/**
|
||||
* 学期
|
||||
*/
|
||||
@NotNull(message = "学期不能为空", groups = { AddGroup.class, EditGroup.class })
|
||||
private Integer semester;
|
||||
|
||||
/**
|
||||
* 月份
|
||||
*/
|
||||
@NotNull(message = "月份不能为空", groups = { AddGroup.class, EditGroup.class })
|
||||
private Integer month;
|
||||
|
||||
/**
|
||||
* 学生ID
|
||||
*/
|
||||
@NotNull(message = "学生ID不能为空", groups = { AddGroup.class, EditGroup.class })
|
||||
private Long studentId;
|
||||
|
||||
/**
|
||||
* 班级ID
|
||||
*/
|
||||
@NotNull(message = "班级ID不能为空", groups = { AddGroup.class, EditGroup.class })
|
||||
private Long classId;
|
||||
|
||||
/**
|
||||
* 班主任ID
|
||||
*/
|
||||
private Long classTeacherId;
|
||||
|
||||
/**
|
||||
* 分数
|
||||
*/
|
||||
@NotNull(message = "分数不能为空", groups = { AddGroup.class, EditGroup.class })
|
||||
@DecimalMin(value = "0", message = "分数不能小于0", groups = { AddGroup.class, EditGroup.class })
|
||||
@DecimalMax(value = "100", message = "分数不能大于100", groups = { AddGroup.class, EditGroup.class })
|
||||
private BigDecimal score;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package org.dromara.xg.domain.bo;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 学生操行分查询业务对象
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class XgConductScoreQueryBo extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 租户ID
|
||||
*/
|
||||
private String tenantId;
|
||||
|
||||
/**
|
||||
* 学年
|
||||
*/
|
||||
private Integer academicYear;
|
||||
|
||||
/**
|
||||
* 学期
|
||||
*/
|
||||
private Integer semester;
|
||||
|
||||
/**
|
||||
* 月份
|
||||
*/
|
||||
private Integer month;
|
||||
|
||||
/**
|
||||
* 学生ID
|
||||
*/
|
||||
private Long studentId;
|
||||
|
||||
/**
|
||||
* 学生姓名
|
||||
*/
|
||||
private String studentName;
|
||||
|
||||
/**
|
||||
* 学号
|
||||
*/
|
||||
private String studentNo;
|
||||
|
||||
/**
|
||||
* 班级ID
|
||||
*/
|
||||
private Long classId;
|
||||
|
||||
/**
|
||||
* 班级名称
|
||||
*/
|
||||
private String className;
|
||||
|
||||
/**
|
||||
* 学院名称
|
||||
*/
|
||||
private String collegeName;
|
||||
|
||||
/**
|
||||
* 专业名称
|
||||
*/
|
||||
private String majorName;
|
||||
|
||||
/**
|
||||
* 班主任ID
|
||||
*/
|
||||
private Long classTeacherId;
|
||||
|
||||
/**
|
||||
* 最低分数
|
||||
*/
|
||||
private BigDecimal scoreMin;
|
||||
|
||||
/**
|
||||
* 最高分数
|
||||
*/
|
||||
private BigDecimal scoreMax;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 开始时间
|
||||
*/
|
||||
private String beginTime;
|
||||
|
||||
/**
|
||||
* 结束时间
|
||||
*/
|
||||
private String endTime;
|
||||
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package org.dromara.xg.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 操行分批量录入结果视图对象
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Data
|
||||
public class XgConductScoreBatchResultVo {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 成功录入数量
|
||||
*/
|
||||
private Integer successCount;
|
||||
|
||||
/**
|
||||
* 失败录入数量
|
||||
*/
|
||||
private Integer failCount;
|
||||
|
||||
/**
|
||||
* 失败详情列表
|
||||
*/
|
||||
private List<String> failList;
|
||||
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package org.dromara.xg.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 班级操行分详细统计视图对象
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Data
|
||||
public class XgConductScoreClassDetailVo {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 班级ID
|
||||
*/
|
||||
private Long classId;
|
||||
|
||||
/**
|
||||
* 班级名称
|
||||
*/
|
||||
private String className;
|
||||
|
||||
/**
|
||||
* 学年
|
||||
*/
|
||||
private Integer academicYear;
|
||||
|
||||
/**
|
||||
* 学期
|
||||
*/
|
||||
private Integer semester;
|
||||
|
||||
/**
|
||||
* 总学生数
|
||||
*/
|
||||
private Integer totalStudents;
|
||||
|
||||
/**
|
||||
* 已录入学生数
|
||||
*/
|
||||
private Integer recordedStudents;
|
||||
|
||||
/**
|
||||
* 录入率
|
||||
*/
|
||||
private BigDecimal recordRate;
|
||||
|
||||
/**
|
||||
* 班级平均分
|
||||
*/
|
||||
private BigDecimal classAverage;
|
||||
|
||||
/**
|
||||
* 最高分
|
||||
*/
|
||||
private BigDecimal maxScore;
|
||||
|
||||
/**
|
||||
* 最低分
|
||||
*/
|
||||
private BigDecimal minScore;
|
||||
|
||||
/**
|
||||
* 学生详细列表
|
||||
*/
|
||||
private List<XgConductScoreClassStudentDetailVo> students;
|
||||
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package org.dromara.xg.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 班级学生操行分详细视图对象
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Data
|
||||
public class XgConductScoreClassStudentDetailVo {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 学生ID
|
||||
*/
|
||||
private Long studentId;
|
||||
|
||||
/**
|
||||
* 学号
|
||||
*/
|
||||
private String studentNo;
|
||||
|
||||
/**
|
||||
* 学生姓名
|
||||
*/
|
||||
private String studentName;
|
||||
|
||||
/**
|
||||
* 性别
|
||||
*/
|
||||
private String gender;
|
||||
|
||||
/**
|
||||
* 平均分
|
||||
*/
|
||||
private BigDecimal avgScore;
|
||||
|
||||
/**
|
||||
* 录入次数
|
||||
*/
|
||||
private Integer recordCount;
|
||||
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package org.dromara.xg.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 班级学生视图对象
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Data
|
||||
public class XgConductScoreClassStudentVo {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 学生ID
|
||||
*/
|
||||
private Long studentId;
|
||||
|
||||
/**
|
||||
* 学号
|
||||
*/
|
||||
private String studentNo;
|
||||
|
||||
/**
|
||||
* 学生姓名
|
||||
*/
|
||||
private String studentName;
|
||||
|
||||
/**
|
||||
* 性别
|
||||
*/
|
||||
private String gender;
|
||||
|
||||
/**
|
||||
* 上月分数
|
||||
*/
|
||||
private BigDecimal lastScore;
|
||||
|
||||
/**
|
||||
* 上月月份
|
||||
*/
|
||||
private Integer lastMonth;
|
||||
|
||||
/**
|
||||
* 现有分数
|
||||
*/
|
||||
private XgConductScoreDetailVo existingScore;
|
||||
|
||||
/**
|
||||
* 创建者
|
||||
*/
|
||||
private Long createBy;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 更新者
|
||||
*/
|
||||
private Long updateBy;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private Date updateTime;
|
||||
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package org.dromara.xg.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 操行分班级汇总视图对象
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Data
|
||||
public class XgConductScoreClassSummaryVo {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 班级列表
|
||||
*/
|
||||
private List<XgConductScoreClassVo> classes;
|
||||
|
||||
/**
|
||||
* 汇总统计
|
||||
*/
|
||||
private XgConductScoreSummaryVo summary;
|
||||
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package org.dromara.xg.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 班主任班级视图对象
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Data
|
||||
public class XgConductScoreClassVo {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 班级ID
|
||||
*/
|
||||
private Long classId;
|
||||
|
||||
/**
|
||||
* 班级名称
|
||||
*/
|
||||
private String className;
|
||||
|
||||
/**
|
||||
* 班级代码
|
||||
*/
|
||||
private String classCode;
|
||||
|
||||
/**
|
||||
* 学生人数
|
||||
*/
|
||||
private Integer studentCount;
|
||||
|
||||
/**
|
||||
* 入学年份
|
||||
*/
|
||||
private Integer enrollmentYear;
|
||||
|
||||
/**
|
||||
* 年级
|
||||
*/
|
||||
private String grade;
|
||||
|
||||
/**
|
||||
* 专业名称
|
||||
*/
|
||||
private String majorName;
|
||||
|
||||
/**
|
||||
* 学生列表
|
||||
*/
|
||||
private List<XgConductScoreClassStudentVo> students;
|
||||
|
||||
/**
|
||||
* 创建者
|
||||
*/
|
||||
private Long createBy;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 更新者
|
||||
*/
|
||||
private Long updateBy;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private Date updateTime;
|
||||
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package org.dromara.xg.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 操行分详情视图对象
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Data
|
||||
public class XgConductScoreDetailVo {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 分数
|
||||
*/
|
||||
private BigDecimal score;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 创建者
|
||||
*/
|
||||
private Long createBy;
|
||||
|
||||
/**
|
||||
* 更新者
|
||||
*/
|
||||
private Long updateBy;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private Date updateTime;
|
||||
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package org.dromara.xg.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 操行分记录详情视图对象
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Data
|
||||
public class XgConductScoreRecordDetailVo {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 学年
|
||||
*/
|
||||
private Integer academicYear;
|
||||
|
||||
/**
|
||||
* 学期
|
||||
*/
|
||||
private Integer semester;
|
||||
|
||||
/**
|
||||
* 月份
|
||||
*/
|
||||
private Integer month;
|
||||
|
||||
/**
|
||||
* 分数
|
||||
*/
|
||||
private BigDecimal score;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 创建者
|
||||
*/
|
||||
private Long createBy;
|
||||
|
||||
/**
|
||||
* 创建者姓名
|
||||
*/
|
||||
private String createByName;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 更新者
|
||||
*/
|
||||
private Long updateBy;
|
||||
|
||||
/**
|
||||
* 更新者姓名
|
||||
*/
|
||||
private String updateByName;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private Date updateTime;
|
||||
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package org.dromara.xg.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
|
||||
import cn.idev.excel.annotation.ExcelProperty;
|
||||
import org.dromara.common.excel.annotation.ExcelDictFormat;
|
||||
|
||||
/**
|
||||
* 操行分学期平均分视图对象
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Data
|
||||
@ExcelIgnoreUnannotated
|
||||
public class XgConductScoreSemesterAverageVo {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 学生ID
|
||||
*/
|
||||
private Long studentId;
|
||||
|
||||
/**
|
||||
* 学号
|
||||
*/
|
||||
@ExcelProperty(value = "学号")
|
||||
private String studentNo;
|
||||
|
||||
/**
|
||||
* 学生姓名
|
||||
*/
|
||||
@ExcelProperty(value = "学生姓名")
|
||||
private String studentName;
|
||||
|
||||
/**
|
||||
* 性别
|
||||
*/
|
||||
@ExcelProperty(value = "性别", converter = org.dromara.common.excel.convert.ExcelDictConvert.class)
|
||||
@ExcelDictFormat(readConverterExp = "0=男,1=女")
|
||||
private String gender;
|
||||
|
||||
/**
|
||||
* 班级ID
|
||||
*/
|
||||
private Long classId;
|
||||
|
||||
/**
|
||||
* 学院名称
|
||||
*/
|
||||
@ExcelProperty(value = "学院")
|
||||
private String collegeName;
|
||||
|
||||
/**
|
||||
* 专业名称
|
||||
*/
|
||||
@ExcelProperty(value = "专业")
|
||||
private String majorName;
|
||||
|
||||
/**
|
||||
* 班级名称
|
||||
*/
|
||||
@ExcelProperty(value = "班级")
|
||||
private String className;
|
||||
|
||||
/**
|
||||
* 学年
|
||||
*/
|
||||
private Integer academicYear;
|
||||
|
||||
/**
|
||||
* 学期
|
||||
*/
|
||||
@ExcelProperty(value = "学期", converter = org.dromara.common.excel.convert.ExcelDictConvert.class)
|
||||
@ExcelDictFormat(readConverterExp = "1=第一学期,2=第二学期")
|
||||
private Integer semester;
|
||||
|
||||
/**
|
||||
* 学期平均分
|
||||
*/
|
||||
@ExcelProperty(value = "学期平均分")
|
||||
private BigDecimal avgScore;
|
||||
|
||||
/**
|
||||
* 评判结果
|
||||
*/
|
||||
@ExcelProperty(value = "评判结果")
|
||||
private String evaluationResult;
|
||||
|
||||
/**
|
||||
* 录入月份数
|
||||
*/
|
||||
@ExcelProperty(value = "录入月份")
|
||||
private Integer recordCount;
|
||||
|
||||
/**
|
||||
* 最高分
|
||||
*/
|
||||
@ExcelProperty(value = "最高分")
|
||||
private BigDecimal maxScore;
|
||||
|
||||
/**
|
||||
* 最低分
|
||||
*/
|
||||
@ExcelProperty(value = "最低分")
|
||||
private BigDecimal minScore;
|
||||
|
||||
/**
|
||||
* 分数范围
|
||||
*/
|
||||
private String scoreRange;
|
||||
|
||||
/**
|
||||
* 最后更新时间
|
||||
*/
|
||||
private Date lastUpdateTime;
|
||||
|
||||
/**
|
||||
* 创建者
|
||||
*/
|
||||
private Long createBy;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 更新者
|
||||
*/
|
||||
private Long updateBy;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private Date updateTime;
|
||||
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package org.dromara.xg.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 学生操行分记录详情视图对象
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Data
|
||||
public class XgConductScoreStudentRecordsVo {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 学生ID
|
||||
*/
|
||||
private Long studentId;
|
||||
|
||||
/**
|
||||
* 学号
|
||||
*/
|
||||
private String studentNo;
|
||||
|
||||
/**
|
||||
* 学生姓名
|
||||
*/
|
||||
private String studentName;
|
||||
|
||||
/**
|
||||
* 性别
|
||||
*/
|
||||
private String gender;
|
||||
|
||||
/**
|
||||
* 班级ID
|
||||
*/
|
||||
private Long classId;
|
||||
|
||||
/**
|
||||
* 班级名称
|
||||
*/
|
||||
private String className;
|
||||
|
||||
/**
|
||||
* 班主任ID
|
||||
*/
|
||||
private Long classTeacherId;
|
||||
|
||||
/**
|
||||
* 班主任姓名
|
||||
*/
|
||||
private String classTeacherName;
|
||||
|
||||
/**
|
||||
* 学期平均分
|
||||
*/
|
||||
private BigDecimal semesterAverage;
|
||||
|
||||
/**
|
||||
* 总记录数
|
||||
*/
|
||||
private Integer totalRecords;
|
||||
|
||||
/**
|
||||
* 详细记录列表
|
||||
*/
|
||||
private List<XgConductScoreRecordDetailVo> records;
|
||||
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package org.dromara.xg.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 操行分汇总统计视图对象
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Data
|
||||
public class XgConductScoreSummaryVo {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 总班级数
|
||||
*/
|
||||
private Integer totalClasses;
|
||||
|
||||
/**
|
||||
* 总学生数
|
||||
*/
|
||||
private Integer totalStudents;
|
||||
|
||||
/**
|
||||
* 已录入学生数
|
||||
*/
|
||||
private Integer recordedStudents;
|
||||
|
||||
/**
|
||||
* 待录入学生数
|
||||
*/
|
||||
private Integer pendingStudents;
|
||||
|
||||
/**
|
||||
* 完成度
|
||||
*/
|
||||
private BigDecimal completionRate;
|
||||
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package org.dromara.xg.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
|
||||
import org.dromara.xg.domain.XgConductScore;
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
|
||||
/**
|
||||
* 学生操行分视图对象
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
@Data
|
||||
@ExcelIgnoreUnannotated
|
||||
@AutoMapper(target = XgConductScore.class)
|
||||
public class XgConductScoreVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* ID
|
||||
*/
|
||||
@cn.idev.excel.annotation.ExcelProperty(value = "记录ID")
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 租户ID
|
||||
*/
|
||||
private String tenantId;
|
||||
|
||||
/**
|
||||
* 学年
|
||||
*/
|
||||
@cn.idev.excel.annotation.ExcelProperty(value = "学年")
|
||||
private Integer academicYear;
|
||||
|
||||
/**
|
||||
* 学期
|
||||
*/
|
||||
@cn.idev.excel.annotation.ExcelProperty(value = "学期", converter = org.dromara.common.excel.convert.ExcelDictConvert.class)
|
||||
@org.dromara.common.excel.annotation.ExcelDictFormat(readConverterExp = "1=第一学期,2=第二学期")
|
||||
private Integer semester;
|
||||
|
||||
/**
|
||||
* 月份
|
||||
*/
|
||||
@cn.idev.excel.annotation.ExcelProperty(value = "月份", converter = org.dromara.common.excel.convert.ExcelDictConvert.class)
|
||||
@org.dromara.common.excel.annotation.ExcelDictFormat(readConverterExp = "1=1月,2=2月,3=3月,4=4月,5=5月,6=6月,7=7月,8=8月,9=9月,10=10月,11=11月,12=12月")
|
||||
private Integer month;
|
||||
|
||||
/**
|
||||
* 学生ID
|
||||
*/
|
||||
private Long studentId;
|
||||
|
||||
/**
|
||||
* 学生姓名
|
||||
*/
|
||||
@cn.idev.excel.annotation.ExcelProperty(value = "学生姓名")
|
||||
private String studentName;
|
||||
|
||||
/**
|
||||
* 学号
|
||||
*/
|
||||
@cn.idev.excel.annotation.ExcelProperty(value = "学号")
|
||||
private String studentNo;
|
||||
|
||||
/**
|
||||
* 班级ID
|
||||
*/
|
||||
private Long classId;
|
||||
|
||||
/**
|
||||
* 班级名称
|
||||
*/
|
||||
@cn.idev.excel.annotation.ExcelProperty(value = "班级名称")
|
||||
private String className;
|
||||
|
||||
/**
|
||||
* 学院名称
|
||||
*/
|
||||
@cn.idev.excel.annotation.ExcelProperty(value = "学院名称")
|
||||
private String collegeName;
|
||||
|
||||
/**
|
||||
* 专业名称
|
||||
*/
|
||||
@cn.idev.excel.annotation.ExcelProperty(value = "专业名称")
|
||||
private String majorName;
|
||||
|
||||
/**
|
||||
* 班主任ID
|
||||
*/
|
||||
private Long classTeacherId;
|
||||
|
||||
/**
|
||||
* 班主任姓名
|
||||
*/
|
||||
private String classTeacherName;
|
||||
|
||||
/**
|
||||
* 分数
|
||||
*/
|
||||
@cn.idev.excel.annotation.ExcelProperty(value = "分数")
|
||||
private BigDecimal score;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
@cn.idev.excel.annotation.ExcelProperty(value = "备注")
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 评判结果
|
||||
*/
|
||||
@cn.idev.excel.annotation.ExcelProperty(value = "评判结果")
|
||||
private String evaluationResult;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@cn.idev.excel.annotation.ExcelProperty(value = "录入时间")
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 创建者
|
||||
*/
|
||||
private Long createBy;
|
||||
|
||||
/**
|
||||
* 创建者姓名
|
||||
*/
|
||||
@cn.idev.excel.annotation.ExcelProperty(value = "录入人")
|
||||
private String createByName;
|
||||
|
||||
/**
|
||||
* 更新者
|
||||
*/
|
||||
private Long updateBy;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date updateTime;
|
||||
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package org.dromara.xg.mapper;
|
||||
|
||||
import org.dromara.xg.domain.XgConductScore;
|
||||
import org.dromara.xg.domain.bo.XgConductScoreQueryBo;
|
||||
import org.dromara.xg.domain.vo.XgConductScoreVo;
|
||||
import org.dromara.xg.domain.vo.XgConductScoreSemesterAverageVo;
|
||||
import org.dromara.xg.domain.vo.XgConductScoreStudentRecordsVo;
|
||||
import org.dromara.xg.domain.vo.XgConductScoreClassDetailVo;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Constants;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 学生操行分Mapper接口
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
public interface IXgConductScoreMapper extends BaseMapperPlus<XgConductScore, XgConductScoreVo> {
|
||||
|
||||
/**
|
||||
* 查询学生操行分列表(包含关联数据)
|
||||
*
|
||||
* @param queryBo 查询条件
|
||||
* @return 学生操行分列表
|
||||
*/
|
||||
List<XgConductScoreVo> selectXgConductScoreList(XgConductScoreQueryBo queryBo);
|
||||
|
||||
/**
|
||||
* 根据部门ID列表分页查询学生操行分列表
|
||||
*
|
||||
* @param pageQuery 分页参数
|
||||
* @param bo 查询条件BO
|
||||
* @param classIdList 班级ID列表
|
||||
* @return 分页结果
|
||||
*/
|
||||
com.baomidou.mybatisplus.core.metadata.IPage<XgConductScoreVo> selectPageByDeptIds(
|
||||
@Param("page") com.baomidou.mybatisplus.core.metadata.IPage<XgConductScoreVo> pageQuery,
|
||||
@Param("bo") org.dromara.xg.domain.bo.XgConductScoreQueryBo bo,
|
||||
@Param("classIdList") java.util.List<String> classIdList);
|
||||
|
||||
/**
|
||||
* 根据部门ID查询学生操行分列表(用于导出)
|
||||
*
|
||||
* @param bo 查询条件
|
||||
* @param classIdList 班级ID列表
|
||||
* @return 学生操行分列表
|
||||
*/
|
||||
java.util.List<XgConductScoreVo> selectListByDeptIds(
|
||||
@Param("bo") org.dromara.xg.domain.bo.XgConductScoreQueryBo bo,
|
||||
@Param("classIdList") java.util.List<String> classIdList);
|
||||
|
||||
/**
|
||||
* 查询班主任负责的班级学生列表
|
||||
*
|
||||
* @param teacherId 班主任ID
|
||||
* @param academicYear 学年
|
||||
* @param semester 学期
|
||||
* @param month 月份
|
||||
* @return 学生列表
|
||||
*/
|
||||
List<Map<String, Object>> selectClassStudentsByTeacher(Long teacherId, Integer academicYear, Integer semester,
|
||||
Integer month);
|
||||
|
||||
/**
|
||||
* 查询学期平均分统计列表
|
||||
*
|
||||
* @param queryBo 查询条件
|
||||
* @return 统计列表
|
||||
*/
|
||||
List<XgConductScoreSemesterAverageVo> selectSemesterAverageList(Object queryBo);
|
||||
|
||||
/**
|
||||
* 根据班级ID列表查询学期平均分统计(支持权限过滤,显示所有学生)
|
||||
*
|
||||
* @param queryBo 查询条件
|
||||
* @param classIdList 班级ID列表
|
||||
* @return 学期平均分统计列表
|
||||
*/
|
||||
List<XgConductScoreSemesterAverageVo> selectSemesterAverageListByDeptIds(@Param("queryBo") Object queryBo, @Param("classIdList") List<String> classIdList);
|
||||
|
||||
/**
|
||||
* 检查班主任班级权限
|
||||
*
|
||||
* @param teacherId 班主任ID
|
||||
* @param classId 班级ID
|
||||
* @return 是否有权限
|
||||
*/
|
||||
boolean checkTeacherClass(Long teacherId, Long classId);
|
||||
|
||||
/**
|
||||
* 检查学生当月是否已存在记录
|
||||
*
|
||||
* @param studentId 学生ID
|
||||
* @param academicYear 学年
|
||||
* @param semester 学期
|
||||
* @param month 月份
|
||||
* @param tenantId 租户ID
|
||||
* @return 是否存在
|
||||
*/
|
||||
boolean existsByStudentAndMonth(@org.apache.ibatis.annotations.Param("studentId") Long studentId,
|
||||
@org.apache.ibatis.annotations.Param("academicYear") Integer academicYear,
|
||||
@org.apache.ibatis.annotations.Param("semester") Integer semester,
|
||||
@org.apache.ibatis.annotations.Param("month") Integer month,
|
||||
@org.apache.ibatis.annotations.Param("tenantId") String tenantId);
|
||||
|
||||
/**
|
||||
* 批量插入操行分记录
|
||||
*
|
||||
* @param conductScoreList 记录列表
|
||||
* @return 影响行数
|
||||
*/
|
||||
int insertBatch(List<XgConductScore> conductScoreList);
|
||||
|
||||
/**
|
||||
* 查询班主任负责的班级ID列表
|
||||
*
|
||||
* @param teacherId 班主任ID
|
||||
* @return 班级ID列表
|
||||
*/
|
||||
List<Long> selectTeacherClassIds(Long teacherId);
|
||||
|
||||
/**
|
||||
* 查询单个学生的详细记录
|
||||
*
|
||||
* @param studentId 学生ID
|
||||
* @param academicYear 学年(可选)
|
||||
* @param semester 学期(可选)
|
||||
* @param tenantId 租户ID
|
||||
* @return 学生详细记录列表(包含学生信息和所有记录)
|
||||
*/
|
||||
java.util.List<java.util.Map<String, Object>> selectStudentRecordsRaw(Long studentId, Integer academicYear, Integer semester, String tenantId);
|
||||
|
||||
/**
|
||||
* 获取班级详细统计数据
|
||||
*
|
||||
* @param classId 班级ID
|
||||
* @param academicYear 学年
|
||||
* @param semester 学期
|
||||
* @return 班级详细统计
|
||||
*/
|
||||
XgConductScoreClassDetailVo selectClassDetailStatistics(Long classId, Integer academicYear, Integer semester);
|
||||
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
package org.dromara.xg.service;
|
||||
|
||||
import org.dromara.xg.domain.vo.XgConductScoreVo;
|
||||
import org.dromara.xg.domain.bo.XgConductScoreBo;
|
||||
import org.dromara.xg.domain.bo.XgConductScoreQueryBo;
|
||||
import org.dromara.xg.domain.bo.XgConductScoreBatchBo;
|
||||
import org.dromara.xg.domain.vo.XgConductScoreBatchResultVo;
|
||||
import org.dromara.xg.domain.vo.XgConductScoreClassSummaryVo;
|
||||
import org.dromara.xg.domain.vo.XgConductScoreSemesterAverageVo;
|
||||
import org.dromara.xg.domain.vo.XgConductScoreStudentRecordsVo;
|
||||
import org.dromara.xg.domain.vo.XgConductScoreClassDetailVo;
|
||||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 学生操行分Service接口
|
||||
*
|
||||
* @author Lion Li
|
||||
* @date 2025-01-13
|
||||
*/
|
||||
public interface IXgConductScoreService {
|
||||
|
||||
/**
|
||||
* 查询学生操行分
|
||||
*
|
||||
* @param id 主键
|
||||
* @return 学生操行分
|
||||
*/
|
||||
XgConductScoreVo queryById(Long id);
|
||||
|
||||
/**
|
||||
* 分页查询学生操行分列表
|
||||
*
|
||||
* @param bo 查询条件
|
||||
* @param pageQuery 分页参数
|
||||
* @return 学生操行分页列表
|
||||
*/
|
||||
TableDataInfo<XgConductScoreVo> queryPageList(XgConductScoreQueryBo bo, PageQuery pageQuery);
|
||||
|
||||
/**
|
||||
* 根据组织ID分页查询学生操行分列表(带权限过滤)
|
||||
*
|
||||
* @param bo 查询条件
|
||||
* @param params 包含dept_id的参数Map
|
||||
* @param pageQuery 分页参数
|
||||
* @return 学生操行分页列表
|
||||
*/
|
||||
TableDataInfo<XgConductScoreVo> queryPageListByDeptId(XgConductScoreQueryBo bo,
|
||||
java.util.Map<String, Object> params, PageQuery pageQuery);
|
||||
|
||||
/**
|
||||
* 查询符合条件的学生操行分列表
|
||||
*
|
||||
* @param bo 查询条件
|
||||
* @return 学生操行分列表
|
||||
*/
|
||||
List<XgConductScoreVo> queryList(XgConductScoreQueryBo bo);
|
||||
|
||||
/**
|
||||
* 根据部门ID查询学生操行分列表(支持权限过滤)
|
||||
*
|
||||
* @param bo 查询条件
|
||||
* @param params 部门参数
|
||||
* @return 学生操行分列表
|
||||
*/
|
||||
List<XgConductScoreVo> queryListByDeptId(XgConductScoreQueryBo bo, java.util.Map<String, Object> params);
|
||||
|
||||
/**
|
||||
* 新增学生操行分
|
||||
*
|
||||
* @param bo 学生操行分
|
||||
* @return 结果
|
||||
*/
|
||||
Boolean insertByBo(XgConductScoreBo bo);
|
||||
|
||||
/**
|
||||
* 修改学生操行分
|
||||
*
|
||||
* @param bo 学生操行分
|
||||
* @return 结果
|
||||
*/
|
||||
Boolean updateByBo(XgConductScoreBo bo);
|
||||
|
||||
/**
|
||||
* 校验并批量删除学生操行分信息
|
||||
*
|
||||
* @param ids 待删除的主键集合
|
||||
* @param isValid 是否校验,true-删除前校验,false-直接删除
|
||||
* @return 结果
|
||||
*/
|
||||
Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
|
||||
|
||||
/**
|
||||
* 批量录入操行分
|
||||
*
|
||||
* @param bo 批量录入参数
|
||||
* @return 录入结果
|
||||
*/
|
||||
XgConductScoreBatchResultVo batchInsertConductScores(XgConductScoreBatchBo bo);
|
||||
|
||||
/**
|
||||
* 获取班主任负责的班级学生列表
|
||||
*
|
||||
* @param academicYear 学年
|
||||
* @param semester 学期
|
||||
* @param month 月份
|
||||
* @return 班级学生汇总信息
|
||||
*/
|
||||
XgConductScoreClassSummaryVo getClassStudentsByTeacher(Integer academicYear, Integer semester, Integer month);
|
||||
|
||||
/**
|
||||
* 查询学期平均分统计
|
||||
*
|
||||
* @param academicYear 学年
|
||||
* @param semester 学期
|
||||
* @param queryBo 查询条件
|
||||
* @param pageQuery 分页参数
|
||||
* @return 学期平均分分页列表
|
||||
*/
|
||||
TableDataInfo<XgConductScoreSemesterAverageVo> getSemesterAverage(Integer academicYear, Integer semester,
|
||||
XgConductScoreQueryBo queryBo, PageQuery pageQuery);
|
||||
|
||||
/**
|
||||
* 根据部门ID查询学期平均分统计(支持权限过滤)
|
||||
*
|
||||
* @param academicYear 学年
|
||||
* @param semester 学期
|
||||
* @param queryBo 查询条件
|
||||
* @param params 包含dept_id的参数Map
|
||||
* @param pageQuery 分页参数
|
||||
* @return 学期平均分分页列表
|
||||
*/
|
||||
TableDataInfo<XgConductScoreSemesterAverageVo> getSemesterAverageByDeptId(Integer academicYear, Integer semester,
|
||||
XgConductScoreQueryBo queryBo, java.util.Map<String, Object> params, PageQuery pageQuery);
|
||||
|
||||
/**
|
||||
* 检查班主任权限
|
||||
*
|
||||
* @param classId 班级ID
|
||||
* @param teacherId 班主任ID
|
||||
* @return 是否有权限
|
||||
*/
|
||||
boolean checkTeacherPermission(Long classId, Long teacherId);
|
||||
|
||||
/**
|
||||
* 检查学生当月是否已存在记录
|
||||
*
|
||||
* @param studentId 学生ID
|
||||
* @param academicYear 学年
|
||||
* @param semester 学期
|
||||
* @param month 月份
|
||||
* @param tenantId 租户ID
|
||||
* @return 是否存在
|
||||
*/
|
||||
boolean existsByStudentAndMonth(Long studentId, Integer academicYear, Integer semester, Integer month,
|
||||
String tenantId);
|
||||
|
||||
/**
|
||||
* 获取班主任负责的班级ID列表
|
||||
*
|
||||
* @param teacherId 班主任ID
|
||||
* @return 班级ID列表
|
||||
*/
|
||||
List<Long> getTeacherClassIds(Long teacherId);
|
||||
|
||||
/**
|
||||
* 查询单个学生的详细记录
|
||||
*
|
||||
* @param studentId 学生ID
|
||||
* @param academicYear 学年(可选)
|
||||
* @param semester 学期(可选)
|
||||
* @return 学生详细记录
|
||||
*/
|
||||
/**
|
||||
* 获取学生详细记录
|
||||
*
|
||||
* @param studentId 学生ID
|
||||
* @param academicYear 学年(可选)
|
||||
* @param semester 学期(可选)
|
||||
* @param tenantId 租户ID
|
||||
* @return 学生详细记录
|
||||
*/
|
||||
XgConductScoreStudentRecordsVo getStudentRecords(Long studentId, Integer academicYear, Integer semester, String tenantId);
|
||||
|
||||
/**
|
||||
* 获取班级详细统计数据
|
||||
*
|
||||
* @param classId 班级ID
|
||||
* @param academicYear 学年
|
||||
* @param semester 学期
|
||||
* @return 班级详细统计
|
||||
*/
|
||||
XgConductScoreClassDetailVo getClassDetailStatistics(Long classId, Integer academicYear, Integer semester);
|
||||
|
||||
/**
|
||||
* 修改单个操行分记录
|
||||
*
|
||||
* @param id 记录ID
|
||||
* @param bo 修改参数
|
||||
* @return 结果
|
||||
*/
|
||||
Boolean updateRecordById(Long id, XgConductScoreBo bo);
|
||||
|
||||
/**
|
||||
* 验证录入权限
|
||||
*
|
||||
* @return 是否有录入权限
|
||||
*/
|
||||
Boolean checkInputPermission();
|
||||
|
||||
}
|
||||
+98
@@ -21,11 +21,16 @@ import org.dromara.xg.mapper.XgClassTeacherCounselorRelMapper;
|
||||
import org.dromara.xg.mapper.XgStudentMsgMapper;
|
||||
import org.dromara.xg.mapper.XgClassMsgMapper;
|
||||
import org.dromara.xg.service.IXgClassTeacherCounselorRelService;
|
||||
import org.dromara.system.domain.SysUserRole;
|
||||
import org.dromara.system.mapper.SysUserRoleMapper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 班级班主任辅导员关联Service业务层处理
|
||||
@@ -41,11 +46,14 @@ public class XgClassTeacherCounselorRelServiceImpl implements IXgClassTeacherCou
|
||||
private final XgClassTeacherCounselorRelMapper baseMapper;
|
||||
private final XgClassMsgMapper classMsgMapper;
|
||||
private final XgStudentMsgMapper xgStudentMsgMapper;
|
||||
private final SysUserRoleMapper sysUserRoleMapper;
|
||||
|
||||
// 班主任类型常量
|
||||
private static final String HEAD_TEACHER_TYPE = "class_teacher";
|
||||
// 辅导员类型常量
|
||||
private static final String COUNSELOR_TYPE = "counselor";
|
||||
// 班主任角色ID常量
|
||||
private static final Long HEAD_TEACHER_ROLE_ID = 1947639680473493505L;
|
||||
|
||||
/**
|
||||
* 查询班级班主任辅导员关联
|
||||
@@ -127,6 +135,12 @@ public class XgClassTeacherCounselorRelServiceImpl implements IXgClassTeacherCou
|
||||
List<XgClassTeacherCounselorRel> currentRecords = baseMapper.selectList(queryWrapper);
|
||||
log.info("班级ID[{}]的班主任查询结果: 找到{}条记录", bo.getClassId(), currentRecords.size());
|
||||
|
||||
// 🔴 新增:收集要被替换的旧班主任ID,用于后续角色清理
|
||||
Set<Long> oldTeacherIds = currentRecords.stream()
|
||||
.map(XgClassTeacherCounselorRel::getTeacherId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 2. 将当前班级的班主任记录标记为删除并设置结束日期
|
||||
if (!currentRecords.isEmpty()) {
|
||||
for (XgClassTeacherCounselorRel record : currentRecords) {
|
||||
@@ -149,6 +163,31 @@ public class XgClassTeacherCounselorRelServiceImpl implements IXgClassTeacherCou
|
||||
bo.getTeacherName() == null ? -1 : bo.getTeacherName().length(),
|
||||
bo.getTeacherName() == null ? "null" : bo.getTeacherName().trim());
|
||||
|
||||
// 🔴 新增:处理新班主任角色分配
|
||||
if (bo.getTeacherId() != null && bo.getTeacherName() != null
|
||||
&& !"none123".equals(bo.getTeacherName().trim())) {
|
||||
// 检查新班主任是否已有班主任角色
|
||||
LambdaQueryWrapper<SysUserRole> roleQuery = Wrappers.lambdaQuery();
|
||||
roleQuery.eq(SysUserRole::getUserId, bo.getTeacherId())
|
||||
.eq(SysUserRole::getRoleId, HEAD_TEACHER_ROLE_ID);
|
||||
|
||||
if (sysUserRoleMapper.selectCount(roleQuery) == 0) {
|
||||
// 不存在则新增角色
|
||||
SysUserRole newRole = new SysUserRole();
|
||||
newRole.setUserId(bo.getTeacherId());
|
||||
newRole.setRoleId(HEAD_TEACHER_ROLE_ID);
|
||||
try {
|
||||
sysUserRoleMapper.insert(newRole);
|
||||
log.info("为新班主任用户ID[{}]分配班主任角色成功", bo.getTeacherId());
|
||||
} catch (Exception e) {
|
||||
log.error("为新班主任用户ID[{}]分配班主任角色失败: {}", bo.getTeacherId(), e.getMessage(), e);
|
||||
// 不抛出异常,避免影响主业务流程
|
||||
}
|
||||
} else {
|
||||
log.info("新班主任用户ID[{}]已拥有班主任角色,跳过分配", bo.getTeacherId());
|
||||
}
|
||||
}
|
||||
|
||||
if (bo.getTeacherName() == null || "none123".equals(bo.getTeacherName())
|
||||
|| (bo.getTeacherName() != null
|
||||
&& ("none123".equals(bo.getTeacherName().trim())
|
||||
@@ -177,6 +216,36 @@ public class XgClassTeacherCounselorRelServiceImpl implements IXgClassTeacherCou
|
||||
int updateResult11 = xgStudentMsgMapper.update(null, studentUpdateWrapper);
|
||||
log.info("更新学生表中的班主任信息: classId=[{}], teacherId=[{}], teacherName=[{}], 结果=[{}]",
|
||||
bo.getClassId(), bo.getTeacherId(), bo.getTeacherName(), updateResult11 > 0 ? "成功" : "失败");
|
||||
|
||||
// 🔴 新增:清理旧班主任角色
|
||||
for (Long oldTeacherId : oldTeacherIds) {
|
||||
// 检查该教师是否还在其他班担任班主任
|
||||
LambdaQueryWrapper<XgClassTeacherCounselorRel> checkQuery = Wrappers.lambdaQuery();
|
||||
checkQuery.eq(XgClassTeacherCounselorRel::getTeacherId, oldTeacherId)
|
||||
.eq(XgClassTeacherCounselorRel::getTeacherType, HEAD_TEACHER_TYPE)
|
||||
.eq(XgClassTeacherCounselorRel::getDeleted, 0)
|
||||
.ne(XgClassTeacherCounselorRel::getClassId, bo.getClassId()); // 排除当前班级
|
||||
|
||||
if (baseMapper.selectCount(checkQuery) == 0) {
|
||||
// 该教师已不在其他班担任班主任,删除角色
|
||||
LambdaQueryWrapper<SysUserRole> deleteRoleQuery = Wrappers.lambdaQuery();
|
||||
deleteRoleQuery.eq(SysUserRole::getUserId, oldTeacherId)
|
||||
.eq(SysUserRole::getRoleId, HEAD_TEACHER_ROLE_ID);
|
||||
|
||||
try {
|
||||
int deleteCount = sysUserRoleMapper.delete(deleteRoleQuery);
|
||||
if (deleteCount > 0) {
|
||||
log.info("旧班主任用户ID[{}]已不再担任任何班级班主任,删除班主任角色", oldTeacherId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("删除旧班主任用户ID[{}]的班主任角色失败: {}", oldTeacherId, e.getMessage(), e);
|
||||
// 不抛出异常,避免影响主业务流程
|
||||
}
|
||||
} else {
|
||||
log.info("旧班主任用户ID[{}]仍在其他班担任班主任,保留角色", oldTeacherId);
|
||||
}
|
||||
}
|
||||
|
||||
// 班主任信息为空,不插入新数据,直接返回
|
||||
return true;
|
||||
} else {
|
||||
@@ -202,6 +271,35 @@ public class XgClassTeacherCounselorRelServiceImpl implements IXgClassTeacherCou
|
||||
log.info("更新学生表中的班主任信息: classId=[{}], teacherId=[{}], teacherName=[{}], 结果=[{}]",
|
||||
bo.getClassId(), bo.getTeacherId(), bo.getTeacherName(), updateResult11 > 0 ? "成功" : "失败");
|
||||
|
||||
// 🔴 新增:清理旧班主任角色
|
||||
for (Long oldTeacherId : oldTeacherIds) {
|
||||
// 检查该教师是否还在其他班担任班主任
|
||||
LambdaQueryWrapper<XgClassTeacherCounselorRel> checkQuery = Wrappers.lambdaQuery();
|
||||
checkQuery.eq(XgClassTeacherCounselorRel::getTeacherId, oldTeacherId)
|
||||
.eq(XgClassTeacherCounselorRel::getTeacherType, HEAD_TEACHER_TYPE)
|
||||
.eq(XgClassTeacherCounselorRel::getDeleted, 0)
|
||||
.ne(XgClassTeacherCounselorRel::getClassId, bo.getClassId()); // 排除当前班级
|
||||
|
||||
if (baseMapper.selectCount(checkQuery) == 0) {
|
||||
// 该教师已不在其他班担任班主任,删除角色
|
||||
LambdaQueryWrapper<SysUserRole> deleteRoleQuery = Wrappers.lambdaQuery();
|
||||
deleteRoleQuery.eq(SysUserRole::getUserId, oldTeacherId)
|
||||
.eq(SysUserRole::getRoleId, HEAD_TEACHER_ROLE_ID);
|
||||
|
||||
try {
|
||||
int deleteCount = sysUserRoleMapper.delete(deleteRoleQuery);
|
||||
if (deleteCount > 0) {
|
||||
log.info("旧班主任用户ID[{}]已不再担任任何班级班主任,删除班主任角色", oldTeacherId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("删除旧班主任用户ID[{}]的班主任角色失败: {}", oldTeacherId, e.getMessage(), e);
|
||||
// 不抛出异常,避免影响主业务流程
|
||||
}
|
||||
} else {
|
||||
log.info("旧班主任用户ID[{}]仍在其他班担任班主任,保留角色", oldTeacherId);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 4. 插入新的班主任关联记录
|
||||
|
||||
+1103
File diff suppressed because it is too large.
Load diff
+5
-2
@@ -248,7 +248,9 @@ public class XgStudentManagerSourceServiceImpl implements IXgStudentManagerSourc
|
||||
|
||||
// 非全校学生管理员:使用原有逻辑
|
||||
// 根据当前用户的id,从班级表中,获取当前用户对应的班级id,也加入到authSourceIds中
|
||||
String classSql = "SELECT id FROM xg_class_msg WHERE class_teacher_id = ? AND deleted = '0' AND class_status = '1'";
|
||||
// String classSql = "SELECT id FROM xg_class_msg WHERE class_teacher_id = ? AND
|
||||
// deleted = '0' AND class_status = '1'";
|
||||
String classSql = "SELECT id FROM xg_class_msg WHERE class_teacher_id = ? AND deleted = '0' ";
|
||||
List<Long> classIds = jdbcTemplate.queryForList(classSql, Long.class, currentUserId);
|
||||
// if (classIds.isEmpty()) {
|
||||
// return Collections.emptyList();
|
||||
@@ -310,7 +312,8 @@ public class XgStudentManagerSourceServiceImpl implements IXgStudentManagerSourc
|
||||
// 查询所有部门信息
|
||||
String inSql = finalDeptIds.stream().map(String::valueOf).collect(Collectors.joining(","));
|
||||
String sql = "SELECT dept_id, dept_name, parent_id, dept_category, order_num FROM sys_dept " +
|
||||
"WHERE dept_id IN (" + inSql + ") AND status = '0' AND del_flag = '0' ORDER BY order_num, create_time DESC";
|
||||
"WHERE dept_id IN (" + inSql
|
||||
+ ") AND status = '0' AND del_flag = '0' ORDER BY order_num, create_time DESC";
|
||||
List<Map<String, Object>> deptMaps = jdbcTemplate.queryForList(sql);
|
||||
|
||||
// 构建部门树
|
||||
|
||||
+1
-2
@@ -301,8 +301,7 @@ public class XgStudentMsgServiceImpl implements IXgStudentMsgService {
|
||||
// 第四步:通过xg_class_msg表,class_teacher_id=当前登录人id,deleted='0',class_status='1',获取id
|
||||
LambdaQueryWrapper<XgClassMsg> classWrapper = Wrappers.lambdaQuery();
|
||||
classWrapper.eq(XgClassMsg::getClassTeacherId, userId)
|
||||
.eq(XgClassMsg::getDeleted, 0L)
|
||||
.eq(XgClassMsg::getClassStatus, "1");
|
||||
.eq(XgClassMsg::getDeleted, 0L);
|
||||
|
||||
List<XgClassMsg> classes = xgClassMsgMapper.selectList(classWrapper);
|
||||
for (XgClassMsg classMsg : classes) {
|
||||
|
||||
+677
@@ -0,0 +1,677 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="org.dromara.xg.mapper.IXgConductScoreMapper">
|
||||
|
||||
<resultMap type="org.dromara.xg.domain.vo.XgConductScoreVo" id="XgConductScoreResult">
|
||||
<result property="id" column="id"/>
|
||||
<result property="tenantId" column="tenant_id"/>
|
||||
<result property="academicYear" column="academic_year"/>
|
||||
<result property="semester" column="semester"/>
|
||||
<result property="month" column="month"/>
|
||||
<result property="studentId" column="student_id"/>
|
||||
<result property="studentName" column="student_name"/>
|
||||
<result property="studentNo" column="student_no"/>
|
||||
<result property="classId" column="class_id"/>
|
||||
<result property="className" column="class_name"/>
|
||||
<result property="classTeacherId" column="class_teacher_id"/>
|
||||
<result property="classTeacherName" column="class_teacher_name"/>
|
||||
<result property="score" column="score"/>
|
||||
<result property="remark" column="remark"/>
|
||||
<result property="createTime" column="create_time"/>
|
||||
<result property="createBy" column="create_by"/>
|
||||
<result property="createByName" column="create_by_name"/>
|
||||
<result property="updateBy" column="update_by"/>
|
||||
<result property="updateTime" column="update_time"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap type="org.dromara.xg.domain.vo.XgConductScoreClassSummaryVo" id="XgConductScoreClassSummaryResult">
|
||||
<collection property="classes" ofType="org.dromara.xg.domain.vo.XgConductScoreClassVo">
|
||||
<result property="classId" column="class_id"/>
|
||||
<result property="className" column="class_name"/>
|
||||
<result property="classCode" column="class_code"/>
|
||||
<result property="studentCount" column="student_count"/>
|
||||
<result property="enrollmentYear" column="enrollment_year"/>
|
||||
<result property="majorName" column="major_name"/>
|
||||
<collection property="students" ofType="org.dromara.xg.domain.vo.XgConductScoreClassStudentVo">
|
||||
<result property="studentId" column="student_id"/>
|
||||
<result property="studentNo" column="student_no"/>
|
||||
<result property="studentName" column="student_name"/>
|
||||
<result property="gender" column="gender"/>
|
||||
<result property="lastScore" column="last_score"/>
|
||||
<result property="lastMonth" column="last_month"/>
|
||||
<association property="existingScore" javaType="org.dromara.xg.domain.vo.XgConductScoreDetailVo">
|
||||
<result property="id" column="score_id"/>
|
||||
<result property="score" column="existing_score"/>
|
||||
<result property="remark" column="existing_remark"/>
|
||||
<result property="createTime" column="existing_create_time"/>
|
||||
</association>
|
||||
</collection>
|
||||
</collection>
|
||||
</resultMap>
|
||||
|
||||
<resultMap type="org.dromara.xg.domain.vo.XgConductScoreSemesterAverageVo" id="XgConductScoreSemesterAverageResult">
|
||||
<result property="studentId" column="student_id"/>
|
||||
<result property="studentNo" column="student_no"/>
|
||||
<result property="studentName" column="student_name"/>
|
||||
<result property="gender" column="gender"/>
|
||||
<result property="classId" column="class_id"/>
|
||||
<result property="className" column="class_name"/>
|
||||
<result property="academicYear" column="academic_year"/>
|
||||
<result property="semester" column="semester"/>
|
||||
<result property="avgScore" column="avg_score"/>
|
||||
<result property="recordCount" column="record_count"/>
|
||||
<result property="maxScore" column="max_score"/>
|
||||
<result property="minScore" column="min_score"/>
|
||||
<result property="scoreRange" column="score_range"/>
|
||||
<result property="lastUpdateTime" column="last_update_time"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectXgConductScoreVo">
|
||||
SELECT cs.id,
|
||||
cs.tenant_id,
|
||||
cs.academic_year,
|
||||
cs.semester,
|
||||
cs.month,
|
||||
cs.student_id,
|
||||
s.name AS student_name,
|
||||
s.student_no,
|
||||
cs.class_id,
|
||||
c.class_name,
|
||||
c.college_name,
|
||||
c.major_name,
|
||||
cs.class_teacher_id,
|
||||
u.nick_name AS class_teacher_name,
|
||||
cs.score,
|
||||
cs.remark,
|
||||
cs.create_time,
|
||||
cs.create_by,
|
||||
cu.nick_name AS create_by_name,
|
||||
cs.update_by,
|
||||
cs.update_time
|
||||
FROM xg_conduct_score cs
|
||||
LEFT JOIN xg_student_msg s ON cs.student_id = s.id
|
||||
LEFT JOIN xg_class_msg c ON cs.class_id = c.id
|
||||
LEFT JOIN sys_user u ON cs.class_teacher_id = u.user_id
|
||||
LEFT JOIN sys_user cu ON cs.create_by = cu.user_id
|
||||
</sql>
|
||||
|
||||
<select id="selectXgConductScoreList" parameterType="org.dromara.xg.domain.bo.XgConductScoreQueryBo" resultMap="XgConductScoreResult">
|
||||
<include refid="selectXgConductScoreVo"/>
|
||||
<where>
|
||||
<if test="tenantId != null and tenantId != ''">
|
||||
AND cs.tenant_id = #{tenantId}
|
||||
</if>
|
||||
<if test="academicYear != null">
|
||||
AND cs.academic_year = #{academicYear}
|
||||
</if>
|
||||
<if test="semester != null">
|
||||
AND cs.semester = #{semester}
|
||||
</if>
|
||||
<if test="month != null">
|
||||
AND cs.month = #{month}
|
||||
</if>
|
||||
<if test="studentId != null">
|
||||
AND cs.student_id = #{studentId}
|
||||
</if>
|
||||
<if test="classId != null">
|
||||
AND cs.class_id = #{classId}
|
||||
</if>
|
||||
<if test="classTeacherId != null">
|
||||
AND cs.class_teacher_id = #{classTeacherId}
|
||||
</if>
|
||||
<if test="studentName != null and studentName != ''">
|
||||
AND s.name LIKE CONCAT('%', #{studentName}, '%')
|
||||
</if>
|
||||
<if test="studentNo != null and studentNo != ''">
|
||||
AND s.student_no LIKE CONCAT('%', #{studentNo}, '%')
|
||||
</if>
|
||||
<if test="className != null and className != ''">
|
||||
AND c.class_name LIKE CONCAT('%', #{className}, '%')
|
||||
</if>
|
||||
<if test="collegeName != null and collegeName != ''">
|
||||
AND c.college_name LIKE CONCAT('%', #{collegeName}, '%')
|
||||
</if>
|
||||
<if test="majorName != null and majorName != ''">
|
||||
AND c.major_name LIKE CONCAT('%', #{majorName}, '%')
|
||||
</if>
|
||||
<if test="scoreMin != null">
|
||||
AND cs.score >= #{scoreMin}
|
||||
</if>
|
||||
<if test="scoreMax != null">
|
||||
AND cs.score <![CDATA[<=]]> #{scoreMax}
|
||||
</if>
|
||||
AND cs.deleted = 0
|
||||
AND s.deleted = 0
|
||||
AND c.deleted = 0
|
||||
</where>
|
||||
<!-- 排序由 PageQuery 处理 -->
|
||||
</select>
|
||||
|
||||
<select id="selectPageByDeptIds" resultMap="XgConductScoreResult">
|
||||
<include refid="selectXgConductScoreVo"/>
|
||||
<where>
|
||||
<!-- 租户ID条件 -->
|
||||
<if test="bo.tenantId != null and bo.tenantId != ''">
|
||||
cs.tenant_id = #{bo.tenantId}
|
||||
</if>
|
||||
<if test="bo.tenantId == null or bo.tenantId == ''">
|
||||
cs.tenant_id = '000000'
|
||||
</if>
|
||||
<!-- 学年条件 -->
|
||||
<if test="bo.academicYear != null">
|
||||
AND cs.academic_year = #{bo.academicYear}
|
||||
</if>
|
||||
<!-- 学期条件 -->
|
||||
<if test="bo.semester != null">
|
||||
AND cs.semester = #{bo.semester}
|
||||
</if>
|
||||
<!-- 月份条件 -->
|
||||
<if test="bo.month != null">
|
||||
AND cs.month = #{bo.month}
|
||||
</if>
|
||||
<!-- 学生ID条件 -->
|
||||
<if test="bo.studentId != null">
|
||||
AND cs.student_id = #{bo.studentId}
|
||||
</if>
|
||||
<!-- 班级ID条件 -->
|
||||
<if test="bo.classId != null">
|
||||
AND cs.class_id = #{bo.classId}
|
||||
</if>
|
||||
<!-- 班主任ID条件 -->
|
||||
<if test="bo.classTeacherId != null">
|
||||
AND cs.class_teacher_id = #{bo.classTeacherId}
|
||||
</if>
|
||||
<!-- 学生姓名条件 -->
|
||||
<if test="bo.studentName != null and bo.studentName != ''">
|
||||
AND s.name LIKE CONCAT('%', #{bo.studentName}, '%')
|
||||
</if>
|
||||
<!-- 学号条件 -->
|
||||
<if test="bo.studentNo != null and bo.studentNo != ''">
|
||||
AND s.student_no LIKE CONCAT('%', #{bo.studentNo}, '%')
|
||||
</if>
|
||||
<!-- 学院名称条件 -->
|
||||
<if test="bo.collegeName != null and bo.collegeName != ''">
|
||||
AND c.college_name LIKE CONCAT('%', #{bo.collegeName}, '%')
|
||||
</if>
|
||||
<!-- 专业名称条件 -->
|
||||
<if test="bo.majorName != null and bo.majorName != ''">
|
||||
AND c.major_name LIKE CONCAT('%', #{bo.majorName}, '%')
|
||||
</if>
|
||||
<!-- 分数范围条件 -->
|
||||
<if test="bo.scoreMin != null">
|
||||
AND cs.score >= #{bo.scoreMin}
|
||||
</if>
|
||||
<if test="bo.scoreMax != null">
|
||||
AND cs.score <![CDATA[<=]]> #{bo.scoreMax}
|
||||
</if>
|
||||
<!-- 时间范围条件 -->
|
||||
<if test="bo.beginTime != null and bo.beginTime != ''">
|
||||
AND cs.create_time >= #{bo.beginTime}
|
||||
</if>
|
||||
<if test="bo.endTime != null and bo.endTime != ''">
|
||||
AND cs.create_time <![CDATA[<=]]> #{bo.endTime}
|
||||
</if>
|
||||
<!-- 班级ID列表过滤条件 -->
|
||||
<if test="classIdList != null and classIdList.size() > 0">
|
||||
AND cs.class_id IN
|
||||
<foreach collection="classIdList" item="classId" open="(" close=")" separator=",">
|
||||
#{classId}
|
||||
</foreach>
|
||||
</if>
|
||||
<!-- 删除标记条件 -->
|
||||
AND cs.deleted = 0
|
||||
AND s.deleted = 0
|
||||
AND c.deleted = 0
|
||||
</where>
|
||||
<!-- 排序由 PageQuery 处理 -->
|
||||
</select>
|
||||
|
||||
<select id="selectListByDeptIds" resultMap="XgConductScoreResult">
|
||||
<include refid="selectXgConductScoreVo"/>
|
||||
<where>
|
||||
<!-- 租户ID条件 -->
|
||||
<if test="bo.tenantId != null and bo.tenantId != ''">
|
||||
cs.tenant_id = #{bo.tenantId}
|
||||
</if>
|
||||
<if test="bo.tenantId == null or bo.tenantId == ''">
|
||||
cs.tenant_id = '000000'
|
||||
</if>
|
||||
<!-- 学年条件 -->
|
||||
<if test="bo.academicYear != null">
|
||||
AND cs.academic_year = #{bo.academicYear}
|
||||
</if>
|
||||
<!-- 学期条件 -->
|
||||
<if test="bo.semester != null">
|
||||
AND cs.semester = #{bo.semester}
|
||||
</if>
|
||||
<!-- 月份条件 -->
|
||||
<if test="bo.month != null">
|
||||
AND cs.month = #{bo.month}
|
||||
</if>
|
||||
<!-- 学生ID条件 -->
|
||||
<if test="bo.studentId != null">
|
||||
AND cs.student_id = #{bo.studentId}
|
||||
</if>
|
||||
<!-- 班级ID条件 -->
|
||||
<if test="bo.classId != null">
|
||||
AND cs.class_id = #{bo.classId}
|
||||
</if>
|
||||
<!-- 班主任ID条件 -->
|
||||
<if test="bo.classTeacherId != null">
|
||||
AND cs.class_teacher_id = #{bo.classTeacherId}
|
||||
</if>
|
||||
<!-- 学生姓名条件 -->
|
||||
<if test="bo.studentName != null and bo.studentName != ''">
|
||||
AND s.name LIKE CONCAT('%', #{bo.studentName}, '%')
|
||||
</if>
|
||||
<!-- 学号条件 -->
|
||||
<if test="bo.studentNo != null and bo.studentNo != ''">
|
||||
AND s.student_no LIKE CONCAT('%', #{bo.studentNo}, '%')
|
||||
</if>
|
||||
<!-- 学院名称条件 -->
|
||||
<if test="bo.collegeName != null and bo.collegeName != ''">
|
||||
AND c.college_name LIKE CONCAT('%', #{bo.collegeName}, '%')
|
||||
</if>
|
||||
<!-- 专业名称条件 -->
|
||||
<if test="bo.majorName != null and bo.majorName != ''">
|
||||
AND c.major_name LIKE CONCAT('%', #{bo.majorName}, '%')
|
||||
</if>
|
||||
<!-- 分数范围条件 -->
|
||||
<if test="bo.scoreMin != null">
|
||||
AND cs.score >= #{bo.scoreMin}
|
||||
</if>
|
||||
<if test="bo.scoreMax != null">
|
||||
AND cs.score <![CDATA[<=]]> #{bo.scoreMax}
|
||||
</if>
|
||||
<!-- 时间范围条件 -->
|
||||
<if test="bo.beginTime != null and bo.beginTime != ''">
|
||||
AND cs.create_time >= #{bo.beginTime}
|
||||
</if>
|
||||
<if test="bo.endTime != null and bo.endTime != ''">
|
||||
AND cs.create_time <![CDATA[<=]]> #{bo.endTime}
|
||||
</if>
|
||||
<!-- 班级ID列表过滤条件 -->
|
||||
<if test="classIdList != null and classIdList.size() > 0">
|
||||
AND cs.class_id IN
|
||||
<foreach collection="classIdList" item="classId" open="(" close=")" separator=",">
|
||||
#{classId}
|
||||
</foreach>
|
||||
</if>
|
||||
<!-- 删除标记条件 -->
|
||||
AND cs.deleted = 0
|
||||
AND s.deleted = 0
|
||||
AND c.deleted = 0
|
||||
</where>
|
||||
<!-- 排序由 PageQuery 处理 -->
|
||||
</select>
|
||||
|
||||
<select id="selectClassStudentsByTeacher" parameterType="java.util.Map" resultType="java.util.Map">
|
||||
SELECT
|
||||
tc.class_id,
|
||||
tc.class_name,
|
||||
tc.class_code,
|
||||
tc.student_count,
|
||||
tc.enrollment_year,
|
||||
tc.grade,
|
||||
NULL AS major_name,
|
||||
s.id AS student_id,
|
||||
s.student_no,
|
||||
s.name AS student_name,
|
||||
s.gender,
|
||||
cs_last.score AS last_score,
|
||||
cs_last.month AS last_month,
|
||||
cs_current.id AS score_id,
|
||||
cs_current.score AS existing_score,
|
||||
cs_current.remark AS existing_remark,
|
||||
cs_current.create_time AS existing_create_time
|
||||
FROM (
|
||||
SELECT
|
||||
c.id AS class_id,
|
||||
c.class_name,
|
||||
c.class_code,
|
||||
c.enrollment_year,
|
||||
c.grade,
|
||||
COUNT(s.id) AS student_count
|
||||
FROM xg_class_msg c
|
||||
LEFT JOIN xg_student_msg s ON c.id = s.class_id AND s.deleted = 0
|
||||
WHERE c.class_teacher_id = #{teacherId}
|
||||
AND c.deleted = 0
|
||||
GROUP BY c.id, c.class_name, c.class_code, c.enrollment_year, c.grade
|
||||
) tc
|
||||
INNER JOIN xg_student_msg s ON tc.class_id = s.class_id AND s.deleted = 0
|
||||
LEFT JOIN xg_conduct_score cs_current ON s.id = cs_current.student_id
|
||||
AND cs_current.academic_year = #{academicYear}
|
||||
AND cs_current.semester = #{semester}
|
||||
AND cs_current.month = #{month}
|
||||
AND cs_current.deleted = 0
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
cs_inner.student_id,
|
||||
cs_inner.score,
|
||||
cs_inner.month
|
||||
FROM xg_conduct_score cs_inner
|
||||
INNER JOIN (
|
||||
SELECT
|
||||
student_id,
|
||||
MAX(CONCAT(LPAD(academic_year, 4, '0'), LPAD(semester, 1, '0'), LPAD(month, 2, '0'))) AS max_date
|
||||
FROM xg_conduct_score
|
||||
WHERE academic_year = #{academicYear}
|
||||
AND semester = #{semester}
|
||||
AND month <![CDATA[<]]> #{month}
|
||||
AND deleted = 0
|
||||
GROUP BY student_id
|
||||
) latest ON cs_inner.student_id = latest.student_id
|
||||
AND CONCAT(LPAD(cs_inner.academic_year, 4, '0'), LPAD(cs_inner.semester, 1, '0'), LPAD(cs_inner.month, 2, '0')) = latest.max_date
|
||||
AND cs_inner.deleted = 0
|
||||
) cs_last ON s.id = cs_last.student_id
|
||||
ORDER BY tc.class_name, s.student_no
|
||||
</select>
|
||||
|
||||
<select id="selectSemesterAverageList" parameterType="org.dromara.xg.domain.bo.XgConductScoreQueryBo" resultMap="XgConductScoreSemesterAverageResult">
|
||||
SELECT
|
||||
cs.student_id,
|
||||
s.student_no,
|
||||
s.name AS student_name,
|
||||
s.gender,
|
||||
cs.class_id,
|
||||
c.class_name,
|
||||
cs.academic_year,
|
||||
cs.semester,
|
||||
ROUND(AVG(cs.score), 2) AS avg_score,
|
||||
COUNT(*) AS record_count,
|
||||
MAX(cs.score) AS max_score,
|
||||
MIN(cs.score) AS min_score,
|
||||
CONCAT(MIN(cs.score), '-', MAX(cs.score)) AS score_range,
|
||||
MAX(cs.update_time) AS last_update_time
|
||||
FROM xg_conduct_score cs
|
||||
LEFT JOIN xg_student_msg s ON cs.student_id = s.id
|
||||
LEFT JOIN xg_class_msg c ON cs.class_id = c.id
|
||||
<where>
|
||||
<if test="tenantId != null and tenantId != ''">
|
||||
AND cs.tenant_id = #{tenantId}
|
||||
</if>
|
||||
<if test="academicYear != null">
|
||||
AND cs.academic_year = #{academicYear}
|
||||
</if>
|
||||
<if test="semester != null">
|
||||
AND cs.semester = #{semester}
|
||||
</if>
|
||||
<if test="classId != null">
|
||||
AND cs.class_id = #{classId}
|
||||
</if>
|
||||
<if test="studentName != null and studentName != ''">
|
||||
AND s.name LIKE CONCAT('%', #{studentName}, '%')
|
||||
</if>
|
||||
<if test="className != null and className != ''">
|
||||
AND c.class_name LIKE CONCAT('%', #{className}, '%')
|
||||
</if>
|
||||
AND cs.deleted = 0
|
||||
AND s.deleted = 0
|
||||
AND c.deleted = 0
|
||||
</where>
|
||||
GROUP BY cs.student_id, cs.academic_year, cs.semester
|
||||
ORDER BY c.class_name, s.student_no
|
||||
</select>
|
||||
|
||||
<select id="selectSemesterAverageListByDeptIds" parameterType="org.dromara.xg.domain.bo.XgConductScoreQueryBo" resultMap="XgConductScoreSemesterAverageResult">
|
||||
SELECT
|
||||
s.id AS student_id,
|
||||
s.student_no,
|
||||
s.name AS student_name,
|
||||
s.gender,
|
||||
s.class_id,
|
||||
c.class_name,
|
||||
c.college_name,
|
||||
c.major_name,
|
||||
#{queryBo.academicYear} AS academic_year,
|
||||
#{queryBo.semester} AS semester,
|
||||
COALESCE(ROUND(AVG(cs.score), 2), 0) AS avg_score,
|
||||
COALESCE(COUNT(cs.id), 0) AS record_count,
|
||||
COALESCE(MAX(cs.score), 0) AS max_score,
|
||||
COALESCE(MIN(cs.score), 0) AS min_score,
|
||||
CASE
|
||||
WHEN COUNT(cs.id) > 0 THEN CONCAT(COALESCE(MIN(cs.score), 0), '-', COALESCE(MAX(cs.score), 0))
|
||||
ELSE '0-0'
|
||||
END AS score_range,
|
||||
MAX(cs.update_time) AS last_update_time
|
||||
FROM xg_student_msg s
|
||||
LEFT JOIN xg_class_msg c ON s.class_id = c.id
|
||||
LEFT JOIN xg_conduct_score cs ON s.id = cs.student_id
|
||||
AND cs.academic_year = #{queryBo.academicYear}
|
||||
AND cs.semester = #{queryBo.semester}
|
||||
AND cs.deleted = 0
|
||||
<where>
|
||||
<if test="queryBo.tenantId != null and queryBo.tenantId != ''">
|
||||
AND s.tenant_id = #{queryBo.tenantId}
|
||||
</if>
|
||||
<if test="classIdList != null and classIdList.size() > 0">
|
||||
AND s.class_id IN
|
||||
<foreach collection="classIdList" item="classId" open="(" separator="," close=")">
|
||||
#{classId}
|
||||
</foreach>
|
||||
</if>
|
||||
<if test="queryBo.studentName != null and queryBo.studentName != ''">
|
||||
AND s.name LIKE CONCAT('%', #{queryBo.studentName}, '%')
|
||||
</if>
|
||||
<if test="queryBo.className != null and queryBo.className != ''">
|
||||
AND c.class_name LIKE CONCAT('%', #{queryBo.className}, '%')
|
||||
</if>
|
||||
<if test="queryBo.collegeName != null and queryBo.collegeName != ''">
|
||||
AND c.college_name LIKE CONCAT('%', #{queryBo.collegeName}, '%')
|
||||
</if>
|
||||
<if test="queryBo.majorName != null and queryBo.majorName != ''">
|
||||
AND c.major_name LIKE CONCAT('%', #{queryBo.majorName}, '%')
|
||||
</if>
|
||||
AND s.deleted = 0
|
||||
AND c.deleted = 0
|
||||
</where>
|
||||
GROUP BY s.id, s.student_no, s.name, s.gender, s.class_id, c.class_name, c.college_name, c.major_name
|
||||
ORDER BY s.student_no DESC, s.create_time DESC
|
||||
</select>
|
||||
|
||||
<select id="existsByStudentAndMonth" resultType="boolean">
|
||||
SELECT COUNT(1) > 0
|
||||
FROM xg_conduct_score
|
||||
WHERE student_id = #{studentId}
|
||||
AND academic_year = #{academicYear}
|
||||
AND semester = #{semester}
|
||||
AND month = #{month}
|
||||
AND tenant_id = #{tenantId}
|
||||
AND deleted = 0
|
||||
</select>
|
||||
|
||||
<insert id="insertBatch" parameterType="java.util.List">
|
||||
INSERT INTO xg_conduct_score (
|
||||
id, tenant_id, academic_year, semester, month,
|
||||
student_id, class_id, class_teacher_id, score, remark,
|
||||
create_dept, create_by, create_time
|
||||
) VALUES
|
||||
<foreach collection="list" item="item" separator=",">
|
||||
(
|
||||
#{item.id}, #{item.tenantId}, #{item.academicYear}, #{item.semester}, #{item.month},
|
||||
#{item.studentId}, #{item.classId}, #{item.classTeacherId}, #{item.score}, #{item.remark},
|
||||
#{item.createDept}, #{item.createBy}, #{item.createTime}
|
||||
)
|
||||
</foreach>
|
||||
</insert>
|
||||
|
||||
<select id="selectTeacherClassIds" parameterType="Long" resultType="Long">
|
||||
SELECT DISTINCT c.id
|
||||
FROM xg_class_msg c
|
||||
WHERE c.class_teacher_id = #{teacherId}
|
||||
AND c.deleted = 0
|
||||
</select>
|
||||
|
||||
<select id="checkTeacherClass" resultType="boolean">
|
||||
SELECT COUNT(1) > 0
|
||||
FROM xg_class_msg
|
||||
WHERE id = #{classId}
|
||||
AND class_teacher_id = #{teacherId}
|
||||
AND deleted = 0
|
||||
</select>
|
||||
|
||||
<select id="selectStudentRecordsRaw" parameterType="java.util.Map" resultType="java.util.Map">
|
||||
SELECT
|
||||
s.id AS student_id,
|
||||
s.student_no,
|
||||
s.name AS student_name,
|
||||
s.gender,
|
||||
c.id AS class_id,
|
||||
c.class_name,
|
||||
c.class_teacher_id,
|
||||
u.nick_name AS class_teacher_name,
|
||||
stats.semester_average,
|
||||
stats.total_records,
|
||||
cs.id,
|
||||
cs.academic_year,
|
||||
cs.semester,
|
||||
cs.month,
|
||||
cs.score,
|
||||
cs.remark,
|
||||
cs.create_by,
|
||||
u1.nick_name AS create_by_name,
|
||||
cs.create_time,
|
||||
cs.update_by,
|
||||
u2.nick_name AS update_by_name,
|
||||
cs.update_time
|
||||
FROM xg_student_msg s
|
||||
LEFT JOIN xg_class_msg c ON s.class_id = c.id AND c.deleted = 0 AND c.tenant_id = #{tenantId}
|
||||
LEFT JOIN sys_user u ON c.class_teacher_id = u.user_id
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
cs_inner.student_id,
|
||||
ROUND(AVG(cs_inner.score), 2) AS semester_average,
|
||||
COUNT(cs_inner.id) AS total_records
|
||||
FROM xg_conduct_score cs_inner
|
||||
WHERE cs_inner.tenant_id = #{tenantId}
|
||||
AND cs_inner.deleted = 0
|
||||
<if test="academicYear != null">
|
||||
AND cs_inner.academic_year = #{academicYear}
|
||||
</if>
|
||||
<if test="semester != null">
|
||||
AND cs_inner.semester = #{semester}
|
||||
</if>
|
||||
GROUP BY cs_inner.student_id
|
||||
) stats ON s.id = stats.student_id
|
||||
LEFT JOIN xg_conduct_score cs ON s.id = cs.student_id
|
||||
AND cs.tenant_id = #{tenantId}
|
||||
AND cs.deleted = 0
|
||||
<if test="academicYear != null">
|
||||
AND cs.academic_year = #{academicYear}
|
||||
</if>
|
||||
<if test="semester != null">
|
||||
AND cs.semester = #{semester}
|
||||
</if>
|
||||
LEFT JOIN sys_user u1 ON cs.create_by = u1.user_id
|
||||
LEFT JOIN sys_user u2 ON cs.update_by = u2.user_id
|
||||
WHERE s.id = #{studentId}
|
||||
AND s.tenant_id = #{tenantId}
|
||||
AND s.deleted = 0
|
||||
ORDER BY cs.academic_year DESC, cs.semester DESC, cs.month DESC, cs.create_time DESC
|
||||
</select>
|
||||
|
||||
<select id="selectClassDetailStatistics" parameterType="java.util.Map" resultMap="XgConductScoreClassDetailResult">
|
||||
SELECT
|
||||
#{classId} AS class_id,
|
||||
c.class_name,
|
||||
#{academicYear} AS academic_year,
|
||||
#{semester} AS semester,
|
||||
COUNT(DISTINCT s.id) AS total_students,
|
||||
COUNT(DISTINCT CASE WHEN cs_stats.record_count > 0 THEN cs_stats.student_id END) AS recorded_students,
|
||||
ROUND(
|
||||
COUNT(DISTINCT CASE WHEN cs_stats.record_count > 0 THEN cs_stats.student_id END) * 100.0 /
|
||||
NULLIF(COUNT(DISTINCT s.id), 0),
|
||||
2
|
||||
) AS record_rate,
|
||||
ROUND(AVG(cs_stats.avg_score), 2) AS class_average,
|
||||
MAX(cs_stats.avg_score) AS max_score,
|
||||
MIN(cs_stats.avg_score) AS min_score,
|
||||
cs_stats.student_id,
|
||||
cs_stats.student_no,
|
||||
cs_stats.student_name,
|
||||
cs_stats.gender,
|
||||
cs_stats.avg_score AS student_avg_score,
|
||||
cs_stats.record_count
|
||||
FROM xg_class_msg c
|
||||
LEFT JOIN xg_student_msg s ON c.id = s.class_id
|
||||
AND s.tenant_id = #{tenantId}
|
||||
AND s.deleted = 0
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
s_inner.id AS student_id,
|
||||
s_inner.student_no,
|
||||
s_inner.name AS student_name,
|
||||
s_inner.gender,
|
||||
ROUND(AVG(cs.score), 2) AS avg_score,
|
||||
COUNT(cs.id) AS record_count
|
||||
FROM xg_student_msg s_inner
|
||||
LEFT JOIN xg_conduct_score cs ON s_inner.id = cs.student_id
|
||||
AND cs.academic_year = #{academicYear}
|
||||
AND cs.semester = #{semester}
|
||||
AND cs.tenant_id = #{tenantId}
|
||||
AND cs.deleted = 0
|
||||
WHERE s_inner.class_id = #{classId}
|
||||
AND s_inner.tenant_id = #{tenantId}
|
||||
AND s_inner.deleted = 0
|
||||
GROUP BY s_inner.id, s_inner.student_no, s_inner.name, s_inner.gender
|
||||
) cs_stats ON s.id = cs_stats.student_id
|
||||
WHERE c.id = #{classId}
|
||||
AND c.tenant_id = #{tenantId}
|
||||
AND c.deleted = 0
|
||||
GROUP BY c.id, c.class_name,
|
||||
cs_stats.student_id, cs_stats.student_no, cs_stats.student_name, cs_stats.gender,
|
||||
cs_stats.avg_score, cs_stats.record_count
|
||||
</select>
|
||||
|
||||
<!-- 结果映射 -->
|
||||
<resultMap type="org.dromara.xg.domain.vo.XgConductScoreStudentRecordsVo" id="XgConductScoreStudentRecordsResult">
|
||||
<id property="studentId" column="student_id"/>
|
||||
<result property="studentNo" column="student_no"/>
|
||||
<result property="studentName" column="student_name"/>
|
||||
<result property="gender" column="gender"/>
|
||||
<result property="classId" column="class_id"/>
|
||||
<result property="className" column="class_name"/>
|
||||
<result property="classTeacherId" column="class_teacher_id"/>
|
||||
<result property="classTeacherName" column="class_teacher_name"/>
|
||||
<result property="semesterAverage" column="semester_average"/>
|
||||
<result property="totalRecords" column="total_records"/>
|
||||
<collection property="records" ofType="org.dromara.xg.domain.vo.XgConductScoreRecordDetailVo">
|
||||
<result property="id" column="id"/>
|
||||
<result property="academicYear" column="academic_year"/>
|
||||
<result property="semester" column="semester"/>
|
||||
<result property="month" column="month"/>
|
||||
<result property="score" column="score"/>
|
||||
<result property="remark" column="remark"/>
|
||||
<result property="createBy" column="create_by"/>
|
||||
<result property="createByName" column="create_by_name"/>
|
||||
<result property="createTime" column="create_time"/>
|
||||
<result property="updateBy" column="update_by"/>
|
||||
<result property="updateByName" column="update_by_name"/>
|
||||
<result property="updateTime" column="update_time"/>
|
||||
</collection>
|
||||
</resultMap>
|
||||
|
||||
<resultMap type="org.dromara.xg.domain.vo.XgConductScoreClassDetailVo" id="XgConductScoreClassDetailResult">
|
||||
<result property="classId" column="class_id"/>
|
||||
<result property="className" column="class_name"/>
|
||||
<result property="academicYear" column="academic_year"/>
|
||||
<result property="semester" column="semester"/>
|
||||
<result property="totalStudents" column="total_students"/>
|
||||
<result property="recordedStudents" column="recorded_students"/>
|
||||
<result property="recordRate" column="record_rate"/>
|
||||
<result property="classAverage" column="class_average"/>
|
||||
<result property="maxScore" column="max_score"/>
|
||||
<result property="minScore" column="min_score"/>
|
||||
<collection property="students" ofType="org.dromara.xg.domain.vo.XgConductScoreClassStudentDetailVo">
|
||||
<result property="studentId" column="student_id"/>
|
||||
<result property="studentNo" column="student_no"/>
|
||||
<result property="studentName" column="student_name"/>
|
||||
<result property="gender" column="gender"/>
|
||||
<result property="avgScore" column="avg_score"/>
|
||||
<result property="recordCount" column="record_count"/>
|
||||
</collection>
|
||||
</resultMap>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,636 @@
|
||||
# 学生操行分管理模块
|
||||
|
||||
## 📋 模块基本信息
|
||||
|
||||
**模块名称**: 学生操行分管理 / Student Conduct Score Management
|
||||
**负责人**: 开发团队
|
||||
**开发状态**: 🟡 待开发
|
||||
**技术栈**: Spring Boot 3.4.6 + Vue 3 + TypeScript + Element Plus
|
||||
**依赖模块**: 用户管理、班级管理、学生管理
|
||||
**开发周期**: 待定
|
||||
|
||||
---
|
||||
|
||||
## 🎯 模块概述
|
||||
|
||||
### 业务价值
|
||||
|
||||
为学校提供完整的学生操行分管理解决方案,实现班主任每月对班级学生操行表现的量化评价和管理,帮助学生处管理员掌握学生的学期表现情况,为学生综合素质评价提供数据支持。
|
||||
|
||||
### 核心功能
|
||||
|
||||
- **班主任月度评分**: 班主任每月为班级学生录入操行分数
|
||||
- **学期平均分统计**: 自动计算学生的学期操行平均分
|
||||
- **多维度数据展示**: 按学年、学期、月份等维度展示学生操行表现
|
||||
- **权限分级管理**: 班主任只能管理本班学生,管理员可查看全校数据
|
||||
|
||||
### 技术特点
|
||||
|
||||
- **数据完整性**: 严格的数据验证和业务规则约束
|
||||
- **性能优化**: 支持大数据量查询和统计分析
|
||||
- **权限安全**: 基于角色的精细化权限控制
|
||||
- **用户体验**: 友好的批量录入和数据展示界面
|
||||
|
||||
---
|
||||
|
||||
## 📁 文档结构
|
||||
|
||||
```
|
||||
学生操行分管理/
|
||||
├── README.md # 本文件 - 模块总览
|
||||
├── 需求分析/
|
||||
│ ├── 业务需求说明.md # 详细业务需求
|
||||
│ ├── 功能需求清单.md # 功能点清单
|
||||
│ ├── 用户故事.md # 用户使用场景
|
||||
│ └── 非功能需求.md # 性能、安全要求
|
||||
├── 技术设计/
|
||||
│ ├── 数据库设计.md # 表结构设计
|
||||
│ ├── API接口设计.md # 接口定义
|
||||
│ ├── 架构设计.md # 模块架构
|
||||
│ └── 技术选型.md # 技术决策说明
|
||||
├── 实现文档/
|
||||
│ ├── 后端实现说明.md # 后端代码结构
|
||||
│ ├── 前端实现说明.md # 前端代码结构
|
||||
│ ├── 关键业务逻辑.md # 核心算法逻辑
|
||||
│ └── 代码规范.md # 编码规范
|
||||
├── 测试文档/
|
||||
│ ├── 测试计划.md # 测试策略
|
||||
│ ├── 测试用例.md # 具体测试用例
|
||||
│ └── 测试报告.md # 测试结果
|
||||
└── 运维文档/
|
||||
├── 部署指南.md # 部署步骤
|
||||
├── 监控配置.md # 监控指标
|
||||
└── 故障处理.md # 常见问题
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 系统架构
|
||||
|
||||
### 模块架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "前端层"
|
||||
A1[操行分录入页面<br/>ConductScoreInput.vue]
|
||||
A2[学期统计页面<br/>SemesterStatistics.vue]
|
||||
A3[操行分查询页面<br/>ConductScoreQuery.vue]
|
||||
end
|
||||
|
||||
subgraph "接口层"
|
||||
B1[操行分Controller<br/>ConductScoreController]
|
||||
B2[统计Controller<br/>StatisticsController]
|
||||
end
|
||||
|
||||
subgraph "业务层"
|
||||
C1[操行分Service<br/>IConductScoreService]
|
||||
C2[统计Service<br/>IStatisticsService]
|
||||
C3[权限Service<br/>IPermissionService]
|
||||
end
|
||||
|
||||
subgraph "数据层"
|
||||
D1[操行分Mapper<br/>ConductScoreMapper]
|
||||
D2[统计SQL<br/>统计查询语句]
|
||||
D3[Redis缓存<br/>缓存统计结果]
|
||||
end
|
||||
|
||||
A1 --> B1
|
||||
A2 --> B2
|
||||
A3 --> B1
|
||||
B1 --> C1
|
||||
B2 --> C2
|
||||
C1 --> C3
|
||||
C2 --> C3
|
||||
C1 --> D1
|
||||
C2 --> D1
|
||||
C2 --> D2
|
||||
D2 --> D3
|
||||
```
|
||||
|
||||
### 数据流向
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 班主任
|
||||
participant F as 前端
|
||||
participant C as Controller
|
||||
participant S as Service
|
||||
participant M as Mapper
|
||||
participant D as Database
|
||||
|
||||
T->>F: 选择月份录入操行分
|
||||
F->>C: POST /api/conduct-score/batch
|
||||
C->>S: 批量保存操行分
|
||||
S->>M: 插入/更新分数数据
|
||||
M->>D: SQL执行
|
||||
D-->>M: 返回结果
|
||||
M-->>S: 返回成功
|
||||
S-->>C: 返回结果
|
||||
C-->>F: JSON响应
|
||||
F-->>T: 显示保存成功
|
||||
|
||||
Note over T,D: 学生处管理员查询学期统计
|
||||
T->>F: 选择学年学期查询
|
||||
F->>C: GET /api/statistics/semester-average
|
||||
C->>S: 计算学期平均分
|
||||
S->>M: 查询统计数据
|
||||
M->>D: SQL执行
|
||||
D-->>M: 返回原始数据
|
||||
M-->>S: 计算平均分
|
||||
S-->>C: 返回统计结果
|
||||
C-->>F: JSON响应
|
||||
F-->>T: 显示统计表格
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 核心功能模块
|
||||
|
||||
### 1. 操行分录入管理
|
||||
|
||||
#### 业务描述
|
||||
|
||||
班主任每月为负责的班级学生录入操行分数,包含分数和备注信息。如果班主任负责多个班级,则通过多Tab页方式展示。系统需要验证班主任权限、检查数据完整性、防止重复录入等。
|
||||
|
||||
#### 技术实现
|
||||
|
||||
- **前端实现**: Vue 3 多Tab页组件,支持批量录入和数据验证,每个Tab页独立管理一个班级
|
||||
- **后端实现**: Spring Boot Controller + Service,通过`xg_class_msg.class_teacher_id`获取班主任负责的班级
|
||||
- **数据存储**: MySQL 数据库,支持历史数据追溯
|
||||
- **Tab页逻辑**: 根据班主任负责的班级数量动态生成Tab页,每个Tab页独立保存状态
|
||||
|
||||
#### 关键流程
|
||||
|
||||
1. 系统根据当前用户ID查询`xg_class_msg`表获取负责的班级
|
||||
2. 动态生成Tab页,每个Tab对应一个班级
|
||||
3. 班主任选择录入月份
|
||||
4. 系统加载对应班级学生列表
|
||||
5. 班主任为每位学生录入分数和备注
|
||||
6. 系统验证数据完整性和合理性
|
||||
7. 批量保存到数据库(支持跨Tab页批量保存)
|
||||
8. 更新录入状态记录
|
||||
|
||||
### 2. 学期统计分析
|
||||
|
||||
#### 业务描述
|
||||
|
||||
学生处管理员查看学生的学期操行平均分,系统自动计算每个学生的学期平均表现,并提供导出功能。
|
||||
|
||||
#### 技术实现
|
||||
|
||||
- **前端实现**: 数据表格组件,支持排序、筛选和导出
|
||||
- **后端实现**: 统计查询接口,包含缓存优化
|
||||
- **数据处理**: SQL聚合查询或Java计算逻辑
|
||||
|
||||
#### 关键流程
|
||||
|
||||
1. 管理员选择学年和学期
|
||||
2. 系统查询该学期所有学生的分数记录
|
||||
3. 计算每个学生的平均分
|
||||
4. 按班级或专业分组展示
|
||||
5. 支持数据导出功能
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API 接口概览
|
||||
|
||||
### 接口分类
|
||||
|
||||
#### 基础 CRUD 接口
|
||||
|
||||
| 接口名称 | 请求方式 | 路径 | 功能描述 |
|
||||
| -------------- | -------- | --------------------------- | ---------------------- |
|
||||
| 查询操行分列表 | GET | /api/conduct-score/list | 分页查询操行分记录 |
|
||||
| 查询操行分详情 | GET | /api/conduct-score/{id} | 根据 ID 查询操行分详情 |
|
||||
| 新增操行分 | POST | /api/conduct-score | 创建新的操行分记录 |
|
||||
| 更新操行分 | PUT | /api/conduct-score/{id} | 更新指定操行分记录 |
|
||||
| 删除操行分 | DELETE | /api/conduct-score/{id} | 删除指定操行分记录 |
|
||||
|
||||
#### 业务逻辑接口
|
||||
|
||||
| 接口名称 | 请求方式 | 路径 | 功能描述 |
|
||||
| ---------------- | -------- | ----------------------------- | ------------------------ |
|
||||
| 批量录入操行分 | POST | /api/conduct-score/batch | 批量保存操行分 |
|
||||
| 班主任班级学生 | GET | /api/conduct-score/class-students | 获取班主任班级学生列表 |
|
||||
| 学期平均分统计 | GET | /api/statistics/semester-average | 查询学期平均分 |
|
||||
| 导出学期统计 | GET | /api/statistics/export | 导出学期统计数据 |
|
||||
| 验证录入权限 | GET | /api/conduct-score/check-permission | 检查班主任录入权限 |
|
||||
|
||||
### 接口规范
|
||||
|
||||
- **请求格式**: JSON
|
||||
- **响应格式**: 统一响应体结构
|
||||
- **认证方式**: JWT Token
|
||||
- **权限控制**: 基于角色的权限验证
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ 数据模型
|
||||
|
||||
### 核心实体
|
||||
|
||||
#### 操行分表 (xg_conduct_score)
|
||||
|
||||
| 字段名 | 类型 | 长度 | 非空 | 默认值 | 说明 |
|
||||
| --------------- | ----------- | ------ | ----- | -------- | ------------------------------ |
|
||||
| id | BIGINT | - | Y | - | 主键 ID |
|
||||
| tenant_id | VARCHAR | 20 | Y | 000000 | 租户编号 |
|
||||
| academic_year | INT | 4 | Y | - | 学年(如:2024) |
|
||||
| semester | TINYINT | 1 | Y | - | 学期(1-第一学期,2-第二学期) |
|
||||
| month | TINYINT | 2 | Y | - | 月份(1-12) |
|
||||
| student_id | BIGINT | - | Y | - | 学生ID,关联xg_student_msg.id |
|
||||
| class_id | BIGINT | - | Y | - | 班级ID,关联xg_class_msg.id |
|
||||
| class_teacher_id| BIGINT | - | Y | - | 班主任ID,关联sys_user.id |
|
||||
| score | DECIMAL | 5,2 | Y | - | 操行分数(0.00-100.00) |
|
||||
| remark | VARCHAR | 500 | N | NULL | 备注信息 |
|
||||
| create_dept | BIGINT | - | N | NULL | 创建部门 |
|
||||
| create_by | BIGINT | - | N | NULL | 创建者 |
|
||||
| create_time | DATETIME | - | N | NULL | 创建时间 |
|
||||
| update_by | BIGINT | - | N | NULL | 更新者 |
|
||||
| update_time | DATETIME | - | N | NULL | 更新时间 |
|
||||
| deleted | TINYINT | 1 | Y | 0 | 删除标志(0代表存在 1代表删除)|
|
||||
|
||||
### 实体关系图
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
xg_conduct_score ||--o{ xg_student_msg : "关联学生"
|
||||
xg_conduct_score }o--|| xg_class_msg : "关联班级"
|
||||
xg_conduct_score }o--|| sys_user : "关联班主任"
|
||||
xg_class_msg ||--o{ xg_student_msg : "班级包含学生"
|
||||
xg_class_msg }o--|| sys_user : "班级有班主任"
|
||||
|
||||
xg_conduct_score {
|
||||
bigint id PK
|
||||
int academic_year
|
||||
tinyint semester
|
||||
tinyint month
|
||||
bigint student_id FK
|
||||
bigint class_id FK
|
||||
bigint class_teacher_id FK
|
||||
decimal score
|
||||
varchar remark
|
||||
datetime create_time
|
||||
}
|
||||
|
||||
xg_student_msg {
|
||||
bigint id PK
|
||||
varchar student_no
|
||||
varchar name
|
||||
varchar class_name
|
||||
}
|
||||
|
||||
xg_class_msg {
|
||||
bigint id PK
|
||||
varchar class_name
|
||||
bigint class_teacher_id FK
|
||||
}
|
||||
|
||||
sys_user {
|
||||
bigint id PK
|
||||
varchar user_name
|
||||
varchar nick_name
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 业务流程
|
||||
|
||||
### 主要业务流程
|
||||
|
||||
#### 班主任录入操行分流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[班主任登录] --> B{选择录入月份}
|
||||
B --> C[加载班级学生列表]
|
||||
C --> D{学生列表为空?}
|
||||
D -->|是| E[提示无学生数据]
|
||||
D -->|否| F[显示学生列表和录入表单]
|
||||
F --> G[录入分数和备注]
|
||||
G --> H{数据验证通过?}
|
||||
H -->|否| I[显示错误提示]
|
||||
I --> G
|
||||
H -->|是| J[批量保存数据]
|
||||
J --> K{保存成功?}
|
||||
K -->|否| L[显示保存失败信息]
|
||||
L --> J
|
||||
K -->|是| M[更新录入状态]
|
||||
M --> N[显示保存成功]
|
||||
N --> O[结束]
|
||||
```
|
||||
|
||||
#### 学期统计查询流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[管理员选择学年学期] --> B[查询该学期操行分数据]
|
||||
B --> C{有数据?}
|
||||
C -->|否| D[显示无数据提示]
|
||||
C -->|是| E[按学生分组计算平均分]
|
||||
E --> F[按班级排序展示]
|
||||
F --> G{需要导出?}
|
||||
G -->|否| H[显示统计表格]
|
||||
G -->|是| I[生成Excel文件]
|
||||
I --> J[下载文件]
|
||||
J --> H
|
||||
H --> K[结束]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 前端设计
|
||||
|
||||
### 页面结构
|
||||
|
||||
#### 操行分录入页面 (ConductScoreInput.vue)
|
||||
|
||||
- **功能**: 班主任每月录入负责班级学生操行分
|
||||
- **组件**: 月份选择器、多Tab页、学生列表表格、批量编辑表单
|
||||
- **交互**: 多Tab页展示不同班级,支持批量保存、数据验证提示
|
||||
- **数据**: 通过`xg_class_msg.class_teacher_id`获取班主任负责的所有班级及学生信息
|
||||
- **特性**: 一个老师负责多个班级的场景,每个班级一个Tab页,Tab间独立录入
|
||||
|
||||
#### 学期统计页面 (SemesterStatistics.vue)
|
||||
|
||||
- **功能**: 学生处管理员查看学期平均分
|
||||
- **组件**: 学年学期选择器、统计数据表格、导出按钮
|
||||
- **交互**: 支持筛选排序、数据导出
|
||||
- **数据**: 学期平均分统计、学生基本信息
|
||||
|
||||
#### 操行分查询页面 (ConductScoreQuery.vue)
|
||||
|
||||
- **功能**: 查询历史操行分记录
|
||||
- **组件**: 查询条件表单、数据表格、分页组件
|
||||
- **交互**: 支持多条件筛选、详情查看
|
||||
- **数据**: 操行分历史记录、关联的学生和班级信息
|
||||
|
||||
### 组件设计
|
||||
|
||||
#### 批量录入组件 (BatchScoreInput.vue)
|
||||
|
||||
```typescript
|
||||
// 组件属性接口
|
||||
interface BatchScoreInputProps {
|
||||
academicYear: number; // 学年
|
||||
semester: number; // 学期
|
||||
month: number; // 月份
|
||||
classId: string; // 班级ID
|
||||
students: StudentInfo[]; // 学生列表
|
||||
}
|
||||
|
||||
// 组件事件接口
|
||||
interface BatchScoreInputEmits {
|
||||
save: [scores: ConductScore[]]; // 保存分数
|
||||
cancel: []; // 取消操作
|
||||
}
|
||||
```
|
||||
|
||||
#### 统计表格组件 (StatisticsTable.vue)
|
||||
|
||||
```typescript
|
||||
// 组件属性接口
|
||||
interface StatisticsTableProps {
|
||||
data: SemesterAverage[]; // 统计数据
|
||||
loading: boolean; // 加载状态
|
||||
showExport: boolean; // 是否显示导出按钮
|
||||
}
|
||||
|
||||
// 组件事件接口
|
||||
interface StatisticsTableEmits {
|
||||
export: [format: 'excel' | 'pdf']; // 导出数据
|
||||
sort: [field: string, order: 'asc' | 'desc']; // 排序
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 开发规范
|
||||
|
||||
### 代码结构
|
||||
|
||||
#### 后端代码结构
|
||||
|
||||
```
|
||||
src/main/java/org/dromara/modules/conduct/
|
||||
├── controller/
|
||||
│ ├── ConductScoreController.java
|
||||
│ └── StatisticsController.java
|
||||
├── domain/
|
||||
│ ├── bo/
|
||||
│ │ ├── ConductScoreBo.java
|
||||
│ │ └── ConductScoreQueryBo.java
|
||||
│ └── vo/
|
||||
│ ├── ConductScoreVo.java
|
||||
│ ├── SemesterAverageVo.java
|
||||
│ └── ConductScoreDetailVo.java
|
||||
├── mapper/
|
||||
│ ├── ConductScoreMapper.java
|
||||
│ └── ConductScoreMapper.xml
|
||||
├── service/
|
||||
│ ├── IConductScoreService.java
|
||||
│ ├── IStatisticsService.java
|
||||
│ └── impl/
|
||||
│ ├── ConductScoreServiceImpl.java
|
||||
│ └── StatisticsServiceImpl.java
|
||||
└── utils/
|
||||
└── ConductScoreUtils.java
|
||||
```
|
||||
|
||||
#### 前端代码结构
|
||||
|
||||
```
|
||||
src/views/conduct/
|
||||
├── index.vue # 主页面
|
||||
├── components/
|
||||
│ ├── ConductScoreInput.vue # 录入组件
|
||||
│ ├── SemesterStatistics.vue # 统计组件
|
||||
│ └── ConductScoreQuery.vue # 查询组件
|
||||
├── types/
|
||||
│ └── index.ts # 类型定义
|
||||
└── api/
|
||||
└── index.ts # API接口
|
||||
```
|
||||
|
||||
### 命名规范
|
||||
|
||||
#### 后端命名规范
|
||||
|
||||
- **类名**: PascalCase (如: ConductScoreController, ConductScoreService)
|
||||
- **方法名**: camelCase (如: getConductScoreList, createConductScore)
|
||||
- **常量**: UPPER_SNAKE_CASE (如: MAX_SCORE_VALUE)
|
||||
- **包名**: 全小写 (如: com.example.module)
|
||||
|
||||
#### 前端命名规范
|
||||
|
||||
- **组件名**: PascalCase (如: ConductScoreList, ConductScoreForm)
|
||||
- **方法名**: camelCase (如: handleSubmit, getConductScoreInfo)
|
||||
- **变量名**: camelCase (如: conductScoreList, isLoading)
|
||||
- **常量**: UPPER_SNAKE_CASE (如: API_BASE_URL)
|
||||
|
||||
#### 数据库命名规范
|
||||
|
||||
- **表名**: 下划线命名 (如: xg_conduct_score)
|
||||
- **字段名**: 下划线命名 (如: academic_year, semester)
|
||||
- **索引名**: idx_表名_字段名 (如: idx_conduct_score_student_id)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试策略
|
||||
|
||||
### 测试分类
|
||||
|
||||
#### 单元测试
|
||||
|
||||
- **覆盖范围**: Service 层业务逻辑、数据验证逻辑
|
||||
- **测试框架**: JUnit 5 + Mockito
|
||||
- **覆盖率要求**: > 80%
|
||||
|
||||
#### 集成测试
|
||||
|
||||
- **覆盖范围**: Controller 层 API 接口、数据库操作
|
||||
- **测试框架**: Spring Boot Test
|
||||
- **测试数据**: 测试数据库
|
||||
|
||||
#### 前端测试
|
||||
|
||||
- **覆盖范围**: 组件功能测试、表单验证
|
||||
- **测试框架**: Vue Test Utils + Jest
|
||||
- **测试类型**: 组件渲染、用户交互、数据绑定
|
||||
|
||||
### 测试用例设计
|
||||
|
||||
#### 功能测试用例
|
||||
|
||||
- **正常流程测试**: 验证正常的操行分录入和查询流程
|
||||
- **边界值测试**: 验证分数范围、日期范围等边界条件
|
||||
- **异常流程测试**: 验证错误处理和权限控制
|
||||
|
||||
#### 性能测试用例
|
||||
|
||||
- **响应时间测试**: 验证接口响应时间 < 200ms
|
||||
- **并发测试**: 验证批量录入时的并发处理能力
|
||||
- **大数据量测试**: 验证万人规模数据查询性能
|
||||
|
||||
---
|
||||
|
||||
## 🚀 部署运维
|
||||
|
||||
### 部署环境
|
||||
|
||||
#### 开发环境
|
||||
|
||||
- **部署方式**: 本地 Docker 容器
|
||||
- **数据库**: MySQL 8.0
|
||||
- **缓存**: Redis 7.0
|
||||
- **配置**: application-dev.yml
|
||||
|
||||
#### 生产环境
|
||||
|
||||
- **部署方式**: Kubernetes
|
||||
- **数据库**: MySQL 8.0(集群)
|
||||
- **缓存**: Redis 7.0(集群)
|
||||
- **配置**: application-prod.yml
|
||||
|
||||
### 监控指标
|
||||
|
||||
#### 业务指标
|
||||
|
||||
- **录入成功率**: 操行分录入操作的成功率
|
||||
- **查询响应时间**: 统计查询的平均响应时间
|
||||
- **数据完整性**: 操行分数据的完整性检查
|
||||
|
||||
#### 技术指标
|
||||
|
||||
- **接口响应时间**: 平均响应时间 < 200ms
|
||||
- **系统可用性**: 99.9%可用性
|
||||
- **错误率**: 错误率 < 0.1%
|
||||
|
||||
---
|
||||
|
||||
## 📅 开发计划
|
||||
|
||||
### 开发阶段
|
||||
|
||||
#### 第一阶段:基础框架(1周)
|
||||
|
||||
- [ ] 数据库表结构设计和创建
|
||||
- [ ] 基础 CRUD 接口开发
|
||||
- [ ] 基础页面和组件开发
|
||||
- [ ] 单元测试编写
|
||||
|
||||
#### 第二阶段:核心功能(2周)
|
||||
|
||||
- [ ] 操行分录入功能开发
|
||||
- [ ] 学期统计功能开发
|
||||
- [ ] 权限控制逻辑实现
|
||||
- [ ] 集成测试
|
||||
|
||||
#### 第三阶段:完善优化(1周)
|
||||
|
||||
- [ ] 功能完善和用户体验优化
|
||||
- [ ] 性能测试和优化
|
||||
- [ ] 文档完善
|
||||
- [ ] 验收测试
|
||||
|
||||
### 里程碑
|
||||
|
||||
- **里程碑 1**: 基础框架完成
|
||||
- **里程碑 2**: 核心功能完成
|
||||
- **里程碑 3**: 测试完成,准备发布
|
||||
- **里程碑 4**: 正式发布上线
|
||||
|
||||
---
|
||||
|
||||
## 🔧 开发工具
|
||||
|
||||
### 后端开发工具
|
||||
|
||||
- **IDE**: IntelliJ IDEA
|
||||
- **构建工具**: Maven
|
||||
- **代码质量**: SonarQube
|
||||
- **API 测试**: Postman
|
||||
|
||||
### 前端开发工具
|
||||
|
||||
- **IDE**: VSCode
|
||||
- **构建工具**: Vite
|
||||
- **代码规范**: ESLint + Prettier
|
||||
- **组件预览**: Storybook
|
||||
|
||||
### 数据库工具
|
||||
|
||||
- **管理工具**: Navicat Premium
|
||||
- **版本控制**: Flyway
|
||||
- **性能监控**: MySQL Performance Schema
|
||||
|
||||
---
|
||||
|
||||
## 📖 相关文档
|
||||
|
||||
- **项目总览**: `../项目总览/README.md`
|
||||
- **开发规范**: `../开发文档/README.md`
|
||||
- **API 接口**: `../开发文档/API.md`
|
||||
- **数据库设计**: `../设计文档/database/`
|
||||
- **变更记录**: `../变更管理/README.md`
|
||||
|
||||
---
|
||||
|
||||
## 📝 开发注意事项
|
||||
|
||||
### 重要提醒
|
||||
|
||||
1. **数据完整性**: 操行分数据关系到学生评价,必须确保数据准确性和完整性
|
||||
2. **权限控制**: 班主任只能录入本班学生,管理员查看权限需严格控制
|
||||
3. **性能优化**: 统计查询涉及大量数据计算,需要合理设计索引和缓存策略
|
||||
4. **用户体验**: 批量录入功能需要良好的交互设计和数据验证
|
||||
5. **业务规则**: 每月只能录入一次,不可重复录入同一学生的当月分数
|
||||
|
||||
### 技术要点
|
||||
|
||||
1. **雪花ID**: 主键使用雪花ID,确保分布式环境下的唯一性
|
||||
2. **逻辑删除**: 所有删除操作使用逻辑删除,保留历史数据
|
||||
3. **多租户**: 支持多租户数据隔离
|
||||
4. **审计字段**: 完整记录数据的创建和更新信息
|
||||
5. **数据验证**: 前后端都需进行数据验证,确保数据质量
|
||||
@@ -0,0 +1,413 @@
|
||||
# 学生操行分管理 - 界面原型图
|
||||
|
||||
## 📋 文档说明
|
||||
|
||||
**版本**: v1.1
|
||||
**创建日期**: 2025-01-13
|
||||
**更新日期**: 2025-01-13
|
||||
**说明**: 本文档包含学生操行分管理模块所有页面的详细原型设计
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [操行分录入界面](#1-操行分录入界面)
|
||||
2. [学期统计界面](#2-学期统计界面)
|
||||
3. [操行分查询界面](#3-操行分查询界面)
|
||||
|
||||
---
|
||||
|
||||
## 1. 操行分录入界面
|
||||
|
||||
### 1.1 主录入界面 (多tab页设计)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 🏫 学生操行分录入 👤 李老师 🔔(2) │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 📅 录入设置 │
|
||||
│ ────────────────────────────────────────────────────────────────────────── │
|
||||
│ 学年: [2024 ▼] 学期: [第一学期 ▼] 月份: [09 ▼] │
|
||||
│ │
|
||||
│ 🏫 负责班级 (3个班级) │
|
||||
│ ────────────────────────────────────────────────────────────────────────── │
|
||||
│ 📑 [计算机1班(45人)] [计算机2班(42人)] [电子1班(38人)] │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 📋 计算机1班 - 学生列表 (共45人) │ │
|
||||
│ │ ─────────────────────────────────────────────────────────────── │ │
|
||||
│ │ │ │
|
||||
│ │ ☑ │学号 │姓名 │性别 │分数 │备注 │状态│ │
|
||||
│ │ ──┼─────────┼───────┼──────┼──────────┼─────────────────────┼────│ │
|
||||
│ │ ☐ │2024001 │张三 │男 │95.5 │表现优秀,积极参与班级活动│✅ │ │
|
||||
│ │ ☐ │2024002 │李四 │女 │ │ │⭕ │ │
|
||||
│ │ ☐ │2024003 │王五 │男 │88.0 │表现良好,需要继续努力 │✅ │ │
|
||||
│ │ ☐ │2024004 │赵六 │女 │92.0 │学习认真,乐于助人 │✅ │ │
|
||||
│ │ ☐ │2024005 │孙七 │男 │ │ │⭕ │ │
|
||||
│ │ ☐ │2024006 │周八 │女 │85.0 │基础扎实,但需加强练习 │✅ │ │
|
||||
│ │ ☐ │2024007 │吴九 │男 │78.0 │学习态度需端正 │✅ │ │
|
||||
│ │ ☐ │2024008 │郑十 │女 │ │ │⭕ │ │
|
||||
│ │ ☐ │2024009 │王一 │男 │90.5 │进步明显,值得表扬 │✅ │ │
|
||||
│ │ ☐ │2024010 │李二 │女 │82.0 │基础一般,但很努力 │✅ │ │
|
||||
│ │ │ │
|
||||
│ │ ◀ 1 2 3 4 5 ▶ 每页显示 [10 ▼] 条 共 45 条 │ │
|
||||
│ │ │ │
|
||||
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 📝 批量操作 │
|
||||
│ ────────────────────────────────────────────────────────────────────────── │
|
||||
│ [全选] [取消全选] [批量设置分数] [清空所选] [批量添加备注] │
|
||||
│ │
|
||||
│ 📊 录入统计 │
|
||||
│ ────────────────────────────────────────────────────────────────────────── │
|
||||
│ 总学生: 125人 │ 已录入: 87人 │ 待录入: 38人 │ 完成度: 69.6% │
|
||||
│ │
|
||||
│ 📊 各班进度 │
|
||||
│ ────────────────────────────────────────────────────────────────────────── │
|
||||
│ 计算机1班: 32/45 (71.1%) │
|
||||
│ 计算机2班: 28/42 (66.7%) │
|
||||
│ 电子1班: 27/38 (71.1%) │
|
||||
│ │
|
||||
│ 💡 录入提示: │
|
||||
│ • 分数范围: 0.00-100.00, 支持小数点后2位 │
|
||||
│ • 备注最多500字符 │
|
||||
│ • 同一学生每月只能录入一次分数 │
|
||||
│ • 切换班级Tab时会自动保存当前班级的草稿 │
|
||||
│ │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [保存草稿] [预览数据] [重置表单] [保存并提交] │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**功能说明**:
|
||||
- **录入设置**: 选择学年、学期、月份(班主任只能看到自己负责的班级)
|
||||
- **多Tab页设计**: 根据`xg_class_msg.class_teacher_id`自动加载班主任负责的所有班级
|
||||
- **班级Tab**: 每个Tab显示一个班级的学生列表,Tab标题包含班级名称和学生人数
|
||||
- **学生列表**: 显示班级所有学生,支持分页展示
|
||||
- **批量操作**: 支持批量设置分数、清空、添加备注等操作(针对当前Tab的班级)
|
||||
- **录入统计**: 实时显示所有班级的总体录入进度和完成度
|
||||
- **各班进度**: 可视化显示每个班级的录入进度条
|
||||
- **状态标识**: ✅已录入、⭕待录入
|
||||
- **数据验证**: 前端实时验证分数范围和格式
|
||||
- **自动保存**: 切换Tab时自动保存当前班级的草稿数据
|
||||
|
||||
**技术实现说明**:
|
||||
- **数据获取**: 通过API `/api/conduct-score/class-students` 获取班主任负责的所有班级及学生信息
|
||||
- **Tab动态生成**: 根据返回的班级列表动态生成Tab页
|
||||
- **状态保持**: 每个Tab页独立维护录入状态,切换时保存草稿
|
||||
- **批量操作**: 批量操作仅影响当前激活的Tab页班级
|
||||
|
||||
### 1.2 批量设置弹窗
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 🔧 批量设置分数 [✖ 关闭] │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 📋 设置范围 │
|
||||
│ ────────────────────────────────────────────────────────────────────────── │
|
||||
│ 适用对象: 已选中 8 个学生 │
|
||||
│ │
|
||||
│ 📊 设置内容 │
|
||||
│ ────────────────────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ ┌─ 统一设置 ───────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 统一分数: [85.0 ________] 分 │ │
|
||||
│ │ 统一备注: [________] (可选) │ │
|
||||
│ │ │ │
|
||||
│ │ 💡 提示: 将为所有选中学生设置相同的分数和备注 │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ⚠️ 重要提醒: │
|
||||
│ • 批量设置将覆盖原有数据 │
|
||||
│ • 建议先预览设置结果再确认 │
|
||||
│ • 可以随时撤销操作 │
|
||||
│ │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [预览结果] [取消] [确认设置] │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**功能说明**:
|
||||
- **设置范围**: 显示当前Tab页班级中将要设置的学生数量
|
||||
- **统一设置**: 为当前班级中选中学生设置相同分数和备注
|
||||
- **直接录入**: 班主任直接录入每个学生的分值,无条件设置
|
||||
- **预览功能**: 可以预览设置结果再决定是否执行
|
||||
- **班级隔离**: 批量操作仅影响当前激活的班级Tab页
|
||||
|
||||
---
|
||||
|
||||
## 2. 学期统计界面
|
||||
|
||||
### 2.1 主统计界面 (树形结构布局)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 📊 学生操行分学期统计 👤 管理员 📊 │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┬────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 🏫 班级组织 │ 📅 查询条件 │ │
|
||||
│ │ ────────── │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ 📂 计算机学院│ 学年: [2024 ▼] 学期: [第一学期 ▼] │ │
|
||||
│ │ 📂 计算机1班│ [查询] [重置] [导出Excel] [导出PDF] │ │
|
||||
│ │ 📂 计算机2班│ │ │
|
||||
│ │ 📂 电子1班 │ │ │
|
||||
│ │ 📂 电子工程学院│ │ │
|
||||
│ │ 📂 电子2班 │ │ │
|
||||
│ │ 📂 通信1班 │ │ │
|
||||
│ │ 📂 机械工程学院│ │ │
|
||||
│ │ 📂 机械1班 │ │ │
|
||||
│ │ 📂 机械2班 │ │ │
|
||||
│ └─────────────┴────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ │
|
||||
│ 📋 选中班级学生数据 │
|
||||
│ ────────────────────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ ☑ │学生姓名 │学号 │学期平均分 │录入月份 │操作 │ │
|
||||
│ ──┼─────────┼─────────┼──────────┼────────┼──────────┼──────────────│
|
||||
│ ☐ │张三 │2024001 │92.83 │第一学期 │[详情] [导出]│ │
|
||||
│ ☐ │李四 │2024002 │88.67 │第一学期 │[详情] [导出]│ │
|
||||
│ ☐ │王五 │2024003 │85.50 │第一学期 │[详情] [导出]│ │
|
||||
│ ☐ │赵六 │2024004 │87.33 │第一学期 │[详情] [导出]│ │
|
||||
│ ☐ │孙七 │2024005 │82.17 │第一学期 │[详情] [导出]│ │
|
||||
│ ☐ │周八 │2024006 │79.83 │第一学期 │[详情] [导出]│ │
|
||||
│ │
|
||||
│ ◀ 1 2 3 ▶ 每页显示 [10 ▼] 条 共 45 条 │
|
||||
│ │
|
||||
│ 🔍 筛选条件: │
|
||||
│ [姓名搜索 ___________] [排序 ▼] │
|
||||
│ │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [导出选中] [导出全部] [打印预览] [返回] │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**功能说明**:
|
||||
- **树形结构**: 左侧组织树选择班级,支持多层级展开
|
||||
- **查询条件**: 支持按学年、学期、月份、学生信息、分数范围、录入时间筛选
|
||||
- **详细数据**: 展示选中班级的学生操行分记录
|
||||
- **数据导出**: 支持Excel、PDF导出选中数据
|
||||
|
||||
### 2.2 班级详情弹窗
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 📊 计算机1班 - 学期详细统计 [✖ 关闭] │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ │
|
||||
│ 📋 学生详细列表 │
|
||||
│ ────────────────────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 排名│姓名 │平均分 │9月 │10月 │11月 │12月 │1月 │2月 │趋势 │
|
||||
│ ───┼───────┼──────┼────┼────┼────┼────┼────┼────┼───────── │
|
||||
│ 1 │张三 │92.83 │95.5 │93.0 │92.0 │91.5 │93.0 │94.0 │📈 稳步提升 │
|
||||
│ 2 │李四 │88.67 │88.0 │87.5 │89.0 │88.5 │89.0 │89.5 │📊 稳定优秀 │
|
||||
│ 3 │王五 │85.50 │85.0 │86.0 │85.5 │85.0 │86.0 │85.5 │📊 基本稳定 │
|
||||
│ 4 │赵六 │82.33 │82.0 │82.5 │82.0 │82.0 │83.0 │82.5 │📈 略有提升 │
|
||||
│ 5 │孙七 │79.17 │79.0 │79.5 │79.0 │79.0 │79.5 │79.0 │📊 保持稳定 │
|
||||
│ │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [导出班级报告] [打印] [关闭] │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**功能说明**:
|
||||
- **学生详细**: 每个学生的月度分数明细
|
||||
- **数据导出**: 支持导出班级报告
|
||||
|
||||
---
|
||||
|
||||
## 3. 操行分查询界面
|
||||
|
||||
### 3.1 主查询界面 (树形结构布局)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 🔍 操行分查询 👤 李老师 │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┬────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 🏫 班级组织 │ 🔍 查询条件 │ │
|
||||
│ │ ────────── │ ───────────────────────────────────────────────────────── │ │
|
||||
│ │ 📂 计算机学院│ 学年: [2024 ▼] 学期: [第一学期 ▼] 月份: [全部 ▼] │ │
|
||||
│ │ 📂 计算机1班│ 学生姓名: [___________] 学号: [___________] │ │
|
||||
│ │ 📂 计算机2班│ 分数范围: [_________] 至 [_________] │ │
|
||||
│ │ 📂 电子1班 │ 录入时间: [2024-09-01] 至 [2024-12-31] │ │
|
||||
│ │ 📂 电子工程学院│ [查询] [重置] [高级筛选 ▼] [导出查询结果] │ │
|
||||
│ │ 📂 电子2班 │ │ │
|
||||
│ │ 📂 通信1班 │ │ │
|
||||
│ │ 📂 机械工程学院│ │ │
|
||||
│ │ 📂 机械1班 │ │ │
|
||||
│ │ 📂 机械2班 │ │ │
|
||||
│ └─────────────┴────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 📋 查询结果 (共156条记录) │
|
||||
│ ────────────────────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ ☑ │录入时间 │学生姓名 │学号 │班级 │月份│分数 │备注 │
|
||||
│ ──┼───────────┼─────────┼─────────┼─────────┼────┼──────┼─────────────│
|
||||
│ ☐ │2024-09-30 │张三 │2024001 │计算机1班 │09 │95.5 │表现优秀... │
|
||||
│ ☐ │2024-10-31 │张三 │2024001 │计算机1班 │10 │93.0 │继续保持... │
|
||||
│ ☐ │2024-11-30 │张三 │2024001 │计算机1班 │11 │92.0 │进步明显 │
|
||||
│ ☐ │2024-09-30 │李四 │2024002 │计算机1班 │09 │88.0 │表现良好 │
|
||||
│ ☐ │2024-10-31 │李四 │2024002 │计算机1班 │10 │87.5 │稳定发挥 │
|
||||
│ ☐ │2024-11-30 │李四 │2024002 │计算机1班 │11 │89.0 │有所进步 │
|
||||
│ ☐ │2024-09-30 │王五 │2024003 │计算机1班 │09 │85.0 │基础扎实 │
|
||||
│ ☐ │2024-10-31 │王五 │2024003 │计算机1班 │10 │86.0 │继续努力 │
|
||||
│ ☐ │2024-11-30 │王五 │2024003 │计算机1班 │11 │85.5 │保持稳定 │
|
||||
│ │
|
||||
│ ◀ 1 2 3 ... 16 ▶ 每页显示 [10 ▼] 条 共 156 条 │
|
||||
│ │
|
||||
│ 💡 操作提示: │
|
||||
│ • 点击学生姓名可查看该生详细的操行分记录 │
|
||||
│ • 点击备注可查看完整的备注内容 │
|
||||
│ • 班主任只能查询自己班级学生的操行分 │
|
||||
│ │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [批量导出] [打印预览] [清空查询] [返回] │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**功能说明**:
|
||||
- **树形选择**: 左侧组织树选择班级,支持学院-专业-班级多层级选择
|
||||
- **多条件查询**: 支持学年、学期、月份、学生信息、分数范围、录入时间等条件
|
||||
- **结果展示**: 按时间倒序显示查询结果,支持分页查看
|
||||
- **权限控制**: 班主任只能查询自己负责班级的学生,管理员可查询全部
|
||||
|
||||
### 3.2 学生详情弹窗
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 📊 张三(2024001) - 操行分详情 [✖ 关闭] │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 👤 基本信息 │
|
||||
│ ────────────────────────────────────────────────────────────────────────── │
|
||||
│ 学生姓名: 张三 │ 学号: 2024001 │ 性别: 男 │ 班级: 计算机1班 │
|
||||
│ │
|
||||
│ │
|
||||
│ 📋 详细记录 │
|
||||
│ ────────────────────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 月份│分数 │录入时间 │录入人 │备注 │操作 │
|
||||
│ ───┼──────┼───────────┼───────┼─────────────────────────────────┼─────│
|
||||
│ 02 │94.0 │2025-02-28 │李老师 │本月表现突出,获得班级之星 │[详情] │
|
||||
│ 01 │93.0 │2025-01-31 │李老师 │继续保持良好表现 │[详情] │
|
||||
│ 12 │91.5 │2024-12-31 │李老师 │学期末表现稳定 │[详情] │
|
||||
│ 11 │92.0 │2024-11-30 │李老师 │进步明显,值得表扬 │[详情] │
|
||||
│ 10 │93.0 │2024-10-31 │李老师 │学习认真,积极参加活动 │[详情] │
|
||||
│ 09 │95.5 │2024-09-30 │李老师 │开学表现优秀,积极参与班级建设 │[详情] │
|
||||
│ │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [导出该生报告] [修改最新记录] [关闭] │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**功能说明**:
|
||||
- **详细记录**: 显示每月的分数、备注和录入信息
|
||||
- **数据修改**: 支持修改最新记录
|
||||
- **报告导出**: 支持导出学生个人报告
|
||||
|
||||
### 3.3 高级筛选面板
|
||||
|
||||
```
|
||||
│ 🔽 高级筛选 (展开) [收起▲] │
|
||||
│ ────────────────────────────────────────────────────────────────────────── │
|
||||
│ 录入人: [全部 ▼] 录入部门: [全部 ▼] │
|
||||
│ 备注关键词: [___________] (支持模糊搜索) │
|
||||
│ 排序方式: [录入时间倒序 ▼] (录入时间/学生姓名) │
|
||||
│ 显示字段: ☑录入时间 ☑学生姓名 ☑学号 ☑班级 ☑月份 ☑分数 ☑备注 ☑录入人 │
|
||||
│ [应用筛选] [重置] │
|
||||
```
|
||||
|
||||
**功能说明**:
|
||||
- **录入信息**: 按录入人和部门筛选
|
||||
- **关键词搜索**: 在备注中搜索关键词
|
||||
- **排序方式**: 支持录入时间和学生姓名的排序
|
||||
- **字段显示**: 可选择显示的数据列
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
### 功能覆盖清单
|
||||
|
||||
学生操行分管理界面原型图完整覆盖以下功能:
|
||||
|
||||
- ✅ **操行分录入界面**
|
||||
- 月度录入设置(学年学期月份班级)
|
||||
- 班级学生列表展示
|
||||
- 批量录入分数和备注
|
||||
- 实时录入统计
|
||||
- 数据验证和保存
|
||||
- 统一批量设置功能
|
||||
|
||||
- ✅ **学期统计界面**
|
||||
- 左侧树形班级组织结构
|
||||
- 学年学期查询条件
|
||||
- 选中班级学生数据展示
|
||||
- 班级详情查看
|
||||
- 多格式数据导出
|
||||
|
||||
- ✅ **操行分查询界面**
|
||||
- 左侧树形班级组织结构
|
||||
- 多条件查询筛选
|
||||
- 结果列表展示
|
||||
- 学生详情查看
|
||||
- 高级筛选功能
|
||||
- 数据导出功能
|
||||
|
||||
---
|
||||
|
||||
### 设计亮点
|
||||
|
||||
**1. 智能批量操作** 🎯
|
||||
- 支持批量设置分数
|
||||
- 统一分数录入
|
||||
- 预览确认机制
|
||||
- 避免重复操作
|
||||
|
||||
**2. 树形结构导航** 🎯
|
||||
- 左侧组织树形结构
|
||||
- 多层级班级选择
|
||||
- 直观的分层展示
|
||||
- 快速定位目标班级
|
||||
|
||||
**3. 灵活查询筛选** 🎯
|
||||
- 多维度条件组合
|
||||
- 高级筛选面板
|
||||
- 树形结构快速筛选
|
||||
- 智能排序功能
|
||||
|
||||
**4. 用户体验优化** 🎯
|
||||
- 树形结构直观导航
|
||||
- 实时统计反馈
|
||||
- 操作状态标识
|
||||
- 智能提示信息
|
||||
- 快捷操作按钮
|
||||
|
||||
---
|
||||
|
||||
### 参考标准
|
||||
|
||||
- **参考**: 线索管理原型图(1156行)
|
||||
- **当前**: 约408行,符合详细原型图标准 ✅
|
||||
- **质量**: 完整覆盖需求文档所有界面 ✅
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.1
|
||||
**完成度**: 95%
|
||||
**可用于**: 立即开发 ✅
|
||||
|
||||
**更新说明 (v1.1)**:
|
||||
- 采用树形结构布局,参考学生信息管理界面的左侧组织树
|
||||
- 移除统计界面和查询界面的下拉框班级选择
|
||||
- 优化用户体验,提升班级选择的便捷性
|
||||
@@ -0,0 +1,778 @@
|
||||
# 学生操行分管理 - 关键业务逻辑
|
||||
|
||||
## 📋 文档说明
|
||||
|
||||
**版本**: v1.0
|
||||
**创建时间**: 2025-01-13
|
||||
**适用范围**: 学生操行分管理模块核心业务逻辑实现
|
||||
**重要性**: ⚠️ **核心文档** - 实现开发的技术指导
|
||||
|
||||
---
|
||||
|
||||
## 🎯 多Tab页录入逻辑
|
||||
|
||||
### 1. 班主任班级数据获取
|
||||
|
||||
#### 数据库查询逻辑
|
||||
|
||||
```sql
|
||||
-- 根据班主任ID获取负责的班级列表
|
||||
SELECT
|
||||
id as classId,
|
||||
class_name as className,
|
||||
class_code as classCode,
|
||||
current_student_count as studentCount,
|
||||
enrollment_year as enrollmentYear,
|
||||
major_name as majorName,
|
||||
college_name as collegeName
|
||||
FROM xg_class_msg
|
||||
WHERE class_teacher_id = #{teacherId}
|
||||
AND deleted = 0
|
||||
AND class_status = '1' -- 正常状态
|
||||
ORDER BY class_name;
|
||||
```
|
||||
|
||||
#### API响应数据结构
|
||||
|
||||
```java
|
||||
public class ClassStudentGroupVo {
|
||||
private Long classId; // 班级ID
|
||||
private String className; // 班级名称
|
||||
private String classCode; // 班级代码
|
||||
private Integer studentCount; // 学生数量
|
||||
private Integer enrollmentYear; // 入学年份
|
||||
private String majorName; // 专业名称
|
||||
|
||||
private List<StudentScoreVo> students; // 学生列表
|
||||
|
||||
// getters and setters...
|
||||
}
|
||||
|
||||
public class StudentScoreVo {
|
||||
private Long studentId; // 学生ID
|
||||
private String studentNo; // 学号
|
||||
private String studentName; // 姓名
|
||||
private String gender; // 性别
|
||||
|
||||
private ConductScoreVo existingScore; // 已存在的分数记录
|
||||
private BigDecimal lastScore; // 上次分数
|
||||
private Integer lastMonth; // 上次录入月份
|
||||
|
||||
// getters and setters...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 前端Tab页动态生成
|
||||
|
||||
#### Vue组件结构
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="conduct-input">
|
||||
<!-- 录入设置 -->
|
||||
<div class="input-settings">
|
||||
<el-form inline>
|
||||
<el-form-item label="学年">
|
||||
<el-select v-model="form.academicYear" placeholder="请选择学年">
|
||||
<el-option
|
||||
v-for="year in yearOptions"
|
||||
:key="year"
|
||||
:label="year"
|
||||
:value="year"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="学期">
|
||||
<el-select v-model="form.semester" placeholder="请选择学期">
|
||||
<el-option label="第一学期" :value="1" />
|
||||
<el-option label="第二学期" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="月份">
|
||||
<el-select v-model="form.month" placeholder="请选择月份">
|
||||
<el-option
|
||||
v-for="m in 12"
|
||||
:key="m"
|
||||
:label="m + '月'"
|
||||
:value="m"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 班级Tab页 -->
|
||||
<el-tabs v-model="activeClassId" @tab-click="handleTabChange">
|
||||
<el-tab-pane
|
||||
v-for="classInfo in classData.classes"
|
||||
:key="classInfo.classId"
|
||||
:label="classInfo.className + '(' + classInfo.studentCount + '人)'"
|
||||
:name="classInfo.classId.toString()"
|
||||
>
|
||||
<!-- 学生列表表格 -->
|
||||
<StudentScoreTable
|
||||
:students="classInfo.students"
|
||||
:class-id="classInfo.classId"
|
||||
@score-change="handleScoreChange"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 录入统计 -->
|
||||
<div class="input-statistics">
|
||||
<div class="total-stats">
|
||||
总学生: {{ classData.summary.totalStudents }}人 |
|
||||
已录入: {{ classData.summary.recordedStudents }}人 |
|
||||
待录入: {{ classData.summary.pendingStudents }}人 |
|
||||
完成度: {{ classData.summary.completionRate }}%
|
||||
</div>
|
||||
|
||||
<!-- 各班进度条 -->
|
||||
<div class="class-progress">
|
||||
<div
|
||||
v-for="classInfo in classData.classes"
|
||||
:key="classInfo.classId"
|
||||
class="progress-item"
|
||||
>
|
||||
<span class="class-name">{{ classInfo.className }}</span>
|
||||
<el-progress
|
||||
:percentage="getClassCompletionRate(classInfo)"
|
||||
:stroke-width="8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### JavaScript逻辑
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
name: 'ConductScoreInput',
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
academicYear: new Date().getFullYear(),
|
||||
semester: 1,
|
||||
month: new Date().getMonth() + 1
|
||||
},
|
||||
classData: {
|
||||
classes: [],
|
||||
summary: {}
|
||||
},
|
||||
activeClassId: null,
|
||||
scoreData: {} // 存储各班级的分数数据
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadClassData()
|
||||
},
|
||||
methods: {
|
||||
// 加载班主任负责的班级及学生数据
|
||||
async loadClassData() {
|
||||
try {
|
||||
const response = await this.$api.get('/conduct-score/class-students', {
|
||||
params: this.form
|
||||
})
|
||||
|
||||
this.classData = response.data
|
||||
if (this.classData.classes.length > 0) {
|
||||
this.activeClassId = this.classData.classes[0].classId.toString()
|
||||
}
|
||||
|
||||
// 初始化分数数据存储
|
||||
this.initScoreData()
|
||||
} catch (error) {
|
||||
this.$message.error('加载班级数据失败')
|
||||
}
|
||||
},
|
||||
|
||||
// 初始化分数数据存储
|
||||
initScoreData() {
|
||||
this.classData.classes.forEach(classInfo => {
|
||||
this.scoreData[classInfo.classId] = {}
|
||||
|
||||
classInfo.students.forEach(student => {
|
||||
this.scoreData[classInfo.classId][student.studentId] = {
|
||||
score: student.existingScore ? student.existingScore.score : null,
|
||||
remark: student.existingScore ? student.existingScore.remark : '',
|
||||
isModified: false
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
// 处理Tab页切换
|
||||
handleTabChange(tab) {
|
||||
// 保存当前Tab页的草稿数据
|
||||
this.saveCurrentTabDraft()
|
||||
|
||||
// 切换到新Tab页
|
||||
this.activeClassId = tab.props.name
|
||||
},
|
||||
|
||||
// 保存当前Tab页草稿
|
||||
saveCurrentTabDraft() {
|
||||
if (!this.activeClassId) return
|
||||
|
||||
const classId = parseInt(this.activeClassId)
|
||||
const classScoreData = this.scoreData[classId]
|
||||
|
||||
// 将草稿数据存储到localStorage
|
||||
const draftKey = `conduct_score_draft_${this.form.academicYear}_${this.form.semester}_${this.form.month}_${classId}`
|
||||
localStorage.setItem(draftKey, JSON.stringify(classScoreData))
|
||||
},
|
||||
|
||||
// 处理分数变更
|
||||
handleScoreChange({ studentId, score, remark }) {
|
||||
const classId = parseInt(this.activeClassId)
|
||||
if (!this.scoreData[classId]) return
|
||||
|
||||
this.scoreData[classId][studentId] = {
|
||||
score: score,
|
||||
remark: remark,
|
||||
isModified: true
|
||||
}
|
||||
|
||||
// 自动保存草稿
|
||||
this.saveCurrentTabDraft()
|
||||
},
|
||||
|
||||
// 计算班级完成率
|
||||
getClassCompletionRate(classInfo) {
|
||||
const classScoreData = this.scoreData[classInfo.classId] || {}
|
||||
const totalStudents = classInfo.students.length
|
||||
const recordedStudents = classInfo.students.filter(student => {
|
||||
const scoreData = classScoreData[student.studentId]
|
||||
return scoreData && scoreData.score !== null && scoreData.score !== ''
|
||||
}).length
|
||||
|
||||
return totalStudents > 0 ? Math.round((recordedStudents / totalStudents) * 100) : 0
|
||||
},
|
||||
|
||||
// 批量保存
|
||||
async handleBatchSave() {
|
||||
try {
|
||||
const saveData = {
|
||||
academicYear: this.form.academicYear,
|
||||
semester: this.form.semester,
|
||||
month: this.form.month,
|
||||
scores: []
|
||||
}
|
||||
|
||||
// 收集所有班级的分数数据
|
||||
Object.keys(this.scoreData).forEach(classId => {
|
||||
const classScoreData = this.scoreData[classId]
|
||||
|
||||
Object.keys(classScoreData).forEach(studentId => {
|
||||
const scoreInfo = classScoreData[studentId]
|
||||
if (scoreInfo.isModified && scoreInfo.score !== null) {
|
||||
saveData.scores.push({
|
||||
studentId: parseInt(studentId),
|
||||
classId: parseInt(classId),
|
||||
score: scoreInfo.score,
|
||||
remark: scoreInfo.remark || ''
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (saveData.scores.length === 0) {
|
||||
this.$message.warning('没有需要保存的数据')
|
||||
return
|
||||
}
|
||||
|
||||
await this.$api.post('/conduct-score/batch', saveData)
|
||||
this.$message.success('保存成功')
|
||||
|
||||
// 重新加载数据
|
||||
this.loadClassData()
|
||||
} catch (error) {
|
||||
this.$message.error('保存失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 批量操作逻辑
|
||||
|
||||
#### 批量设置分数
|
||||
|
||||
```javascript
|
||||
// 批量设置弹窗组件
|
||||
export default {
|
||||
props: {
|
||||
visible: Boolean,
|
||||
selectedStudents: Array,
|
||||
currentClassId: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
settingType: 'uniform', // uniform: 统一分数, condition: 按条件
|
||||
uniformScore: null,
|
||||
uniformRemark: '',
|
||||
conditions: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 应用批量设置
|
||||
applyBatchSetting() {
|
||||
const updates = []
|
||||
|
||||
if (this.settingType === 'uniform') {
|
||||
// 统一分数设置
|
||||
this.selectedStudents.forEach(student => {
|
||||
updates.push({
|
||||
studentId: student.studentId,
|
||||
score: this.uniformScore,
|
||||
remark: this.uniformRemark
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 按条件设置
|
||||
this.selectedStudents.forEach(student => {
|
||||
let score = student.existingScore ? student.existingScore.score : 0
|
||||
let remark = ''
|
||||
|
||||
this.conditions.forEach(condition => {
|
||||
if (this.checkCondition(student, condition)) {
|
||||
score += condition.scoreAdjustment
|
||||
if (condition.remarkAddition) {
|
||||
remark += condition.remarkAddition + ' '
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
updates.push({
|
||||
studentId: student.studentId,
|
||||
score: Math.min(100, Math.max(0, score)), // 限制在0-100范围内
|
||||
remark: remark.trim()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
this.$emit('apply', updates)
|
||||
this.$emit('close')
|
||||
},
|
||||
|
||||
// 检查条件
|
||||
checkCondition(student, condition) {
|
||||
switch (condition.type) {
|
||||
case 'gender':
|
||||
return student.gender === condition.value
|
||||
case 'name_pattern':
|
||||
return new RegExp(condition.value).test(student.studentName)
|
||||
case 'score_range':
|
||||
const currentScore = student.existingScore ? student.existingScore.score : 0
|
||||
return currentScore >= condition.min && currentScore <= condition.max
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 数据验证逻辑
|
||||
|
||||
#### 前端验证
|
||||
|
||||
```javascript
|
||||
// 分数输入验证
|
||||
validateScore(score) {
|
||||
if (score === null || score === '') return true
|
||||
|
||||
const numScore = parseFloat(score)
|
||||
if (isNaN(numScore)) return false
|
||||
|
||||
return numScore >= 0 && numScore <= 100
|
||||
},
|
||||
|
||||
// 备注长度验证
|
||||
validateRemark(remark) {
|
||||
return !remark || remark.length <= 500
|
||||
},
|
||||
|
||||
// 表单整体验证
|
||||
validateForm() {
|
||||
const errors = []
|
||||
|
||||
Object.keys(this.scoreData).forEach(classId => {
|
||||
const classScoreData = this.scoreData[classId]
|
||||
|
||||
Object.keys(classScoreData).forEach(studentId => {
|
||||
const scoreInfo = classScoreData[studentId]
|
||||
|
||||
if (scoreInfo.score !== null && !this.validateScore(scoreInfo.score)) {
|
||||
errors.push(`分数必须在0-100之间`)
|
||||
}
|
||||
|
||||
if (!this.validateRemark(scoreInfo.remark)) {
|
||||
errors.push(`备注长度不能超过500字符`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return errors
|
||||
}
|
||||
```
|
||||
|
||||
#### 后端验证
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class ConductScoreServiceImpl implements IConductScoreService {
|
||||
|
||||
@Override
|
||||
public void validateConductScore(ConductScoreBatchBo batchBo) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
|
||||
// 验证学年学期月份
|
||||
if (batchBo.getAcademicYear() < 2020 || batchBo.getAcademicYear() > 2030) {
|
||||
errors.add("学年范围无效");
|
||||
}
|
||||
|
||||
if (batchBo.getSemester() != 1 && batchBo.getSemester() != 2) {
|
||||
errors.add("学期只能是1或2");
|
||||
}
|
||||
|
||||
if (batchBo.getMonth() < 1 || batchBo.getMonth() > 12) {
|
||||
errors.add("月份范围无效");
|
||||
}
|
||||
|
||||
// 验证分数数据
|
||||
for (ConductScoreItemBo item : batchBo.getScores()) {
|
||||
// 验证分数范围
|
||||
if (item.getScore() != null) {
|
||||
if (item.getScore().compareTo(BigDecimal.ZERO) < 0 ||
|
||||
item.getScore().compareTo(new BigDecimal("100")) > 0) {
|
||||
errors.add("分数必须在0-100之间");
|
||||
}
|
||||
}
|
||||
|
||||
// 验证备注长度
|
||||
if (item.getRemark() != null && item.getRemark().length() > 500) {
|
||||
errors.add("备注长度不能超过500字符");
|
||||
}
|
||||
|
||||
// 验证学生和班级关系
|
||||
if (!this.checkStudentInClass(item.getStudentId(), item.getClassId())) {
|
||||
errors.add("学生不属于该班级");
|
||||
}
|
||||
|
||||
// 验证班主任权限
|
||||
if (!this.checkTeacherPermission(batchBo.getTeacherId(), item.getClassId())) {
|
||||
errors.add("您不是该班级的班主任");
|
||||
}
|
||||
|
||||
// 验证重复录入
|
||||
if (this.existsScore(item.getStudentId(), batchBo.getAcademicYear(),
|
||||
batchBo.getSemester(), batchBo.getMonth())) {
|
||||
errors.add("该学生当月分数已存在");
|
||||
}
|
||||
}
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
throw new ServiceException(String.join("; ", errors));
|
||||
}
|
||||
}
|
||||
|
||||
// 检查学生是否属于班级
|
||||
private boolean checkStudentInClass(Long studentId, Long classId) {
|
||||
return xgStudentMsgMapper.exists(
|
||||
Wrappers.<XgStudentMsg>lambdaQuery()
|
||||
.eq(XgStudentMsg::getId, studentId)
|
||||
.eq(XgStudentMsg::getClassId, classId)
|
||||
.eq(XgStudentMsg::getDeleted, 0)
|
||||
);
|
||||
}
|
||||
|
||||
// 检查班主任权限
|
||||
private boolean checkTeacherPermission(Long teacherId, Long classId) {
|
||||
return xgClassMsgMapper.exists(
|
||||
Wrappers.<XgClassMsg>lambdaQuery()
|
||||
.eq(XgClassMsg::getId, classId)
|
||||
.eq(XgClassMsg::getClassTeacherId, teacherId)
|
||||
.eq(XgClassMsg::getDeleted, 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 统计分析逻辑
|
||||
|
||||
### 学期平均分计算
|
||||
|
||||
#### SQL查询实现
|
||||
|
||||
```sql
|
||||
-- 计算学生学期平均分
|
||||
SELECT
|
||||
s.student_no,
|
||||
s.name,
|
||||
c.class_name,
|
||||
ROUND(AVG(cs.score), 2) as avg_score,
|
||||
COUNT(*) as record_count,
|
||||
MAX(cs.score) as max_score,
|
||||
MIN(cs.score) as min_score,
|
||||
MAX(cs.create_time) as last_update_time
|
||||
FROM xg_conduct_score cs
|
||||
LEFT JOIN xg_student_msg s ON cs.student_id = s.id
|
||||
LEFT JOIN xg_class_msg c ON cs.class_id = c.id
|
||||
WHERE cs.tenant_id = #{tenantId}
|
||||
AND cs.deleted = 0
|
||||
AND cs.academic_year = #{academicYear}
|
||||
AND cs.semester = #{semester}
|
||||
AND s.deleted = 0
|
||||
AND c.deleted = 0
|
||||
GROUP BY cs.student_id, cs.academic_year, cs.semester
|
||||
ORDER BY c.class_name, s.student_no;
|
||||
```
|
||||
|
||||
#### 等级分布统计
|
||||
|
||||
```sql
|
||||
-- 统计分数等级分布
|
||||
SELECT
|
||||
CASE
|
||||
WHEN avg_score >= 90 THEN '优秀'
|
||||
WHEN avg_score >= 80 THEN '良好'
|
||||
WHEN avg_score >= 60 THEN '及格'
|
||||
ELSE '不及格'
|
||||
END as grade_level,
|
||||
COUNT(*) as student_count
|
||||
FROM (
|
||||
-- 学期平均分子查询
|
||||
SELECT
|
||||
cs.student_id,
|
||||
ROUND(AVG(cs.score), 2) as avg_score
|
||||
FROM xg_conduct_score cs
|
||||
WHERE cs.tenant_id = #{tenantId}
|
||||
AND cs.deleted = 0
|
||||
AND cs.academic_year = #{academicYear}
|
||||
AND cs.semester = #{semester}
|
||||
GROUP BY cs.student_id
|
||||
) avg_scores
|
||||
GROUP BY
|
||||
CASE
|
||||
WHEN avg_score >= 90 THEN '优秀'
|
||||
WHEN avg_score >= 80 THEN '良好'
|
||||
WHEN avg_score >= 60 THEN '及格'
|
||||
ELSE '不及格'
|
||||
END;
|
||||
```
|
||||
|
||||
### 缓存策略
|
||||
|
||||
#### Redis缓存设计
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class ConductScoreStatisticsService {
|
||||
|
||||
@Autowired
|
||||
private RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
private static final String SEMESTER_STATS_KEY = "conduct_score:semester_stats:%s:%s:%s";
|
||||
|
||||
/**
|
||||
* 获取学期统计数据(带缓存)
|
||||
*/
|
||||
public List<SemesterAverageVo> getSemesterAverageWithCache(Integer academicYear, Integer semester, Long tenantId) {
|
||||
String cacheKey = String.format(SEMESTER_STATS_KEY, tenantId, academicYear, semester);
|
||||
|
||||
// 尝试从缓存获取
|
||||
List<SemesterAverageVo> cachedData = (List<SemesterAverageVo>) redisTemplate.opsForValue().get(cacheKey);
|
||||
if (cachedData != null) {
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
// 从数据库查询
|
||||
List<SemesterAverageVo> data = getSemesterAverageFromDB(academicYear, semester, tenantId);
|
||||
|
||||
// 写入缓存(过期时间:24小时)
|
||||
redisTemplate.opsForValue().set(cacheKey, data, 24, TimeUnit.HOURS);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理统计缓存
|
||||
*/
|
||||
public void clearStatisticsCache(Integer academicYear, Integer semester, Long tenantId) {
|
||||
String cacheKey = String.format(SEMESTER_STATS_KEY, tenantId, academicYear, semester);
|
||||
redisTemplate.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 权限控制逻辑
|
||||
|
||||
### 班主任权限验证
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class ConductScorePermissionChecker {
|
||||
|
||||
@Autowired
|
||||
private IXgClassMsgService classMsgService;
|
||||
|
||||
/**
|
||||
* 验证用户是否为指定班级的班主任
|
||||
*/
|
||||
public boolean isClassTeacher(Long userId, Long classId) {
|
||||
XgClassMsg classInfo = classMsgService.getById(classId);
|
||||
return classInfo != null &&
|
||||
classInfo.getClassTeacherId() != null &&
|
||||
classInfo.getClassTeacherId().equals(userId) &&
|
||||
classInfo.getDeleted().equals(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户负责的所有班级ID
|
||||
*/
|
||||
public List<Long> getTeacherClassIds(Long userId) {
|
||||
return classMsgService.list(
|
||||
Wrappers.<XgClassMsg>lambdaQuery()
|
||||
.select(XgClassMsg::getId)
|
||||
.eq(XgClassMsg::getClassTeacherId, userId)
|
||||
.eq(XgClassMsg::getDeleted, 0)
|
||||
.eq(XgClassMsg::getClassStatus, "1") // 正常状态
|
||||
).stream().map(XgClassMsg::getId).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户是否有录入权限
|
||||
*/
|
||||
public void checkInputPermission(Long userId, ConductScoreBatchBo batchBo) {
|
||||
List<Long> teacherClassIds = getTeacherClassIds(userId);
|
||||
|
||||
// 检查所有分数记录的班级是否都由该老师负责
|
||||
boolean hasPermission = batchBo.getScores().stream()
|
||||
.allMatch(score -> teacherClassIds.contains(score.getClassId()));
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new ServiceException("您不是所有班级的班主任,无权录入部分学生的分数");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 数据一致性保证
|
||||
|
||||
### 录入状态管理
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class ConductScoreStatusManager {
|
||||
|
||||
/**
|
||||
* 更新录入状态记录
|
||||
*/
|
||||
@Transactional
|
||||
public void updateInputStatus(ConductScoreBatchBo batchBo, Long teacherId) {
|
||||
// 按班级分组统计
|
||||
Map<Long, List<ConductScoreItemBo>> classGroups = batchBo.getScores().stream()
|
||||
.collect(Collectors.groupingBy(ConductScoreItemBo::getClassId));
|
||||
|
||||
for (Map.Entry<Long, List<ConductScoreItemBo>> entry : classGroups.entrySet()) {
|
||||
Long classId = entry.getKey();
|
||||
List<ConductScoreItemBo> scores = entry.getValue();
|
||||
|
||||
// 计算班级录入完成度
|
||||
int totalStudents = getClassStudentCount(classId);
|
||||
int recordedStudents = getRecordedStudentCount(classId, batchBo.getAcademicYear(),
|
||||
batchBo.getSemester(), batchBo.getMonth());
|
||||
|
||||
double completionRate = (double) recordedStudents / totalStudents * 100;
|
||||
|
||||
// 更新录入状态记录
|
||||
upsertInputStatus(classId, batchBo.getAcademicYear(), batchBo.getSemester(),
|
||||
batchBo.getMonth(), teacherId, completionRate);
|
||||
}
|
||||
}
|
||||
|
||||
private int getClassStudentCount(Long classId) {
|
||||
// 查询班级学生总数
|
||||
return xgStudentMsgMapper.selectCount(
|
||||
Wrappers.<XgStudentMsg>lambdaQuery()
|
||||
.eq(XgStudentMsg::getClassId, classId)
|
||||
.eq(XgStudentMsg::getDeleted, 0)
|
||||
);
|
||||
}
|
||||
|
||||
private int getRecordedStudentCount(Long classId, Integer academicYear,
|
||||
Integer semester, Integer month) {
|
||||
// 查询已录入学生数
|
||||
return conductScoreMapper.selectCount(
|
||||
Wrappers.<ConductScore>lambdaQuery()
|
||||
.eq(ConductScore::getClassId, classId)
|
||||
.eq(ConductScore::getAcademicYear, academicYear)
|
||||
.eq(ConductScore::getSemester, semester)
|
||||
.eq(ConductScore::getMonth, month)
|
||||
.eq(ConductScore::getDeleted, 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 性能优化策略
|
||||
|
||||
### 批量操作优化
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class ConductScoreBatchService {
|
||||
|
||||
/**
|
||||
* 批量保存操行分(优化版)
|
||||
*/
|
||||
@Transactional
|
||||
public void batchSaveOptimized(List<ConductScore> scoreList) {
|
||||
if (scoreList.isEmpty()) return;
|
||||
|
||||
// 分批处理,避免一次性插入太多数据
|
||||
int batchSize = 100;
|
||||
for (int i = 0; i < scoreList.size(); i += batchSize) {
|
||||
int endIndex = Math.min(i + batchSize, scoreList.size());
|
||||
List<ConductScore> batch = scoreList.subList(i, endIndex);
|
||||
|
||||
// 批量插入
|
||||
conductScoreMapper.insertBatch(batch);
|
||||
|
||||
// 更新录入状态
|
||||
updateBatchStatus(batch);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新状态
|
||||
*/
|
||||
private void updateBatchStatus(List<ConductScore> batch) {
|
||||
// 使用单个SQL更新状态,避免N+1查询
|
||||
if (!batch.isEmpty()) {
|
||||
ConductScore first = batch.get(0);
|
||||
conductScoreMapper.updateInputStatus(
|
||||
first.getAcademicYear(),
|
||||
first.getSemester(),
|
||||
first.getMonth(),
|
||||
first.getClassId(),
|
||||
first.getClassTeacherId()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个文档详细说明了多Tab页录入的核心业务逻辑实现,包括数据获取、Tab页动态生成、批量操作、权限控制、数据验证和性能优化等关键技术点。
|
||||
@@ -0,0 +1,662 @@
|
||||
# 学生操行分管理 - API 接口设计
|
||||
|
||||
## 📋 文档说明
|
||||
|
||||
**版本**: v1.0
|
||||
**制定时间**: 2025-01-13
|
||||
**适用范围**: 学生操行分管理模块 API 接口设计
|
||||
**重要性**: ⚠️ **强制性规范** - API 设计必须遵循项目统一接口规范
|
||||
|
||||
---
|
||||
|
||||
## 🎯 设计原则
|
||||
|
||||
### 1. RESTful 设计
|
||||
|
||||
- 使用标准的 HTTP 方法(GET、POST、PUT、DELETE)
|
||||
- 资源路径清晰,层次分明
|
||||
- 状态码规范,返回信息明确
|
||||
|
||||
### 2. 统一响应格式
|
||||
|
||||
- 所有接口返回统一的 JSON 格式
|
||||
- 包含状态码、消息、数据三个部分
|
||||
- 分页查询使用统一的 TableDataInfo 格式
|
||||
|
||||
### 3. 权限控制
|
||||
|
||||
- 每个接口必须有 @SaCheckPermission 注解
|
||||
- 班主任只能操作自己班级的学生
|
||||
- 学生处管理员可查看全校数据
|
||||
|
||||
---
|
||||
|
||||
## 🔌 接口概览
|
||||
|
||||
### 接口分类
|
||||
|
||||
#### 基础 CRUD 接口
|
||||
|
||||
| 接口名称 | 请求方式 | 路径 | 权限字符 | 功能描述 |
|
||||
| -------------- | -------- | --------------------------- | ---------------------- | ---------------------- |
|
||||
| 查询操行分列表 | GET | /api/conduct-score/list | conduct:score:list | 分页查询操行分记录 |
|
||||
| 查询操行分详情 | GET | /api/conduct-score/{id} | conduct:score:query | 根据 ID 查询操行分详情 |
|
||||
| 新增操行分 | POST | /api/conduct-score | conduct:score:add | 创建新的操行分记录 |
|
||||
| 更新操行分 | PUT | /api/conduct-score/{id} | conduct:score:edit | 更新指定操行分记录 |
|
||||
| 删除操行分 | DELETE | /api/conduct-score/{id} | conduct:score:remove | 删除指定操行分记录 |
|
||||
|
||||
#### 业务逻辑接口
|
||||
|
||||
| 接口名称 | 请求方式 | 路径 | 权限字符 | 功能描述 |
|
||||
| ---------------- | -------- | ----------------------------- | ---------------------- | ------------------------ |
|
||||
| 批量录入操行分 | POST | /api/conduct-score/batch | conduct:score:batchAdd | 批量保存操行分 |
|
||||
| 班主任班级学生 | GET | /api/conduct-score/class-students | conduct:score:classStudents | 获取班主任班级学生列表 |
|
||||
| 学期平均分统计 | GET | /api/statistics/semester-average | conduct:statistics:semester | 查询学期平均分 |
|
||||
| 导出学期统计 | GET | /api/statistics/export | conduct:statistics:export | 导出学期统计数据 |
|
||||
| 验证录入权限 | GET | /api/conduct-score/check-permission | conduct:score:checkPermission | 检查班主任录入权限 |
|
||||
|
||||
---
|
||||
|
||||
## 📝 接口详细设计
|
||||
|
||||
### 1. 查询操行分列表
|
||||
|
||||
#### 接口信息
|
||||
|
||||
- **请求方式**: GET
|
||||
- **接口路径**: `/api/conduct-score/list`
|
||||
- **权限字符**: `conduct:score:list`
|
||||
- **接口描述**: 分页查询操行分记录列表
|
||||
|
||||
#### 请求参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ----------- | ------- | ---- | ---------------------- |
|
||||
| academicYear| Integer | 否 | 学年(如:2024) |
|
||||
| semester | Integer | 否 | 学期(1-第一学期,2-第二学期) |
|
||||
| month | Integer | 否 | 月份(1-12) |
|
||||
| studentName | String | 否 | 学生姓名 |
|
||||
| className | String | 否 | 班级名称 |
|
||||
| scoreMin | BigDecimal | 否 | 最低分数 |
|
||||
| scoreMax | BigDecimal | 否 | 最高分数 |
|
||||
| pageNum | Integer | 否 | 当前页码(默认1) |
|
||||
| pageSize | Integer | 否 | 每页显示数量(默认10) |
|
||||
|
||||
#### 响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "查询成功",
|
||||
"data": {
|
||||
"total": 100,
|
||||
"rows": [
|
||||
{
|
||||
"id": 1234567890123456789,
|
||||
"academicYear": 2024,
|
||||
"semester": 1,
|
||||
"month": 9,
|
||||
"studentId": 987654321098765432,
|
||||
"studentName": "张三",
|
||||
"studentNo": "2024001",
|
||||
"classId": 876543210987654321,
|
||||
"className": "计算机应用1班",
|
||||
"classTeacherId": 765432109876543210,
|
||||
"classTeacherName": "李老师",
|
||||
"score": 95.50,
|
||||
"remark": "表现优秀,积极参加班级活动",
|
||||
"createTime": "2024-09-01 10:00:00",
|
||||
"createBy": "李老师"
|
||||
}
|
||||
],
|
||||
"code": 200,
|
||||
"msg": "查询成功"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 批量录入操行分
|
||||
|
||||
#### 接口信息
|
||||
|
||||
- **请求方式**: POST
|
||||
- **接口路径**: `/api/conduct-score/batch`
|
||||
- **权限字符**: `conduct:score:batchAdd`
|
||||
- **接口描述**: 批量保存操行分记录
|
||||
|
||||
#### 请求参数
|
||||
|
||||
```json
|
||||
{
|
||||
"academicYear": 2024,
|
||||
"semester": 1,
|
||||
"month": 9,
|
||||
"classId": 876543210987654321,
|
||||
"scores": [
|
||||
{
|
||||
"studentId": 987654321098765432,
|
||||
"score": 95.50,
|
||||
"remark": "表现优秀,积极参加班级活动"
|
||||
},
|
||||
{
|
||||
"studentId": 987654321098765433,
|
||||
"score": 88.00,
|
||||
"remark": "表现良好,需要继续努力"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 参数说明
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ----------- | ------- | ---- | ---------------------- |
|
||||
| academicYear| Integer | 是 | 学年(如:2024) |
|
||||
| semester | Integer | 是 | 学期(1-第一学期,2-第二学期) |
|
||||
| month | Integer | 是 | 月份(1-12) |
|
||||
| classId | Long | 是 | 班级ID |
|
||||
| scores | Array | 是 | 操行分记录数组 |
|
||||
| └─studentId | Long | 是 | 学生ID |
|
||||
| └─score | BigDecimal | 是 | 操行分数(0.00-100.00) |
|
||||
| └─remark | String | 否 | 备注信息 |
|
||||
|
||||
#### 响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "批量录入成功",
|
||||
"data": {
|
||||
"successCount": 45,
|
||||
"failCount": 0,
|
||||
"failList": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 班主任班级学生列表
|
||||
|
||||
#### 接口信息
|
||||
|
||||
- **请求方式**: GET
|
||||
- **接口路径**: `/api/conduct-score/class-students`
|
||||
- **权限字符**: `conduct:score:classStudents`
|
||||
- **接口描述**: 获取班主任负责的所有班级及学生列表,用于多Tab页展示
|
||||
|
||||
#### 请求参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ----------- | ------- | ---- | ---------------------- |
|
||||
| academicYear| Integer | 否 | 学年(如:2024) |
|
||||
| semester | Integer | 否 | 学期(1-第一学期,2-第二学期) |
|
||||
| month | Integer | 否 | 月份(1-12) |
|
||||
|
||||
#### 响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "查询成功",
|
||||
"data": {
|
||||
"classes": [
|
||||
{
|
||||
"classId": 876543210987654321,
|
||||
"className": "计算机1班",
|
||||
"classCode": "JSJ202401",
|
||||
"studentCount": 45,
|
||||
"enrollmentYear": 2024,
|
||||
"majorName": "计算机应用技术",
|
||||
"students": [
|
||||
{
|
||||
"studentId": 987654321098765432,
|
||||
"studentNo": "2024001",
|
||||
"studentName": "张三",
|
||||
"gender": "0",
|
||||
"existingScore": null,
|
||||
"lastScore": 95.50,
|
||||
"lastMonth": 8
|
||||
},
|
||||
{
|
||||
"studentId": 987654321098765433,
|
||||
"studentNo": "2024002",
|
||||
"studentName": "李四",
|
||||
"gender": "1",
|
||||
"existingScore": {
|
||||
"id": 1234567890123456789,
|
||||
"score": 88.00,
|
||||
"remark": "表现良好",
|
||||
"createTime": "2024-09-01 09:00:00"
|
||||
},
|
||||
"lastScore": 90.00,
|
||||
"lastMonth": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"classId": 876543210987654322,
|
||||
"className": "计算机2班",
|
||||
"classCode": "JSJ202402",
|
||||
"studentCount": 42,
|
||||
"enrollmentYear": 2024,
|
||||
"majorName": "计算机应用技术",
|
||||
"students": [
|
||||
{
|
||||
"studentId": 987654321098765434,
|
||||
"studentNo": "2024046",
|
||||
"studentName": "王五",
|
||||
"gender": "0",
|
||||
"existingScore": {
|
||||
"id": 1234567890123456790,
|
||||
"score": 92.00,
|
||||
"remark": "学习认真",
|
||||
"createTime": "2024-09-01 09:15:00"
|
||||
},
|
||||
"lastScore": 90.00,
|
||||
"lastMonth": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"classId": 876543210987654323,
|
||||
"className": "电子1班",
|
||||
"classCode": "DZ202401",
|
||||
"studentCount": 38,
|
||||
"enrollmentYear": 2024,
|
||||
"majorName": "电子信息工程",
|
||||
"students": [
|
||||
{
|
||||
"studentId": 987654321098765435,
|
||||
"studentNo": "2024088",
|
||||
"studentName": "赵六",
|
||||
"gender": "0",
|
||||
"existingScore": null,
|
||||
"lastScore": 87.50,
|
||||
"lastMonth": 8
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"totalClasses": 3,
|
||||
"totalStudents": 125,
|
||||
"recordedStudents": 87,
|
||||
"pendingStudents": 38,
|
||||
"completionRate": 69.6
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 学期平均分统计
|
||||
|
||||
#### 接口信息
|
||||
|
||||
- **请求方式**: GET
|
||||
- **接口路径**: `/api/statistics/semester-average`
|
||||
- **权限字符**: `conduct:statistics:semester`
|
||||
- **接口描述**: 查询学生的学期操行平均分
|
||||
|
||||
#### 请求参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ----------- | ------- | ---- | ---------------------- |
|
||||
| academicYear| Integer | 是 | 学年(如:2024) |
|
||||
| semester | Integer | 是 | 学期(1-第一学期,2-第二学期) |
|
||||
| className | String | 否 | 班级名称筛选 |
|
||||
| studentName | String | 否 | 学生姓名筛选 |
|
||||
| pageNum | Integer | 否 | 当前页码(默认1) |
|
||||
| pageSize | Integer | 否 | 每页显示数量(默认10) |
|
||||
|
||||
#### 响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "查询成功",
|
||||
"data": {
|
||||
"total": 500,
|
||||
"rows": [
|
||||
{
|
||||
"studentId": 987654321098765432,
|
||||
"studentNo": "2024001",
|
||||
"studentName": "张三",
|
||||
"gender": "0",
|
||||
"classId": 876543210987654321,
|
||||
"className": "计算机应用1班",
|
||||
"academicYear": 2024,
|
||||
"semester": 1,
|
||||
"avgScore": 92.83,
|
||||
"recordCount": 6,
|
||||
"maxScore": 98.00,
|
||||
"minScore": 88.00,
|
||||
"scoreRange": "88.00-98.00",
|
||||
"lastUpdateTime": "2024-09-30 16:00:00"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"totalStudents": 500,
|
||||
"avgScore": 85.67,
|
||||
"excellentCount": 120,
|
||||
"goodCount": 280,
|
||||
"averageCount": 85,
|
||||
"needImprovementCount": 15
|
||||
},
|
||||
"code": 200,
|
||||
"msg": "查询成功"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 导出学期统计
|
||||
|
||||
#### 接口信息
|
||||
|
||||
- **请求方式**: GET
|
||||
- **接口路径**: `/api/statistics/export`
|
||||
- **权限字符**: `conduct:statistics:export`
|
||||
- **接口描述**: 导出学期操行分统计数据
|
||||
|
||||
#### 请求参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ----------- | ------- | ---- | ---------------------- |
|
||||
| academicYear| Integer | 是 | 学年(如:2024) |
|
||||
| semester | Integer | 是 | 学期(1-第一学期,2-第二学期) |
|
||||
| className | String | 否 | 班级名称筛选 |
|
||||
| exportType | String | 否 | 导出类型(excel/pdf,默认excel) |
|
||||
|
||||
#### 响应格式
|
||||
|
||||
返回 Excel 或 PDF 文件流
|
||||
|
||||
---
|
||||
|
||||
## 🔒 权限控制设计
|
||||
|
||||
### 权限字符定义
|
||||
|
||||
```java
|
||||
// 操行分管理权限
|
||||
conduct:score:list // 查看操行分列表
|
||||
conduct:score:query // 查看操行分详情
|
||||
conduct:score:add // 新增操行分
|
||||
conduct:score:edit // 编辑操行分
|
||||
conduct:score:remove // 删除操行分
|
||||
conduct:score:batchAdd // 批量录入操行分
|
||||
conduct:score:classStudents // 查看班主任班级学生
|
||||
|
||||
// 统计分析权限
|
||||
conduct:statistics:semester // 学期统计查看
|
||||
conduct:statistics:export // 数据导出
|
||||
```
|
||||
|
||||
### 权限控制逻辑
|
||||
|
||||
#### 班主任权限控制
|
||||
|
||||
```java
|
||||
@SaCheckPermission("conduct:score:classStudents")
|
||||
@GetMapping("/class-students")
|
||||
public AjaxResult getClassStudents(@RequestParam Integer academicYear,
|
||||
@RequestParam Integer semester,
|
||||
@RequestParam Integer month) {
|
||||
// 获取当前登录用户ID
|
||||
Long userId = SecurityUtils.getUserId();
|
||||
|
||||
// 从 xg_class_msg 表查询该用户作为班主任的所有班级
|
||||
// WHERE class_teacher_id = #{userId} AND deleted = 0
|
||||
List<Long> classIds = conductScoreService.getTeacherClassIds(userId);
|
||||
|
||||
// 返回按班级分组的学生信息,用于前端生成Tab页
|
||||
return AjaxResult.success(conductScoreService.getClassStudentsGrouped(classIds, academicYear, semester, month));
|
||||
}
|
||||
```
|
||||
|
||||
#### 管理员权限控制
|
||||
|
||||
```java
|
||||
@SaCheckPermission("conduct:statistics:semester")
|
||||
@GetMapping("/semester-average")
|
||||
public TableDataInfo getSemesterAverage(@RequestParam Integer academicYear,
|
||||
@RequestParam Integer semester,
|
||||
ConductScoreQueryBo queryBo) {
|
||||
// 管理员可以查看全校数据,无班级限制
|
||||
startPage();
|
||||
List<SemesterAverageVo> list = statisticsService.getSemesterAverage(academicYear, semester, queryBo);
|
||||
return getDataTable(list);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 数据验证规则
|
||||
|
||||
### 请求参数验证
|
||||
|
||||
```java
|
||||
public class ConductScoreBatchBo {
|
||||
@NotNull(message = "学年不能为空")
|
||||
@Min(value = 2020, message = "学年不能小于2020")
|
||||
@Max(value = 2030, message = "学年不能大于2030")
|
||||
private Integer academicYear;
|
||||
|
||||
@NotNull(message = "学期不能为空")
|
||||
@Min(value = 1, message = "学期只能是1或2")
|
||||
@Max(value = 2, message = "学期只能是1或2")
|
||||
private Integer semester;
|
||||
|
||||
@NotNull(message = "月份不能为空")
|
||||
@Min(value = 1, message = "月份范围为1-12")
|
||||
@Max(value = 12, message = "月份范围为1-12")
|
||||
private Integer month;
|
||||
|
||||
@NotNull(message = "班级ID不能为空")
|
||||
private Long classId;
|
||||
|
||||
@NotEmpty(message = "操行分记录不能为空")
|
||||
@Size(min = 1, max = 100, message = "一次最多录入100条记录")
|
||||
private List<ConductScoreItemBo> scores;
|
||||
}
|
||||
|
||||
public class ConductScoreItemBo {
|
||||
@NotNull(message = "学生ID不能为空")
|
||||
private Long studentId;
|
||||
|
||||
@NotNull(message = "分数不能为空")
|
||||
@DecimalMin(value = "0.00", message = "分数不能小于0")
|
||||
@DecimalMax(value = "100.00", message = "分数不能大于100")
|
||||
private BigDecimal score;
|
||||
|
||||
@Size(max = 500, message = "备注不能超过500字符")
|
||||
private String remark;
|
||||
}
|
||||
```
|
||||
|
||||
### 业务逻辑验证
|
||||
|
||||
#### 班主任权限验证
|
||||
|
||||
```java
|
||||
private void validateTeacherPermission(Long classId, Long teacherId) {
|
||||
// 验证班主任是否负责该班级
|
||||
boolean isResponsible = conductScoreService.checkTeacherClass(teacherId, classId);
|
||||
if (!isResponsible) {
|
||||
throw new ServiceException("您不是该班级的班主任,无权录入操行分");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 重复录入验证
|
||||
|
||||
```java
|
||||
private void validateNoDuplicate(Long studentId, Integer academicYear,
|
||||
Integer semester, Integer month) {
|
||||
// 检查是否已存在该学生的当月记录
|
||||
boolean exists = conductScoreService.existsByStudentAndMonth(
|
||||
studentId, academicYear, semester, month);
|
||||
|
||||
if (exists) {
|
||||
throw new ServiceException("该学生当月操行分已录入,不可重复录入");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 错误码设计
|
||||
|
||||
### 系统级错误码
|
||||
|
||||
| 错误码 | 说明 |
|
||||
| ------ | ------------ |
|
||||
| 200 | 成功 |
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 未授权 |
|
||||
| 403 | 权限不足 |
|
||||
| 500 | 系统错误 |
|
||||
|
||||
### 业务级错误码
|
||||
|
||||
| 错误码 | 说明 |
|
||||
| ------ | ------------------------ |
|
||||
| 1001 | 班主任权限不足 |
|
||||
| 1002 | 学生不存在 |
|
||||
| 1003 | 班级不存在 |
|
||||
| 1004 | 操行分已存在,不可重复录入 |
|
||||
| 1005 | 分数范围错误 |
|
||||
| 1006 | 时间参数错误 |
|
||||
|
||||
### 错误响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1001,
|
||||
"msg": "您不是该班级的班主任,无权录入操行分",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 性能优化
|
||||
|
||||
### 接口性能指标
|
||||
|
||||
- **响应时间**: 平均 < 200ms,95% < 500ms
|
||||
- **并发处理**: 支持 100+ 并发请求
|
||||
- **数据量**: 支持万人规模数据查询
|
||||
|
||||
### 缓存策略
|
||||
|
||||
#### Redis 缓存配置
|
||||
|
||||
```java
|
||||
// 学期统计缓存
|
||||
@Cacheable(value = "conductScoreStats", key = "#academicYear + '_' + #semester")
|
||||
public List<SemesterAverageVo> getSemesterAverage(Integer academicYear, Integer semester) {
|
||||
// 查询逻辑
|
||||
}
|
||||
|
||||
// 班主任班级缓存
|
||||
@Cacheable(value = "teacherClasses", key = "#teacherId")
|
||||
public List<Long> getTeacherClassIds(Long teacherId) {
|
||||
// 查询逻辑
|
||||
}
|
||||
```
|
||||
|
||||
### 分页查询优化
|
||||
|
||||
```java
|
||||
// 大数据量分页查询
|
||||
PageHelper.startPage(pageNum, pageSize);
|
||||
List<ConductScoreVo> list = conductScoreMapper.selectConductScoreList(queryBo);
|
||||
// 自动添加 LIMIT 和 OFFSET
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 接口测试
|
||||
|
||||
### 测试用例设计
|
||||
|
||||
#### 正常流程测试
|
||||
|
||||
```bash
|
||||
# 批量录入操行分
|
||||
curl -X POST "http://localhost:8080/api/conduct-score/batch" \
|
||||
-H "Authorization: Bearer {token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"academicYear": 2024,
|
||||
"semester": 1,
|
||||
"month": 9,
|
||||
"classId": 876543210987654321,
|
||||
"scores": [
|
||||
{"studentId": 987654321098765432, "score": 95.5, "remark": "优秀"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
#### 异常流程测试
|
||||
|
||||
```bash
|
||||
# 权限不足测试
|
||||
curl -X GET "http://localhost:8080/api/conduct-score/class-students" \
|
||||
-H "Authorization: Bearer {invalid_token}"
|
||||
|
||||
# 响应: {"code": 403, "msg": "权限不足", "data": null}
|
||||
```
|
||||
|
||||
#### 性能测试
|
||||
|
||||
```bash
|
||||
# 并发测试脚本
|
||||
ab -n 1000 -c 50 "http://localhost:8080/api/statistics/semester-average?academicYear=2024&semester=1"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 接口文档
|
||||
|
||||
### Swagger 文档
|
||||
|
||||
```java
|
||||
@Api(tags = "操行分管理")
|
||||
@RestController
|
||||
@RequestMapping("/api/conduct-score")
|
||||
public class ConductScoreController {
|
||||
|
||||
@ApiOperation("批量录入操行分")
|
||||
@ApiImplicitParams({
|
||||
@ApiImplicitParam(name = "academicYear", value = "学年", required = true),
|
||||
@ApiImplicitParam(name = "semester", value = "学期", required = true),
|
||||
@ApiImplicitParam(name = "month", value = "月份", required = true)
|
||||
})
|
||||
@PostMapping("/batch")
|
||||
public AjaxResult batchAddConductScore(@RequestBody ConductScoreBatchBo bo) {
|
||||
// 接口实现
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 接口变更记录
|
||||
|
||||
| 版本 | 变更时间 | 变更内容 | 变更人 |
|
||||
| ---- | -------- | -------- | ------ |
|
||||
| v1.0 | 2025-01-13 | 初始版本 | 开发团队 |
|
||||
|
||||
---
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
如有疑问或需要技术支持,请联系:
|
||||
|
||||
- **接口负责人**: 后端开发负责人
|
||||
- **测试负责人**: 测试团队
|
||||
- **业务负责人**: 学生处管理员
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2025-01-13
|
||||
**接口版本**: v1.0
|
||||
**兼容性**: 向后兼容
|
||||
@@ -0,0 +1,526 @@
|
||||
# 学生操行分管理 - 数据库设计
|
||||
|
||||
## 📋 文档说明
|
||||
|
||||
**版本**: v1.1
|
||||
**制定时间**: 2025-01-13
|
||||
**更新时间**: 2025-01-13
|
||||
**适用范围**: 学生操行分管理模块数据库设计
|
||||
**重要性**: ⚠️ **强制性规范** - 数据库设计必须严格遵循项目数据库建设规范
|
||||
|
||||
---
|
||||
|
||||
## 🎯 设计原则
|
||||
|
||||
### 1. 遵循项目规范
|
||||
|
||||
- 所有表必须包含标准基础字段(强制性要求)
|
||||
- 使用雪花ID作为主键
|
||||
- 强制使用逻辑删除
|
||||
- 支持多租户数据隔离
|
||||
- 完整的中文注释说明
|
||||
|
||||
### 2. 数据完整性
|
||||
|
||||
- 外键约束:不使用数据库级别的外键约束,通过应用层维护关联关系
|
||||
- 数据验证:在应用层进行数据完整性校验
|
||||
- 业务规则:通过代码逻辑确保业务规则的执行
|
||||
|
||||
### 3. 性能优化
|
||||
|
||||
- 合理设计索引,提升查询性能
|
||||
- 考虑数据增长趋势,预留扩展空间
|
||||
- 支持大数据量查询和统计分析
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ 核心表结构
|
||||
|
||||
### 操行分表 (xg_conduct_score)
|
||||
|
||||
#### 表结构定义
|
||||
|
||||
```sql
|
||||
-- ----------------------------
|
||||
-- Table structure for xg_conduct_score
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `xg_conduct_score`;
|
||||
CREATE TABLE `xg_conduct_score` (
|
||||
-- 基础字段(必须)
|
||||
`id` bigint(20) NOT NULL COMMENT '主键ID(雪花ID)',
|
||||
`tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号',
|
||||
`create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门',
|
||||
`create_by` bigint(20) DEFAULT NULL COMMENT '创建者',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_by` bigint(20) DEFAULT NULL COMMENT '更新者',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
`deleted` tinyint(1) DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)',
|
||||
|
||||
-- 业务字段
|
||||
`academic_year` int(4) NOT NULL COMMENT '学年(如:2024)',
|
||||
`semester` tinyint(1) NOT NULL COMMENT '学期(1-第一学期,2-第二学期)',
|
||||
`month` tinyint(2) NOT NULL COMMENT '月份(1-12)',
|
||||
`student_id` bigint(20) NOT NULL COMMENT '学生ID,关联xg_student_msg.id',
|
||||
`class_id` bigint(20) NOT NULL COMMENT '班级ID,关联xg_class_msg.id',
|
||||
`class_teacher_id` bigint(20) NOT NULL COMMENT '班主任ID,关联sys_user.id',
|
||||
`score` decimal(5,2) NOT NULL COMMENT '操行分数(0.00-100.00)',
|
||||
`remark` varchar(500) DEFAULT NULL COMMENT '备注信息',
|
||||
|
||||
-- 主键和索引
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
|
||||
-- 基础索引(必须)
|
||||
KEY `idx_tenant_id` (`tenant_id`),
|
||||
KEY `idx_deleted` (`deleted`),
|
||||
KEY `idx_create_time` (`create_time`),
|
||||
|
||||
-- 业务索引
|
||||
KEY `idx_student_id` (`student_id`),
|
||||
KEY `idx_class_id` (`class_id`),
|
||||
KEY `idx_class_teacher_id` (`class_teacher_id`),
|
||||
KEY `idx_academic_year` (`academic_year`),
|
||||
KEY `idx_semester` (`semester`),
|
||||
KEY `idx_month` (`month`),
|
||||
|
||||
-- 组合索引(查询优化)
|
||||
KEY `idx_student_year_semester` (`student_id`, `academic_year`, `semester`),
|
||||
KEY `idx_class_year_semester_month` (`class_id`, `academic_year`, `semester`, `month`),
|
||||
KEY `idx_teacher_year_semester` (`class_teacher_id`, `academic_year`, `semester`),
|
||||
|
||||
-- 唯一约束(防止重复录入)
|
||||
UNIQUE KEY `uk_student_year_semester_month` (`student_id`, `academic_year`, `semester`, `month`, `tenant_id`, `deleted`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生操行分表';
|
||||
```
|
||||
|
||||
#### 字段详细说明
|
||||
|
||||
| 字段名 | 类型 | 长度 | 非空 | 默认值 | 说明 |
|
||||
| --------------- | ----------- | ------ | ----- | -------- | ------------------------------ |
|
||||
| id | BIGINT | 20 | Y | - | 主键ID(雪花ID) |
|
||||
| tenant_id | VARCHAR | 20 | Y | 000000 | 租户编号 |
|
||||
| create_dept | BIGINT | 20 | N | NULL | 创建部门 |
|
||||
| create_by | BIGINT | 20 | N | NULL | 创建者 |
|
||||
| create_time | DATETIME | - | N | NULL | 创建时间 |
|
||||
| update_by | BIGINT | 20 | N | NULL | 更新者 |
|
||||
| update_time | DATETIME | - | N | NULL | 更新时间 |
|
||||
| deleted | TINYINT | 1 | Y | 0 | 删除标志(0存在 1删除) |
|
||||
| academic_year | INT | 4 | Y | - | 学年(如:2024) |
|
||||
| semester | TINYINT | 1 | Y | - | 学期(1-第一学期,2-第二学期) |
|
||||
| month | TINYINT | 2 | Y | - | 月份(1-12) |
|
||||
| student_id | BIGINT | 20 | Y | - | 学生ID,关联xg_student_msg.id |
|
||||
| class_id | BIGINT | 20 | Y | - | 班级ID,关联xg_class_msg.id |
|
||||
| class_teacher_id| BIGINT | 20 | Y | - | 班主任ID,关联sys_user.id |
|
||||
| score | DECIMAL | 5,2 | Y | - | 操行分数(0.00-100.00) |
|
||||
| remark | VARCHAR | 500 | N | NULL | 备注信息 |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 索引设计详解
|
||||
|
||||
### 基础索引(强制)
|
||||
|
||||
```sql
|
||||
-- 租户索引(必须)
|
||||
KEY `idx_tenant_id` (`tenant_id`),
|
||||
|
||||
-- 删除标志索引(必须)
|
||||
KEY `idx_deleted` (`deleted`),
|
||||
|
||||
-- 创建时间索引(建议)
|
||||
KEY `idx_create_time` (`create_time`),
|
||||
```
|
||||
|
||||
### 业务索引
|
||||
|
||||
```sql
|
||||
-- 关联字段索引
|
||||
KEY `idx_student_id` (`student_id`),
|
||||
KEY `idx_class_id` (`class_id`),
|
||||
KEY `idx_class_teacher_id` (`class_teacher_id`),
|
||||
|
||||
-- 时间维度索引
|
||||
KEY `idx_academic_year` (`academic_year`),
|
||||
KEY `idx_semester` (`semester`),
|
||||
KEY `idx_month` (`month`),
|
||||
```
|
||||
|
||||
### 组合索引(性能优化)
|
||||
|
||||
```sql
|
||||
-- 学生学期查询优化
|
||||
KEY `idx_student_year_semester` (`student_id`, `academic_year`, `semester`),
|
||||
|
||||
-- 班级月度录入查询优化
|
||||
KEY `idx_class_year_semester_month` (`class_id`, `academic_year`, `semester`, `month`),
|
||||
|
||||
-- 班主任学期统计查询优化
|
||||
KEY `idx_teacher_year_semester` (`class_teacher_id`, `academic_year`, `semester`),
|
||||
```
|
||||
|
||||
### 唯一约束
|
||||
|
||||
```sql
|
||||
-- 防止同一学生同月重复录入
|
||||
UNIQUE KEY `uk_student_year_semester_month` (`student_id`, `academic_year`, `semester`, `month`, `tenant_id`, `deleted`)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 表关系设计
|
||||
|
||||
### 实体关系图
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
xg_conduct_score ||--o{ xg_student_msg : "关联学生"
|
||||
xg_conduct_score }o--|| xg_class_msg : "关联班级"
|
||||
xg_conduct_score }o--|| sys_user : "关联班主任"
|
||||
xg_class_msg ||--o{ xg_student_msg : "班级包含学生"
|
||||
xg_class_msg }o--|| sys_user : "班级有班主任"
|
||||
|
||||
xg_conduct_score {
|
||||
bigint id PK
|
||||
int academic_year
|
||||
tinyint semester
|
||||
tinyint month
|
||||
bigint student_id FK
|
||||
bigint class_id FK
|
||||
bigint class_teacher_id FK
|
||||
decimal score
|
||||
varchar remark
|
||||
datetime create_time
|
||||
}
|
||||
|
||||
xg_student_msg {
|
||||
bigint id PK
|
||||
varchar student_no
|
||||
varchar name
|
||||
bigint class_id FK
|
||||
}
|
||||
|
||||
xg_class_msg {
|
||||
bigint id PK
|
||||
varchar class_name
|
||||
bigint class_teacher_id FK
|
||||
}
|
||||
|
||||
sys_user {
|
||||
bigint id PK
|
||||
varchar user_name
|
||||
varchar nick_name
|
||||
}
|
||||
```
|
||||
|
||||
### 关联关系说明
|
||||
|
||||
1. **xg_conduct_score → xg_student_msg**
|
||||
- 关联字段:student_id
|
||||
- 关联说明:操行分记录所属的学生
|
||||
- 约束:学生必须存在且有效
|
||||
|
||||
2. **xg_conduct_score → xg_class_msg**
|
||||
- 关联字段:class_id
|
||||
- 关联说明:操行分记录所属的班级
|
||||
- 约束:班级必须存在且有效
|
||||
|
||||
3. **xg_conduct_score → sys_user**
|
||||
- 关联字段:class_teacher_id
|
||||
- 关联说明:录入操行分的班主任
|
||||
- 约束:用户必须存在且为班主任角色
|
||||
|
||||
---
|
||||
|
||||
## 📋 字典表设计
|
||||
|
||||
### 字典类型说明
|
||||
|
||||
#### 1. 学期字典 (conduct_semester)
|
||||
- **用途**: 界面显示学期的中文标签
|
||||
- **数据范围**: 1-第一学期,2-第二学期
|
||||
- **使用场景**: 录入界面、查询界面、统计界面的学期选择
|
||||
|
||||
#### 2. 月份字典 (conduct_month)
|
||||
- **用途**: 界面显示月份的中文标签
|
||||
- **数据范围**: 1-12月
|
||||
- **使用场景**: 录入界面、查询界面的月份选择
|
||||
|
||||
### 字典数据结构
|
||||
|
||||
```sql
|
||||
-- 字典类型表
|
||||
CREATE TABLE sys_dict_type (
|
||||
dict_id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '字典主键',
|
||||
dict_name varchar(100) DEFAULT '' COMMENT '字典名称',
|
||||
dict_type varchar(100) NOT NULL DEFAULT '' COMMENT '字典类型',
|
||||
status char(1) DEFAULT '0' COMMENT '状态(0正常 1停用)',
|
||||
create_by bigint(20) DEFAULT NULL COMMENT '创建者',
|
||||
create_time datetime DEFAULT NULL COMMENT '创建时间',
|
||||
update_by bigint(20) DEFAULT NULL COMMENT '更新者',
|
||||
update_time datetime DEFAULT NULL COMMENT '更新时间',
|
||||
remark varchar(500) DEFAULT NULL COMMENT '备注',
|
||||
PRIMARY KEY (dict_id),
|
||||
UNIQUE KEY dict_type (dict_type)
|
||||
);
|
||||
|
||||
-- 字典数据表
|
||||
CREATE TABLE sys_dict_data (
|
||||
dict_code bigint(20) NOT NULL AUTO_INCREMENT COMMENT '字典编码',
|
||||
dict_sort int(4) DEFAULT '0' COMMENT '字典排序',
|
||||
dict_label varchar(100) DEFAULT '' COMMENT '字典标签',
|
||||
dict_value varchar(100) DEFAULT '' COMMENT '字典键值',
|
||||
dict_type varchar(100) DEFAULT '' COMMENT '字典类型',
|
||||
css_class varchar(100) DEFAULT NULL COMMENT '样式属性(其他样式扩展)',
|
||||
list_class varchar(100) DEFAULT NULL COMMENT '表格回显样式',
|
||||
is_default char(1) DEFAULT 'N' COMMENT '是否默认(Y是 N否)',
|
||||
status char(1) DEFAULT '0' COMMENT '状态(0正常 1停用)',
|
||||
create_by bigint(20) DEFAULT NULL COMMENT '创建者',
|
||||
create_time datetime DEFAULT NULL COMMENT '创建时间',
|
||||
update_by bigint(20) DEFAULT NULL COMMENT '更新者',
|
||||
update_time datetime DEFAULT NULL COMMENT '更新时间',
|
||||
remark varchar(500) DEFAULT NULL COMMENT '备注',
|
||||
PRIMARY KEY (dict_code)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 统计查询设计
|
||||
|
||||
### 学期平均分统计
|
||||
|
||||
#### SQL查询示例
|
||||
|
||||
```sql
|
||||
-- 学生学期平均分统计
|
||||
SELECT
|
||||
s.student_no,
|
||||
s.name,
|
||||
c.class_name,
|
||||
cs.academic_year,
|
||||
cs.semester,
|
||||
ROUND(AVG(cs.score), 2) as avg_score,
|
||||
COUNT(*) as record_count
|
||||
FROM xg_conduct_score cs
|
||||
LEFT JOIN xg_student_msg s ON cs.student_id = s.id
|
||||
LEFT JOIN xg_class_msg c ON cs.class_id = c.id
|
||||
WHERE cs.tenant_id = #{tenantId}
|
||||
AND cs.deleted = 0
|
||||
AND cs.academic_year = #{academicYear}
|
||||
AND cs.semester = #{semester}
|
||||
AND s.deleted = 0
|
||||
AND c.deleted = 0
|
||||
GROUP BY cs.student_id, cs.academic_year, cs.semester
|
||||
ORDER BY c.class_name, s.student_no;
|
||||
```
|
||||
|
||||
#### 班主任班级统计
|
||||
|
||||
```sql
|
||||
-- 班主任查看本班学生操行分
|
||||
SELECT
|
||||
s.student_no,
|
||||
s.name,
|
||||
cs.month,
|
||||
cs.score,
|
||||
cs.remark,
|
||||
cs.create_time
|
||||
FROM xg_conduct_score cs
|
||||
LEFT JOIN xg_student_msg s ON cs.student_id = s.id
|
||||
WHERE cs.tenant_id = #{tenantId}
|
||||
AND cs.deleted = 0
|
||||
AND cs.class_teacher_id = #{teacherId}
|
||||
AND cs.academic_year = #{academicYear}
|
||||
AND cs.semester = #{semester}
|
||||
AND cs.month = #{month}
|
||||
AND s.deleted = 0
|
||||
ORDER BY s.student_no;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 数据安全设计
|
||||
|
||||
### 数据隔离
|
||||
|
||||
1. **租户隔离**
|
||||
- 所有查询必须包含 `tenant_id = #{tenantId}` 条件
|
||||
- 防止跨租户数据访问
|
||||
|
||||
2. **权限隔离**
|
||||
- 班主任只能查询自己班级的学生
|
||||
- 学生处管理员可以查询全校数据
|
||||
- 通过 `class_teacher_id` 字段控制数据访问权限
|
||||
|
||||
### 数据验证
|
||||
|
||||
1. **分数范围验证**
|
||||
- score 字段必须在 0.00-100.00 范围内
|
||||
- 小数点后最多2位
|
||||
|
||||
2. **时间逻辑验证**
|
||||
- academic_year 必须是有效的年份
|
||||
- semester 只能是 1 或 2
|
||||
- month 必须在 1-12 范围内
|
||||
|
||||
3. **关联数据验证**
|
||||
- student_id 对应的学生必须存在
|
||||
- class_id 对应的班级必须存在
|
||||
- class_teacher_id 对应的用户必须存在且为班主任
|
||||
|
||||
---
|
||||
|
||||
## 📈 性能优化策略
|
||||
|
||||
### 索引优化
|
||||
|
||||
1. **查询模式分析**
|
||||
- 按学生查询历史记录:`student_id + 时间维度`
|
||||
- 按班级批量录入:`class_id + 时间维度`
|
||||
- 按班主任统计:`class_teacher_id + 时间维度`
|
||||
- 学期统计查询:`academic_year + semester`
|
||||
|
||||
2. **组合索引设计**
|
||||
- `idx_student_year_semester`:学生学期查询
|
||||
- `idx_class_year_semester_month`:班级月度录入
|
||||
- `idx_teacher_year_semester`:班主任统计
|
||||
|
||||
### 数据分区(预留)
|
||||
|
||||
```sql
|
||||
-- 按学年分区(未来扩展)
|
||||
PARTITION BY RANGE (academic_year) (
|
||||
PARTITION p2024 VALUES LESS THAN (2025),
|
||||
PARTITION p2025 VALUES LESS THAN (2026),
|
||||
PARTITION p_future VALUES LESS THAN MAXVALUE
|
||||
);
|
||||
```
|
||||
|
||||
### 缓存策略
|
||||
|
||||
1. **学期统计缓存**
|
||||
- 将学期平均分统计结果缓存到 Redis
|
||||
- 缓存键:`conduct_score:stats:{tenantId}:{academicYear}:{semester}`
|
||||
- 过期时间:24小时
|
||||
|
||||
2. **班级学生缓存**
|
||||
- 缓存班主任的班级学生列表
|
||||
- 缓存键:`conduct_score:class_students:{teacherId}`
|
||||
- 过期时间:1小时
|
||||
|
||||
---
|
||||
|
||||
## 🔄 数据迁移
|
||||
|
||||
### 初始数据
|
||||
|
||||
#### 1. 学期字典
|
||||
```sql
|
||||
-- 学期字典类型
|
||||
INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
|
||||
VALUES ('学期', 'conduct_semester', '0', 1, NOW(), '学生操行分管理学期字典');
|
||||
|
||||
-- 学期字典数据
|
||||
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, status, create_by, create_time, remark) VALUES
|
||||
(1, '第一学期', '1', 'conduct_semester', '0', 1, NOW(), '上学期'),
|
||||
(2, '第二学期', '2', 'conduct_semester', '0', 1, NOW(), '下学期');
|
||||
```
|
||||
|
||||
#### 2. 月份字典
|
||||
```sql
|
||||
-- 月份字典类型
|
||||
INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
|
||||
VALUES ('月份', 'conduct_month', '0', 1, NOW(), '学生操行分管理月份字典');
|
||||
|
||||
-- 月份字典数据
|
||||
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, status, create_by, create_time, remark) VALUES
|
||||
(1, '1月', '1', 'conduct_month', '0', 1, NOW(), '一月份'),
|
||||
(2, '2月', '2', 'conduct_month', '0', 1, NOW(), '二月份'),
|
||||
(3, '3月', '3', 'conduct_month', '0', 1, NOW(), '三月份'),
|
||||
(4, '4月', '4', 'conduct_month', '0', 1, NOW(), '四月份'),
|
||||
(5, '5月', '5', 'conduct_month', '0', 1, NOW(), '五月份'),
|
||||
(6, '6月', '6', 'conduct_month', '0', 1, NOW(), '六月份'),
|
||||
(7, '7月', '7', 'conduct_month', '0', 1, NOW(), '七月份'),
|
||||
(8, '8月', '8', 'conduct_month', '0', 1, NOW(), '八月份'),
|
||||
(9, '9月', '9', 'conduct_month', '0', 1, NOW(), '九月份'),
|
||||
(10, '10月', '10', 'conduct_month', '0', 1, NOW(), '十月份'),
|
||||
(11, '11月', '11', 'conduct_month', '0', 1, NOW(), '十一月份'),
|
||||
(12, '12月', '12', 'conduct_month', '0', 1, NOW(), '十二月份');
|
||||
```
|
||||
|
||||
### 历史数据迁移(如果需要)
|
||||
|
||||
```sql
|
||||
-- 从其他系统迁移历史操行分数据
|
||||
INSERT INTO xg_conduct_score (
|
||||
id, tenant_id, academic_year, semester, month,
|
||||
student_id, class_id, class_teacher_id, score, remark,
|
||||
create_by, create_time
|
||||
)
|
||||
SELECT
|
||||
#{snowflakeId}, '000000', academic_year, semester, month,
|
||||
student_id, class_id, class_teacher_id, score, remark,
|
||||
#{currentUserId}, NOW()
|
||||
FROM temp_conduct_score_data;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 数据完整性检查
|
||||
|
||||
### 定期检查脚本
|
||||
|
||||
```sql
|
||||
-- 检查孤立记录(学生不存在)
|
||||
SELECT cs.* FROM xg_conduct_score cs
|
||||
LEFT JOIN xg_student_msg s ON cs.student_id = s.id
|
||||
WHERE cs.deleted = 0 AND (s.id IS NULL OR s.deleted = 1);
|
||||
|
||||
-- 检查孤立记录(班级不存在)
|
||||
SELECT cs.* FROM xg_conduct_score cs
|
||||
LEFT JOIN xg_class_msg c ON cs.class_id = c.id
|
||||
WHERE cs.deleted = 0 AND (c.id IS NULL OR c.deleted = 1);
|
||||
|
||||
-- 检查孤立记录(班主任不存在)
|
||||
SELECT cs.* FROM xg_conduct_score cs
|
||||
LEFT JOIN sys_user u ON cs.class_teacher_id = u.user_id
|
||||
WHERE cs.deleted = 0 AND u.user_id IS NULL;
|
||||
|
||||
-- 检查分数范围异常
|
||||
SELECT * FROM xg_conduct_score
|
||||
WHERE deleted = 0 AND (score < 0 OR score > 100);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 使用说明
|
||||
|
||||
### 建表顺序
|
||||
|
||||
1. 确保依赖表已存在:`xg_student_msg`、`xg_class_msg`、`sys_user`
|
||||
2. 执行操行分表创建脚本
|
||||
3. 创建相关索引
|
||||
4. 初始化字典数据:学期字典、月份字典
|
||||
|
||||
### 注意事项
|
||||
|
||||
1. **数据一致性**:录入时必须确保学生、班级、班主任的关联关系正确
|
||||
2. **性能监控**:定期监控查询性能,根据实际使用情况调整索引
|
||||
3. **数据备份**:重要数据定期备份,防止数据丢失
|
||||
4. **审计记录**:所有数据变更都要记录审计日志
|
||||
|
||||
---
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
如有疑问或需要技术支持,请联系:
|
||||
|
||||
- **技术负责人**:项目技术负责人
|
||||
- **数据库管理员**:负责数据库运维
|
||||
- **业务负责人**:学生处管理员
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2025-01-13
|
||||
**数据库版本**: MySQL 8.0+
|
||||
**字符集**: utf8mb4
|
||||
**字典表**: 已定义学期和月份字典
|
||||
@@ -0,0 +1,473 @@
|
||||
# 学生操行分管理 - 业务需求说明
|
||||
|
||||
## 📋 文档说明
|
||||
|
||||
**版本**: v1.0
|
||||
**制定时间**: 2025-01-13
|
||||
**适用范围**: 学生操行分管理模块业务需求分析
|
||||
**重要性**: ⚠️ **核心文档** - 系统开发的基础和验收标准
|
||||
|
||||
---
|
||||
|
||||
## 🎯 需求概述
|
||||
|
||||
### 业务背景
|
||||
|
||||
学校需要建立完整的学生操行评价体系,帮助班主任每月对班级学生进行操行表现量化评价,并为学生处管理员提供学期统计分析功能。该系统将操行分作为学生综合素质评价的重要组成部分,为学校管理决策提供数据支持。
|
||||
|
||||
### 业务价值
|
||||
|
||||
1. **量化评价**: 将班主任对学生的操行评价从定性转为定量,便于管理和比较
|
||||
2. **过程记录**: 记录学生每月操行表现变化趋势,为学生成长提供参考
|
||||
3. **统计分析**: 学期结束自动生成平均分统计,为学生评优评先提供依据
|
||||
4. **管理决策**: 为学生处管理工作提供数据化支撑
|
||||
|
||||
### 需求目标
|
||||
|
||||
实现一个完整的学生操行分管理系统,包括:
|
||||
- 班主任每月操行分录入功能
|
||||
- 学生处管理员学期统计查看功能
|
||||
- 完整的数据查询和导出功能
|
||||
- 严格的权限控制和数据安全
|
||||
|
||||
---
|
||||
|
||||
## 👥 用户角色分析
|
||||
|
||||
### 1. 班主任
|
||||
|
||||
#### 用户画像
|
||||
|
||||
- **身份**: 学校各班级的任课教师或班主任
|
||||
- **职责**: 负责班级学生的日常管理和操行评价
|
||||
- **技术水平**: 中等,熟悉办公软件操作
|
||||
- **使用频率**: 每月1次(月末录入当月操行分)
|
||||
|
||||
#### 业务需求
|
||||
|
||||
1. **班级学生查看**
|
||||
- 查看自己负责班级的学生名单
|
||||
- 了解学生基本信息(学号、姓名、性别等)
|
||||
|
||||
2. **操行分录入**
|
||||
- 选择录入月份(默认为当月)
|
||||
- 为每位学生录入操行分数(0-100分)
|
||||
- 添加相应的备注说明
|
||||
- 批量保存所有学生的分数
|
||||
|
||||
3. **历史记录查看**
|
||||
- 查看本班学生历史操行分记录
|
||||
- 了解学生的操行表现变化趋势
|
||||
|
||||
#### 使用场景
|
||||
|
||||
- **月末评价**: 每月最后一天,班主任登录系统录入当月操行分
|
||||
- **学期总结**: 学期结束前,查看班级整体操行表现情况
|
||||
- **学生沟通**: 与学生或家长沟通时参考操行分记录
|
||||
|
||||
### 2. 学生处管理员
|
||||
|
||||
#### 用户画像
|
||||
|
||||
- **身份**: 学校学生处工作人员
|
||||
- **职责**: 负责全校学生管理工作和数据统计
|
||||
- **技术水平**: 较高,熟悉数据分析和报表制作
|
||||
- **使用频率**: 每周1-2次,学期结束时频率更高
|
||||
|
||||
#### 业务需求
|
||||
|
||||
1. **学期统计分析**
|
||||
- 选择学年和学期,查看全校学生操行平均分
|
||||
- 按班级、年级等维度进行分组统计
|
||||
- 查看优秀、良好、一般等分数段分布
|
||||
|
||||
2. **数据查询筛选**
|
||||
- 按班级、姓名等条件筛选学生
|
||||
- 查看学生的详细操行分记录
|
||||
- 导出统计数据用于报表制作
|
||||
|
||||
3. **数据导出**
|
||||
- 导出Excel格式的统计报表
|
||||
- 支持自定义导出字段和格式
|
||||
|
||||
#### 使用场景
|
||||
|
||||
- **学期考核**: 学期结束时生成学生操行表现统计报表
|
||||
- **评优评先**: 为学生评优、奖学金评选提供数据依据
|
||||
- **家校沟通**: 为家长会或家访提供学生表现数据
|
||||
- **管理工作**: 分析班级管理效果,为教学管理提供参考
|
||||
|
||||
---
|
||||
|
||||
## 📋 功能需求清单
|
||||
|
||||
### 核心功能需求
|
||||
|
||||
#### F01: 操行分录入管理
|
||||
|
||||
**优先级**: 高 (P1)
|
||||
**需求描述**: 班主任每月为班级学生录入操行分数
|
||||
|
||||
**功能点**:
|
||||
- F01.1: 选择录入月份(支持选择历史月份补录)
|
||||
- F01.2: 显示班级学生列表(包含学号、姓名、性别)
|
||||
- F01.3: 为每位学生录入分数(0.00-100.00,保留2位小数)
|
||||
- F01.4: 添加备注信息(最多500字符)
|
||||
- F01.5: 批量保存所有学生的操行分
|
||||
- F01.6: 验证数据完整性(分数必填,备注可选)
|
||||
- F01.7: 防止重复录入(同一学生同月只能录入一次)
|
||||
|
||||
**验收标准**:
|
||||
- ✅ 可成功录入45人班级的操行分(5分钟内完成)
|
||||
- ✅ 数据验证完整,分数范围正确
|
||||
- ✅ 重复录入时给出明确提示
|
||||
|
||||
#### F02: 学期平均分统计
|
||||
|
||||
**优先级**: 高 (P1)
|
||||
**需求描述**: 自动计算并展示学生的学期操行平均分
|
||||
|
||||
**功能点**:
|
||||
- F02.1: 选择学年和学期进行统计
|
||||
- F02.2: 显示学生列表及平均分
|
||||
- F02.3: 展示统计信息(最高分、最低分、记录次数)
|
||||
- F02.4: 按班级分组显示
|
||||
- F02.5: 支持分页展示(默认每页20条)
|
||||
- F02.6: 实时计算平均分(基于每月分数)
|
||||
|
||||
**验收标准**:
|
||||
- ✅ 正确计算学期平均分(6个月分数平均)
|
||||
- ✅ 统计信息准确(最大值、最小值、记录数)
|
||||
- ✅ 班级分组清晰,数据排序合理
|
||||
|
||||
#### F03: 数据查询功能
|
||||
|
||||
**优先级**: 中 (P2)
|
||||
**需求描述**: 提供灵活的数据查询和筛选功能
|
||||
|
||||
**功能点**:
|
||||
- F03.1: 按学年、学期、月份筛选
|
||||
- F03.2: 按学生姓名、班级名称筛选
|
||||
- F03.3: 按分数范围筛选(最低分-最高分)
|
||||
- F03.4: 支持组合条件查询
|
||||
- F03.5: 结果列表展示(支持排序)
|
||||
- F03.6: 详情查看(查看单条记录的详细信息)
|
||||
|
||||
**验收标准**:
|
||||
- ✅ 多条件组合查询正常工作
|
||||
- ✅ 查询结果准确,响应时间<2秒
|
||||
- ✅ 列表排序功能正常
|
||||
|
||||
#### F04: 数据导出功能
|
||||
|
||||
**优先级**: 中 (P2)
|
||||
**需求描述**: 支持将统计数据导出为Excel文件
|
||||
|
||||
**功能点**:
|
||||
- F04.1: 导出学期平均分统计表
|
||||
- F04.2: 支持自定义导出字段
|
||||
- F04.3: Excel格式,支持公式计算
|
||||
- F04.4: 文件命名规范(包含学年学期信息)
|
||||
|
||||
**验收标准**:
|
||||
- ✅ 导出文件格式正确,数据完整
|
||||
- ✅ 文件名包含必要信息,便于识别
|
||||
- ✅ 大数据量导出不影响系统性能
|
||||
|
||||
### 辅助功能需求
|
||||
|
||||
#### F05: 权限控制
|
||||
|
||||
**优先级**: 高 (P1)
|
||||
**需求描述**: 严格的权限控制,确保数据安全
|
||||
|
||||
**功能点**:
|
||||
- F05.1: 班主任只能录入自己班级的学生
|
||||
- F05.2: 学生处管理员可查看全校数据
|
||||
- F05.3: 操作权限验证(增删改查分离)
|
||||
- F05.4: 数据隔离(不同用户看到不同数据)
|
||||
|
||||
**验收标准**:
|
||||
- ✅ 班主任无法录入其他班级学生分数
|
||||
- ✅ 管理员可查看所有班级数据
|
||||
- ✅ 权限不足时给出明确提示
|
||||
|
||||
#### F06: 数据验证
|
||||
|
||||
**优先级**: 高 (P1)
|
||||
**需求描述**: 完整的数据验证机制
|
||||
|
||||
**功能点**:
|
||||
- F06.1: 分数范围验证(0.00-100.00)
|
||||
- F06.2: 必填字段验证
|
||||
- F06.3: 数据类型验证
|
||||
- F06.4: 关联数据存在性验证
|
||||
- F06.5: 业务规则验证(不可重复录入)
|
||||
|
||||
**验收标准**:
|
||||
- ✅ 所有验证规则正常工作
|
||||
- ✅ 错误信息明确易懂
|
||||
- ✅ 验证失败时数据不会保存
|
||||
|
||||
---
|
||||
|
||||
## 🔄 业务流程
|
||||
|
||||
### 主要业务流程
|
||||
|
||||
#### 班主任录入操行分流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[班主任登录系统] --> B[选择操行分录入菜单]
|
||||
B --> C[选择录入月份]
|
||||
C --> D[系统加载班级学生列表]
|
||||
D --> E[班主任录入学生分数和备注]
|
||||
E --> F{数据验证通过?}
|
||||
F -->|否| G[显示错误信息,重新录入]
|
||||
G --> E
|
||||
F -->|是| H[点击保存按钮]
|
||||
H --> I[系统批量保存数据]
|
||||
I --> J{保存成功?}
|
||||
J -->|否| K[显示保存失败信息]
|
||||
K --> I
|
||||
J -->|是| L[显示保存成功提示]
|
||||
L --> M[返回列表页面或继续其他操作]
|
||||
```
|
||||
|
||||
#### 学生处管理员查看统计流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[管理员登录系统] --> B[选择操行分统计菜单]
|
||||
B --> C[选择学年和学期]
|
||||
C --> D[系统查询统计数据]
|
||||
D --> E[显示学生平均分列表]
|
||||
E --> F{需要筛选?}
|
||||
F -->|是| G[输入筛选条件]
|
||||
G --> H[重新查询显示]
|
||||
H --> I[查看详细统计信息]
|
||||
F -->|否| I
|
||||
I --> J{需要导出?}
|
||||
J -->|否| K[查看完毕,结束]
|
||||
J -->|是| L[选择导出格式]
|
||||
L --> M[生成并下载文件]
|
||||
M --> K
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 数据需求
|
||||
|
||||
### 数据录入需求
|
||||
|
||||
#### 操行分数据字段
|
||||
|
||||
| 字段名称 | 数据类型 | 必填 | 取值范围 | 说明 |
|
||||
| -------- | -------- | ---- | -------- | ---- |
|
||||
| 学年 | 整数 | 是 | 2020-2030 | 如:2024 |
|
||||
| 学期 | 整数 | 是 | 1-2 | 1=第一学期,2=第二学期 |
|
||||
| 月份 | 整数 | 是 | 1-12 | 月份 |
|
||||
| 学生ID | 长整数 | 是 | - | 关联学生表 |
|
||||
| 班级ID | 长整数 | 是 | - | 关联班级表 |
|
||||
| 班主任ID | 长整数 | 是 | - | 关联用户表 |
|
||||
| 操行分数 | 小数 | 是 | 0.00-100.00 | 保留2位小数 |
|
||||
| 备注 | 文本 | 否 | 最多500字符 | 评价说明 |
|
||||
|
||||
#### 数据约束
|
||||
|
||||
1. **唯一性约束**: 同一学生在同一学年、学期、月份只能有一条记录
|
||||
2. **关联约束**: 学生必须属于班主任负责的班级
|
||||
3. **时间约束**: 录入月份不能超过当前月份
|
||||
4. **分数约束**: 必须在0-100范围内,允许小数点后2位
|
||||
|
||||
### 数据查询需求
|
||||
|
||||
#### 统计数据字段
|
||||
|
||||
| 字段名称 | 计算方式 | 显示格式 | 说明 |
|
||||
| -------- | -------- | -------- | ---- |
|
||||
| 学生基本信息 | 关联查询 | 学号+姓名 | 从学生表获取 |
|
||||
| 班级信息 | 关联查询 | 班级名称 | 从班级表获取 |
|
||||
| 学期平均分 | AVG(每月分数) | 保留2位小数 | 6个月分数的平均值 |
|
||||
| 最高分数 | MAX(每月分数) | 保留2位小数 | 学期内最高分 |
|
||||
| 最低分数 | MIN(每月分数) | 保留2位小数 | 学期内最低分 |
|
||||
| 记录次数 | COUNT(*) | 整数 | 有效记录数 |
|
||||
| 最后更新时间 | MAX(更新时间) | 日期时间 | 数据最后更新时间 |
|
||||
|
||||
### 数据导出需求
|
||||
|
||||
#### Excel导出格式
|
||||
|
||||
| 列名 | 数据类型 | 说明 |
|
||||
| ---- | -------- | ---- |
|
||||
| 学号 | 文本 | 学生学号 |
|
||||
| 姓名 | 文本 | 学生姓名 |
|
||||
| 性别 | 文本 | 男/女 |
|
||||
| 班级 | 文本 | 班级名称 |
|
||||
| 学期平均分 | 数值 | 保留2位小数 |
|
||||
| 最高分 | 数值 | 学期最高分 |
|
||||
| 最低分 | 数值 | 学期最低分 |
|
||||
| 记录次数 | 数值 | 有效记录数 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 非功能需求
|
||||
|
||||
### 性能需求
|
||||
|
||||
1. **响应时间**
|
||||
- 页面加载:< 3秒
|
||||
- 数据查询:< 2秒
|
||||
- 数据保存:< 5秒(批量保存45人数据)
|
||||
|
||||
2. **并发处理**
|
||||
- 支持同时在线用户:50人
|
||||
- 支持并发录入:10人同时录入
|
||||
|
||||
3. **数据量**
|
||||
- 支持学生数:5000人
|
||||
- 支持历史数据:3年
|
||||
|
||||
### 安全性需求
|
||||
|
||||
1. **权限控制**
|
||||
- 班主任数据隔离:只能操作本班数据
|
||||
- 操作审计:记录所有数据变更操作
|
||||
- 登录验证:必须登录才能操作
|
||||
|
||||
2. **数据安全**
|
||||
- 数据加密:敏感数据加密存储
|
||||
- 数据备份:定期自动备份
|
||||
- 数据恢复:支持数据恢复
|
||||
|
||||
### 易用性需求
|
||||
|
||||
1. **界面设计**
|
||||
- 简洁直观的操作界面
|
||||
- 清晰的数据展示格式
|
||||
- 友好的错误提示信息
|
||||
|
||||
2. **操作便捷**
|
||||
- 批量操作支持
|
||||
- 快捷键支持(Enter键切换输入框)
|
||||
- 数据验证实时提示
|
||||
|
||||
### 可维护性需求
|
||||
|
||||
1. **代码结构**
|
||||
- 模块化设计,职责分离
|
||||
- 统一的编码规范
|
||||
- 完整的注释文档
|
||||
|
||||
2. **日志记录**
|
||||
- 操作日志:记录用户操作
|
||||
- 错误日志:记录系统异常
|
||||
- 审计日志:记录数据变更
|
||||
|
||||
---
|
||||
|
||||
## 🔄 业务规则
|
||||
|
||||
### 录入规则
|
||||
|
||||
1. **时间规则**
|
||||
- 每月最后3天可以录入当月分数
|
||||
- 可以补录之前月份的分数(班主任权限)
|
||||
- 不能录入未来月份的分数
|
||||
|
||||
2. **权限规则**
|
||||
- 班主任只能录入自己负责班级的学生
|
||||
- 同一学生每月只能被录入一次分数
|
||||
- 录入后可以修改,但需要记录修改历史
|
||||
|
||||
3. **数据规则**
|
||||
- 分数必须在0-100范围内
|
||||
- 备注信息最多500字符
|
||||
- 所有必填字段必须填写完整
|
||||
|
||||
### 统计规则
|
||||
|
||||
1. **计算规则**
|
||||
- 学期平均分 = 6个月分数的算术平均值
|
||||
- 如果某月缺失分数,该月不计入平均分计算
|
||||
- 平均分保留2位小数
|
||||
|
||||
2. **展示规则**
|
||||
- 按班级分组显示
|
||||
- 按平均分降序排列
|
||||
- 显示统计概览信息
|
||||
|
||||
---
|
||||
|
||||
## 📋 验收标准
|
||||
|
||||
### 功能验收标准
|
||||
|
||||
1. **操行分录入功能**
|
||||
- ✅ 班主任可成功录入45人班级的所有操行分
|
||||
- ✅ 数据验证完整,错误信息明确
|
||||
- ✅ 重复录入时正确提示并阻止
|
||||
|
||||
2. **统计功能**
|
||||
- ✅ 学期平均分计算正确
|
||||
- ✅ 统计数据展示完整
|
||||
- ✅ 查询筛选功能正常
|
||||
|
||||
3. **权限控制**
|
||||
- ✅ 班主任只能看到自己班级数据
|
||||
- ✅ 管理员可查看全校数据
|
||||
- ✅ 权限不足时正确提示
|
||||
|
||||
### 性能验收标准
|
||||
|
||||
1. **响应时间**
|
||||
- ✅ 页面加载时间 < 3秒
|
||||
- ✅ 数据查询时间 < 2秒
|
||||
- ✅ 批量保存时间 < 5秒
|
||||
|
||||
2. **并发性能**
|
||||
- ✅ 支持10个用户同时录入
|
||||
- ✅ 系统响应正常,无明显卡顿
|
||||
|
||||
### 兼容性验收标准
|
||||
|
||||
1. **浏览器兼容**
|
||||
- ✅ Chrome 80+
|
||||
- ✅ Firefox 75+
|
||||
- ✅ Edge 80+
|
||||
|
||||
2. **移动端适配**
|
||||
- ✅ 支持主流移动设备
|
||||
- ✅ 响应式布局正常
|
||||
|
||||
---
|
||||
|
||||
## 📞 需求确认
|
||||
|
||||
### 需求评审记录
|
||||
|
||||
| 评审时间 | 评审人员 | 评审内容 | 评审结果 |
|
||||
| -------- | -------- | -------- | -------- |
|
||||
| 2025-01-13 | 产品经理、开发团队、班主任代表、学生处管理员 | 业务需求说明 | ✅ 通过 |
|
||||
|
||||
### 需求变更记录
|
||||
|
||||
| 变更时间 | 变更内容 | 变更原因 | 影响评估 |
|
||||
| -------- | -------- | -------- | -------- |
|
||||
| - | - | - | - |
|
||||
|
||||
---
|
||||
|
||||
## 📋 相关文档
|
||||
|
||||
- **技术设计**: `../技术设计/数据库设计.md`
|
||||
- **API接口**: `../技术设计/API接口设计.md`
|
||||
- **用户故事**: `用户故事.md`
|
||||
- **非功能需求**: `非功能需求.md`
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2025-01-13
|
||||
**需求版本**: v1.0
|
||||
**确认状态**: ✅ 已确认
|
||||
@@ -0,0 +1,563 @@
|
||||
# 学生操行分管理API
|
||||
|
||||
## 📋 文档说明
|
||||
|
||||
**版本**: v1.0
|
||||
**制定时间**: 2025-01-13
|
||||
**适用范围**: 学生操行分管理模块API接口
|
||||
**重要性**: ⚠️ **强制性规范** - API设计必须遵循项目统一接口规范
|
||||
|
||||
---
|
||||
|
||||
## 🎯 设计原则
|
||||
|
||||
### 1. RESTful设计
|
||||
- 使用标准的HTTP方法(GET、POST、PUT、DELETE)
|
||||
- 资源路径清晰,层次分明
|
||||
- 状态码规范,返回信息明确
|
||||
|
||||
### 2. 统一响应格式
|
||||
- 所有接口返回统一的JSON格式
|
||||
- 包含状态码、消息、数据三个部分
|
||||
- 分页查询使用统一的TableDataInfo格式
|
||||
|
||||
### 3. 权限控制
|
||||
- 每个接口必须有@SaCheckPermission注解
|
||||
- 班主任只能操作自己班级的学生
|
||||
- 学生处管理员可查看全校数据
|
||||
|
||||
---
|
||||
|
||||
## 🔌 接口概览
|
||||
|
||||
### 接口分类
|
||||
|
||||
#### 基础CRUD接口
|
||||
|
||||
| 接口名称 | 请求方式 | 路径 | 权限字符 | 功能描述 |
|
||||
| -------- | -------- | ---- | -------- | -------- |
|
||||
| 查询操行分列表 | GET | /api/xg/conduct-score/list | xg:conduct:score:list | 分页查询操行分记录 |
|
||||
| 查询操行分详情 | GET | /api/xg/conduct-score/{id} | xg:conduct:score:query | 根据ID查询操行分详情 |
|
||||
| 新增操行分 | POST | /api/xg/conduct-score | xg:conduct:score:add | 创建新的操行分记录 |
|
||||
| 更新操行分 | PUT | /api/xg/conduct-score/{id} | xg:conduct:score:edit | 更新指定操行分记录 |
|
||||
| 删除操行分 | DELETE | /api/xg/conduct-score/{ids} | xg:conduct:score:remove | 删除指定操行分记录 |
|
||||
|
||||
#### 业务逻辑接口
|
||||
|
||||
| 接口名称 | 请求方式 | 路径 | 权限字符 | 功能描述 |
|
||||
| -------- | -------- | ---- | -------- | -------- |
|
||||
| 批量录入操行分 | POST | /api/xg/conduct-score/batch | xg:conduct:score:batchAdd | 批量保存操行分 |
|
||||
| 班主任班级学生 | GET | /api/xg/conduct-score/class-students | xg:conduct:score:classStudents | 获取班主任班级学生列表 |
|
||||
| 学期平均分统计 | GET | /api/xg/conduct-score/semester-average | xg:conduct:score:semester | 查询学期平均分 |
|
||||
| 导出学期统计 | GET | /api/xg/conduct-score/export-semester | xg:conduct:score:export | 导出学期统计数据 |
|
||||
| 验证录入权限 | GET | /api/xg/conduct-score/check-permission | xg:conduct:score:checkPermission | 检查班主任录入权限 |
|
||||
| 获取学生详细记录 | GET | /api/xg/conduct-score/student-records/{studentId} | xg:conduct:score:studentRecords | 获取学生学期内每月操行分详情 |
|
||||
|
||||
---
|
||||
|
||||
## 📝 接口详细设计
|
||||
|
||||
### 1. 查询操行分列表
|
||||
|
||||
#### 接口信息
|
||||
- **请求方式**: GET
|
||||
- **接口路径**: `/api/xg/conduct-score/list`
|
||||
- **权限字符**: `xg:conduct:score:list`
|
||||
- **接口描述**: 分页查询操行分记录列表
|
||||
|
||||
#### 请求参数
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ------ | ---- | ---- | ---- |
|
||||
| academicYear | Integer | 否 | 学年(如:2024) |
|
||||
| semester | Integer | 否 | 学期(1-第一学期,2-第二学期) |
|
||||
| month | Integer | 否 | 月份(1-12) |
|
||||
| studentName | String | 否 | 学生姓名 |
|
||||
| className | String | 否 | 班级名称 |
|
||||
| scoreMin | BigDecimal | 否 | 最低分数 |
|
||||
| scoreMax | BigDecimal | 否 | 最高分数 |
|
||||
| pageNum | Integer | 否 | 当前页码(默认1) |
|
||||
| pageSize | Integer | 否 | 每页显示数量(默认10) |
|
||||
|
||||
#### 响应格式
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "查询成功",
|
||||
"data": {
|
||||
"total": 100,
|
||||
"rows": [
|
||||
{
|
||||
"id": 1234567890123456789,
|
||||
"academicYear": 2024,
|
||||
"semester": 1,
|
||||
"month": 9,
|
||||
"studentId": 987654321098765432,
|
||||
"studentName": "张三",
|
||||
"studentNo": "2024001",
|
||||
"classId": 876543210987654321,
|
||||
"className": "计算机应用1班",
|
||||
"classTeacherId": 765432109876543210,
|
||||
"classTeacherName": "李老师",
|
||||
"score": 95.50,
|
||||
"remark": "表现优秀,积极参加班级活动",
|
||||
"createTime": "2024-09-01 10:00:00",
|
||||
"createBy": "李老师"
|
||||
}
|
||||
],
|
||||
"code": 200,
|
||||
"msg": "查询成功"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 批量录入操行分
|
||||
|
||||
#### 接口信息
|
||||
- **请求方式**: POST
|
||||
- **接口路径**: `/api/xg/conduct-score/batch`
|
||||
- **权限字符**: `xg:conduct:score:batchAdd`
|
||||
- **接口描述**: 批量保存操行分记录
|
||||
|
||||
#### 请求参数
|
||||
```json
|
||||
{
|
||||
"academicYear": 2024,
|
||||
"semester": 1,
|
||||
"month": 9,
|
||||
"classId": 876543210987654321,
|
||||
"scores": [
|
||||
{
|
||||
"studentId": 987654321098765432,
|
||||
"score": 95.50,
|
||||
"remark": "表现优秀,积极参加班级活动"
|
||||
},
|
||||
{
|
||||
"studentId": 987654321098765433,
|
||||
"score": 88.00,
|
||||
"remark": "表现良好,需要继续努力"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 参数说明
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ------ | ---- | ---- | ---- |
|
||||
| academicYear | Integer | 是 | 学年(如:2024) |
|
||||
| semester | Integer | 是 | 学期(1-第一学期,2-第二学期) |
|
||||
| month | Integer | 是 | 月份(1-12) |
|
||||
| classId | Long | 是 | 班级ID |
|
||||
| scores | Array | 是 | 操行分记录数组 |
|
||||
| └─studentId | Long | 是 | 学生ID |
|
||||
| └─score | BigDecimal | 是 | 操行分数(0.00-100.00) |
|
||||
| └─remark | String | 否 | 备注信息 |
|
||||
|
||||
#### 响应格式
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "批量录入成功",
|
||||
"data": {
|
||||
"successCount": 45,
|
||||
"failCount": 0,
|
||||
"failList": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 班主任班级学生列表
|
||||
|
||||
#### 接口信息
|
||||
- **请求方式**: GET
|
||||
- **接口路径**: `/api/xg/conduct-score/class-students`
|
||||
- **权限字符**: `xg:conduct:score:classStudents`
|
||||
- **接口描述**: 获取班主任负责的所有班级及学生列表,用于多Tab页展示
|
||||
|
||||
#### 请求参数
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ------ | ---- | ---- | ---- |
|
||||
| academicYear | Integer | 否 | 学年(如:2024) |
|
||||
| semester | Integer | 否 | 学期(1-第一学期,2-第二学期) |
|
||||
| month | Integer | 否 | 月份(1-12) |
|
||||
|
||||
#### 响应格式
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "查询成功",
|
||||
"data": {
|
||||
"classes": [
|
||||
{
|
||||
"classId": 876543210987654321,
|
||||
"className": "计算机1班",
|
||||
"classCode": "JSJ202401",
|
||||
"studentCount": 45,
|
||||
"enrollmentYear": 2024,
|
||||
"majorName": "计算机应用技术",
|
||||
"students": [
|
||||
{
|
||||
"studentId": 987654321098765432,
|
||||
"studentNo": "2024001",
|
||||
"studentName": "张三",
|
||||
"gender": "0",
|
||||
"existingScore": null,
|
||||
"lastScore": 95.50,
|
||||
"lastMonth": 8
|
||||
},
|
||||
{
|
||||
"studentId": 987654321098765433,
|
||||
"studentNo": "2024002",
|
||||
"studentName": "李四",
|
||||
"gender": "1",
|
||||
"existingScore": {
|
||||
"id": 1234567890123456789,
|
||||
"score": 88.00,
|
||||
"remark": "表现良好",
|
||||
"createTime": "2024-09-01 09:00:00"
|
||||
},
|
||||
"lastScore": 90.00,
|
||||
"lastMonth": 8
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"totalClasses": 3,
|
||||
"totalStudents": 125,
|
||||
"recordedStudents": 87,
|
||||
"pendingStudents": 38,
|
||||
"completionRate": 69.6
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 学期平均分统计
|
||||
|
||||
#### 接口信息
|
||||
- **请求方式**: GET
|
||||
- **接口路径**: `/api/xg/conduct-score/semester-average`
|
||||
- **权限字符**: `xg:conduct:score:semester`
|
||||
- **接口描述**: 查询学生的学期操行平均分
|
||||
|
||||
#### 请求参数
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ------ | ---- | ---- | ---- |
|
||||
| academicYear | Integer | 是 | 学年(如:2024) |
|
||||
| semester | Integer | 是 | 学期(1-第一学期,2-第二学期) |
|
||||
| className | String | 否 | 班级名称筛选 |
|
||||
| studentName | String | 否 | 学生姓名筛选 |
|
||||
| pageNum | Integer | 否 | 当前页码(默认1) |
|
||||
| pageSize | Integer | 否 | 每页显示数量(默认10) |
|
||||
|
||||
#### 响应格式
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "查询成功",
|
||||
"data": {
|
||||
"total": 500,
|
||||
"rows": [
|
||||
{
|
||||
"studentId": 987654321098765432,
|
||||
"studentNo": "2024001",
|
||||
"studentName": "张三",
|
||||
"gender": "0",
|
||||
"classId": 876543210987654321,
|
||||
"className": "计算机应用1班",
|
||||
"academicYear": 2024,
|
||||
"semester": 1,
|
||||
"avgScore": 92.83,
|
||||
"recordCount": 6,
|
||||
"maxScore": 98.00,
|
||||
"minScore": 88.00,
|
||||
"scoreRange": "88.00-98.00",
|
||||
"lastUpdateTime": "2024-09-30 16:00:00"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"totalStudents": 500,
|
||||
"avgScore": 85.67,
|
||||
"excellentCount": 120,
|
||||
"goodCount": 280,
|
||||
"averageCount": 85,
|
||||
"needImprovementCount": 15
|
||||
},
|
||||
"code": 200,
|
||||
"msg": "查询成功"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 获取学生详细记录
|
||||
|
||||
#### 接口信息
|
||||
- **请求方式**: GET
|
||||
- **接口路径**: `/api/xg/conduct-score/student-records/{studentId}`
|
||||
- **权限字符**: `xg:conduct:score:studentRecords`
|
||||
- **接口描述**: 获取指定学生在某学期内1-12月份的操行分详细记录,没有数据的月份显示为空
|
||||
|
||||
#### 请求参数
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| ------ | ---- | ---- | ---- |
|
||||
| studentId | Long | 是 | 学生ID(路径参数) |
|
||||
| academicYear | Integer | 否 | 学年(如:2024) |
|
||||
| semester | Integer | 否 | 学期(1-第一学期,2-第二学期) |
|
||||
| tenantId | String | 是 | 租户ID(多租户支持) |
|
||||
|
||||
#### 响应格式
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "查询成功",
|
||||
"data": {
|
||||
"studentId": 987654321098765432,
|
||||
"studentNo": "2024001",
|
||||
"studentName": "张三",
|
||||
"gender": "0",
|
||||
"classId": 876543210987654321,
|
||||
"className": "计算机应用1班",
|
||||
"classTeacherId": 765432109876543210,
|
||||
"classTeacherName": "李老师",
|
||||
"semesterAverage": 92.83,
|
||||
"totalRecords": 6,
|
||||
"records": [
|
||||
{
|
||||
"id": 1234567890123456789,
|
||||
"academicYear": 2024,
|
||||
"semester": 1,
|
||||
"month": 9,
|
||||
"score": 95.50,
|
||||
"remark": "表现优秀,积极参加班级活动",
|
||||
"createBy": 765432109876543210,
|
||||
"createByName": "李老师",
|
||||
"createTime": "2024-09-01 10:00:00",
|
||||
"updateBy": 765432109876543210,
|
||||
"updateByName": "李老师",
|
||||
"updateTime": "2024-09-01 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": 1234567890123456790,
|
||||
"academicYear": 2024,
|
||||
"semester": 1,
|
||||
"month": 10,
|
||||
"score": 92.00,
|
||||
"remark": "表现良好,继续保持",
|
||||
"createBy": 765432109876543210,
|
||||
"createByName": "李老师",
|
||||
"createTime": "2024-10-01 09:30:00",
|
||||
"updateBy": 765432109876543210,
|
||||
"updateByName": "李老师",
|
||||
"updateTime": "2024-10-01 09:30:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 数据说明
|
||||
- `records` 数组:包含该学生该学期内所有有数据的月份记录
|
||||
- 没有数据的月份不会出现在 `records` 数组中,前端需要生成1-12月的完整列表
|
||||
- `semesterAverage`:该学生该学期的平均分
|
||||
- `totalRecords`:该学生该学期录入的总记录数
|
||||
|
||||
---
|
||||
|
||||
## 🔒 权限控制设计
|
||||
|
||||
### 权限字符定义
|
||||
|
||||
```java
|
||||
// 操行分管理权限
|
||||
xg:conduct:score:list // 查看操行分列表
|
||||
xg:conduct:score:query // 查看操行分详情
|
||||
xg:conduct:score:add // 新增操行分
|
||||
xg:conduct:score:edit // 编辑操行分
|
||||
xg:conduct:score:remove // 删除操行分
|
||||
xg:conduct:score:batchAdd // 批量录入操行分
|
||||
xg:conduct:score:classStudents // 查看班主任班级学生
|
||||
xg:conduct:score:semester // 学期统计查看
|
||||
xg:conduct:score:export // 数据导出
|
||||
xg:conduct:score:studentRecords // 获取学生详细记录
|
||||
```
|
||||
|
||||
### 权限控制逻辑
|
||||
|
||||
#### 班主任权限控制
|
||||
```java
|
||||
@SaCheckPermission("xg:conduct:score:classStudents")
|
||||
@GetMapping("/class-students")
|
||||
public AjaxResult getClassStudents(@RequestParam Integer academicYear,
|
||||
@RequestParam Integer semester,
|
||||
@RequestParam Integer month) {
|
||||
// 获取当前登录用户ID
|
||||
Long userId = SecurityUtils.getUserId();
|
||||
|
||||
// 从 xg_class_msg 表查询该用户作为班主任的所有班级
|
||||
// WHERE class_teacher_id = #{userId} AND deleted = 0
|
||||
List<Long> classIds = conductScoreService.getTeacherClassIds(userId);
|
||||
|
||||
// 返回按班级分组的学生信息,用于前端生成Tab页
|
||||
return AjaxResult.success(conductScoreService.getClassStudentsByTeacher(academicYear, semester, month));
|
||||
}
|
||||
```
|
||||
|
||||
#### 管理员权限控制
|
||||
```java
|
||||
@SaCheckPermission("xg:conduct:score:semester")
|
||||
@GetMapping("/semester-average")
|
||||
public TableDataInfo getSemesterAverage(@RequestParam Integer academicYear,
|
||||
@RequestParam Integer semester,
|
||||
XgConductScoreQueryBo queryBo,
|
||||
PageQuery pageQuery) {
|
||||
// 管理员可以查看全校数据,无班级限制
|
||||
return xgConductScoreService.getSemesterAverage(academicYear, semester, queryBo, pageQuery);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 数据验证规则
|
||||
|
||||
### 请求参数验证
|
||||
|
||||
```java
|
||||
public class XgConductScoreBatchBo {
|
||||
@NotNull(message = "学年不能为空")
|
||||
@Min(value = 2020, message = "学年不能小于2020")
|
||||
@Max(value = 2030, message = "学年不能大于2030")
|
||||
private Integer academicYear;
|
||||
|
||||
@NotNull(message = "学期不能为空")
|
||||
@Min(value = 1, message = "学期只能是1或2")
|
||||
@Max(value = 2, message = "学期只能是1或2")
|
||||
private Integer semester;
|
||||
|
||||
@NotNull(message = "月份不能为空")
|
||||
@Min(value = 1, message = "月份范围为1-12")
|
||||
@Max(value = 12, message = "月份范围为1-12")
|
||||
private Integer month;
|
||||
|
||||
@NotNull(message = "班级ID不能为空")
|
||||
private Long classId;
|
||||
|
||||
@NotEmpty(message = "操行分记录不能为空")
|
||||
@Size(min = 1, max = 100, message = "一次最多录入100条记录")
|
||||
@Valid
|
||||
private List<XgConductScoreItemBo> scores;
|
||||
}
|
||||
```
|
||||
|
||||
### 业务逻辑验证
|
||||
|
||||
#### 班主任权限验证
|
||||
```java
|
||||
private void validateTeacherPermission(Long classId, Long teacherId) {
|
||||
// 验证班主任是否负责该班级
|
||||
boolean isResponsible = conductScoreService.checkTeacherClass(teacherId, classId);
|
||||
if (!isResponsible) {
|
||||
throw new ServiceException("您不是该班级的班主任,无权录入操行分");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 重复录入验证
|
||||
```java
|
||||
private void validateNoDuplicate(Long studentId, Integer academicYear,
|
||||
Integer semester, Integer month) {
|
||||
// 检查是否已存在该学生的当月记录
|
||||
boolean exists = conductScoreService.existsByStudentAndMonth(
|
||||
studentId, academicYear, semester, month);
|
||||
|
||||
if (exists) {
|
||||
throw new ServiceException("该学生当月操行分已录入,不可重复录入");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 错误码设计
|
||||
|
||||
### 业务级错误码
|
||||
|
||||
| 错误码 | 说明 |
|
||||
| ------ | ---- |
|
||||
| 1001 | 班主任权限不足 |
|
||||
| 1002 | 学生不存在 |
|
||||
| 1003 | 班级不存在 |
|
||||
| 1004 | 操行分已存在,不可重复录入 |
|
||||
| 1005 | 分数范围错误 |
|
||||
| 1006 | 时间参数错误 |
|
||||
|
||||
### 错误响应格式
|
||||
```json
|
||||
{
|
||||
"code": 1001,
|
||||
"msg": "您不是该班级的班主任,无权录入操行分",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 性能优化
|
||||
|
||||
### 接口性能指标
|
||||
- **响应时间**: 平均 < 200ms,95% < 500ms
|
||||
- **并发处理**: 支持 100+ 并发请求
|
||||
- **数据量**: 支持万人规模数据查询
|
||||
|
||||
### 缓存策略
|
||||
```java
|
||||
// 学期统计缓存
|
||||
@Cacheable(value = "conductScoreStats", key = "#academicYear + '_' + #semester")
|
||||
public List<XgConductScoreSemesterAverageVo> getSemesterAverage(Integer academicYear, Integer semester) {
|
||||
// 查询逻辑
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 接口测试
|
||||
|
||||
### 测试用例设计
|
||||
|
||||
#### 正常流程测试
|
||||
```bash
|
||||
# 批量录入操行分
|
||||
curl -X POST "http://localhost:8080/api/xg/conduct-score/batch" \
|
||||
-H "Authorization: Bearer {token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"academicYear": 2024,
|
||||
"semester": 1,
|
||||
"month": 9,
|
||||
"classId": 876543210987654321,
|
||||
"scores": [
|
||||
{"studentId": 987654321098765432, "score": 95.5, "remark": "优秀"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
#### 异常流程测试
|
||||
```bash
|
||||
# 权限不足测试
|
||||
curl -X GET "http://localhost:8080/api/xg/conduct-score/class-students" \
|
||||
-H "Authorization: Bearer {invalid_token}"
|
||||
# 响应: {"code": 403, "msg": "权限不足", "data": null}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- **数据库设计**: `../技术设计/数据库设计.md`
|
||||
- **前端设计**: `../前端设计/界面原型图.md`
|
||||
- **项目API总览**: `API文档总览.md`
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2025-01-13
|
||||
**接口版本**: v1.0
|
||||
**兼容性**: 向后兼容
|
||||
@@ -0,0 +1,125 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 查询操行分列表
|
||||
export function conductScoreList(query: any) {
|
||||
return request({
|
||||
url: '/xg/conduct-score/list',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 根据部门ID查询操行分列表(带权限过滤)
|
||||
export function conductScoreListByDeptId(query: any) {
|
||||
return request({
|
||||
url: '/xg/conduct-score/listByDeptId',
|
||||
method: 'post',
|
||||
data: query
|
||||
})
|
||||
}
|
||||
|
||||
// 查询操行分详细
|
||||
export function conductScoreInfo(id: any) {
|
||||
return request({
|
||||
url: '/xg/conduct-score/' + id,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 新增操行分
|
||||
export function conductScoreAdd(data: any) {
|
||||
return request({
|
||||
url: '/xg/conduct-score',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 修改操行分
|
||||
export function conductScoreEdit(data: any) {
|
||||
return request({
|
||||
url: '/xg/conduct-score/' + data.id,
|
||||
method: 'put',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 删除操行分
|
||||
export function conductScoreRemove(ids: any) {
|
||||
return request({
|
||||
url: '/xg/conduct-score/' + ids,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 导出操行分
|
||||
export function conductScoreExport(query: any) {
|
||||
return request({
|
||||
url: '/xg/conduct-score/export',
|
||||
method: 'post',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 批量录入操行分
|
||||
export function conductScoreBatchAdd(data: any) {
|
||||
return request({
|
||||
url: '/xg/conduct-score/batch',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 获取班主任负责的班级学生列表
|
||||
export function conductScoreClassStudents(query: any) {
|
||||
return request({
|
||||
url: '/xg/conduct-score/class-students',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 查询学期平均分统计
|
||||
export function conductScoreSemesterAverage(query: any) {
|
||||
return request({
|
||||
url: '/xg/conduct-score/semester-average',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 根据部门ID查询学期平均分统计(带权限过滤)
|
||||
export function conductScoreSemesterAverageByDeptId(query: any) {
|
||||
return request({
|
||||
url: '/xg/conduct-score/semester-average-by-dept-id',
|
||||
method: 'post',
|
||||
data: query
|
||||
})
|
||||
}
|
||||
|
||||
// 导出学期统计数据
|
||||
export function conductScoreExportSemester(query: any) {
|
||||
return request({
|
||||
url: '/xg/conduct-score/export-semester',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 验证录入权限
|
||||
export function conductScoreCheckPermission(query: any) {
|
||||
return request({
|
||||
url: '/xg/conduct-score/check-permission',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 获取学生详细记录(按月份)
|
||||
export function conductScoreGetStudentRecords(studentId: any, academicYear: any, semester: any, tenantId: any) {
|
||||
return request({
|
||||
url: `/xg/conduct-score/student-records/${studentId}`,
|
||||
method: 'get',
|
||||
params: { academicYear, semester, tenantId }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
<template>
|
||||
<div class="p-2 page-container">
|
||||
<div class="layout-container">
|
||||
<!-- 班级Tab页(最上面) -->
|
||||
<el-tabs v-if="classTabs.length > 0" v-model="activeTab" type="card" class="mb-[10px] tabs-top" @tab-click="handleTabClick">
|
||||
<el-tab-pane v-for="tab in classTabs" :key="tab.classId" :label="tab.className" :name="tab.classId.toString()">
|
||||
<!-- 录入统计(在检索栏上方) -->
|
||||
<div v-if="summary" class="mb-[10px]">
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<div class="stat-item-mini">
|
||||
<div class="stat-label-mini">总学生数</div>
|
||||
<div class="stat-value-mini text-blue-600">{{ summary.totalStudents }}</div>
|
||||
</div>
|
||||
<div class="stat-item-mini">
|
||||
<div class="stat-label-mini">已录入</div>
|
||||
<div class="stat-value-mini text-green-600">{{ summary.recordedStudents }}</div>
|
||||
</div>
|
||||
<div class="stat-item-mini">
|
||||
<div class="stat-label-mini">待录入</div>
|
||||
<div class="stat-value-mini text-orange-600">{{ summary.pendingStudents }}</div>
|
||||
</div>
|
||||
<div class="stat-item-mini">
|
||||
<div class="stat-label-mini">完成度</div>
|
||||
<div class="stat-value-mini text-purple-600">{{ summary.completionRate }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 检索条件区域 -->
|
||||
<div v-show="showSearch" class="mb-[10px]">
|
||||
<el-card shadow="hover">
|
||||
<el-form ref="queryFormRef" :model="queryParams" label-width="80px">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="学年" prop="academicYear">
|
||||
<el-select v-model="queryParams.academicYear" placeholder="选择学年" clearable @change="handleQuery">
|
||||
<el-option v-for="year in yearOptions" :key="year.value" :label="year.label" :value="year.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="学期" prop="semester">
|
||||
<el-select v-model="queryParams.semester" placeholder="选择学期" clearable @change="handleQuery">
|
||||
<el-option v-for="semester in semesterOptions" :key="semester.value" :label="semester.label" :value="semester.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="月份" prop="month">
|
||||
<el-select v-model="queryParams.month" placeholder="选择月份" clearable @change="handleQuery">
|
||||
<el-option v-for="month in monthOptions" :key="month.value" :label="month.label" :value="month.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="学号" prop="studentNo">
|
||||
<el-input v-model="queryParams.studentNo" placeholder="请输入学号" clearable @keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="学生姓名" prop="studentName">
|
||||
<el-input v-model="queryParams.studentName" placeholder="请输入学生姓名" clearable @keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-col :span="24" class="search-btn-container">
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-card shadow="never" class="list-card">
|
||||
<template #header>
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="1.5">
|
||||
<el-button type="primary" plain icon="Plus" @click="handleBatchInput">批量打分</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button type="success" icon="Check" @click="handleSaveAll">保存全部</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button icon="Refresh" @click="handleRefresh">刷新</el-button>
|
||||
</el-col>
|
||||
<RightToolbar v-model:show-search="showSearch" :columns="columns" :search="true" @query-table="getList"></RightToolbar>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<!-- 批量录入弹窗 -->
|
||||
<el-dialog v-model="batchModalVisible" title="批量打分" width="420px" append-to-body :close-on-click-modal="false" destroy-on-close>
|
||||
<el-form :model="batchModalForm" label-width="80px">
|
||||
<el-form-item label="分数" prop="score">
|
||||
<el-input-number
|
||||
v-model="batchModalForm.score"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:precision="2"
|
||||
:step="0.5"
|
||||
size="small"
|
||||
placeholder="输入分数"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input
|
||||
v-model="batchModalForm.remark"
|
||||
type="textarea"
|
||||
size="small"
|
||||
placeholder="输入备注(可选)"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
:rows="3"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="batchModalVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmBatchModalSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-table
|
||||
v-if="currentClassStudents && currentClassStudents.length > 0"
|
||||
v-loading="tabLoading"
|
||||
border
|
||||
:data="filteredStudents"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="50" align="center" />
|
||||
<el-table-column
|
||||
v-if="columns[0].visible"
|
||||
key="studentNo"
|
||||
label="学号"
|
||||
align="center"
|
||||
prop="studentNo"
|
||||
width="120"
|
||||
:show-overflow-tooltip="true"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="columns[1].visible"
|
||||
key="studentName"
|
||||
label="姓名"
|
||||
align="center"
|
||||
prop="studentName"
|
||||
width="100"
|
||||
:show-overflow-tooltip="true"
|
||||
/>
|
||||
<el-table-column v-if="columns[2].visible" key="gender" label="性别" align="center" width="80">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.gender === '0' ? 'primary' : 'success'" size="small">
|
||||
{{ scope.row.gender === '0' ? '男' : '女' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns[3].visible" key="score" label="分数" align="center" width="150">
|
||||
<template #default="scope">
|
||||
<el-input-number v-model="scope.row.score" :min="0" :max="100" :precision="2" :step="0.5" size="small" placeholder="输入分数" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns[4].visible" key="evaluation" label="评判结果" align="center" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="getEvaluationResult(scope.row.score)" :type="getEvaluationResult(scope.row.score) === '优秀' ? 'success' :
|
||||
getEvaluationResult(scope.row.score) === '良好' ? 'primary' :
|
||||
getEvaluationResult(scope.row.score) === '合格' ? 'warning' : 'danger'" size="small">
|
||||
{{ getEvaluationResult(scope.row.score) }}
|
||||
</el-tag>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns[5].visible" key="remark" label="备注" align="center" min-width="200">
|
||||
<template #default="scope">
|
||||
<el-input v-model="scope.row.remark" size="small" placeholder="输入备注(可选)" maxlength="500" show-word-limit />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns[6].visible" key="status" label="状态" align="center" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.existingScore" type="success" size="small">已录入</el-tag>
|
||||
<el-tag v-else-if="scope.row.score" type="warning" size="small">待保存</el-tag>
|
||||
<el-tag v-else type="info" size="small">未录入</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns[7].visible" key="lastScore" label="上月分数" align="center" width="100">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.lastScore" class="text-primary">{{ scope.row.lastScore }}</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="120" class-name="small-padding fixed-width">
|
||||
<template #default="scope">
|
||||
<el-tooltip content="录入分数" placement="top">
|
||||
<el-button link type="primary" icon="Edit" @click="handleSingleInput(scope.row)"></el-button>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="no-data-placeholder" style="padding: 40px; text-align: center; color: #909399">暂无学生数据</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 调试信息(开发环境显示) -->
|
||||
<div v-else style="padding: 20px; text-align: center; color: #909399">正在加载班级数据...</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="ConductScoreInput">
|
||||
import { ref, reactive, onMounted, computed } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
// API接口
|
||||
import { conductScoreClassStudents, conductScoreBatchAdd } from '@/api/xg/conductScore';
|
||||
|
||||
// 响应式数据
|
||||
const queryParams = reactive({
|
||||
academicYear: new Date().getFullYear(),
|
||||
semester: 1,
|
||||
month: new Date().getMonth() + 1,
|
||||
studentNo: '',
|
||||
studentName: ''
|
||||
});
|
||||
|
||||
// 班级Tab页数据
|
||||
const classTabs = ref([]);
|
||||
const activeTab = ref('');
|
||||
const classStudentData = ref(new Map());
|
||||
|
||||
// 当前班级学生列表
|
||||
const currentClassStudents = computed(() => {
|
||||
if (!activeTab.value) return [];
|
||||
return classStudentData.value.get(activeTab.value) || [];
|
||||
});
|
||||
|
||||
// 过滤后的学生列表
|
||||
const filteredStudents = computed(() => {
|
||||
let data = [...currentClassStudents.value];
|
||||
|
||||
// 学号筛选
|
||||
if (queryParams.studentNo) {
|
||||
data = data.filter((item) => item.studentNo?.includes(queryParams.studentNo));
|
||||
}
|
||||
|
||||
// 姓名筛选
|
||||
if (queryParams.studentName) {
|
||||
data = data.filter((item) => item.studentName?.includes(queryParams.studentName));
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
// 汇总统计 - 基于当前班级计算
|
||||
const summary = computed(() => {
|
||||
if (!currentClassStudents.value || currentClassStudents.value.length === 0) {
|
||||
return {
|
||||
totalStudents: 0,
|
||||
recordedStudents: 0,
|
||||
pendingStudents: 0,
|
||||
completionRate: 0
|
||||
};
|
||||
}
|
||||
|
||||
const students = currentClassStudents.value;
|
||||
const totalStudents = students.length;
|
||||
const recordedStudents = students.filter((student) => student.existingScore !== null && student.existingScore !== undefined).length;
|
||||
const pendingStudents = totalStudents - recordedStudents;
|
||||
const completionRate = totalStudents > 0 ? Math.round((recordedStudents / totalStudents) * 100) : 0;
|
||||
|
||||
return {
|
||||
totalStudents,
|
||||
recordedStudents,
|
||||
pendingStudents,
|
||||
completionRate
|
||||
};
|
||||
});
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false);
|
||||
const tabLoading = ref(false);
|
||||
|
||||
// 选择相关
|
||||
const selectedStudents = ref([]);
|
||||
|
||||
// 表格列配置
|
||||
const columns = ref([
|
||||
{ key: 0, label: `学号`, visible: true },
|
||||
{ key: 1, label: `姓名`, visible: true },
|
||||
{ key: 2, label: `性别`, visible: true },
|
||||
{ key: 3, label: `分数`, visible: true },
|
||||
{ key: 4, label: `评判结果`, visible: true },
|
||||
{ key: 5, label: `备注`, visible: true },
|
||||
{ key: 6, label: `状态`, visible: true },
|
||||
{ key: 7, label: `上月分数`, visible: true }
|
||||
]);
|
||||
|
||||
// 搜索显示控制
|
||||
const showSearch = ref(true);
|
||||
|
||||
// 表单引用
|
||||
const queryFormRef = ref();
|
||||
|
||||
// 选项数据
|
||||
const yearOptions = computed(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
return Array.from({ length: 8 }, (_, i) => ({
|
||||
label: `${currentYear - 7 + i}年`,
|
||||
value: currentYear - 7 + i
|
||||
}));
|
||||
});
|
||||
|
||||
const semesterOptions = [
|
||||
{ label: '第一学期', value: 1 },
|
||||
{ label: '第二学期', value: 2 }
|
||||
];
|
||||
|
||||
const monthOptions = Array.from({ length: 12 }, (_, i) => ({
|
||||
label: `${i + 1}月`,
|
||||
value: i + 1
|
||||
}));
|
||||
|
||||
// 评判结果计算方法
|
||||
const getEvaluationResult = (score) => {
|
||||
if (score === null || score === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (score >= 85) {
|
||||
return '优秀';
|
||||
} else if (score >= 75) {
|
||||
return '良好';
|
||||
} else if (score >= 60) {
|
||||
return '合格';
|
||||
} else {
|
||||
return '不合格';
|
||||
}
|
||||
};
|
||||
|
||||
// 方法
|
||||
const handleQuery = () => {
|
||||
getList();
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value?.resetFields();
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleTabClick = (tab) => {
|
||||
activeTab.value = tab.props.name;
|
||||
// 可以在这里添加切换Tab时的保存逻辑
|
||||
};
|
||||
|
||||
const getList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await conductScoreClassStudents(queryParams);
|
||||
|
||||
if (response.code !== 200) {
|
||||
throw new Error(response.msg || '获取数据失败');
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
|
||||
console.log('获取到的数据:', data); // 调试信息
|
||||
|
||||
if (!data || !data.classes || data.classes.length === 0) {
|
||||
console.log('没有班级数据:', data); // 调试信息
|
||||
ElMessage.warning('暂无班级数据,请检查是否已分配班主任权限或联系管理员');
|
||||
classTabs.value = [];
|
||||
classStudentData.value.clear();
|
||||
summary.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置Tab页,按年级倒序排列
|
||||
classTabs.value = data.classes
|
||||
.sort((a, b) => {
|
||||
// 年级倒序排列(高年级在前,如2024级排在2023级前面)
|
||||
const gradeA = a.grade || '';
|
||||
const gradeB = b.grade || '';
|
||||
if (!gradeA && !gradeB) return 0;
|
||||
if (!gradeA) return 1; // 空值排在后面
|
||||
if (!gradeB) return -1; // 空值排在后面
|
||||
return gradeB.localeCompare(gradeA); // 降序排列
|
||||
})
|
||||
.map((cls) => ({
|
||||
classId: cls.classId,
|
||||
className: cls.className,
|
||||
studentCount: cls.studentCount || 0
|
||||
}));
|
||||
|
||||
console.log('创建的Tab页:', classTabs.value); // 调试信息
|
||||
|
||||
// 设置学生数据
|
||||
classStudentData.value.clear();
|
||||
data.classes.forEach((cls) => {
|
||||
if (cls.students && Array.isArray(cls.students)) {
|
||||
const students = cls.students.map((student) => {
|
||||
// 更严格的已录入判断:existingScore必须是对象且包含score字段
|
||||
const hasExistingScore =
|
||||
student.existingScore &&
|
||||
typeof student.existingScore === 'object' &&
|
||||
student.existingScore.score !== null &&
|
||||
student.existingScore.score !== undefined;
|
||||
|
||||
return {
|
||||
...student,
|
||||
studentId: student.studentId,
|
||||
studentNo: student.studentNo || '',
|
||||
studentName: student.studentName || '',
|
||||
gender: student.gender || '0',
|
||||
lastScore: student.lastScore || null,
|
||||
existingScore: hasExistingScore ? student.existingScore : null,
|
||||
score: hasExistingScore ? student.existingScore.score : null,
|
||||
remark: hasExistingScore ? student.existingScore.remark || '' : ''
|
||||
};
|
||||
});
|
||||
classStudentData.value.set(cls.classId.toString(), students);
|
||||
} else {
|
||||
classStudentData.value.set(cls.classId.toString(), []);
|
||||
}
|
||||
});
|
||||
|
||||
// 设置默认激活Tab
|
||||
if (classTabs.value.length > 0) {
|
||||
if (!activeTab.value || !classStudentData.value.has(activeTab.value)) {
|
||||
activeTab.value = classTabs.value[0].classId.toString();
|
||||
}
|
||||
} else {
|
||||
activeTab.value = '';
|
||||
}
|
||||
|
||||
// 汇总统计现在通过计算属性动态计算,无需手动设置
|
||||
|
||||
if (classTabs.value.length > 0) {
|
||||
ElMessage.success(`成功加载 ${classTabs.value.length} 个班级数据`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载班级学生数据失败:', error);
|
||||
const errorMessage = error.response?.data?.msg || error.message || '加载数据失败,请稍后重试';
|
||||
ElMessage.error(errorMessage);
|
||||
|
||||
classTabs.value = [];
|
||||
classStudentData.value.clear();
|
||||
activeTab.value = '';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 批量操作方法
|
||||
const handleBatchInput = () => {
|
||||
// 打开批量录入弹窗(弹窗中输入分数和备注,保存时应用到所选学生)
|
||||
console.log('handleBatchInput selectedStudents:', selectedStudents.value);
|
||||
if (!selectedStudents.value || selectedStudents.value.length === 0) {
|
||||
ElMessage.warning('请先选择要批量录入的学生');
|
||||
return;
|
||||
}
|
||||
|
||||
// reset form and show dialog
|
||||
batchModalForm.score = null;
|
||||
batchModalForm.remark = '';
|
||||
batchModalVisible.value = true;
|
||||
// debug info for users to see modal opening
|
||||
ElMessage.info('已打开批量录入弹窗');
|
||||
};
|
||||
|
||||
const handleSaveAll = () => {
|
||||
const validStudents = filteredStudents.value.filter(
|
||||
(student) => student.score !== null && student.score !== undefined && student.existingScore === null
|
||||
);
|
||||
|
||||
if (validStudents.length === 0) {
|
||||
ElMessage.warning('没有需要保存的数据');
|
||||
return;
|
||||
}
|
||||
|
||||
ElMessageBox.confirm(`确定要保存当前班级的 ${validStudents.length} 个学生的操行分吗?`, '保存确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
saveBatchData(validStudents);
|
||||
});
|
||||
};
|
||||
|
||||
const saveBatchData = async (students) => {
|
||||
try {
|
||||
const batchData = {
|
||||
academicYear: queryParams.academicYear,
|
||||
semester: queryParams.semester,
|
||||
month: queryParams.month,
|
||||
classId: activeTab.value, // 直接传递字符串,避免JavaScript大整数精度丢失
|
||||
scores: students.map((student) => ({
|
||||
studentId: student.studentId,
|
||||
score: student.score,
|
||||
remark: student.remark || ''
|
||||
}))
|
||||
};
|
||||
|
||||
const response = await conductScoreBatchAdd(batchData);
|
||||
|
||||
if (response.code === 200) {
|
||||
ElMessage.success(`成功保存 ${response.data.successCount} 条记录`);
|
||||
if (response.data.failCount > 0) {
|
||||
ElMessage.warning(`失败 ${response.data.failCount} 条记录`);
|
||||
}
|
||||
getList();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存数据失败:', error);
|
||||
ElMessage.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 批量录入弹窗数据与确认保存
|
||||
const batchModalVisible = ref(false);
|
||||
const batchModalForm = reactive({
|
||||
score: null,
|
||||
remark: ''
|
||||
});
|
||||
|
||||
const confirmBatchModalSave = () => {
|
||||
// 批量录入弹窗填写的分数/备注将应用到所有已勾选的学生(包含已录入的,视为覆盖)
|
||||
const targets = selectedStudents.value || [];
|
||||
|
||||
if (targets.length === 0) {
|
||||
ElMessage.warning('请先选择要批量录入的学生');
|
||||
batchModalVisible.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (batchModalForm.score === null || batchModalForm.score === undefined) {
|
||||
ElMessage.warning('请在弹窗中填写分数');
|
||||
return;
|
||||
}
|
||||
|
||||
const studentsToSave = targets.map((stu) => ({
|
||||
studentId: stu.studentId,
|
||||
score: batchModalForm.score,
|
||||
remark: batchModalForm.remark || ''
|
||||
}));
|
||||
|
||||
batchModalVisible.value = false;
|
||||
// 保存时会覆盖已有分数(后端应支持更新)
|
||||
saveBatchData(studentsToSave);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleSingleInput = (row) => {
|
||||
// 单条保存:支持覆盖已录入的分数。根据是否已有分数,调整提示文案。
|
||||
if (row.score === null || row.score === undefined) {
|
||||
ElMessage.warning('请先输入分数');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmMessage = row.existingScore
|
||||
? `该学生已有分数,确定要覆盖 ${row.studentName} 的分数为 ${row.score} 吗?`
|
||||
: `确定保存 ${row.studentName} 的分数 ${row.score} 吗?`;
|
||||
|
||||
ElMessageBox.confirm(confirmMessage, '保存确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
// 传入简化对象以避免多余字段
|
||||
saveBatchData([
|
||||
{
|
||||
studentId: row.studentId,
|
||||
score: row.score,
|
||||
remark: row.remark || ''
|
||||
}
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectionChange = (selection) => {
|
||||
selectedStudents.value = selection;
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
/* allow content to determine height to avoid forcing large blank areas */
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
/* 搜索按钮容器样式 */
|
||||
.search-btn-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.search-btn-container .el-form-item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-item-small {
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.stat-item-mini {
|
||||
padding: 8px 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value-small {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value-mini {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stat-label-small {
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-label-mini {
|
||||
color: #000000;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.no-data-placeholder {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* 列表卡片自适应高度,避免底部留白 */
|
||||
.list-card {
|
||||
/* give the list card room; removed fixed max-height per request */
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 增加 tab 区域高度,保证表格有足够可视空间 */
|
||||
.tabs-top ::deep(.el-tabs__header) {
|
||||
min-height: 56px;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.tabs-top ::deep(.el-tabs__nav) {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.tabs-top ::deep(.el-tab-pane) {
|
||||
/* ensure tab pane scrolls when needed; removed fixed min/max heights */
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
padding-right: 8px !important;
|
||||
}
|
||||
|
||||
.tabs-top ::deep(.el-tabs__content) {
|
||||
max-height: calc(100vh - 160px) !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
/* 覆盖底层残留的固定高度规则,防止页面产生空白区域 */
|
||||
::deep(.el-tab-pane),
|
||||
.tabs-top ::deep(.el-tab-pane) {
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
padding-right: 8px !important;
|
||||
}
|
||||
|
||||
::deep(.el-tabs__content),
|
||||
.tabs-top ::deep(.el-tabs__content) {
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
overflow: auto !important;
|
||||
padding-right: 1px !important;
|
||||
padding-left: 1px !important;
|
||||
}
|
||||
|
||||
/* 自定义颜色类 */
|
||||
.text-primary {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.text-blue-600 {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.text-green-600 {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.text-orange-600 {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.text-purple-600 {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.text-gray-400 {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
/* Tab 页通用样式:移除固定高度,允许自然伸缩 */
|
||||
::deep(.el-tab-pane) {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
::deep(.el-tabs__content) {
|
||||
overflow: hidden;
|
||||
padding-right: 1px !important;
|
||||
padding-left: 1px !important;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
::deep(.el-tab-pane::-webkit-scrollbar) {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::deep(.el-tab-pane::-webkit-scrollbar-track) {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::deep(.el-tab-pane::-webkit-scrollbar-thumb) {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::deep(.el-tab-pane::-webkit-scrollbar-thumb:hover) {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -115,19 +115,19 @@
|
||||
<!-- <el-col :span="1.5">
|
||||
<el-button :disabled="multiple" icon="Delete" @click="handleDelete()"> 删除 </el-button>
|
||||
</el-col> -->
|
||||
<el-col :span="1.2">
|
||||
<el-col :span="1.2" v-hasPerm="['studentMsg:status:tuixue']">
|
||||
<el-button type="danger" :disabled="multiple" @click="openStatusChangeModal('TUI_XUE')">退学</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.2">
|
||||
<el-col :span="1.2" v-hasPerm="['studentMsg:status:xiuxue']">
|
||||
<el-button type="warning" :disabled="multiple" @click="openStatusChangeModal('XIU_XUE')">休学</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.2">
|
||||
<el-col :span="1.2" v-hasPerm="['studentMsg:status:fuxue']">
|
||||
<el-button type="success" :disabled="multiple" @click="openStatusChangeModal('FU_XUE')">复学</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.2">
|
||||
<el-col :span="1.2" v-hasPerm="['studentMsg:status:biye']">
|
||||
<el-button type="primary" :disabled="multiple" @click="openStatusChangeModal('BI_YE')">毕业</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.2">
|
||||
<el-col :span="1.2" v-hasPerm="['studentMsg:class:change']">
|
||||
<el-button type="warning" :disabled="multiple" @click="openChangeClassModal">换班级</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
学生操行分历史数据迁移 - 脚本1:Excel数据导入临时表
|
||||
|
||||
功能说明:
|
||||
- 读取操行.xls文件
|
||||
- 数据清洗和验证
|
||||
- 导入到temp_conduct_score_import临时表
|
||||
|
||||
执行方式:
|
||||
python 01_import_excel_to_temp.py
|
||||
|
||||
注意事项:
|
||||
- 执行前确保Excel文件存在
|
||||
- 执行前确保临时表已创建
|
||||
- 支持断点续传(已导入的数据会跳过)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
import pymysql
|
||||
from pymysql.cursors import DictCursor
|
||||
|
||||
from db_config import DB_CONFIG, TABLE_CONFIG, CONSTANTS
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler("logs/01_import_excel.log", mode='a', encoding='utf-8')
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger("import_excel")
|
||||
|
||||
class ExcelImporter:
|
||||
"""Excel导入处理器"""
|
||||
|
||||
def __init__(self, excel_file: str):
|
||||
self.excel_file = excel_file
|
||||
self.db_config = DB_CONFIG
|
||||
self.temp_table = TABLE_CONFIG["temp_import"]
|
||||
self.batch_size = CONSTANTS["BATCH_SIZE"]
|
||||
self.connection = None
|
||||
|
||||
def connect_db(self) -> None:
|
||||
"""连接数据库"""
|
||||
try:
|
||||
self.connection = pymysql.connect(**self.db_config)
|
||||
logger.info("数据库连接成功")
|
||||
except Exception as e:
|
||||
logger.error(f"数据库连接失败: {e}")
|
||||
raise
|
||||
|
||||
def disconnect_db(self) -> None:
|
||||
"""断开数据库连接"""
|
||||
if self.connection:
|
||||
self.connection.close()
|
||||
logger.info("数据库连接已断开")
|
||||
|
||||
def read_excel_data(self) -> pd.DataFrame:
|
||||
"""读取Excel文件"""
|
||||
try:
|
||||
logger.info(f"开始读取Excel文件: {self.excel_file}")
|
||||
|
||||
# 检查文件是否存在
|
||||
if not Path(self.excel_file).exists():
|
||||
raise FileNotFoundError(f"Excel文件不存在: {self.excel_file}")
|
||||
|
||||
# 读取Excel文件
|
||||
df = pd.read_excel(self.excel_file)
|
||||
|
||||
logger.info(f"Excel文件读取成功,共{len(df)}行数据")
|
||||
logger.info(f"列名: {list(df.columns)}")
|
||||
|
||||
return df
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"读取Excel文件失败: {e}")
|
||||
raise
|
||||
|
||||
def validate_data(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""数据验证和清洗"""
|
||||
logger.info("开始数据验证和清洗")
|
||||
|
||||
# 检查必需列
|
||||
required_columns = ['学号', '学年', '学期', '平时分数']
|
||||
missing_columns = [col for col in required_columns if col not in df.columns]
|
||||
if missing_columns:
|
||||
raise ValueError(f"Excel文件缺少必需列: {missing_columns}")
|
||||
|
||||
# 数据清洗
|
||||
original_count = len(df)
|
||||
|
||||
# 删除空行(学号为空的行)
|
||||
df = df.dropna(subset=['学号'])
|
||||
logger.info(f"删除空学号行: {original_count - len(df)}行")
|
||||
|
||||
# 去除学号前后的空格
|
||||
df.loc[:, '学号'] = df['学号'].astype(str).str.strip()
|
||||
|
||||
# 删除重复的学号+学年+学期记录(保留第一条)
|
||||
before_dedupe = len(df)
|
||||
df = df.drop_duplicates(subset=['学号', '学年', '学期'], keep='first')
|
||||
logger.info(f"删除重复记录: {before_dedupe - len(df)}行")
|
||||
|
||||
# 验证学年格式
|
||||
df.loc[:, 'academic_year_valid'] = df['学年'].astype(str).str.match(r'^\d{4}-\d{4}$')
|
||||
invalid_years = df[~df['academic_year_valid']]
|
||||
if len(invalid_years) > 0:
|
||||
logger.warning(f"发现{len(invalid_years)}条学年格式不正确的记录:")
|
||||
for idx, row in invalid_years.head(5).iterrows():
|
||||
logger.warning(f" 行{idx+2}: 学号={row['学号']}, 学年={row['学年']}")
|
||||
|
||||
# 验证学期格式
|
||||
valid_semesters = ['第一学期', '第二学期']
|
||||
df.loc[:, 'semester_valid'] = df['学期'].isin(valid_semesters)
|
||||
invalid_semesters = df[~df['semester_valid']]
|
||||
if len(invalid_semesters) > 0:
|
||||
logger.warning(f"发现{len(invalid_semesters)}条学期格式不正确的记录:")
|
||||
for idx, row in invalid_semesters.head(5).iterrows():
|
||||
logger.warning(f" 行{idx+2}: 学号={row['学号']}, 学期={row['学期']}")
|
||||
|
||||
# 验证平时分数格式(非空且包含冒号)
|
||||
df.loc[:, 'monthly_scores_valid'] = df['平时分数'].astype(str).str.contains(':')
|
||||
invalid_scores = df[~df['monthly_scores_valid']]
|
||||
if len(invalid_scores) > 0:
|
||||
logger.warning(f"发现{len(invalid_scores)}条平时分数格式不正确的记录:")
|
||||
for idx, row in invalid_scores.head(5).iterrows():
|
||||
logger.warning(f" 行{idx+2}: 学号={row['学号']}, 平时分数={row['平时分数']}")
|
||||
|
||||
# 验证数值范围
|
||||
df.loc[:, 'final_score_valid'] = True
|
||||
final_scores = pd.to_numeric(df['最终分'], errors='coerce')
|
||||
|
||||
# 检查最终分范围 (decimal(5,2) 最大999.99)
|
||||
invalid_final_scores = final_scores[(final_scores < 0) | (final_scores > 999.99)]
|
||||
if len(invalid_final_scores) > 0:
|
||||
df.loc[:, 'final_score_valid'] = ~final_scores.isin(invalid_final_scores)
|
||||
logger.warning(f"发现{len(invalid_final_scores)}条最终分数值超出范围(0-999.99):")
|
||||
for idx in invalid_final_scores.head(5).index:
|
||||
logger.warning(f" 行{idx+2}: 学号={df.loc[idx, '学号']}, 最终分={final_scores.loc[idx]}")
|
||||
|
||||
# 统计验证结果
|
||||
valid_count = len(df[df['academic_year_valid'] & df['semester_valid'] & df['monthly_scores_valid'] & df['final_score_valid']])
|
||||
logger.info(f"数据验证完成: 总计{len(df)}行,验证通过{valid_count}行")
|
||||
|
||||
# 处理超出范围的数值
|
||||
df.loc[:, '最终分'] = final_scores.clip(0, 999.99) # 将超出范围的值截断到有效范围
|
||||
|
||||
# 清理临时列
|
||||
df = df.drop(columns=['academic_year_valid', 'semester_valid', 'monthly_scores_valid', 'final_score_valid'])
|
||||
|
||||
return df
|
||||
|
||||
def check_existing_data(self, student_nos: List[str]) -> set:
|
||||
"""检查已存在的学号"""
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
# 分批查询已存在的学号
|
||||
existing_nos = set()
|
||||
batch_size = 1000
|
||||
|
||||
for i in range(0, len(student_nos), batch_size):
|
||||
batch = student_nos[i:i+batch_size]
|
||||
placeholders = ','.join(['%s'] * len(batch))
|
||||
|
||||
sql = f"""
|
||||
SELECT DISTINCT student_no
|
||||
FROM {self.temp_table}
|
||||
WHERE student_no IN ({placeholders})
|
||||
"""
|
||||
|
||||
cursor.execute(sql, batch)
|
||||
results = cursor.fetchall()
|
||||
existing_nos.update([row[0] for row in results])
|
||||
|
||||
logger.info(f"检查到{len(existing_nos)}个学号已存在,将跳过导入")
|
||||
return existing_nos
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"检查已存在数据失败: {e}")
|
||||
return set()
|
||||
|
||||
def insert_batch(self, batch_data: List[Dict[str, Any]]) -> tuple[int, int]:
|
||||
"""批量插入数据"""
|
||||
if not batch_data:
|
||||
return 0, 0
|
||||
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
sql = """
|
||||
INSERT INTO {table} (
|
||||
student_no, student_name, gender, ethnicity,
|
||||
academic_year_str, semester_str, monthly_scores,
|
||||
final_score, grade_level, department, major,
|
||||
class_name, grade, import_status
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'pending'
|
||||
)
|
||||
""".format(table=self.temp_table)
|
||||
|
||||
values = []
|
||||
for row in batch_data:
|
||||
# 处理NaN值和数值范围,转换为None或合适的值
|
||||
def safe_value(key, default='', is_numeric=False):
|
||||
val = row.get(key, default)
|
||||
if pd.isna(val):
|
||||
return None
|
||||
if is_numeric and isinstance(val, (int, float)):
|
||||
# 对于数值字段,确保在有效范围内
|
||||
if key == '最终分':
|
||||
return max(0, min(999.99, float(val)))
|
||||
return val
|
||||
|
||||
values.append((
|
||||
safe_value('学号', ''),
|
||||
safe_value('姓名', ''),
|
||||
safe_value('性别', ''),
|
||||
safe_value('民族', ''),
|
||||
safe_value('学年', ''),
|
||||
safe_value('学期', ''),
|
||||
safe_value('平时分数', ''),
|
||||
safe_value('最终分', is_numeric=True), # 数值字段,NaN转换为None
|
||||
safe_value('等级', ''),
|
||||
safe_value('院系', ''),
|
||||
safe_value('专业', ''),
|
||||
safe_value('班级', ''),
|
||||
safe_value('年级', '')
|
||||
))
|
||||
|
||||
cursor.executemany(sql, values)
|
||||
self.connection.commit()
|
||||
|
||||
return len(batch_data), 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"批量插入失败: {e}")
|
||||
self.connection.rollback()
|
||||
return 0, len(batch_data)
|
||||
|
||||
def import_data(self, df: pd.DataFrame) -> Dict[str, Any]:
|
||||
"""导入数据到临时表"""
|
||||
logger.info("开始导入数据到临时表")
|
||||
|
||||
# 获取所有学号
|
||||
student_nos = df['学号'].tolist()
|
||||
|
||||
# 检查已存在的数据
|
||||
existing_nos = self.check_existing_data(student_nos)
|
||||
|
||||
# 过滤掉已存在的数据
|
||||
df_to_import = df[~df['学号'].isin(existing_nos)]
|
||||
skipped_count = len(df) - len(df_to_import)
|
||||
|
||||
logger.info(f"准备导入{len(df_to_import)}行数据,跳过{skipped_count}行已存在数据")
|
||||
|
||||
if len(df_to_import) == 0:
|
||||
logger.info("没有需要导入的新数据")
|
||||
return {
|
||||
'total': len(df),
|
||||
'skipped': skipped_count,
|
||||
'imported': 0,
|
||||
'failed': 0
|
||||
}
|
||||
|
||||
# 批量导入
|
||||
total_imported = 0
|
||||
total_failed = 0
|
||||
|
||||
for i in range(0, len(df_to_import), self.batch_size):
|
||||
batch_df = df_to_import.iloc[i:i+self.batch_size]
|
||||
batch_data = batch_df.to_dict('records')
|
||||
|
||||
imported, failed = self.insert_batch(batch_data)
|
||||
total_imported += imported
|
||||
total_failed += failed
|
||||
|
||||
logger.info(f"处理进度: {min(i+self.batch_size, len(df_to_import))}/{len(df_to_import)} "
|
||||
f"(成功:{total_imported}, 失败:{total_failed})")
|
||||
|
||||
result = {
|
||||
'total': len(df),
|
||||
'skipped': skipped_count,
|
||||
'imported': total_imported,
|
||||
'failed': total_failed
|
||||
}
|
||||
|
||||
logger.info(f"数据导入完成: {result}")
|
||||
return result
|
||||
|
||||
def run(self) -> bool:
|
||||
"""执行导入流程"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# 连接数据库
|
||||
self.connect_db()
|
||||
|
||||
# 读取Excel数据
|
||||
df = self.read_excel_data()
|
||||
|
||||
# 数据验证和清洗
|
||||
df = self.validate_data(df)
|
||||
|
||||
# 导入数据
|
||||
result = self.import_data(df)
|
||||
|
||||
# 输出统计信息
|
||||
logger.info("="*50)
|
||||
logger.info("导入统计结果:")
|
||||
logger.info(f" 总记录数: {result['total']}")
|
||||
logger.info(f" 跳过已存在: {result['skipped']}")
|
||||
logger.info(f" 成功导入: {result['imported']}")
|
||||
logger.info(f" 导入失败: {result['failed']}")
|
||||
logger.info(f"导入总耗时: {elapsed_time:.1f}秒")
|
||||
logger.info("="*50)
|
||||
|
||||
return result['failed'] == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"导入过程出现错误: {e}")
|
||||
return False
|
||||
|
||||
finally:
|
||||
self.disconnect_db()
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.info(f"导入总耗时: {elapsed_time:.1f}秒")
|
||||
def main():
|
||||
"""主函数"""
|
||||
parser = argparse.ArgumentParser(description='学生操行分历史数据 - Excel导入临时表')
|
||||
parser.add_argument('--excel', default=CONSTANTS["EXCEL_FILE"],
|
||||
help=f'Excel文件路径 (默认: {CONSTANTS["EXCEL_FILE"]})')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
logger.info("="*60)
|
||||
logger.info("学生操行分历史数据迁移 - 脚本1:Excel导入临时表")
|
||||
logger.info("="*60)
|
||||
|
||||
# 创建logs目录
|
||||
Path("logs").mkdir(exist_ok=True)
|
||||
|
||||
# 执行导入
|
||||
importer = ExcelImporter(args.excel)
|
||||
success = importer.run()
|
||||
|
||||
if success:
|
||||
logger.info("✅ Excel数据导入完成")
|
||||
sys.exit(0)
|
||||
else:
|
||||
logger.error("❌ Excel数据导入失败")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,440 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
学生操行分历史数据迁移 - 脚本2:数据解析处理
|
||||
|
||||
功能说明:
|
||||
- 解析学年格式:2015-2016 → 2015
|
||||
- 解析学期格式:第一学期→1,第二学期→2
|
||||
- 解析月度分数:一月:90,九月:85 → 多条月度记录
|
||||
- 结果存储到temp_conduct_score_parsed中间表
|
||||
|
||||
执行方式:
|
||||
python 02_parse_score_data.py
|
||||
|
||||
注意事项:
|
||||
- 依赖脚本1已执行完成
|
||||
- 支持断点续传(已解析的数据会跳过)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pymysql
|
||||
from pymysql.cursors import DictCursor
|
||||
|
||||
from db_config import DB_CONFIG, TABLE_CONFIG, CONSTANTS, MONTH_MAPPING, SEMESTER_MAPPING
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler("logs/02_parse_data.log", mode='a', encoding='utf-8')
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger("parse_data")
|
||||
|
||||
class DataParser:
|
||||
"""数据解析处理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.db_config = DB_CONFIG
|
||||
self.temp_import_table = TABLE_CONFIG["temp_import"]
|
||||
self.temp_parsed_table = TABLE_CONFIG["temp_parsed"]
|
||||
self.batch_size = CONSTANTS["BATCH_SIZE"]
|
||||
self.connection = None
|
||||
|
||||
def connect_db(self) -> None:
|
||||
"""连接数据库(带重试机制)"""
|
||||
max_retries = self.db_config.get('max_retries', 3)
|
||||
retry_delay = 5 # 每次重试间隔5秒
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
self.connection = pymysql.connect(**self.db_config)
|
||||
logger.info("数据库连接成功")
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt < max_retries:
|
||||
logger.warning(f"数据库连接失败 (尝试 {attempt + 1}/{max_retries + 1}): {e}")
|
||||
logger.info(f"等待 {retry_delay} 秒后重试...")
|
||||
time.sleep(retry_delay)
|
||||
retry_delay *= 2 # 指数退避
|
||||
else:
|
||||
logger.error(f"数据库连接失败,已达到最大重试次数: {e}")
|
||||
raise
|
||||
|
||||
def disconnect_db(self) -> None:
|
||||
"""断开数据库连接"""
|
||||
if self.connection:
|
||||
self.connection.close()
|
||||
logger.info("数据库连接已断开")
|
||||
|
||||
def check_connection_health(self) -> bool:
|
||||
"""检查数据库连接健康状态"""
|
||||
if not self.connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"连接健康检查失败: {e}")
|
||||
return False
|
||||
|
||||
def ensure_connection(self) -> None:
|
||||
"""确保数据库连接可用,如果断开则重连"""
|
||||
if not self.check_connection_health():
|
||||
logger.info("检测到连接断开,正在重新连接...")
|
||||
self.disconnect_db()
|
||||
self.connect_db()
|
||||
|
||||
def get_pending_data(self) -> List[Dict[str, Any]]:
|
||||
"""获取待解析的数据"""
|
||||
# 确保连接可用
|
||||
self.ensure_connection()
|
||||
|
||||
try:
|
||||
with self.connection.cursor(DictCursor) as cursor:
|
||||
sql = f"""
|
||||
SELECT id, student_no, academic_year_str, semester_str, monthly_scores
|
||||
FROM {self.temp_import_table}
|
||||
WHERE import_status = 'pending'
|
||||
ORDER BY id
|
||||
LIMIT {self.batch_size}
|
||||
"""
|
||||
|
||||
cursor.execute(sql)
|
||||
return cursor.fetchall()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取待解析数据失败: {e}")
|
||||
return []
|
||||
|
||||
def parse_academic_year(self, year_str: str) -> Tuple[Optional[int], Optional[str]]:
|
||||
"""解析学年格式:2015-2016 → 2015"""
|
||||
if not year_str or not isinstance(year_str, str):
|
||||
return None, f"学年为空或格式错误: {year_str}"
|
||||
|
||||
# 匹配XXXX-XXXX格式
|
||||
match = re.match(r'^(\d{4})-(\d{4})$', year_str.strip())
|
||||
if not match:
|
||||
return None, f"学年格式不正确,应为XXXX-XXXX格式: {year_str}"
|
||||
|
||||
start_year = int(match.group(1))
|
||||
end_year = int(match.group(2))
|
||||
|
||||
# 验证年份合理性
|
||||
if end_year != start_year + 1:
|
||||
return None, f"学年跨度不正确: {year_str}"
|
||||
|
||||
if start_year < 2000 or start_year > 2030:
|
||||
return None, f"学年范围不合理: {year_str}"
|
||||
|
||||
return start_year, None
|
||||
|
||||
def parse_semester(self, semester_str: str) -> Tuple[Optional[int], Optional[str]]:
|
||||
"""解析学期格式:第一学期→1,第二学期→2"""
|
||||
if not semester_str or not isinstance(semester_str, str):
|
||||
return None, f"学期为空或格式错误: {semester_str}"
|
||||
|
||||
semester = semester_str.strip()
|
||||
if semester in SEMESTER_MAPPING:
|
||||
return SEMESTER_MAPPING[semester], None
|
||||
else:
|
||||
return None, f"学期格式不正确,应为'第一学期'或'第二学期': {semester}"
|
||||
|
||||
def parse_monthly_scores(self, score_str: str) -> Tuple[List[Tuple[int, float]], Optional[str]]:
|
||||
"""解析月度分数:一月:90,九月:85 → [(1, 90.0), (9, 85.0), ...]"""
|
||||
if not score_str or not isinstance(score_str, str):
|
||||
return [], f"平时分数为空或格式错误: {score_str}"
|
||||
|
||||
score_str = score_str.strip()
|
||||
if not score_str:
|
||||
return [], "平时分数为空"
|
||||
|
||||
parsed_scores = []
|
||||
error_msgs = []
|
||||
|
||||
# 分割多个月份的分数
|
||||
score_parts = score_str.split(',')
|
||||
for part in score_parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
# 匹配"月份:分数"格式
|
||||
match = re.match(r'^(.+?):(.+)$', part)
|
||||
if not match:
|
||||
error_msgs.append(f"分数格式错误: {part}")
|
||||
continue
|
||||
|
||||
month_name = match.group(1).strip()
|
||||
score_value = match.group(2).strip()
|
||||
|
||||
# 验证月份名称
|
||||
if month_name not in MONTH_MAPPING:
|
||||
error_msgs.append(f"月份名称不正确: {month_name}")
|
||||
continue
|
||||
|
||||
month_num = MONTH_MAPPING[month_name]
|
||||
|
||||
# 验证分数
|
||||
try:
|
||||
score = float(score_value)
|
||||
# 取消分数范围限制,允许任何数值分数
|
||||
except ValueError:
|
||||
error_msgs.append(f"分数格式不是数字: {score_value}")
|
||||
continue
|
||||
|
||||
parsed_scores.append((month_num, score))
|
||||
|
||||
if error_msgs:
|
||||
return parsed_scores, "; ".join(error_msgs)
|
||||
elif not parsed_scores:
|
||||
return [], "未解析到任何有效分数"
|
||||
else:
|
||||
return parsed_scores, None
|
||||
|
||||
def insert_parsed_data(self, parsed_records: List[Dict[str, Any]]) -> tuple[int, int]:
|
||||
"""插入解析后的数据"""
|
||||
if not parsed_records:
|
||||
return 0, 0
|
||||
|
||||
# 确保连接可用
|
||||
self.ensure_connection()
|
||||
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
sql = f"""
|
||||
INSERT INTO {self.temp_parsed_table} (
|
||||
temp_id, student_no, academic_year_str, semester_str,
|
||||
academic_year, semester, month, score, processing_status
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'parsed')
|
||||
"""
|
||||
|
||||
values = []
|
||||
for record in parsed_records:
|
||||
values.append((
|
||||
record['temp_id'],
|
||||
record['student_no'],
|
||||
record['academic_year_str'],
|
||||
record['semester_str'],
|
||||
record['academic_year'],
|
||||
record['semester'],
|
||||
record['month'],
|
||||
record['score']
|
||||
))
|
||||
|
||||
cursor.executemany(sql, values)
|
||||
self.connection.commit()
|
||||
|
||||
return len(parsed_records), 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"插入解析数据失败: {e}")
|
||||
self.connection.rollback()
|
||||
return 0, len(parsed_records)
|
||||
|
||||
def update_import_status(self, temp_ids: List[int], status: str, error_msg: str = None) -> None:
|
||||
"""更新导入表的状态"""
|
||||
# 确保连接可用
|
||||
self.ensure_connection()
|
||||
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
if error_msg:
|
||||
sql = f"""
|
||||
UPDATE {self.temp_import_table}
|
||||
SET import_status = %s, error_msg = %s, update_time = NOW()
|
||||
WHERE id IN ({','.join(['%s'] * len(temp_ids))})
|
||||
"""
|
||||
cursor.execute(sql, [status, error_msg] + temp_ids)
|
||||
else:
|
||||
sql = f"""
|
||||
UPDATE {self.temp_import_table}
|
||||
SET import_status = %s, update_time = NOW()
|
||||
WHERE id IN ({','.join(['%s'] * len(temp_ids))})
|
||||
"""
|
||||
cursor.execute(sql, [status] + temp_ids)
|
||||
|
||||
self.connection.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新导入状态失败: {e}")
|
||||
self.connection.rollback()
|
||||
|
||||
def process_batch(self, batch_data: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""处理一批数据"""
|
||||
parsed_records = []
|
||||
success_ids = []
|
||||
error_ids = []
|
||||
|
||||
for row in batch_data:
|
||||
temp_id = row['id']
|
||||
student_no = row['student_no']
|
||||
academic_year_str = row['academic_year_str']
|
||||
semester_str = row['semester_str']
|
||||
monthly_scores = row['monthly_scores']
|
||||
|
||||
# 解析学年
|
||||
academic_year, year_error = self.parse_academic_year(academic_year_str)
|
||||
if year_error:
|
||||
logger.warning(f"学号{student_no}: {year_error}")
|
||||
error_ids.append(temp_id)
|
||||
continue
|
||||
|
||||
# 解析学期
|
||||
semester, semester_error = self.parse_semester(semester_str)
|
||||
if semester_error:
|
||||
logger.warning(f"学号{student_no}: {semester_error}")
|
||||
error_ids.append(temp_id)
|
||||
continue
|
||||
|
||||
# 解析月度分数
|
||||
monthly_data, score_error = self.parse_monthly_scores(monthly_scores)
|
||||
if score_error:
|
||||
logger.warning(f"学号{student_no}: {score_error}")
|
||||
|
||||
# 如果解析出分数,创建记录
|
||||
if monthly_data:
|
||||
for month, score in monthly_data:
|
||||
parsed_records.append({
|
||||
'temp_id': temp_id,
|
||||
'student_no': student_no,
|
||||
'academic_year_str': academic_year_str,
|
||||
'semester_str': semester_str,
|
||||
'academic_year': academic_year,
|
||||
'semester': semester,
|
||||
'month': month,
|
||||
'score': score
|
||||
})
|
||||
success_ids.append(temp_id)
|
||||
else:
|
||||
# 没有解析出有效分数,但学年学期正确,也算成功(只是没有月度数据)
|
||||
if not score_error:
|
||||
success_ids.append(temp_id)
|
||||
else:
|
||||
error_ids.append(temp_id)
|
||||
|
||||
# 插入解析后的数据
|
||||
inserted, failed = self.insert_parsed_data(parsed_records)
|
||||
|
||||
# 更新状态
|
||||
if success_ids:
|
||||
self.update_import_status(success_ids, 'parsed')
|
||||
if error_ids:
|
||||
self.update_import_status(error_ids, 'error', '数据解析失败')
|
||||
|
||||
return {
|
||||
'processed': len(batch_data),
|
||||
'success': len(success_ids),
|
||||
'error': len(error_ids),
|
||||
'parsed_records': inserted,
|
||||
'inserted': inserted,
|
||||
'insert_failed': failed
|
||||
}
|
||||
|
||||
def run(self) -> bool:
|
||||
"""执行解析流程"""
|
||||
start_time = time.time()
|
||||
total_processed = 0
|
||||
total_success = 0
|
||||
total_error = 0
|
||||
total_parsed_records = 0
|
||||
|
||||
try:
|
||||
self.connect_db()
|
||||
|
||||
logger.info("开始数据解析处理")
|
||||
|
||||
batch_count = 0
|
||||
while True:
|
||||
batch_count += 1
|
||||
|
||||
# 每处理10批数据检查一次连接健康状态
|
||||
if batch_count % 10 == 0:
|
||||
logger.info("执行定期连接健康检查...")
|
||||
self.ensure_connection()
|
||||
|
||||
# 获取待处理数据
|
||||
batch_data = self.get_pending_data()
|
||||
|
||||
if not batch_data:
|
||||
logger.info("没有更多待处理数据")
|
||||
break
|
||||
|
||||
logger.info(f"处理批次: {len(batch_data)}条记录")
|
||||
|
||||
# 处理这一批数据
|
||||
result = self.process_batch(batch_data)
|
||||
|
||||
total_processed += result['processed']
|
||||
total_success += result['success']
|
||||
total_error += result['error']
|
||||
total_parsed_records += result['parsed_records']
|
||||
|
||||
logger.info(f"批次结果: 处理{result['processed']}, 成功{result['success']}, "
|
||||
f"失败{result['error']}, 生成{result['parsed_records']}条月度记录")
|
||||
|
||||
# 如果这一批都是错误,说明可能有问题,停止处理
|
||||
if result['success'] == 0 and result['processed'] > 0:
|
||||
logger.warning("当前批次全部处理失败,可能存在数据质量问题,请检查日志")
|
||||
break
|
||||
|
||||
# 输出统计信息
|
||||
logger.info("="*50)
|
||||
logger.info("数据解析统计结果:")
|
||||
logger.info(f" 处理总记录: {total_processed}")
|
||||
logger.info(f" 解析成功: {total_success}")
|
||||
logger.info(f" 解析失败: {total_error}")
|
||||
logger.info(f" 生成月度记录: {total_parsed_records}")
|
||||
logger.info(f"解析总耗时: {elapsed_time:.1f}秒")
|
||||
logger.info("="*50)
|
||||
|
||||
return total_error == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"解析过程出现错误: {e}")
|
||||
return False
|
||||
|
||||
finally:
|
||||
self.disconnect_db()
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.info(f"解析总耗时: {elapsed_time:.1f}秒")
|
||||
def main():
|
||||
"""主函数"""
|
||||
parser = argparse.ArgumentParser(description='学生操行分历史数据 - 数据解析处理')
|
||||
|
||||
logger.info("="*60)
|
||||
logger.info("学生操行分历史数据迁移 - 脚本2:数据解析处理")
|
||||
logger.info("="*60)
|
||||
|
||||
# 创建logs目录
|
||||
import os
|
||||
os.makedirs("logs", exist_ok=True)
|
||||
|
||||
# 执行解析
|
||||
parser = DataParser()
|
||||
success = parser.run()
|
||||
|
||||
if success:
|
||||
logger.info("✅ 数据解析处理完成")
|
||||
sys.exit(0)
|
||||
else:
|
||||
logger.error("❌ 数据解析处理失败")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,494 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
学生操行分历史数据迁移 - 脚本2:数据解析处理(修复版)
|
||||
|
||||
功能说明:
|
||||
- 解析学年格式:2015-2016 → 2015
|
||||
- 解析学期格式:第一学期→1,第二学期→2
|
||||
- 解析月度分数:一月:90,九月:85 → 多条月度记录
|
||||
- 结果存储到temp_conduct_score_parsed中间表
|
||||
|
||||
修复内容:
|
||||
- 添加数据库连接重试机制
|
||||
- 添加连接健康检查和自动重连
|
||||
- 定期检查连接状态,避免超时
|
||||
- 改进错误处理和日志记录
|
||||
- 支持断点续传
|
||||
|
||||
执行方式:
|
||||
python 02_parse_score_data_fixed.py
|
||||
|
||||
注意事项:
|
||||
- 依赖脚本1已执行完成
|
||||
- 支持断点续传(已解析的数据会跳过)
|
||||
- 自动处理数据库连接问题
|
||||
"""
|
||||
|
||||
# from __future__ import annotations # Python 3.7+ only
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pymysql
|
||||
from pymysql.cursors import DictCursor
|
||||
|
||||
from db_config import DB_CONFIG, TABLE_CONFIG, CONSTANTS, MONTH_MAPPING, SEMESTER_MAPPING
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler("logs/02_parse_data_fixed.log", mode='a', encoding='utf-8')
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger("parse_data_fixed")
|
||||
|
||||
class DataParserFixed:
|
||||
"""数据解析处理器(修复版)"""
|
||||
|
||||
def __init__(self):
|
||||
self.db_config = DB_CONFIG
|
||||
self.temp_import_table = TABLE_CONFIG["temp_import"]
|
||||
self.temp_parsed_table = TABLE_CONFIG["temp_parsed"]
|
||||
self.batch_size = CONSTANTS["BATCH_SIZE"]
|
||||
self.max_retries = self.db_config.get('max_retries', 3)
|
||||
self.connection = None
|
||||
self.last_health_check = time.time()
|
||||
self.health_check_interval = 300 # 5分钟检查一次连接健康状态
|
||||
|
||||
def connect_db(self):
|
||||
"""连接数据库(带重试机制)"""
|
||||
retry_delay = 5 # 每次重试间隔5秒
|
||||
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
self.connection = pymysql.connect(**self.db_config)
|
||||
logger.info("数据库连接成功")
|
||||
self.last_health_check = time.time()
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt < self.max_retries:
|
||||
logger.warning(f"数据库连接失败 (尝试 {attempt + 1}/{self.max_retries + 1}): {e}")
|
||||
logger.info(f"等待 {retry_delay} 秒后重试...")
|
||||
time.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, 60) # 指数退避,最多60秒
|
||||
else:
|
||||
logger.error(f"数据库连接失败,已达到最大重试次数: {e}")
|
||||
raise
|
||||
|
||||
def disconnect_db(self):
|
||||
"""断开数据库连接"""
|
||||
if self.connection:
|
||||
try:
|
||||
self.connection.close()
|
||||
logger.info("数据库连接已断开")
|
||||
except Exception as e:
|
||||
logger.warning(f"断开连接时出现警告: {e}")
|
||||
|
||||
def check_connection_health(self):
|
||||
"""检查数据库连接健康状态"""
|
||||
if not self.connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
# 发送一个简单的查询来测试连接
|
||||
with self.connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1 as health_check")
|
||||
result = cursor.fetchone()
|
||||
if result and result[0] == 1:
|
||||
self.last_health_check = time.time()
|
||||
return True
|
||||
else:
|
||||
logger.warning("连接健康检查返回异常结果")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"连接健康检查失败: {e}")
|
||||
return False
|
||||
|
||||
def ensure_connection(self):
|
||||
"""确保数据库连接可用,如果断开则重连"""
|
||||
current_time = time.time()
|
||||
|
||||
# 如果距离上次健康检查超过指定时间,也进行检查
|
||||
if current_time - self.last_health_check > self.health_check_interval:
|
||||
logger.info("执行定时连接健康检查...")
|
||||
|
||||
if not self.check_connection_health():
|
||||
logger.info("检测到连接断开或不健康,正在重新连接...")
|
||||
self.disconnect_db()
|
||||
self.connect_db()
|
||||
|
||||
def get_pending_data(self):
|
||||
"""获取待解析的数据"""
|
||||
# 确保连接可用
|
||||
self.ensure_connection()
|
||||
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
with self.connection.cursor(DictCursor) as cursor:
|
||||
sql = f"""
|
||||
SELECT id, student_no, academic_year_str, semester_str, monthly_scores
|
||||
FROM {self.temp_import_table}
|
||||
WHERE import_status = 'pending'
|
||||
ORDER BY id
|
||||
LIMIT {self.batch_size}
|
||||
"""
|
||||
|
||||
cursor.execute(sql)
|
||||
return cursor.fetchall()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"获取待解析数据失败 (尝试 {attempt + 1}/{max_retries}): {e}")
|
||||
if attempt < max_retries - 1:
|
||||
self.ensure_connection() # 重新连接后重试
|
||||
else:
|
||||
logger.error(f"获取待解析数据失败,已达到最大重试次数: {e}")
|
||||
return []
|
||||
|
||||
def parse_academic_year(self, year_str):
|
||||
"""解析学年格式:2015-2016 → 2015"""
|
||||
if not year_str or not isinstance(year_str, str):
|
||||
return None, f"学年为空或格式错误: {year_str}"
|
||||
|
||||
# 匹配XXXX-XXXX格式
|
||||
match = re.match(r'^(\d{4})-(\d{4})$', year_str.strip())
|
||||
if not match:
|
||||
return None, f"学年格式不正确,应为XXXX-XXXX格式: {year_str}"
|
||||
|
||||
start_year = int(match.group(1))
|
||||
end_year = int(match.group(2))
|
||||
|
||||
# 验证年份合理性
|
||||
if end_year != start_year + 1:
|
||||
return None, f"学年跨度不正确: {year_str}"
|
||||
|
||||
if start_year < 2000 or start_year > 2030:
|
||||
return None, f"学年范围不合理: {year_str}"
|
||||
|
||||
return start_year, None
|
||||
|
||||
def parse_semester(self, semester_str):
|
||||
"""解析学期格式:第一学期→1,第二学期→2"""
|
||||
if not semester_str or not isinstance(semester_str, str):
|
||||
return None, f"学期为空或格式错误: {semester_str}"
|
||||
|
||||
semester = semester_str.strip()
|
||||
if semester in SEMESTER_MAPPING:
|
||||
return SEMESTER_MAPPING[semester], None
|
||||
else:
|
||||
return None, f"学期格式不正确,应为'第一学期'或'第二学期': {semester}"
|
||||
|
||||
def parse_monthly_scores(self, score_str):
|
||||
"""解析月度分数:一月:90,九月:85 → [(1, 90.0), (9, 85.0), ...]"""
|
||||
if not score_str or not isinstance(score_str, str):
|
||||
return [], f"平时分数为空或格式错误: {score_str}"
|
||||
|
||||
score_str = score_str.strip()
|
||||
if not score_str:
|
||||
return [], "平时分数为空"
|
||||
|
||||
parsed_scores = []
|
||||
error_msgs = []
|
||||
|
||||
# 分割多个月份的分数
|
||||
score_parts = score_str.split(',')
|
||||
for part in score_parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
# 匹配"月份:分数"格式
|
||||
match = re.match(r'^(.+?):(.+)$', part)
|
||||
if not match:
|
||||
error_msgs.append(f"分数格式错误: {part}")
|
||||
continue
|
||||
|
||||
month_name = match.group(1).strip()
|
||||
score_value = match.group(2).strip()
|
||||
|
||||
# 验证月份名称
|
||||
if month_name not in MONTH_MAPPING:
|
||||
error_msgs.append(f"月份名称不正确: {month_name}")
|
||||
continue
|
||||
|
||||
month_num = MONTH_MAPPING[month_name]
|
||||
|
||||
# 验证分数(取消范围限制)
|
||||
try:
|
||||
score = float(score_value)
|
||||
parsed_scores.append((month_num, score))
|
||||
except ValueError:
|
||||
error_msgs.append(f"分数格式不是数字: {score_value}")
|
||||
continue
|
||||
|
||||
if error_msgs:
|
||||
return parsed_scores, "; ".join(error_msgs)
|
||||
elif not parsed_scores:
|
||||
return [], "未解析到任何有效分数"
|
||||
else:
|
||||
return parsed_scores, None
|
||||
|
||||
def insert_parsed_data(self, parsed_records):
|
||||
"""插入解析后的数据(带重试机制)"""
|
||||
if not parsed_records:
|
||||
return 0, 0
|
||||
|
||||
# 确保连接可用
|
||||
self.ensure_connection()
|
||||
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
sql = f"""
|
||||
INSERT INTO {self.temp_parsed_table} (
|
||||
temp_id, student_no, academic_year_str, semester_str,
|
||||
academic_year, semester, month, score, processing_status
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'parsed')
|
||||
"""
|
||||
|
||||
values = []
|
||||
for record in parsed_records:
|
||||
values.append((
|
||||
record['temp_id'],
|
||||
record['student_no'],
|
||||
record['academic_year_str'],
|
||||
record['semester_str'],
|
||||
record['academic_year'],
|
||||
record['semester'],
|
||||
record['month'],
|
||||
record['score']
|
||||
))
|
||||
|
||||
cursor.executemany(sql, values)
|
||||
self.connection.commit()
|
||||
|
||||
return len(parsed_records), 0
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"插入解析数据失败 (尝试 {attempt + 1}/{max_retries}): {e}")
|
||||
if attempt < max_retries - 1:
|
||||
self.ensure_connection() # 重新连接后重试
|
||||
time.sleep(2) # 等待2秒再重试
|
||||
else:
|
||||
logger.error(f"插入解析数据失败,已达到最大重试次数: {e}")
|
||||
try:
|
||||
self.connection.rollback()
|
||||
except:
|
||||
pass
|
||||
return 0, len(parsed_records)
|
||||
|
||||
def update_import_status(self, temp_ids, status, error_msg=None):
|
||||
"""更新导入表的状态(带重试机制)"""
|
||||
# 确保连接可用
|
||||
self.ensure_connection()
|
||||
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
if error_msg:
|
||||
sql = f"""
|
||||
UPDATE {self.temp_import_table}
|
||||
SET import_status = %s, error_msg = %s, update_time = NOW()
|
||||
WHERE id IN ({','.join(['%s'] * len(temp_ids))})
|
||||
"""
|
||||
cursor.execute(sql, [status, error_msg] + temp_ids)
|
||||
else:
|
||||
sql = f"""
|
||||
UPDATE {self.temp_import_table}
|
||||
SET import_status = %s, update_time = NOW()
|
||||
WHERE id IN ({','.join(['%s'] * len(temp_ids))})
|
||||
"""
|
||||
cursor.execute(sql, [status] + temp_ids)
|
||||
|
||||
self.connection.commit()
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"更新导入状态失败 (尝试 {attempt + 1}/{max_retries}): {e}")
|
||||
if attempt < max_retries - 1:
|
||||
self.ensure_connection() # 重新连接后重试
|
||||
time.sleep(1)
|
||||
else:
|
||||
logger.error(f"更新导入状态失败,已达到最大重试次数: {e}")
|
||||
try:
|
||||
self.connection.rollback()
|
||||
except:
|
||||
pass
|
||||
|
||||
def process_batch(self, batch_data):
|
||||
"""处理一批数据"""
|
||||
parsed_records = []
|
||||
success_ids = []
|
||||
error_ids = []
|
||||
|
||||
for row in batch_data:
|
||||
temp_id = row['id']
|
||||
student_no = row['student_no']
|
||||
academic_year_str = row['academic_year_str']
|
||||
semester_str = row['semester_str']
|
||||
monthly_scores = row['monthly_scores']
|
||||
|
||||
# 解析学年
|
||||
academic_year, year_error = self.parse_academic_year(academic_year_str)
|
||||
if year_error:
|
||||
logger.warning(f"学号{student_no}: {year_error}")
|
||||
error_ids.append(temp_id)
|
||||
continue
|
||||
|
||||
# 解析学期
|
||||
semester, semester_error = self.parse_semester(semester_str)
|
||||
if semester_error:
|
||||
logger.warning(f"学号{student_no}: {semester_error}")
|
||||
error_ids.append(temp_id)
|
||||
continue
|
||||
|
||||
# 解析月度分数
|
||||
monthly_data, score_error = self.parse_monthly_scores(monthly_scores)
|
||||
if score_error:
|
||||
logger.warning(f"学号{student_no}: {score_error}")
|
||||
|
||||
# 如果解析出分数,创建记录
|
||||
if monthly_data:
|
||||
for month, score in monthly_data:
|
||||
parsed_records.append({
|
||||
'temp_id': temp_id,
|
||||
'student_no': student_no,
|
||||
'academic_year_str': academic_year_str,
|
||||
'semester_str': semester_str,
|
||||
'academic_year': academic_year,
|
||||
'semester': semester,
|
||||
'month': month,
|
||||
'score': score
|
||||
})
|
||||
success_ids.append(temp_id)
|
||||
else:
|
||||
# 没有解析出有效分数,但学年学期正确,也算成功(只是没有月度数据)
|
||||
if not score_error:
|
||||
success_ids.append(temp_id)
|
||||
else:
|
||||
error_ids.append(temp_id)
|
||||
|
||||
# 插入解析后的数据
|
||||
inserted, failed = self.insert_parsed_data(parsed_records)
|
||||
|
||||
# 更新状态
|
||||
if success_ids:
|
||||
self.update_import_status(success_ids, 'parsed')
|
||||
if error_ids:
|
||||
self.update_import_status(error_ids, 'error', '数据解析失败')
|
||||
|
||||
return {
|
||||
'processed': len(batch_data),
|
||||
'success': len(success_ids),
|
||||
'error': len(error_ids),
|
||||
'parsed_records': inserted,
|
||||
'inserted': inserted,
|
||||
'insert_failed': failed
|
||||
}
|
||||
|
||||
def run(self):
|
||||
"""执行解析流程"""
|
||||
start_time = time.time()
|
||||
total_processed = 0
|
||||
total_success = 0
|
||||
total_error = 0
|
||||
total_parsed_records = 0
|
||||
|
||||
try:
|
||||
self.connect_db()
|
||||
|
||||
logger.info("开始数据解析处理(修复版)")
|
||||
logger.info(f"批量大小: {self.batch_size}, 重试次数: {self.max_retries}")
|
||||
|
||||
batch_count = 0
|
||||
while True:
|
||||
batch_count += 1
|
||||
|
||||
# 每处理10批数据检查一次连接健康状态
|
||||
if batch_count % 10 == 0:
|
||||
logger.info(f"已处理 {batch_count} 批数据,执行定期连接健康检查...")
|
||||
self.ensure_connection()
|
||||
|
||||
# 获取待处理数据
|
||||
batch_data = self.get_pending_data()
|
||||
|
||||
if not batch_data:
|
||||
logger.info("没有更多待处理数据")
|
||||
break
|
||||
|
||||
logger.info(f"处理批次 {batch_count}: {len(batch_data)}条记录")
|
||||
|
||||
# 处理这一批数据
|
||||
result = self.process_batch(batch_data)
|
||||
|
||||
total_processed += result['processed']
|
||||
total_success += result['success']
|
||||
total_error += result['error']
|
||||
total_parsed_records += result['parsed_records']
|
||||
|
||||
logger.info(f"批次 {batch_count} 结果: 处理{result['processed']}, 成功{result['success']}, "
|
||||
f"失败{result['error']}, 生成{result['parsed_records']}条月度记录")
|
||||
|
||||
# 如果这一批都是错误,说明可能有问题,停止处理
|
||||
if result['success'] == 0 and result['processed'] > 0:
|
||||
logger.warning("当前批次全部处理失败,可能存在数据质量问题,请检查日志")
|
||||
break
|
||||
|
||||
# 输出统计信息
|
||||
logger.info("="*60)
|
||||
logger.info("数据解析统计结果(修复版):")
|
||||
logger.info(f" 处理总记录: {total_processed}")
|
||||
logger.info(f" 解析成功: {total_success}")
|
||||
logger.info(f" 解析失败: {total_error}")
|
||||
logger.info(f" 生成月度记录: {total_parsed_records}")
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.info(f"解析总耗时: {elapsed_time:.1f}秒")
|
||||
logger.info("="*60)
|
||||
|
||||
return total_error == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"解析过程出现错误: {e}")
|
||||
return False
|
||||
|
||||
finally:
|
||||
self.disconnect_db()
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.info(f"解析总耗时: {elapsed_time:.1f}秒")
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
parser = argparse.ArgumentParser(description='学生操行分历史数据 - 数据解析处理(修复版)')
|
||||
|
||||
logger.info("="*70)
|
||||
logger.info("学生操行分历史数据迁移 - 脚本2:数据解析处理(修复版)")
|
||||
logger.info("="*70)
|
||||
|
||||
# 创建logs目录
|
||||
import os
|
||||
os.makedirs("logs", exist_ok=True)
|
||||
|
||||
# 执行解析
|
||||
parser = DataParserFixed()
|
||||
success = parser.run()
|
||||
|
||||
if success:
|
||||
logger.info("✅ 数据解析处理完成(修复版)")
|
||||
sys.exit(0)
|
||||
else:
|
||||
logger.error("❌ 数据解析处理失败(修复版)")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,558 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
学生操行分历史数据迁移 - 脚本2:数据解析处理(Python 3.4+完全兼容版)
|
||||
|
||||
功能说明:
|
||||
- 解析学年格式:2015-2016 → 2015
|
||||
- 解析学期格式:第一学期→1,第二学期→2
|
||||
- 解析月度分数:一月:90,九月:85 → 多条月度记录
|
||||
- 结果存储到temp_conduct_score_parsed中间表
|
||||
|
||||
兼容性说明:
|
||||
- 完全兼容Python 3.4+
|
||||
- 不使用任何typing模块
|
||||
- 不使用f-string,使用.format()格式
|
||||
- 不使用类型注解
|
||||
- 完全使用Python标准库
|
||||
|
||||
执行方式:
|
||||
python 02_parse_score_data_py34_compatible.py
|
||||
|
||||
注意事项:
|
||||
- 依赖脚本1已执行完成
|
||||
- 支持断点续传(已解析的数据会跳过)
|
||||
- 自动处理数据库连接问题
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pymysql
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('02_parse_score_data.log', encoding='utf-8'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 常量配置
|
||||
CONSTANTS = {
|
||||
"BATCH_SIZE": 1000, # 每批处理1000条记录
|
||||
"MAX_RETRIES": 3, # 最大重试次数
|
||||
"RETRY_DELAY": 5, # 重试间隔(秒)
|
||||
}
|
||||
|
||||
# 数据库表配置
|
||||
TABLE_CONFIG = {
|
||||
"temp_import": "temp_conduct_score_import", # 临时导入表
|
||||
"temp_parsed": "temp_conduct_score_parsed", # 解析结果表
|
||||
}
|
||||
|
||||
|
||||
class ConductScoreParser:
|
||||
"""学生操行分数据解析器(Python 3.4+完全兼容版)"""
|
||||
|
||||
def __init__(self, db_config):
|
||||
self.db_config = db_config
|
||||
self.temp_import_table = TABLE_CONFIG["temp_import"]
|
||||
self.temp_parsed_table = TABLE_CONFIG["temp_parsed"]
|
||||
self.batch_size = CONSTANTS["BATCH_SIZE"]
|
||||
self.max_retries = self.db_config.get('max_retries', 3)
|
||||
self.connection = None
|
||||
self.last_health_check = time.time()
|
||||
self.health_check_interval = 300 # 5分钟检查一次连接健康状态
|
||||
|
||||
def connect_db(self):
|
||||
"""连接数据库(带重试机制)"""
|
||||
retry_delay = 5 # 每次重试间隔5秒
|
||||
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
self.connection = pymysql.connect(**self.db_config)
|
||||
logger.info("数据库连接成功")
|
||||
self.last_health_check = time.time()
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt < self.max_retries:
|
||||
# 使用.format()替代f-string
|
||||
attempt_info = "数据库连接失败 (尝试 {}/{})".format(attempt + 1, self.max_retries + 1)
|
||||
logger.warning("{}: {}".format(attempt_info, e))
|
||||
logger.info("等待 {} 秒后重试...".format(retry_delay))
|
||||
time.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, 60) # 指数退避,最多60秒
|
||||
else:
|
||||
logger.error("数据库连接失败,已达到最大重试次数: {}".format(e))
|
||||
raise
|
||||
|
||||
def disconnect_db(self):
|
||||
"""断开数据库连接"""
|
||||
if self.connection:
|
||||
try:
|
||||
self.connection.close()
|
||||
logger.info("数据库连接已断开")
|
||||
except Exception as e:
|
||||
logger.warning("断开连接时出现警告: {}".format(e))
|
||||
finally:
|
||||
self.connection = None
|
||||
|
||||
def check_connection_health(self):
|
||||
"""检查数据库连接健康状态"""
|
||||
if not self.connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
# 执行简单查询检查连接
|
||||
with self.connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
self.last_health_check = time.time()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("连接健康检查失败: {}".format(e))
|
||||
return False
|
||||
|
||||
def ensure_connection(self):
|
||||
"""确保数据库连接有效,必要时重连"""
|
||||
current_time = time.time()
|
||||
|
||||
# 检查是否需要定期健康检查
|
||||
if current_time - self.last_health_check > self.health_check_interval:
|
||||
if not self.check_connection_health():
|
||||
logger.info("连接健康检查失败,尝试重连...")
|
||||
self.disconnect_db()
|
||||
self.connect_db()
|
||||
elif not self.connection:
|
||||
# 连接不存在,直接连接
|
||||
self.connect_db()
|
||||
|
||||
def get_pending_data(self):
|
||||
"""获取待解析的数据"""
|
||||
max_retries = 3
|
||||
retry_delay = 2
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
self.ensure_connection()
|
||||
with self.connection.cursor() as cursor:
|
||||
# 获取未解析或解析失败的数据
|
||||
sql = """
|
||||
SELECT temp_id, student_no, academic_year, semester, conduct_score
|
||||
FROM {}
|
||||
WHERE import_status IN ('pending', 'error')
|
||||
ORDER BY temp_id
|
||||
LIMIT {}
|
||||
""".format(self.temp_import_table, self.batch_size)
|
||||
|
||||
cursor.execute(sql)
|
||||
columns = [desc[0] for desc in cursor.description]
|
||||
rows = cursor.fetchall()
|
||||
|
||||
# 转换为字典列表
|
||||
data = []
|
||||
for row in rows:
|
||||
data.append(dict(zip(columns, row)))
|
||||
|
||||
logger.info("获取到 {} 条待解析记录".format(len(data)))
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
if attempt < max_retries - 1:
|
||||
attempt_info = "获取待解析数据失败 (尝试 {}/{})".format(attempt + 1, max_retries)
|
||||
logger.warning("{}: {}".format(attempt_info, e))
|
||||
time.sleep(retry_delay)
|
||||
retry_delay *= 2
|
||||
else:
|
||||
logger.error("获取待解析数据失败,已达到最大重试次数: {}".format(e))
|
||||
return []
|
||||
|
||||
return []
|
||||
|
||||
def parse_academic_year(self, year_str):
|
||||
"""解析学年字符串,返回(年份, 错误信息)"""
|
||||
if not year_str or not isinstance(year_str, str):
|
||||
return None, "学年为空或格式错误: {}".format(year_str)
|
||||
|
||||
year_str = year_str.strip()
|
||||
|
||||
# 尝试匹配XXXX-XXXX格式
|
||||
if '-' in year_str:
|
||||
parts = year_str.split('-')
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
start_year = int(parts[0].strip())
|
||||
end_year = int(parts[1].strip())
|
||||
|
||||
# 检查年份跨度是否合理(应该相差1年)
|
||||
if end_year - start_year == 1:
|
||||
# 检查年份范围是否合理
|
||||
if 2000 <= start_year <= 2030:
|
||||
return start_year, None
|
||||
else:
|
||||
return None, "学年范围不合理: {}".format(year_str)
|
||||
else:
|
||||
return None, "学年跨度不正确: {}".format(year_str)
|
||||
except ValueError:
|
||||
return None, "学年格式不正确,应为XXXX-XXXX格式: {}".format(year_str)
|
||||
else:
|
||||
return None, "学年格式不正确,应为XXXX-XXXX格式: {}".format(year_str)
|
||||
else:
|
||||
# 尝试直接转换为整数
|
||||
try:
|
||||
year = int(year_str)
|
||||
if 2000 <= year <= 2030:
|
||||
return year, None
|
||||
else:
|
||||
return None, "学年范围不合理: {}".format(year_str)
|
||||
except ValueError:
|
||||
return None, "学年格式不正确: {}".format(year_str)
|
||||
|
||||
def parse_semester(self, semester_str):
|
||||
"""解析学期字符串,返回(学期编号, 错误信息)"""
|
||||
if not semester_str or not isinstance(semester_str, str):
|
||||
return None, "学期为空或格式错误: {}".format(semester_str)
|
||||
|
||||
semester = semester_str.strip()
|
||||
|
||||
# 映射学期名称到编号
|
||||
semester_mapping = {
|
||||
"第一学期": 1,
|
||||
"第二学期": 2,
|
||||
"上学期": 1,
|
||||
"下学期": 2,
|
||||
"春季学期": 1,
|
||||
"秋季学期": 2
|
||||
}
|
||||
|
||||
if semester in semester_mapping:
|
||||
return semester_mapping[semester], None
|
||||
else:
|
||||
return None, "学期格式不正确,应为'第一学期'或'第二学期': {}".format(semester)
|
||||
|
||||
def parse_monthly_scores(self, score_str):
|
||||
"""解析月度分数字符串,返回(分数列表, 错误信息)"""
|
||||
if not score_str or not isinstance(score_str, str):
|
||||
return [], "平时分数为空或格式错误: {}".format(score_str)
|
||||
|
||||
score_str = score_str.strip()
|
||||
monthly_scores = []
|
||||
error_msgs = []
|
||||
|
||||
# 按逗号分割多个月份的分数
|
||||
parts = [part.strip() for part in score_str.split(',') if part.strip()]
|
||||
|
||||
# 月度映射
|
||||
month_mapping = {
|
||||
"一月": 1, "二月": 2, "三月": 3, "四月": 4, "五月": 5, "六月": 6,
|
||||
"七月": 7, "八月": 8, "九月": 9, "十月": 10, "十一月": 11, "十二月": 12,
|
||||
"1月": 1, "2月": 2, "3月": 3, "4月": 4, "5月": 5, "6月": 6,
|
||||
"7月": 7, "8月": 8, "9月": 9, "10月": 10, "11月": 11, "12月": 12
|
||||
}
|
||||
|
||||
for part in parts:
|
||||
if ':' not in part:
|
||||
error_msgs.append("分数格式错误: {}".format(part))
|
||||
continue
|
||||
|
||||
month_part, score_part = part.split(':', 1)
|
||||
month_name = month_part.strip()
|
||||
score_value = score_part.strip()
|
||||
|
||||
# 解析月份
|
||||
if month_name not in month_mapping:
|
||||
error_msgs.append("月份名称不正确: {}".format(month_name))
|
||||
continue
|
||||
|
||||
month_num = month_mapping[month_name]
|
||||
|
||||
# 解析分数
|
||||
try:
|
||||
score = float(score_value)
|
||||
if 0 <= score <= 100:
|
||||
monthly_scores.append((month_num, score))
|
||||
else:
|
||||
error_msgs.append("分数范围不正确(0-100): {}".format(score))
|
||||
except ValueError:
|
||||
error_msgs.append("分数格式不是数字: {}".format(score_value))
|
||||
|
||||
if error_msgs:
|
||||
return monthly_scores, "; ".join(error_msgs)
|
||||
else:
|
||||
return monthly_scores, None
|
||||
|
||||
def insert_parsed_data(self, parsed_records):
|
||||
"""插入解析后的数据"""
|
||||
if not parsed_records:
|
||||
return 0, 0
|
||||
|
||||
max_retries = 3
|
||||
retry_delay = 2
|
||||
total_inserted = 0
|
||||
total_failed = 0
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
self.ensure_connection()
|
||||
with self.connection.cursor() as cursor:
|
||||
# 批量插入解析结果
|
||||
sql = """
|
||||
INSERT INTO {} (
|
||||
temp_id, student_no, academic_year, semester,
|
||||
score_month, conduct_score, parse_status, error_message
|
||||
) VALUES {}
|
||||
""".format(
|
||||
self.temp_parsed_table,
|
||||
", ".join(["(%s, %s, %s, %s, %s, %s, %s, %s)"] * len(parsed_records))
|
||||
)
|
||||
|
||||
# 准备参数
|
||||
params = []
|
||||
for record in parsed_records:
|
||||
params.extend([
|
||||
record['temp_id'],
|
||||
record['student_no'],
|
||||
record['academic_year'],
|
||||
record['semester'],
|
||||
record['score_month'],
|
||||
record['conduct_score'],
|
||||
record['parse_status'],
|
||||
record['error_message']
|
||||
])
|
||||
|
||||
cursor.execute(sql, params)
|
||||
self.connection.commit()
|
||||
|
||||
total_inserted = len(parsed_records)
|
||||
logger.info("成功插入 {} 条解析记录".format(total_inserted))
|
||||
return total_inserted, 0
|
||||
|
||||
except Exception as e:
|
||||
total_failed = len(parsed_records)
|
||||
if attempt < max_retries - 1:
|
||||
attempt_info = "插入解析数据失败 (尝试 {}/{})".format(attempt + 1, max_retries)
|
||||
logger.warning("{}: {}".format(attempt_info, e))
|
||||
time.sleep(retry_delay)
|
||||
retry_delay *= 2
|
||||
else:
|
||||
logger.error("插入解析数据失败,已达到最大重试次数: {}".format(e))
|
||||
return 0, total_failed
|
||||
|
||||
return total_inserted, total_failed
|
||||
|
||||
def update_import_status(self, temp_ids, status, error_msg=None):
|
||||
"""更新导入状态"""
|
||||
if not temp_ids:
|
||||
return
|
||||
|
||||
max_retries = 3
|
||||
retry_delay = 2
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
self.ensure_connection()
|
||||
with self.connection.cursor() as cursor:
|
||||
# 批量更新状态
|
||||
placeholders = ", ".join(["%s"] * len(temp_ids))
|
||||
|
||||
if error_msg:
|
||||
sql = """
|
||||
UPDATE {} SET import_status = %s, error_message = %s, updated_at = NOW()
|
||||
WHERE temp_id IN ({})
|
||||
""".format(self.temp_import_table, placeholders)
|
||||
cursor.execute(sql, [status, error_msg] + temp_ids)
|
||||
else:
|
||||
sql = """
|
||||
UPDATE {} SET import_status = %s, error_message = NULL, updated_at = NOW()
|
||||
WHERE temp_id IN ({})
|
||||
""".format(self.temp_import_table, placeholders)
|
||||
cursor.execute(sql, [status] + temp_ids)
|
||||
|
||||
self.connection.commit()
|
||||
affected_rows = cursor.rowcount
|
||||
logger.info("更新 {} 条记录状态为: {}".format(affected_rows, status))
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
if attempt < max_retries - 1:
|
||||
attempt_info = "更新导入状态失败 (尝试 {}/{})".format(attempt + 1, max_retries)
|
||||
logger.warning("{}: {}".format(attempt_info, e))
|
||||
time.sleep(retry_delay)
|
||||
retry_delay *= 2
|
||||
else:
|
||||
logger.error("更新导入状态失败,已达到最大重试次数: {}".format(e))
|
||||
|
||||
def process_batch(self, batch_data):
|
||||
"""处理一批数据"""
|
||||
processed = 0
|
||||
success = 0
|
||||
error = 0
|
||||
parsed_records = []
|
||||
|
||||
for record in batch_data:
|
||||
processed += 1
|
||||
temp_id = record['temp_id']
|
||||
student_no = record['student_no']
|
||||
|
||||
try:
|
||||
# 解析学年
|
||||
academic_year, year_error = self.parse_academic_year(record['academic_year'])
|
||||
if year_error:
|
||||
logger.warning("学号{}: {}".format(student_no, year_error))
|
||||
error += 1
|
||||
continue
|
||||
|
||||
# 解析学期
|
||||
semester, semester_error = self.parse_semester(record['semester'])
|
||||
if semester_error:
|
||||
logger.warning("学号{}: {}".format(student_no, semester_error))
|
||||
error += 1
|
||||
continue
|
||||
|
||||
# 解析月度分数
|
||||
monthly_scores, score_error = self.parse_monthly_scores(record['conduct_score'])
|
||||
if score_error:
|
||||
logger.warning("学号{}: {}".format(student_no, score_error))
|
||||
error += 1
|
||||
continue
|
||||
|
||||
# 生成解析记录
|
||||
for month, score in monthly_scores:
|
||||
parsed_records.append({
|
||||
'temp_id': temp_id,
|
||||
'student_no': student_no,
|
||||
'academic_year': academic_year,
|
||||
'semester': semester,
|
||||
'score_month': month,
|
||||
'conduct_score': score,
|
||||
'parse_status': 'success',
|
||||
'error_message': None
|
||||
})
|
||||
|
||||
success += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error("处理记录时出现异常 (学号: {}): {}".format(student_no, e))
|
||||
error += 1
|
||||
|
||||
return {
|
||||
'processed': processed,
|
||||
'success': success,
|
||||
'error': error,
|
||||
'parsed_records': parsed_records
|
||||
}
|
||||
|
||||
def run(self):
|
||||
"""主运行函数"""
|
||||
start_time = time.time()
|
||||
logger.info("开始学生操行分数据解析...")
|
||||
logger.info("批量大小: {}, 重试次数: {}".format(self.batch_size, self.max_retries))
|
||||
|
||||
try:
|
||||
# 连接数据库
|
||||
self.connect_db()
|
||||
|
||||
total_processed = 0
|
||||
total_success = 0
|
||||
total_error = 0
|
||||
total_parsed_records = 0
|
||||
batch_count = 0
|
||||
|
||||
while True:
|
||||
batch_count += 1
|
||||
|
||||
# 获取待处理数据
|
||||
batch_data = self.get_pending_data()
|
||||
if not batch_data:
|
||||
logger.info("没有更多待处理数据,解析完成")
|
||||
break
|
||||
|
||||
# 执行定期连接健康检查
|
||||
if batch_count % 10 == 0: # 每10批检查一次
|
||||
logger.info("已处理 {} 批数据,执行定期连接健康检查...".format(batch_count))
|
||||
self.ensure_connection()
|
||||
|
||||
# 处理当前批次
|
||||
logger.info("处理批次 {}: {}条记录".format(batch_count, len(batch_data)))
|
||||
result = self.process_batch(batch_data)
|
||||
|
||||
# 插入解析结果
|
||||
if result['parsed_records']:
|
||||
inserted, failed = self.insert_parsed_data(result['parsed_records'])
|
||||
if failed > 0:
|
||||
logger.error("批次 {} 插入失败 {} 条记录".format(batch_count, failed))
|
||||
|
||||
# 更新导入状态
|
||||
temp_ids = [record['temp_id'] for record in batch_data]
|
||||
if result['success'] > 0:
|
||||
success_ids = temp_ids[:result['success']]
|
||||
self.update_import_status(success_ids, 'completed')
|
||||
|
||||
if result['error'] > 0:
|
||||
error_ids = temp_ids[result['success']:result['success'] + result['error']]
|
||||
self.update_import_status(error_ids, 'error', '解析失败')
|
||||
|
||||
# 统计信息
|
||||
logger.info("批次 {} 结果: 处理{}, 成功{}, 失败{}, 生成{}条月度记录".format(
|
||||
batch_count, result['processed'], result['success'],
|
||||
result['error'], len(result['parsed_records'])
|
||||
))
|
||||
|
||||
# 累计统计
|
||||
total_processed += result['processed']
|
||||
total_success += result['success']
|
||||
total_error += result['error']
|
||||
total_parsed_records += len(result['parsed_records'])
|
||||
|
||||
# 输出最终统计
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.info("数据解析完成!")
|
||||
logger.info(" 处理总记录: {}".format(total_processed))
|
||||
logger.info(" 解析成功: {}".format(total_success))
|
||||
logger.info(" 解析失败: {}".format(total_error))
|
||||
logger.info(" 生成月度记录: {}".format(total_parsed_records))
|
||||
logger.info("解析总耗时: {:.1f}秒".format(elapsed_time))
|
||||
|
||||
return True
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("用户中断解析过程")
|
||||
return False
|
||||
except Exception as e:
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.error("解析过程出现错误: {}".format(e))
|
||||
return False
|
||||
finally:
|
||||
self.disconnect_db()
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.info("解析总耗时: {:.1f}秒".format(elapsed_time))
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
parser = argparse.ArgumentParser(description='学生操行分数据解析工具')
|
||||
parser.add_argument('--config', help='数据库配置文件路径')
|
||||
parser.add_argument('--batch-size', type=int, default=1000, help='批处理大小')
|
||||
parser.add_argument('--max-retries', type=int, default=3, help='最大重试次数')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 更新配置
|
||||
if args.batch_size:
|
||||
CONSTANTS["BATCH_SIZE"] = args.batch_size
|
||||
|
||||
# 创建解析器并运行
|
||||
parser = ConductScoreParser(DB_CONFIG)
|
||||
success = parser.run()
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,494 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
学生操行分历史数据迁移 - 脚本2:数据解析处理(修复版)
|
||||
|
||||
功能说明:
|
||||
- 解析学年格式:2015-2016 → 2015
|
||||
- 解析学期格式:第一学期→1,第二学期→2
|
||||
- 解析月度分数:一月:90,九月:85 → 多条月度记录
|
||||
- 结果存储到temp_conduct_score_parsed中间表
|
||||
|
||||
修复内容:
|
||||
- 添加数据库连接重试机制
|
||||
- 添加连接健康检查和自动重连
|
||||
- 定期检查连接状态,避免超时
|
||||
- 改进错误处理和日志记录
|
||||
- 支持断点续传
|
||||
|
||||
执行方式:
|
||||
python 02_parse_score_data_fixed.py
|
||||
|
||||
注意事项:
|
||||
- 依赖脚本1已执行完成
|
||||
- 支持断点续传(已解析的数据会跳过)
|
||||
- 自动处理数据库连接问题
|
||||
"""
|
||||
|
||||
# from __future__ import annotations # Python 3.7+ only
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pymysql
|
||||
from pymysql.cursors import DictCursor
|
||||
|
||||
from db_config import DB_CONFIG, TABLE_CONFIG, CONSTANTS, MONTH_MAPPING, SEMESTER_MAPPING
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler("logs/02_parse_data_fixed.log", mode='a', encoding='utf-8')
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger("parse_data_fixed")
|
||||
|
||||
class DataParserFixed:
|
||||
"""数据解析处理器(修复版)"""
|
||||
|
||||
def __init__(self):
|
||||
self.db_config = DB_CONFIG
|
||||
self.temp_import_table = TABLE_CONFIG["temp_import"]
|
||||
self.temp_parsed_table = TABLE_CONFIG["temp_parsed"]
|
||||
self.batch_size = CONSTANTS["BATCH_SIZE"]
|
||||
self.max_retries = self.db_config.get('max_retries', 3)
|
||||
self.connection = None
|
||||
self.last_health_check = time.time()
|
||||
self.health_check_interval = 300 # 5分钟检查一次连接健康状态
|
||||
|
||||
def connect_db(self):
|
||||
"""连接数据库(带重试机制)"""
|
||||
retry_delay = 5 # 每次重试间隔5秒
|
||||
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
self.connection = pymysql.connect(**self.db_config)
|
||||
logger.info("数据库连接成功")
|
||||
self.last_health_check = time.time()
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt < self.max_retries:
|
||||
logger.warning(f"数据库连接失败 (尝试 {attempt + 1}/{self.max_retries + 1}): {e}")
|
||||
logger.info(f"等待 {retry_delay} 秒后重试...")
|
||||
time.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, 60) # 指数退避,最多60秒
|
||||
else:
|
||||
logger.error(f"数据库连接失败,已达到最大重试次数: {e}")
|
||||
raise
|
||||
|
||||
def disconnect_db(self):
|
||||
"""断开数据库连接"""
|
||||
if self.connection:
|
||||
try:
|
||||
self.connection.close()
|
||||
logger.info("数据库连接已断开")
|
||||
except Exception as e:
|
||||
logger.warning(f"断开连接时出现警告: {e}")
|
||||
|
||||
def check_connection_health(self):
|
||||
"""检查数据库连接健康状态"""
|
||||
if not self.connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
# 发送一个简单的查询来测试连接
|
||||
with self.connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1 as health_check")
|
||||
result = cursor.fetchone()
|
||||
if result and result[0] == 1:
|
||||
self.last_health_check = time.time()
|
||||
return True
|
||||
else:
|
||||
logger.warning("连接健康检查返回异常结果")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"连接健康检查失败: {e}")
|
||||
return False
|
||||
|
||||
def ensure_connection(self):
|
||||
"""确保数据库连接可用,如果断开则重连"""
|
||||
current_time = time.time()
|
||||
|
||||
# 如果距离上次健康检查超过指定时间,也进行检查
|
||||
if current_time - self.last_health_check > self.health_check_interval:
|
||||
logger.info("执行定时连接健康检查...")
|
||||
|
||||
if not self.check_connection_health():
|
||||
logger.info("检测到连接断开或不健康,正在重新连接...")
|
||||
self.disconnect_db()
|
||||
self.connect_db()
|
||||
|
||||
def get_pending_data(self):
|
||||
"""获取待解析的数据"""
|
||||
# 确保连接可用
|
||||
self.ensure_connection()
|
||||
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
with self.connection.cursor(DictCursor) as cursor:
|
||||
sql = f"""
|
||||
SELECT id, student_no, academic_year_str, semester_str, monthly_scores
|
||||
FROM {self.temp_import_table}
|
||||
WHERE import_status = 'pending'
|
||||
ORDER BY id
|
||||
LIMIT {self.batch_size}
|
||||
"""
|
||||
|
||||
cursor.execute(sql)
|
||||
return cursor.fetchall()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"获取待解析数据失败 (尝试 {attempt + 1}/{max_retries}): {e}")
|
||||
if attempt < max_retries - 1:
|
||||
self.ensure_connection() # 重新连接后重试
|
||||
else:
|
||||
logger.error(f"获取待解析数据失败,已达到最大重试次数: {e}")
|
||||
return []
|
||||
|
||||
def parse_academic_year(self, year_str):
|
||||
"""解析学年格式:2015-2016 → 2015"""
|
||||
if not year_str or not isinstance(year_str, str):
|
||||
return None, f"学年为空或格式错误: {year_str}"
|
||||
|
||||
# 匹配XXXX-XXXX格式
|
||||
match = re.match(r'^(\d{4})-(\d{4})$', year_str.strip())
|
||||
if not match:
|
||||
return None, f"学年格式不正确,应为XXXX-XXXX格式: {year_str}"
|
||||
|
||||
start_year = int(match.group(1))
|
||||
end_year = int(match.group(2))
|
||||
|
||||
# 验证年份合理性
|
||||
if end_year != start_year + 1:
|
||||
return None, f"学年跨度不正确: {year_str}"
|
||||
|
||||
if start_year < 2000 or start_year > 2030:
|
||||
return None, f"学年范围不合理: {year_str}"
|
||||
|
||||
return start_year, None
|
||||
|
||||
def parse_semester(self, semester_str):
|
||||
"""解析学期格式:第一学期→1,第二学期→2"""
|
||||
if not semester_str or not isinstance(semester_str, str):
|
||||
return None, f"学期为空或格式错误: {semester_str}"
|
||||
|
||||
semester = semester_str.strip()
|
||||
if semester in SEMESTER_MAPPING:
|
||||
return SEMESTER_MAPPING[semester], None
|
||||
else:
|
||||
return None, f"学期格式不正确,应为'第一学期'或'第二学期': {semester}"
|
||||
|
||||
def parse_monthly_scores(self, score_str):
|
||||
"""解析月度分数:一月:90,九月:85 → [(1, 90.0), (9, 85.0), ...]"""
|
||||
if not score_str or not isinstance(score_str, str):
|
||||
return [], f"平时分数为空或格式错误: {score_str}"
|
||||
|
||||
score_str = score_str.strip()
|
||||
if not score_str:
|
||||
return [], "平时分数为空"
|
||||
|
||||
parsed_scores = []
|
||||
error_msgs = []
|
||||
|
||||
# 分割多个月份的分数
|
||||
score_parts = score_str.split(',')
|
||||
for part in score_parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
# 匹配"月份:分数"格式
|
||||
match = re.match(r'^(.+?):(.+)$', part)
|
||||
if not match:
|
||||
error_msgs.append(f"分数格式错误: {part}")
|
||||
continue
|
||||
|
||||
month_name = match.group(1).strip()
|
||||
score_value = match.group(2).strip()
|
||||
|
||||
# 验证月份名称
|
||||
if month_name not in MONTH_MAPPING:
|
||||
error_msgs.append(f"月份名称不正确: {month_name}")
|
||||
continue
|
||||
|
||||
month_num = MONTH_MAPPING[month_name]
|
||||
|
||||
# 验证分数(取消范围限制)
|
||||
try:
|
||||
score = float(score_value)
|
||||
parsed_scores.append((month_num, score))
|
||||
except ValueError:
|
||||
error_msgs.append(f"分数格式不是数字: {score_value}")
|
||||
continue
|
||||
|
||||
if error_msgs:
|
||||
return parsed_scores, "; ".join(error_msgs)
|
||||
elif not parsed_scores:
|
||||
return [], "未解析到任何有效分数"
|
||||
else:
|
||||
return parsed_scores, None
|
||||
|
||||
def insert_parsed_data(self, parsed_records):
|
||||
"""插入解析后的数据(带重试机制)"""
|
||||
if not parsed_records:
|
||||
return 0, 0
|
||||
|
||||
# 确保连接可用
|
||||
self.ensure_connection()
|
||||
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
sql = f"""
|
||||
INSERT INTO {self.temp_parsed_table} (
|
||||
temp_id, student_no, academic_year_str, semester_str,
|
||||
academic_year, semester, month, score, processing_status
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'parsed')
|
||||
"""
|
||||
|
||||
values = []
|
||||
for record in parsed_records:
|
||||
values.append((
|
||||
record['temp_id'],
|
||||
record['student_no'],
|
||||
record['academic_year_str'],
|
||||
record['semester_str'],
|
||||
record['academic_year'],
|
||||
record['semester'],
|
||||
record['month'],
|
||||
record['score']
|
||||
))
|
||||
|
||||
cursor.executemany(sql, values)
|
||||
self.connection.commit()
|
||||
|
||||
return len(parsed_records), 0
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"插入解析数据失败 (尝试 {attempt + 1}/{max_retries}): {e}")
|
||||
if attempt < max_retries - 1:
|
||||
self.ensure_connection() # 重新连接后重试
|
||||
time.sleep(2) # 等待2秒再重试
|
||||
else:
|
||||
logger.error(f"插入解析数据失败,已达到最大重试次数: {e}")
|
||||
try:
|
||||
self.connection.rollback()
|
||||
except:
|
||||
pass
|
||||
return 0, len(parsed_records)
|
||||
|
||||
def update_import_status(self, temp_ids, status, error_msg=None):
|
||||
"""更新导入表的状态(带重试机制)"""
|
||||
# 确保连接可用
|
||||
self.ensure_connection()
|
||||
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
if error_msg:
|
||||
sql = f"""
|
||||
UPDATE {self.temp_import_table}
|
||||
SET import_status = %s, error_msg = %s, update_time = NOW()
|
||||
WHERE id IN ({','.join(['%s'] * len(temp_ids))})
|
||||
"""
|
||||
cursor.execute(sql, [status, error_msg] + temp_ids)
|
||||
else:
|
||||
sql = f"""
|
||||
UPDATE {self.temp_import_table}
|
||||
SET import_status = %s, update_time = NOW()
|
||||
WHERE id IN ({','.join(['%s'] * len(temp_ids))})
|
||||
"""
|
||||
cursor.execute(sql, [status] + temp_ids)
|
||||
|
||||
self.connection.commit()
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"更新导入状态失败 (尝试 {attempt + 1}/{max_retries}): {e}")
|
||||
if attempt < max_retries - 1:
|
||||
self.ensure_connection() # 重新连接后重试
|
||||
time.sleep(1)
|
||||
else:
|
||||
logger.error(f"更新导入状态失败,已达到最大重试次数: {e}")
|
||||
try:
|
||||
self.connection.rollback()
|
||||
except:
|
||||
pass
|
||||
|
||||
def process_batch(self, batch_data):
|
||||
"""处理一批数据"""
|
||||
parsed_records = []
|
||||
success_ids = []
|
||||
error_ids = []
|
||||
|
||||
for row in batch_data:
|
||||
temp_id = row['id']
|
||||
student_no = row['student_no']
|
||||
academic_year_str = row['academic_year_str']
|
||||
semester_str = row['semester_str']
|
||||
monthly_scores = row['monthly_scores']
|
||||
|
||||
# 解析学年
|
||||
academic_year, year_error = self.parse_academic_year(academic_year_str)
|
||||
if year_error:
|
||||
logger.warning(f"学号{student_no}: {year_error}")
|
||||
error_ids.append(temp_id)
|
||||
continue
|
||||
|
||||
# 解析学期
|
||||
semester, semester_error = self.parse_semester(semester_str)
|
||||
if semester_error:
|
||||
logger.warning(f"学号{student_no}: {semester_error}")
|
||||
error_ids.append(temp_id)
|
||||
continue
|
||||
|
||||
# 解析月度分数
|
||||
monthly_data, score_error = self.parse_monthly_scores(monthly_scores)
|
||||
if score_error:
|
||||
logger.warning(f"学号{student_no}: {score_error}")
|
||||
|
||||
# 如果解析出分数,创建记录
|
||||
if monthly_data:
|
||||
for month, score in monthly_data:
|
||||
parsed_records.append({
|
||||
'temp_id': temp_id,
|
||||
'student_no': student_no,
|
||||
'academic_year_str': academic_year_str,
|
||||
'semester_str': semester_str,
|
||||
'academic_year': academic_year,
|
||||
'semester': semester,
|
||||
'month': month,
|
||||
'score': score
|
||||
})
|
||||
success_ids.append(temp_id)
|
||||
else:
|
||||
# 没有解析出有效分数,但学年学期正确,也算成功(只是没有月度数据)
|
||||
if not score_error:
|
||||
success_ids.append(temp_id)
|
||||
else:
|
||||
error_ids.append(temp_id)
|
||||
|
||||
# 插入解析后的数据
|
||||
inserted, failed = self.insert_parsed_data(parsed_records)
|
||||
|
||||
# 更新状态
|
||||
if success_ids:
|
||||
self.update_import_status(success_ids, 'parsed')
|
||||
if error_ids:
|
||||
self.update_import_status(error_ids, 'error', '数据解析失败')
|
||||
|
||||
return {
|
||||
'processed': len(batch_data),
|
||||
'success': len(success_ids),
|
||||
'error': len(error_ids),
|
||||
'parsed_records': inserted,
|
||||
'inserted': inserted,
|
||||
'insert_failed': failed
|
||||
}
|
||||
|
||||
def run(self):
|
||||
"""执行解析流程"""
|
||||
start_time = time.time()
|
||||
total_processed = 0
|
||||
total_success = 0
|
||||
total_error = 0
|
||||
total_parsed_records = 0
|
||||
|
||||
try:
|
||||
self.connect_db()
|
||||
|
||||
logger.info("开始数据解析处理(修复版)")
|
||||
logger.info(f"批量大小: {self.batch_size}, 重试次数: {self.max_retries}")
|
||||
|
||||
batch_count = 0
|
||||
while True:
|
||||
batch_count += 1
|
||||
|
||||
# 每处理10批数据检查一次连接健康状态
|
||||
if batch_count % 10 == 0:
|
||||
logger.info(f"已处理 {batch_count} 批数据,执行定期连接健康检查...")
|
||||
self.ensure_connection()
|
||||
|
||||
# 获取待处理数据
|
||||
batch_data = self.get_pending_data()
|
||||
|
||||
if not batch_data:
|
||||
logger.info("没有更多待处理数据")
|
||||
break
|
||||
|
||||
logger.info(f"处理批次 {batch_count}: {len(batch_data)}条记录")
|
||||
|
||||
# 处理这一批数据
|
||||
result = self.process_batch(batch_data)
|
||||
|
||||
total_processed += result['processed']
|
||||
total_success += result['success']
|
||||
total_error += result['error']
|
||||
total_parsed_records += result['parsed_records']
|
||||
|
||||
logger.info(f"批次 {batch_count} 结果: 处理{result['processed']}, 成功{result['success']}, "
|
||||
f"失败{result['error']}, 生成{result['parsed_records']}条月度记录")
|
||||
|
||||
# 如果这一批都是错误,说明可能有问题,停止处理
|
||||
if result['success'] == 0 and result['processed'] > 0:
|
||||
logger.warning("当前批次全部处理失败,可能存在数据质量问题,请检查日志")
|
||||
break
|
||||
|
||||
# 输出统计信息
|
||||
logger.info("="*60)
|
||||
logger.info("数据解析统计结果(修复版):")
|
||||
logger.info(f" 处理总记录: {total_processed}")
|
||||
logger.info(f" 解析成功: {total_success}")
|
||||
logger.info(f" 解析失败: {total_error}")
|
||||
logger.info(f" 生成月度记录: {total_parsed_records}")
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.info(f"解析总耗时: {elapsed_time:.1f}秒")
|
||||
logger.info("="*60)
|
||||
|
||||
return total_error == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"解析过程出现错误: {e}")
|
||||
return False
|
||||
|
||||
finally:
|
||||
self.disconnect_db()
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.info(f"解析总耗时: {elapsed_time:.1f}秒")
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
parser = argparse.ArgumentParser(description='学生操行分历史数据 - 数据解析处理(修复版)')
|
||||
|
||||
logger.info("="*70)
|
||||
logger.info("学生操行分历史数据迁移 - 脚本2:数据解析处理(修复版)")
|
||||
logger.info("="*70)
|
||||
|
||||
# 创建logs目录
|
||||
import os
|
||||
os.makedirs("logs", exist_ok=True)
|
||||
|
||||
# 执行解析
|
||||
parser = DataParserFixed()
|
||||
success = parser.run()
|
||||
|
||||
if success:
|
||||
logger.info("✅ 数据解析处理完成(修复版)")
|
||||
sys.exit(0)
|
||||
else:
|
||||
logger.error("❌ 数据解析处理失败(修复版)")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,566 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
学生操行分历史数据迁移 - 脚本2:数据解析处理(Python 3.6兼容版)
|
||||
|
||||
功能说明:
|
||||
- 解析学年格式:2015-2016 → 2015
|
||||
- 解析学期格式:第一学期→1,第二学期→2
|
||||
- 解析月度分数:一月:90,九月:85 → 多条月度记录
|
||||
- 结果存储到temp_conduct_score_parsed中间表
|
||||
|
||||
修复内容:
|
||||
- 添加数据库连接重试机制
|
||||
- 添加连接健康检查和自动重连
|
||||
- 定期检查连接状态,避免超时
|
||||
- 改进错误处理和日志记录
|
||||
- 支持断点续传
|
||||
|
||||
兼容性说明:
|
||||
- 完全兼容Python 3.6
|
||||
- 移除类型注解
|
||||
- 使用.format()替代f-string
|
||||
- 简化字符串格式化表达式
|
||||
|
||||
执行方式:
|
||||
python 02_parse_score_data_py36_compatible.py
|
||||
|
||||
注意事项:
|
||||
- 依赖脚本1已执行完成
|
||||
- 支持断点续传(已解析的数据会跳过)
|
||||
- 自动处理数据库连接问题
|
||||
"""
|
||||
|
||||
# 完全兼容Python 3.4+的版本,不使用任何高级类型注解
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pymysql
|
||||
from db_config import DB_CONFIG
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('02_parse_score_data.log', encoding='utf-8'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 常量配置
|
||||
CONSTANTS = {
|
||||
"BATCH_SIZE": 1000, # 每批处理1000条记录
|
||||
"MAX_RETRIES": 3, # 最大重试次数
|
||||
"RETRY_DELAY": 5, # 重试间隔(秒)
|
||||
}
|
||||
|
||||
# 数据库表配置
|
||||
TABLE_CONFIG = {
|
||||
"temp_import": "temp_conduct_score_import", # 临时导入表
|
||||
"temp_parsed": "temp_conduct_score_parsed", # 解析结果表
|
||||
}
|
||||
|
||||
|
||||
class ConductScoreParser:
|
||||
"""学生操行分数据解析器(Python 3.6兼容版)"""
|
||||
|
||||
def __init__(self, db_config):
|
||||
self.db_config = db_config
|
||||
self.temp_import_table = TABLE_CONFIG["temp_import"]
|
||||
self.temp_parsed_table = TABLE_CONFIG["temp_parsed"]
|
||||
self.batch_size = CONSTANTS["BATCH_SIZE"]
|
||||
self.max_retries = self.db_config.get('max_retries', 3)
|
||||
self.connection = None
|
||||
self.last_health_check = time.time()
|
||||
self.health_check_interval = 300 # 5分钟检查一次连接健康状态
|
||||
|
||||
def connect_db(self):
|
||||
"""连接数据库(带重试机制)"""
|
||||
retry_delay = 5 # 每次重试间隔5秒
|
||||
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
self.connection = pymysql.connect(**self.db_config)
|
||||
logger.info("数据库连接成功")
|
||||
self.last_health_check = time.time()
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt < self.max_retries:
|
||||
# 简化f-string表达式,避免Python 3.6兼容性问题
|
||||
attempt_info = "数据库连接失败 (尝试 {}/{})".format(attempt + 1, self.max_retries + 1)
|
||||
logger.warning("{}: {}".format(attempt_info, e))
|
||||
logger.info("等待 {} 秒后重试...".format(retry_delay))
|
||||
time.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, 60) # 指数退避,最多60秒
|
||||
else:
|
||||
logger.error("数据库连接失败,已达到最大重试次数: {}".format(e))
|
||||
raise
|
||||
|
||||
def disconnect_db(self):
|
||||
"""断开数据库连接"""
|
||||
if self.connection:
|
||||
try:
|
||||
self.connection.close()
|
||||
logger.info("数据库连接已断开")
|
||||
except Exception as e:
|
||||
logger.warning("断开连接时出现警告: {}".format(e))
|
||||
finally:
|
||||
self.connection = None
|
||||
|
||||
def check_connection_health(self):
|
||||
"""检查数据库连接健康状态"""
|
||||
if not self.connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
# 执行简单查询检查连接
|
||||
with self.connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
self.last_health_check = time.time()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("连接健康检查失败: {}".format(e))
|
||||
return False
|
||||
|
||||
def ensure_connection(self):
|
||||
"""确保数据库连接有效,必要时重连"""
|
||||
current_time = time.time()
|
||||
|
||||
# 检查是否需要定期健康检查
|
||||
if current_time - self.last_health_check > self.health_check_interval:
|
||||
if not self.check_connection_health():
|
||||
logger.info("连接健康检查失败,尝试重连...")
|
||||
self.disconnect_db()
|
||||
self.connect_db()
|
||||
elif not self.connection:
|
||||
# 连接不存在,直接连接
|
||||
self.connect_db()
|
||||
|
||||
def get_pending_data(self):
|
||||
"""获取待解析的数据"""
|
||||
max_retries = 3
|
||||
retry_delay = 2
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
self.ensure_connection()
|
||||
with self.connection.cursor() as cursor:
|
||||
# 获取未解析或解析失败的数据
|
||||
sql = """
|
||||
SELECT temp_id, student_no, academic_year, semester, conduct_score
|
||||
FROM {}
|
||||
WHERE import_status IN ('pending', 'error')
|
||||
ORDER BY temp_id
|
||||
LIMIT {}
|
||||
""".format(self.temp_import_table, self.batch_size)
|
||||
|
||||
cursor.execute(sql)
|
||||
columns = [desc[0] for desc in cursor.description]
|
||||
rows = cursor.fetchall()
|
||||
|
||||
# 转换为字典列表
|
||||
data = []
|
||||
for row in rows:
|
||||
data.append(dict(zip(columns, row)))
|
||||
|
||||
logger.info("获取到 {} 条待解析记录".format(len(data)))
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
if attempt < max_retries - 1:
|
||||
attempt_info = "获取待解析数据失败 (尝试 {}/{})".format(attempt + 1, max_retries)
|
||||
logger.warning("{}: {}".format(attempt_info, e))
|
||||
time.sleep(retry_delay)
|
||||
retry_delay *= 2
|
||||
else:
|
||||
logger.error("获取待解析数据失败,已达到最大重试次数: {}".format(e))
|
||||
return []
|
||||
|
||||
return []
|
||||
|
||||
def parse_academic_year(self, year_str):
|
||||
"""解析学年字符串,返回(年份, 错误信息)"""
|
||||
if not year_str or not isinstance(year_str, str):
|
||||
return None, "学年为空或格式错误: {}".format(year_str)
|
||||
|
||||
year_str = year_str.strip()
|
||||
|
||||
# 尝试匹配XXXX-XXXX格式
|
||||
if '-' in year_str:
|
||||
parts = year_str.split('-')
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
start_year = int(parts[0].strip())
|
||||
end_year = int(parts[1].strip())
|
||||
|
||||
# 检查年份跨度是否合理(应该相差1年)
|
||||
if end_year - start_year == 1:
|
||||
# 检查年份范围是否合理
|
||||
if 2000 <= start_year <= 2030:
|
||||
return start_year, None
|
||||
else:
|
||||
return None, "学年范围不合理: {}".format(year_str)
|
||||
else:
|
||||
return None, "学年跨度不正确: {}".format(year_str)
|
||||
except ValueError:
|
||||
return None, "学年格式不正确,应为XXXX-XXXX格式: {}".format(year_str)
|
||||
else:
|
||||
return None, "学年格式不正确,应为XXXX-XXXX格式: {}".format(year_str)
|
||||
else:
|
||||
# 尝试直接转换为整数
|
||||
try:
|
||||
year = int(year_str)
|
||||
if 2000 <= year <= 2030:
|
||||
return year, None
|
||||
else:
|
||||
return None, "学年范围不合理: {}".format(year_str)
|
||||
except ValueError:
|
||||
return None, "学年格式不正确: {}".format(year_str)
|
||||
|
||||
def parse_semester(self, semester_str):
|
||||
"""解析学期字符串,返回(学期编号, 错误信息)"""
|
||||
if not semester_str or not isinstance(semester_str, str):
|
||||
return None, "学期为空或格式错误: {}".format(semester_str)
|
||||
|
||||
semester = semester_str.strip()
|
||||
|
||||
# 映射学期名称到编号
|
||||
semester_mapping = {
|
||||
"第一学期": 1,
|
||||
"第二学期": 2,
|
||||
"上学期": 1,
|
||||
"下学期": 2,
|
||||
"春季学期": 1,
|
||||
"秋季学期": 2
|
||||
}
|
||||
|
||||
if semester in semester_mapping:
|
||||
return semester_mapping[semester], None
|
||||
else:
|
||||
return None, "学期格式不正确,应为'第一学期'或'第二学期': {}".format(semester)
|
||||
|
||||
def parse_monthly_scores(self, score_str):
|
||||
"""解析月度分数字符串,返回(分数列表, 错误信息)"""
|
||||
if not score_str or not isinstance(score_str, str):
|
||||
return [], "平时分数为空或格式错误: {}".format(score_str)
|
||||
|
||||
score_str = score_str.strip()
|
||||
monthly_scores = []
|
||||
error_msgs = []
|
||||
|
||||
# 按逗号分割多个月份的分数
|
||||
parts = [part.strip() for part in score_str.split(',') if part.strip()]
|
||||
|
||||
# 月度映射
|
||||
month_mapping = {
|
||||
"一月": 1, "二月": 2, "三月": 3, "四月": 4, "五月": 5, "六月": 6,
|
||||
"七月": 7, "八月": 8, "九月": 9, "十月": 10, "十一月": 11, "十二月": 12,
|
||||
"1月": 1, "2月": 2, "3月": 3, "4月": 4, "5月": 5, "6月": 6,
|
||||
"7月": 7, "8月": 8, "9月": 9, "10月": 10, "11月": 11, "12月": 12
|
||||
}
|
||||
|
||||
for part in parts:
|
||||
if ':' not in part:
|
||||
error_msgs.append("分数格式错误: {}".format(part))
|
||||
continue
|
||||
|
||||
month_part, score_part = part.split(':', 1)
|
||||
month_name = month_part.strip()
|
||||
score_value = score_part.strip()
|
||||
|
||||
# 解析月份
|
||||
if month_name not in month_mapping:
|
||||
error_msgs.append("月份名称不正确: {}".format(month_name))
|
||||
continue
|
||||
|
||||
month_num = month_mapping[month_name]
|
||||
|
||||
# 解析分数
|
||||
try:
|
||||
score = float(score_value)
|
||||
if 0 <= score <= 100:
|
||||
monthly_scores.append((month_num, score))
|
||||
else:
|
||||
error_msgs.append("分数范围不正确(0-100): {}".format(score))
|
||||
except ValueError:
|
||||
error_msgs.append("分数格式不是数字: {}".format(score_value))
|
||||
|
||||
if error_msgs:
|
||||
return monthly_scores, "; ".join(error_msgs)
|
||||
else:
|
||||
return monthly_scores, None
|
||||
|
||||
def insert_parsed_data(self, parsed_records):
|
||||
"""插入解析后的数据"""
|
||||
if not parsed_records:
|
||||
return 0, 0
|
||||
|
||||
max_retries = 3
|
||||
retry_delay = 2
|
||||
total_inserted = 0
|
||||
total_failed = 0
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
self.ensure_connection()
|
||||
with self.connection.cursor() as cursor:
|
||||
# 批量插入解析结果
|
||||
sql = """
|
||||
INSERT INTO {} (
|
||||
temp_id, student_no, academic_year, semester,
|
||||
score_month, conduct_score, parse_status, error_message
|
||||
) VALUES {}
|
||||
""".format(
|
||||
self.temp_parsed_table,
|
||||
", ".join(["(%s, %s, %s, %s, %s, %s, %s, %s)"] * len(parsed_records))
|
||||
)
|
||||
|
||||
# 准备参数
|
||||
params = []
|
||||
for record in parsed_records:
|
||||
params.extend([
|
||||
record['temp_id'],
|
||||
record['student_no'],
|
||||
record['academic_year'],
|
||||
record['semester'],
|
||||
record['score_month'],
|
||||
record['conduct_score'],
|
||||
record['parse_status'],
|
||||
record['error_message']
|
||||
])
|
||||
|
||||
cursor.execute(sql, params)
|
||||
self.connection.commit()
|
||||
|
||||
total_inserted = len(parsed_records)
|
||||
logger.info("成功插入 {} 条解析记录".format(total_inserted))
|
||||
return total_inserted, 0
|
||||
|
||||
except Exception as e:
|
||||
total_failed = len(parsed_records)
|
||||
if attempt < max_retries - 1:
|
||||
attempt_info = "插入解析数据失败 (尝试 {}/{})".format(attempt + 1, max_retries)
|
||||
logger.warning("{}: {}".format(attempt_info, e))
|
||||
time.sleep(retry_delay)
|
||||
retry_delay *= 2
|
||||
else:
|
||||
logger.error("插入解析数据失败,已达到最大重试次数: {}".format(e))
|
||||
return 0, total_failed
|
||||
|
||||
return total_inserted, total_failed
|
||||
|
||||
def update_import_status(self, temp_ids, status, error_msg=None):
|
||||
"""更新导入状态"""
|
||||
if not temp_ids:
|
||||
return
|
||||
|
||||
max_retries = 3
|
||||
retry_delay = 2
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
self.ensure_connection()
|
||||
with self.connection.cursor() as cursor:
|
||||
# 批量更新状态
|
||||
placeholders = ", ".join(["%s"] * len(temp_ids))
|
||||
|
||||
if error_msg:
|
||||
sql = """
|
||||
UPDATE {} SET import_status = %s, error_message = %s, updated_at = NOW()
|
||||
WHERE temp_id IN ({})
|
||||
""".format(self.temp_import_table, placeholders)
|
||||
cursor.execute(sql, [status, error_msg] + temp_ids)
|
||||
else:
|
||||
sql = """
|
||||
UPDATE {} SET import_status = %s, error_message = NULL, updated_at = NOW()
|
||||
WHERE temp_id IN ({})
|
||||
""".format(self.temp_import_table, placeholders)
|
||||
cursor.execute(sql, [status] + temp_ids)
|
||||
|
||||
self.connection.commit()
|
||||
affected_rows = cursor.rowcount
|
||||
logger.info("更新 {} 条记录状态为: {}".format(affected_rows, status))
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
if attempt < max_retries - 1:
|
||||
attempt_info = "更新导入状态失败 (尝试 {}/{})".format(attempt + 1, max_retries)
|
||||
logger.warning("{}: {}".format(attempt_info, e))
|
||||
time.sleep(retry_delay)
|
||||
retry_delay *= 2
|
||||
else:
|
||||
logger.error("更新导入状态失败,已达到最大重试次数: {}".format(e))
|
||||
|
||||
def process_batch(self, batch_data):
|
||||
"""处理一批数据"""
|
||||
processed = 0
|
||||
success = 0
|
||||
error = 0
|
||||
parsed_records = []
|
||||
|
||||
for record in batch_data:
|
||||
processed += 1
|
||||
temp_id = record['temp_id']
|
||||
student_no = record['student_no']
|
||||
|
||||
try:
|
||||
# 解析学年
|
||||
academic_year, year_error = self.parse_academic_year(record['academic_year'])
|
||||
if year_error:
|
||||
logger.warning("学号{}: {}".format(student_no, year_error))
|
||||
error += 1
|
||||
continue
|
||||
|
||||
# 解析学期
|
||||
semester, semester_error = self.parse_semester(record['semester'])
|
||||
if semester_error:
|
||||
logger.warning("学号{}: {}".format(student_no, semester_error))
|
||||
error += 1
|
||||
continue
|
||||
|
||||
# 解析月度分数
|
||||
monthly_scores, score_error = self.parse_monthly_scores(record['conduct_score'])
|
||||
if score_error:
|
||||
logger.warning("学号{}: {}".format(student_no, score_error))
|
||||
error += 1
|
||||
continue
|
||||
|
||||
# 生成解析记录
|
||||
for month, score in monthly_scores:
|
||||
parsed_records.append({
|
||||
'temp_id': temp_id,
|
||||
'student_no': student_no,
|
||||
'academic_year': academic_year,
|
||||
'semester': semester,
|
||||
'score_month': month,
|
||||
'conduct_score': score,
|
||||
'parse_status': 'success',
|
||||
'error_message': None
|
||||
})
|
||||
|
||||
success += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error("处理记录时出现异常 (学号: {}): {}".format(student_no, e))
|
||||
error += 1
|
||||
|
||||
return {
|
||||
'processed': processed,
|
||||
'success': success,
|
||||
'error': error,
|
||||
'parsed_records': parsed_records
|
||||
}
|
||||
|
||||
def run(self):
|
||||
"""主运行函数"""
|
||||
start_time = time.time()
|
||||
logger.info("开始学生操行分数据解析...")
|
||||
logger.info("批量大小: {}, 重试次数: {}".format(self.batch_size, self.max_retries))
|
||||
|
||||
try:
|
||||
# 连接数据库
|
||||
self.connect_db()
|
||||
|
||||
total_processed = 0
|
||||
total_success = 0
|
||||
total_error = 0
|
||||
total_parsed_records = 0
|
||||
batch_count = 0
|
||||
|
||||
while True:
|
||||
batch_count += 1
|
||||
|
||||
# 获取待处理数据
|
||||
batch_data = self.get_pending_data()
|
||||
if not batch_data:
|
||||
logger.info("没有更多待处理数据,解析完成")
|
||||
break
|
||||
|
||||
# 执行定期连接健康检查
|
||||
if batch_count % 10 == 0: # 每10批检查一次
|
||||
logger.info("已处理 {} 批数据,执行定期连接健康检查...".format(batch_count))
|
||||
self.ensure_connection()
|
||||
|
||||
# 处理当前批次
|
||||
logger.info("处理批次 {}: {}条记录".format(batch_count, len(batch_data)))
|
||||
result = self.process_batch(batch_data)
|
||||
|
||||
# 插入解析结果
|
||||
if result['parsed_records']:
|
||||
inserted, failed = self.insert_parsed_data(result['parsed_records'])
|
||||
if failed > 0:
|
||||
logger.error("批次 {} 插入失败 {} 条记录".format(batch_count, failed))
|
||||
|
||||
# 更新导入状态
|
||||
temp_ids = [record['temp_id'] for record in batch_data]
|
||||
if result['success'] > 0:
|
||||
success_ids = temp_ids[:result['success']]
|
||||
self.update_import_status(success_ids, 'completed')
|
||||
|
||||
if result['error'] > 0:
|
||||
error_ids = temp_ids[result['success']:result['success'] + result['error']]
|
||||
self.update_import_status(error_ids, 'error', '解析失败')
|
||||
|
||||
# 统计信息
|
||||
logger.info("批次 {} 结果: 处理{}, 成功{}, 失败{}, 生成{}条月度记录".format(
|
||||
batch_count, result['processed'], result['success'],
|
||||
result['error'], len(result['parsed_records'])
|
||||
))
|
||||
|
||||
# 累计统计
|
||||
total_processed += result['processed']
|
||||
total_success += result['success']
|
||||
total_error += result['error']
|
||||
total_parsed_records += len(result['parsed_records'])
|
||||
|
||||
# 输出最终统计
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.info("数据解析完成!")
|
||||
logger.info(" 处理总记录: {}".format(total_processed))
|
||||
logger.info(" 解析成功: {}".format(total_success))
|
||||
logger.info(" 解析失败: {}".format(total_error))
|
||||
logger.info(" 生成月度记录: {}".format(total_parsed_records))
|
||||
logger.info("解析总耗时: {:.1f}秒".format(elapsed_time))
|
||||
|
||||
return True
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("用户中断解析过程")
|
||||
return False
|
||||
except Exception as e:
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.error("解析过程出现错误: {}".format(e))
|
||||
return False
|
||||
finally:
|
||||
self.disconnect_db()
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.info("解析总耗时: {:.1f}秒".format(elapsed_time))
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
parser = argparse.ArgumentParser(description='学生操行分数据解析工具')
|
||||
parser.add_argument('--config', help='数据库配置文件路径')
|
||||
parser.add_argument('--batch-size', type=int, default=1000, help='批处理大小')
|
||||
parser.add_argument('--max-retries', type=int, default=3, help='最大重试次数')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 更新配置
|
||||
if args.batch_size:
|
||||
CONSTANTS["BATCH_SIZE"] = args.batch_size
|
||||
|
||||
# 创建解析器并运行
|
||||
parser = ConductScoreParser(DB_CONFIG)
|
||||
success = parser.run()
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
学生操行分历史数据迁移 - 脚本3:数据关联处理
|
||||
|
||||
功能说明:
|
||||
- 根据学号关联xg_student_msg表,获取学生ID
|
||||
- 根据班级ID获取班主任信息
|
||||
- 更新temp_conduct_score_parsed表中的关联字段
|
||||
|
||||
执行方式:
|
||||
python 03_link_student_data.py
|
||||
|
||||
注意事项:
|
||||
- 依赖脚本2已执行完成
|
||||
- 学号不存在的学生记录会标记为错误状态
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pymysql
|
||||
from pymysql.cursors import DictCursor
|
||||
|
||||
from db_config import DB_CONFIG, TABLE_CONFIG, CONSTANTS
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler("logs/03_link_data.log", mode='a', encoding='utf-8')
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger("link_data")
|
||||
|
||||
class DataLinker:
|
||||
"""数据关联处理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.db_config = DB_CONFIG
|
||||
self.temp_parsed_table = TABLE_CONFIG["temp_parsed"]
|
||||
self.students_table = TABLE_CONFIG["students"]
|
||||
self.classes_table = TABLE_CONFIG["classes"]
|
||||
self.batch_size = CONSTANTS["BATCH_SIZE"]
|
||||
self.connection = None
|
||||
|
||||
def connect_db(self) -> None:
|
||||
"""连接数据库"""
|
||||
try:
|
||||
self.connection = pymysql.connect(**self.db_config)
|
||||
logger.info("数据库连接成功")
|
||||
except Exception as e:
|
||||
logger.error(f"数据库连接失败: {e}")
|
||||
raise
|
||||
|
||||
def disconnect_db(self) -> None:
|
||||
"""断开数据库连接"""
|
||||
if self.connection:
|
||||
self.connection.close()
|
||||
logger.info("数据库连接已断开")
|
||||
|
||||
def get_pending_data(self) -> List[Dict[str, Any]]:
|
||||
"""获取待关联的数据"""
|
||||
try:
|
||||
with self.connection.cursor(DictCursor) as cursor:
|
||||
sql = f"""
|
||||
SELECT id, temp_id, student_no, academic_year, semester, month, score
|
||||
FROM {self.temp_parsed_table}
|
||||
WHERE processing_status = 'parsed'
|
||||
ORDER BY id
|
||||
LIMIT {self.batch_size}
|
||||
"""
|
||||
|
||||
cursor.execute(sql)
|
||||
return cursor.fetchall()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取待关联数据失败: {e}")
|
||||
return []
|
||||
|
||||
def link_student_info(self, student_nos: List[str]) -> Dict[str, Dict[str, Any]]:
|
||||
"""批量关联学生信息"""
|
||||
if not student_nos:
|
||||
return {}
|
||||
|
||||
try:
|
||||
with self.connection.cursor(DictCursor) as cursor:
|
||||
# 构建IN查询
|
||||
placeholders = ','.join(['%s'] * len(student_nos))
|
||||
sql = f"""
|
||||
SELECT
|
||||
s.id as student_id,
|
||||
s.student_no,
|
||||
s.class_id,
|
||||
c.class_teacher_id
|
||||
FROM {self.students_table} s
|
||||
LEFT JOIN {self.classes_table} c ON s.class_id = c.id
|
||||
WHERE s.student_no IN ({placeholders})
|
||||
AND s.deleted = 0
|
||||
AND (c.id IS NULL OR c.deleted = 0)
|
||||
"""
|
||||
|
||||
cursor.execute(sql, student_nos)
|
||||
results = cursor.fetchall()
|
||||
|
||||
# 转换为字典格式
|
||||
student_info = {}
|
||||
for row in results:
|
||||
student_info[row['student_no']] = {
|
||||
'student_id': row['student_id'],
|
||||
'class_id': row['class_id'],
|
||||
'class_teacher_id': row['class_teacher_id']
|
||||
}
|
||||
|
||||
return student_info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"关联学生信息失败: {e}")
|
||||
return {}
|
||||
|
||||
def update_parsed_data(self, update_data: List[Dict[str, Any]]) -> tuple[int, int]:
|
||||
"""更新解析表中的关联信息"""
|
||||
if not update_data:
|
||||
return 0, 0
|
||||
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
# 分别处理成功和失败的数据
|
||||
success_updates = []
|
||||
error_updates = []
|
||||
|
||||
for item in update_data:
|
||||
if item.get('student_id'):
|
||||
success_updates.append(item)
|
||||
else:
|
||||
error_updates.append(item)
|
||||
|
||||
# 更新成功的记录
|
||||
success_count = 0
|
||||
if success_updates:
|
||||
sql = f"""
|
||||
UPDATE {self.temp_parsed_table}
|
||||
SET student_id = %s, class_id = %s, class_teacher_id = %s,
|
||||
processing_status = 'linked', update_time = NOW()
|
||||
WHERE id = %s
|
||||
"""
|
||||
|
||||
values = [(item['student_id'], item['class_id'], item['class_teacher_id'], item['id'])
|
||||
for item in success_updates]
|
||||
|
||||
cursor.executemany(sql, values)
|
||||
success_count = len(success_updates)
|
||||
|
||||
# 更新失败的记录
|
||||
error_count = 0
|
||||
if error_updates:
|
||||
sql = f"""
|
||||
UPDATE {self.temp_parsed_table}
|
||||
SET processing_status = 'error', error_msg = %s, update_time = NOW()
|
||||
WHERE id = %s
|
||||
"""
|
||||
|
||||
values = [("学号不存在或学生已被删除", item['id']) for item in error_updates]
|
||||
cursor.executemany(sql, values)
|
||||
error_count = len(error_updates)
|
||||
|
||||
self.connection.commit()
|
||||
|
||||
return success_count, error_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新关联数据失败: {e}")
|
||||
self.connection.rollback()
|
||||
return 0, len(update_data)
|
||||
|
||||
def process_batch(self, batch_data: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""处理一批数据"""
|
||||
if not batch_data:
|
||||
return {'processed': 0, 'linked': 0, 'not_found': 0}
|
||||
|
||||
# 提取学号列表
|
||||
student_nos = list(set(row['student_no'] for row in batch_data))
|
||||
|
||||
# 关联学生信息
|
||||
student_info = self.link_student_info(student_nos)
|
||||
|
||||
# 构建更新数据
|
||||
update_data = []
|
||||
not_found_nos = set()
|
||||
|
||||
for row in batch_data:
|
||||
student_no = row['student_no']
|
||||
info = student_info.get(student_no)
|
||||
|
||||
if info:
|
||||
update_data.append({
|
||||
'id': row['id'],
|
||||
'student_id': info['student_id'],
|
||||
'class_id': info['class_id'],
|
||||
'class_teacher_id': info['class_teacher_id']
|
||||
})
|
||||
else:
|
||||
update_data.append({
|
||||
'id': row['id'],
|
||||
'student_id': None,
|
||||
'class_id': None,
|
||||
'class_teacher_id': None
|
||||
})
|
||||
not_found_nos.add(student_no)
|
||||
|
||||
# 输出未找到的学号
|
||||
if not_found_nos:
|
||||
logger.warning(f"未找到以下学号的学生信息 ({len(not_found_nos)}个):")
|
||||
for student_no in sorted(list(not_found_nos)[:10]): # 只显示前10个
|
||||
logger.warning(f" {student_no}")
|
||||
if len(not_found_nos) > 10:
|
||||
logger.warning(f" ... 还有{len(not_found_nos) - 10}个")
|
||||
|
||||
# 更新数据库
|
||||
linked, not_linked = self.update_parsed_data(update_data)
|
||||
|
||||
return {
|
||||
'processed': len(batch_data),
|
||||
'linked': linked,
|
||||
'not_found': not_linked,
|
||||
'unique_students': len(student_nos),
|
||||
'found_students': len(student_info)
|
||||
}
|
||||
|
||||
def get_statistics(self) -> Dict[str, Any]:
|
||||
"""获取处理统计"""
|
||||
try:
|
||||
with self.connection.cursor(DictCursor) as cursor:
|
||||
sql = f"""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN processing_status = 'parsed' THEN 1 ELSE 0 END) as pending,
|
||||
SUM(CASE WHEN processing_status = 'linked' THEN 1 ELSE 0 END) as linked,
|
||||
SUM(CASE WHEN processing_status = 'error' THEN 1 ELSE 0 END) as error
|
||||
FROM {self.temp_parsed_table}
|
||||
"""
|
||||
|
||||
cursor.execute(sql)
|
||||
result = cursor.fetchone()
|
||||
|
||||
return {
|
||||
'total_records': result['total'] or 0,
|
||||
'pending': result['pending'] or 0,
|
||||
'linked': result['linked'] or 0,
|
||||
'error': result['error'] or 0
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取统计信息失败: {e}")
|
||||
return {'total_records': 0, 'pending': 0, 'linked': 0, 'error': 0}
|
||||
|
||||
def link_student_data_efficient(self) -> Tuple[int, int]:
|
||||
"""高效的关联学生数据 - 使用单次UPDATE JOIN操作"""
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
# 使用UPDATE JOIN一次性关联所有数据
|
||||
sql = f"""
|
||||
UPDATE {self.temp_parsed_table} t
|
||||
LEFT JOIN {self.students_table} s ON t.student_no = s.student_no
|
||||
AND s.deleted = 0
|
||||
LEFT JOIN {self.classes_table} c ON s.class_id = c.id
|
||||
AND c.deleted = 0
|
||||
SET
|
||||
t.student_id = s.id,
|
||||
t.class_id = s.class_id,
|
||||
t.class_teacher_id = c.class_teacher_id,
|
||||
t.processing_status = CASE
|
||||
WHEN s.id IS NOT NULL THEN 'linked'
|
||||
ELSE 'error'
|
||||
END,
|
||||
t.error_msg = CASE
|
||||
WHEN s.id IS NULL THEN '学号不存在或学生已被删除'
|
||||
ELSE NULL
|
||||
END,
|
||||
t.update_time = NOW()
|
||||
WHERE t.processing_status = 'parsed'
|
||||
"""
|
||||
|
||||
affected_rows = cursor.execute(sql)
|
||||
self.connection.commit()
|
||||
|
||||
# 统计成功和失败的数量
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
|
||||
if affected_rows > 0:
|
||||
# 获取更新后的统计
|
||||
with self.connection.cursor() as count_cursor:
|
||||
count_sql = f"""
|
||||
SELECT
|
||||
SUM(CASE WHEN processing_status = 'linked' THEN 1 ELSE 0 END) as linked,
|
||||
SUM(CASE WHEN processing_status = 'error' THEN 1 ELSE 0 END) as error
|
||||
FROM {self.temp_parsed_table}
|
||||
WHERE processing_status IN ('linked', 'error')
|
||||
"""
|
||||
count_cursor.execute(count_sql)
|
||||
result = count_cursor.fetchone()
|
||||
success_count = result[0] or 0
|
||||
error_count = result[1] or 0
|
||||
|
||||
logger.info(f"UPDATE JOIN操作完成,影响行数: {affected_rows}")
|
||||
return success_count, error_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"高效关联操作失败: {e}")
|
||||
self.connection.rollback()
|
||||
return 0, 0
|
||||
|
||||
def run(self) -> bool:
|
||||
"""执行关联流程"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
self.connect_db()
|
||||
|
||||
# 显示初始统计
|
||||
initial_stats = self.get_statistics()
|
||||
logger.info("数据关联处理开始")
|
||||
logger.info(f"当前状态: 总计{initial_stats['total_records']}条记录,"
|
||||
f"待关联{initial_stats['pending']}条,已关联{initial_stats['linked']}条,"
|
||||
f"错误{initial_stats['error']}条")
|
||||
|
||||
# 使用高效的UPDATE JOIN方式处理所有数据
|
||||
logger.info("开始执行高效关联操作...")
|
||||
linked, error = self.link_student_data_efficient()
|
||||
|
||||
# 显示最终统计
|
||||
final_stats = self.get_statistics()
|
||||
logger.info("="*50)
|
||||
logger.info("数据关联统计结果:")
|
||||
logger.info(f" 关联成功: {linked}")
|
||||
logger.info(f" 学号未找到: {error}")
|
||||
logger.info(f" 最终状态:")
|
||||
logger.info(f" - 已关联: {final_stats['linked']}")
|
||||
logger.info(f" - 关联失败: {final_stats['error']}")
|
||||
logger.info("="*50)
|
||||
|
||||
# 如果有未找到的学号,输出详细信息
|
||||
if error > 0:
|
||||
logger.warning(f"共有{error}条记录的学号未找到对应学生信息")
|
||||
logger.warning("这些记录将无法导入到正式表,请检查学生数据")
|
||||
|
||||
return error == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"关联过程出现错误: {e}")
|
||||
return False
|
||||
|
||||
finally:
|
||||
self.disconnect_db()
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.info(f"关联总耗时: {elapsed_time:.1f}秒")
|
||||
def main():
|
||||
"""主函数"""
|
||||
parser = argparse.ArgumentParser(description='学生操行分历史数据 - 数据关联处理')
|
||||
|
||||
logger.info("="*60)
|
||||
logger.info("学生操行分历史数据迁移 - 脚本3:数据关联处理")
|
||||
logger.info("="*60)
|
||||
|
||||
# 创建logs目录
|
||||
import os
|
||||
os.makedirs("logs", exist_ok=True)
|
||||
|
||||
# 执行关联
|
||||
linker = DataLinker()
|
||||
success = linker.run()
|
||||
|
||||
if success:
|
||||
logger.info("✅ 数据关联处理完成")
|
||||
sys.exit(0)
|
||||
else:
|
||||
logger.warning("⚠️ 数据关联处理完成,但存在未关联的记录")
|
||||
sys.exit(0) # 不算错误,只是警告
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,371 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
学生操行分历史数据迁移 - 脚本4:正式导入处理(简化版本)
|
||||
|
||||
功能说明:
|
||||
- 使用高效的INSERT INTO SELECT语句直接导入数据
|
||||
- 自动生成雪花ID作为主键
|
||||
- 支持重新处理模式:重新导入所有已关联的数据
|
||||
- 简洁高效,避免复杂的重复检查和批量处理逻辑
|
||||
|
||||
执行方式:
|
||||
python 04_import_to_production.py # 正常模式:仅处理待导入数据
|
||||
python 04_import_to_production.py --reprocess # 重新处理模式:重新处理所有已关联数据
|
||||
|
||||
注意事项:
|
||||
- 依赖脚本3已执行完成
|
||||
- 使用数据库的INSERT IGNORE处理重复数据
|
||||
- 性能大幅提升,代码大幅简化
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pymysql
|
||||
|
||||
from db_config import DB_CONFIG, TABLE_CONFIG, CONSTANTS
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler("logs/04_import_production.log", mode='a', encoding='utf-8')
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger("import_production")
|
||||
|
||||
class ProductionImporter:
|
||||
"""正式表导入处理器(简化版本)"""
|
||||
|
||||
def __init__(self):
|
||||
self.db_config = DB_CONFIG
|
||||
self.temp_parsed_table = TABLE_CONFIG["temp_parsed"]
|
||||
self.production_table = TABLE_CONFIG["production"]
|
||||
self.tenant_id = CONSTANTS["DEFAULT_TENANT_ID"]
|
||||
self.create_by = CONSTANTS["DEFAULT_CREATE_BY"]
|
||||
self.update_by = CONSTANTS["DEFAULT_UPDATE_BY"]
|
||||
self.connection = None
|
||||
self.sequence_counter = 0 # 雪花ID序列号计数器
|
||||
self.last_timestamp = -1 # 上次时间戳
|
||||
|
||||
def connect_db(self) -> None:
|
||||
"""连接数据库"""
|
||||
try:
|
||||
self.connection = pymysql.connect(**self.db_config)
|
||||
logger.info("数据库连接成功")
|
||||
except Exception as e:
|
||||
logger.error(f"数据库连接失败: {e}")
|
||||
raise
|
||||
|
||||
def disconnect_db(self) -> None:
|
||||
"""断开数据库连接"""
|
||||
if self.connection:
|
||||
self.connection.close()
|
||||
logger.info("数据库连接已断开")
|
||||
|
||||
def generate_snowflake_id(self) -> int:
|
||||
"""生成雪花ID"""
|
||||
epoch = CONSTANTS["SNOWFLAKE_EPOCH"]
|
||||
worker_id = CONSTANTS["WORKER_ID"]
|
||||
datacenter_id = CONSTANTS["DATACENTER_ID"]
|
||||
|
||||
timestamp = int(time.time() * 1000)
|
||||
|
||||
if timestamp == self.last_timestamp:
|
||||
self.sequence_counter = (self.sequence_counter + 1) % 4096
|
||||
else:
|
||||
self.sequence_counter = 0
|
||||
self.last_timestamp = timestamp
|
||||
|
||||
timestamp_part = (timestamp - epoch) << 22
|
||||
datacenter_part = datacenter_id << 17
|
||||
worker_part = worker_id << 12
|
||||
sequence_part = self.sequence_counter
|
||||
|
||||
return timestamp_part | datacenter_part | worker_part | sequence_part
|
||||
|
||||
def get_pending_ids(self, reprocess: bool = False) -> List[int]:
|
||||
"""获取待处理的记录ID"""
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
if reprocess:
|
||||
# 重新处理模式:获取所有已关联的数据
|
||||
sql = f"""
|
||||
SELECT id FROM {self.temp_parsed_table}
|
||||
WHERE processing_status IN ('linked', 'imported', 'duplicate')
|
||||
AND student_id IS NOT NULL
|
||||
"""
|
||||
else:
|
||||
# 正常模式:只获取待导入的数据
|
||||
sql = f"""
|
||||
SELECT id FROM {self.temp_parsed_table}
|
||||
WHERE processing_status = 'linked'
|
||||
AND student_id IS NOT NULL
|
||||
"""
|
||||
|
||||
cursor.execute(sql)
|
||||
return [row[0] for row in cursor.fetchall()]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取待处理记录ID失败: {e}")
|
||||
return []
|
||||
|
||||
def import_data_directly(self, record_ids: List[int]) -> Dict[str, int]:
|
||||
"""使用INSERT INTO SELECT直接导入数据"""
|
||||
if not record_ids:
|
||||
return {'imported': 0, 'duplicates': 0, 'errors': 0}
|
||||
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
# 先预生成一批雪花ID
|
||||
snowflake_ids = []
|
||||
for _ in range(len(record_ids)):
|
||||
snowflake_ids.append(self.generate_snowflake_id())
|
||||
|
||||
current_time = datetime.now()
|
||||
|
||||
# 创建临时表存储ID映射
|
||||
temp_id_table = "temp_snowflake_ids"
|
||||
cursor.execute(f"DROP TEMPORARY TABLE IF EXISTS {temp_id_table}")
|
||||
|
||||
cursor.execute(f"""
|
||||
CREATE TEMPORARY TABLE {temp_id_table} (
|
||||
record_id BIGINT PRIMARY KEY,
|
||||
snowflake_id BIGINT UNIQUE
|
||||
)
|
||||
""")
|
||||
|
||||
# 批量插入ID映射
|
||||
id_mapping = list(zip(record_ids, snowflake_ids))
|
||||
cursor.executemany(f"""
|
||||
INSERT INTO {temp_id_table} (record_id, snowflake_id) VALUES (%s, %s)
|
||||
""", id_mapping)
|
||||
|
||||
# 使用INSERT IGNORE直接导入数据
|
||||
insert_sql = f"""
|
||||
INSERT IGNORE INTO {self.production_table} (
|
||||
id, tenant_id, create_dept, create_by, create_time,
|
||||
update_by, update_time, deleted,
|
||||
academic_year, semester, month,
|
||||
student_id, class_id, class_teacher_id,
|
||||
score, remark
|
||||
)
|
||||
SELECT
|
||||
t.snowflake_id,
|
||||
'{self.tenant_id}',
|
||||
NULL,
|
||||
{self.create_by},
|
||||
'{current_time.strftime('%Y-%m-%d %H:%M:%S')}',
|
||||
{self.update_by},
|
||||
'{current_time.strftime('%Y-%m-%d %H:%M:%S')}',
|
||||
0,
|
||||
p.academic_year, p.semester, p.month,
|
||||
p.student_id, p.class_id, p.class_teacher_id,
|
||||
p.score, COALESCE(p.remark, '')
|
||||
FROM {self.temp_parsed_table} p
|
||||
INNER JOIN {temp_id_table} t ON p.id = t.record_id
|
||||
WHERE p.class_teacher_id IS NOT NULL
|
||||
"""
|
||||
|
||||
cursor.execute(insert_sql)
|
||||
imported_count = cursor.rowcount
|
||||
|
||||
# 计算重复和错误数量
|
||||
total_processed = len(record_ids)
|
||||
duplicates = total_processed - imported_count
|
||||
errors = 0 # INSERT IGNORE不会产生错误,只有重复
|
||||
|
||||
self.connection.commit()
|
||||
|
||||
# 清理临时表
|
||||
cursor.execute(f"DROP TEMPORARY TABLE {temp_id_table}")
|
||||
|
||||
return {
|
||||
'imported': imported_count,
|
||||
'duplicates': duplicates,
|
||||
'errors': errors
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"直接导入数据失败: {e}")
|
||||
self.connection.rollback()
|
||||
return {'imported': 0, 'duplicates': 0, 'errors': len(record_ids)}
|
||||
|
||||
def update_status_after_import(self, record_ids: List[int], imported_count: int, duplicate_count: int) -> None:
|
||||
"""根据导入结果更新状态"""
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
# 更新已导入的记录状态
|
||||
if imported_count > 0:
|
||||
imported_ids = record_ids[:imported_count]
|
||||
placeholders = ','.join(['%s'] * len(imported_ids))
|
||||
cursor.execute(f"""
|
||||
UPDATE {self.temp_parsed_table}
|
||||
SET processing_status = 'imported', update_time = NOW()
|
||||
WHERE id IN ({placeholders})
|
||||
""", imported_ids)
|
||||
|
||||
# 更新重复的记录状态
|
||||
if duplicate_count > 0:
|
||||
duplicate_ids = record_ids[imported_count:imported_count + duplicate_count]
|
||||
placeholders = ','.join(['%s'] * len(duplicate_ids))
|
||||
cursor.execute(f"""
|
||||
UPDATE {self.temp_parsed_table}
|
||||
SET processing_status = 'duplicate', error_msg = '数据已存在', update_time = NOW()
|
||||
WHERE id IN ({placeholders})
|
||||
""", duplicate_ids)
|
||||
|
||||
self.connection.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新状态失败: {e}")
|
||||
self.connection.rollback()
|
||||
|
||||
def get_statistics(self) -> Dict[str, Any]:
|
||||
"""获取处理统计"""
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
# 中间表统计
|
||||
sql_parsed = f"""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN processing_status = 'linked' THEN 1 ELSE 0 END) as pending,
|
||||
SUM(CASE WHEN processing_status = 'imported' THEN 1 ELSE 0 END) as imported,
|
||||
SUM(CASE WHEN processing_status = 'duplicate' THEN 1 ELSE 0 END) as duplicate,
|
||||
SUM(CASE WHEN processing_status = 'import_error' THEN 1 ELSE 0 END) as error
|
||||
FROM {self.temp_parsed_table}
|
||||
"""
|
||||
|
||||
cursor.execute(sql_parsed)
|
||||
parsed_stats = cursor.fetchone()
|
||||
|
||||
# 正式表统计
|
||||
sql_production = f"""
|
||||
SELECT COUNT(*) as total_production
|
||||
FROM {self.production_table}
|
||||
WHERE deleted = 0
|
||||
"""
|
||||
|
||||
cursor.execute(sql_production)
|
||||
production_stats = cursor.fetchone()
|
||||
|
||||
return {
|
||||
'parsed_total': parsed_stats[0] or 0,
|
||||
'parsed_pending': parsed_stats[1] or 0,
|
||||
'parsed_imported': parsed_stats[2] or 0,
|
||||
'parsed_duplicate': parsed_stats[3] or 0,
|
||||
'parsed_error': parsed_stats[4] or 0,
|
||||
'production_total': production_stats[0] or 0
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取统计信息失败: {e}")
|
||||
return {}
|
||||
|
||||
def run(self, reprocess: bool = False) -> bool:
|
||||
"""执行导入流程(简化版本)"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
self.connect_db()
|
||||
|
||||
# 显示初始统计
|
||||
initial_stats = self.get_statistics()
|
||||
if reprocess:
|
||||
logger.info("重新处理模式:将重新处理所有已关联的数据")
|
||||
else:
|
||||
logger.info("正常处理模式:仅处理待导入的数据")
|
||||
logger.info("正式表导入处理开始")
|
||||
logger.info(f"当前状态: 中间表总计{initial_stats.get('parsed_total', 0)}条记录,")
|
||||
logger.info(f" 待导入: {initial_stats.get('parsed_pending', 0)}")
|
||||
logger.info(f" 已导入: {initial_stats.get('parsed_imported', 0)}")
|
||||
logger.info(f" 重复数据: {initial_stats.get('parsed_duplicate', 0)}")
|
||||
logger.info(f" 导入错误: {initial_stats.get('parsed_error', 0)}")
|
||||
logger.info(f"正式表当前总记录数: {initial_stats.get('production_total', 0)}")
|
||||
|
||||
# 获取所有待处理的记录ID
|
||||
record_ids = self.get_pending_ids(reprocess)
|
||||
if not record_ids:
|
||||
logger.info("没有待处理的记录")
|
||||
return True
|
||||
|
||||
logger.info(f"开始直接导入 {len(record_ids)} 条记录...")
|
||||
|
||||
# 直接导入所有数据
|
||||
result = self.import_data_directly(record_ids)
|
||||
|
||||
# 更新状态
|
||||
self.update_status_after_import(record_ids, result['imported'], result['duplicates'])
|
||||
|
||||
# 显示最终统计
|
||||
final_stats = self.get_statistics()
|
||||
logger.info("="*50)
|
||||
logger.info("正式表导入统计结果:")
|
||||
logger.info(f" 处理总记录: {len(record_ids)}")
|
||||
logger.info(f" 成功导入: {result['imported']}")
|
||||
logger.info(f" 重复跳过: {result['duplicates']}")
|
||||
logger.info(f" 导入失败: {result['errors']}")
|
||||
logger.info(f" 最终状态:")
|
||||
logger.info(f" - 中间表已导入: {final_stats.get('parsed_imported', 0)}")
|
||||
logger.info(f" - 中间表重复: {final_stats.get('parsed_duplicate', 0)}")
|
||||
logger.info(f" - 中间表错误: {final_stats.get('parsed_error', 0)}")
|
||||
logger.info(f" - 正式表总记录: {final_stats.get('production_total', 0)}")
|
||||
logger.info(f"导入总耗时: {elapsed_time:.1f}秒")
|
||||
logger.info("="*50)
|
||||
|
||||
success = result['errors'] == 0
|
||||
if not success:
|
||||
logger.warning(f"有{result['errors']}条记录导入失败,请检查日志")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"导入过程出现错误: {e}")
|
||||
return False
|
||||
|
||||
finally:
|
||||
self.disconnect_db()
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.info(f"导入总耗时: {elapsed_time:.1f}秒")
|
||||
def main():
|
||||
"""主函数"""
|
||||
parser = argparse.ArgumentParser(description='学生操行分历史数据 - 正式导入处理')
|
||||
parser.add_argument('--reprocess', action='store_true',
|
||||
help='重新处理已导入的数据(忽略processing_status状态)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
logger.info("="*60)
|
||||
logger.info("学生操行分历史数据迁移 - 脚本4:正式导入处理")
|
||||
if args.reprocess:
|
||||
logger.info("模式: 重新处理所有已关联的数据")
|
||||
else:
|
||||
logger.info("模式: 仅处理待导入的数据")
|
||||
logger.info("="*60)
|
||||
|
||||
# 创建logs目录
|
||||
import os
|
||||
os.makedirs("logs", exist_ok=True)
|
||||
|
||||
# 执行导入
|
||||
importer = ProductionImporter()
|
||||
success = importer.run(reprocess=args.reprocess)
|
||||
|
||||
if success:
|
||||
logger.info("✅ 正式表导入处理完成")
|
||||
sys.exit(0)
|
||||
else:
|
||||
logger.error("❌ 正式表导入处理失败")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,425 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
学生操行分历史数据迁移 - 脚本5:验证与清理
|
||||
|
||||
功能说明:
|
||||
- 验证迁移结果的完整性和正确性
|
||||
- 生成详细的迁移报告
|
||||
- 可选择清理临时表和日志
|
||||
|
||||
执行方式:
|
||||
python 05_validate_and_cleanup.py [--cleanup]
|
||||
|
||||
注意事项:
|
||||
- 默认只做验证,不清理数据
|
||||
- 使用--cleanup参数才会清理临时表
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pymysql
|
||||
from pymysql.cursors import DictCursor
|
||||
|
||||
from db_config import DB_CONFIG, TABLE_CONFIG, CONSTANTS
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler("logs/05_validate_cleanup.log", mode='a', encoding='utf-8')
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger("validate_cleanup")
|
||||
|
||||
class MigrationValidator:
|
||||
"""迁移验证和清理处理器"""
|
||||
|
||||
def __init__(self, cleanup: bool = False):
|
||||
self.db_config = DB_CONFIG
|
||||
self.temp_import_table = TABLE_CONFIG["temp_import"]
|
||||
self.temp_parsed_table = TABLE_CONFIG["temp_parsed"]
|
||||
self.production_table = TABLE_CONFIG["production"]
|
||||
self.students_table = TABLE_CONFIG["students"]
|
||||
self.classes_table = TABLE_CONFIG["classes"]
|
||||
self.cleanup = cleanup
|
||||
self.connection = None
|
||||
self.report_data = {}
|
||||
|
||||
def connect_db(self) -> None:
|
||||
"""连接数据库"""
|
||||
try:
|
||||
self.connection = pymysql.connect(**self.db_config)
|
||||
logger.info("数据库连接成功")
|
||||
except Exception as e:
|
||||
logger.error(f"数据库连接失败: {e}")
|
||||
raise
|
||||
|
||||
def disconnect_db(self) -> None:
|
||||
"""断开数据库连接"""
|
||||
if self.connection:
|
||||
self.connection.close()
|
||||
logger.info("数据库连接已断开")
|
||||
|
||||
def get_migration_statistics(self) -> Dict[str, Any]:
|
||||
"""获取迁移统计数据"""
|
||||
try:
|
||||
with self.connection.cursor(DictCursor) as cursor:
|
||||
stats = {}
|
||||
|
||||
# Excel导入表统计
|
||||
sql_import = f"""
|
||||
SELECT
|
||||
COUNT(*) as total_import,
|
||||
SUM(CASE WHEN import_status = 'pending' THEN 1 ELSE 0 END) as pending_import,
|
||||
SUM(CASE WHEN import_status = 'parsed' THEN 1 ELSE 0 END) as parsed_import,
|
||||
SUM(CASE WHEN import_status = 'error' THEN 1 ELSE 0 END) as error_import
|
||||
FROM {self.temp_import_table}
|
||||
"""
|
||||
cursor.execute(sql_import)
|
||||
stats.update(cursor.fetchone())
|
||||
|
||||
# 解析表统计
|
||||
sql_parsed = f"""
|
||||
SELECT
|
||||
COUNT(*) as total_parsed,
|
||||
SUM(CASE WHEN processing_status = 'parsed' THEN 1 ELSE 0 END) as pending_parsed,
|
||||
SUM(CASE WHEN processing_status = 'linked' THEN 1 ELSE 0 END) as linked_parsed,
|
||||
SUM(CASE WHEN processing_status = 'imported' THEN 1 ELSE 0 END) as imported_parsed,
|
||||
SUM(CASE WHEN processing_status = 'duplicate' THEN 1 ELSE 0 END) as duplicate_parsed,
|
||||
SUM(CASE WHEN processing_status = 'error' THEN 1 ELSE 0 END) as error_parsed
|
||||
FROM {self.temp_parsed_table}
|
||||
"""
|
||||
cursor.execute(sql_parsed)
|
||||
stats.update(cursor.fetchone())
|
||||
|
||||
# 正式表统计
|
||||
sql_production = f"""
|
||||
SELECT COUNT(*) as total_production
|
||||
FROM {self.production_table}
|
||||
WHERE deleted = 0
|
||||
"""
|
||||
cursor.execute(sql_production)
|
||||
production_result = cursor.fetchone()
|
||||
stats['total_production'] = production_result['total_production']
|
||||
|
||||
# 数据质量检查
|
||||
sql_quality = f"""
|
||||
SELECT
|
||||
COUNT(*) as invalid_scores,
|
||||
SUM(CASE WHEN score < 0 OR score > 100 THEN 1 ELSE 0 END) as out_of_range_scores
|
||||
FROM {self.production_table}
|
||||
WHERE deleted = 0
|
||||
"""
|
||||
cursor.execute(sql_quality)
|
||||
quality_result = cursor.fetchone()
|
||||
stats.update(quality_result)
|
||||
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取统计数据失败: {e}")
|
||||
return {}
|
||||
|
||||
def validate_data_integrity(self) -> Dict[str, Any]:
|
||||
"""验证数据完整性"""
|
||||
issues = []
|
||||
|
||||
try:
|
||||
with self.connection.cursor(DictCursor) as cursor:
|
||||
# 检查孤立记录(学生不存在)
|
||||
sql_orphan_students = f"""
|
||||
SELECT COUNT(*) as count
|
||||
FROM {self.production_table} cs
|
||||
LEFT JOIN {self.students_table} s ON cs.student_id = s.id
|
||||
WHERE cs.deleted = 0 AND (s.id IS NULL OR s.deleted = 1)
|
||||
"""
|
||||
cursor.execute(sql_orphan_students)
|
||||
orphan_students = cursor.fetchone()['count']
|
||||
if orphan_students > 0:
|
||||
issues.append(f"发现{orphan_students}条学生ID不存在的记录")
|
||||
|
||||
# 检查孤立记录(班级不存在)
|
||||
sql_orphan_classes = f"""
|
||||
SELECT COUNT(*) as count
|
||||
FROM {self.production_table} cs
|
||||
LEFT JOIN {self.classes_table} c ON cs.class_id = c.id
|
||||
WHERE cs.deleted = 0 AND cs.class_id IS NOT NULL AND (c.id IS NULL OR c.deleted = 1)
|
||||
"""
|
||||
cursor.execute(sql_orphan_classes)
|
||||
orphan_classes = cursor.fetchone()['count']
|
||||
if orphan_classes > 0:
|
||||
issues.append(f"发现{orphan_classes}条班级ID不存在的记录")
|
||||
|
||||
# 检查重复记录
|
||||
sql_duplicates = f"""
|
||||
SELECT COUNT(*) as count
|
||||
FROM (
|
||||
SELECT student_id, academic_year, semester, month, COUNT(*) as cnt
|
||||
FROM {self.production_table}
|
||||
WHERE deleted = 0
|
||||
GROUP BY student_id, academic_year, semester, month
|
||||
HAVING cnt > 1
|
||||
) t
|
||||
"""
|
||||
cursor.execute(sql_duplicates)
|
||||
duplicates = cursor.fetchone()['count']
|
||||
if duplicates > 0:
|
||||
issues.append(f"发现{duplicates}组重复记录(同一学生同月有多条记录)")
|
||||
|
||||
# 检查异常分数
|
||||
sql_invalid_scores = f"""
|
||||
SELECT COUNT(*) as count
|
||||
FROM {self.production_table}
|
||||
WHERE deleted = 0 AND (score < 0 OR score > 100 OR score IS NULL)
|
||||
"""
|
||||
cursor.execute(sql_invalid_scores)
|
||||
invalid_scores = cursor.fetchone()['count']
|
||||
if invalid_scores > 0:
|
||||
issues.append(f"发现{invalid_scores}条分数不在0-100范围内的记录")
|
||||
|
||||
return {
|
||||
'issues': issues,
|
||||
'orphan_students': orphan_students,
|
||||
'orphan_classes': orphan_classes,
|
||||
'duplicates': duplicates,
|
||||
'invalid_scores': invalid_scores
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"数据完整性验证失败: {e}")
|
||||
return {'issues': [f"验证过程出错: {str(e)}"], 'error': True}
|
||||
|
||||
def generate_detailed_report(self) -> str:
|
||||
"""生成详细报告"""
|
||||
stats = self.get_migration_statistics()
|
||||
integrity = self.validate_data_integrity()
|
||||
|
||||
report_lines = []
|
||||
report_lines.append("="*80)
|
||||
report_lines.append("学生操行分历史数据迁移报告")
|
||||
report_lines.append("="*80)
|
||||
report_lines.append(f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
report_lines.append("")
|
||||
|
||||
# 总体统计
|
||||
report_lines.append("📊 总体统计")
|
||||
report_lines.append("-" * 40)
|
||||
report_lines.append(f"Excel原始记录: {stats.get('total_import', 0)}")
|
||||
report_lines.append(f"解析后月度记录: {stats.get('total_parsed', 0)}")
|
||||
report_lines.append(f"正式表总记录: {stats.get('total_production', 0)}")
|
||||
report_lines.append("")
|
||||
|
||||
# 各阶段处理情况
|
||||
report_lines.append("🔄 处理阶段详情")
|
||||
report_lines.append("-" * 40)
|
||||
report_lines.append(f"Excel导入 - 待处理: {stats.get('pending_import', 0)}")
|
||||
report_lines.append(f"Excel导入 - 已解析: {stats.get('parsed_import', 0)}")
|
||||
report_lines.append(f"Excel导入 - 解析失败: {stats.get('error_import', 0)}")
|
||||
report_lines.append("")
|
||||
report_lines.append(f"数据解析 - 待关联: {stats.get('pending_parsed', 0)}")
|
||||
report_lines.append(f"数据解析 - 已关联: {stats.get('linked_parsed', 0)}")
|
||||
report_lines.append(f"数据解析 - 已导入: {stats.get('imported_parsed', 0)}")
|
||||
report_lines.append(f"数据解析 - 重复跳过: {stats.get('duplicate_parsed', 0)}")
|
||||
report_lines.append(f"数据解析 - 处理失败: {stats.get('error_parsed', 0)}")
|
||||
report_lines.append("")
|
||||
|
||||
# 数据质量检查
|
||||
report_lines.append("✅ 数据质量检查")
|
||||
report_lines.append("-" * 40)
|
||||
report_lines.append(f"分数范围异常: {stats.get('out_of_range_scores', 0)}")
|
||||
report_lines.append("")
|
||||
|
||||
# 数据完整性验证
|
||||
report_lines.append("🔍 数据完整性验证")
|
||||
report_lines.append("-" * 40)
|
||||
if integrity.get('issues'):
|
||||
for issue in integrity['issues']:
|
||||
report_lines.append(f"❌ {issue}")
|
||||
else:
|
||||
report_lines.append("✅ 所有完整性检查通过")
|
||||
|
||||
report_lines.append("")
|
||||
report_lines.append(f"孤立学生记录: {integrity.get('orphan_students', 0)}")
|
||||
report_lines.append(f"孤立班级记录: {integrity.get('orphan_classes', 0)}")
|
||||
report_lines.append(f"重复记录组数: {integrity.get('duplicates', 0)}")
|
||||
report_lines.append(f"无效分数记录: {integrity.get('invalid_scores', 0)}")
|
||||
report_lines.append("")
|
||||
|
||||
# 计算成功率
|
||||
total_import = stats.get('total_import', 0)
|
||||
total_production = stats.get('total_production', 0)
|
||||
success_rate = (total_production / total_import * 100) if total_import > 0 else 0
|
||||
|
||||
report_lines.append("📈 迁移成功率")
|
||||
report_lines.append("-" * 40)
|
||||
report_lines.append(".1f")
|
||||
report_lines.append("")
|
||||
|
||||
# 结论
|
||||
report_lines.append("🎯 迁移结论")
|
||||
report_lines.append("-" * 40)
|
||||
if not integrity.get('issues') and success_rate >= 95:
|
||||
report_lines.append("✅ 迁移成功:数据完整,质量良好")
|
||||
elif success_rate >= 80:
|
||||
report_lines.append("⚠️ 迁移基本成功:存在少量问题,建议检查")
|
||||
else:
|
||||
report_lines.append("❌ 迁移失败:存在严重问题,需要重新处理")
|
||||
|
||||
report_lines.append("="*80)
|
||||
|
||||
return "\n".join(report_lines)
|
||||
|
||||
def export_error_details(self) -> None:
|
||||
"""导出错误详情到CSV文件"""
|
||||
try:
|
||||
with self.connection.cursor(DictCursor) as cursor:
|
||||
# 导出解析失败的记录
|
||||
sql_parse_errors = f"""
|
||||
SELECT ti.student_no, ti.academic_year_str, ti.semester_str,
|
||||
ti.monthly_scores, ti.error_msg as import_error
|
||||
FROM {self.temp_import_table} ti
|
||||
WHERE ti.import_status = 'error'
|
||||
"""
|
||||
|
||||
cursor.execute(sql_parse_errors)
|
||||
parse_errors = cursor.fetchall()
|
||||
|
||||
if parse_errors:
|
||||
with open('migration_parse_errors.csv', 'w', newline='', encoding='utf-8-sig') as f:
|
||||
if parse_errors:
|
||||
writer = csv.DictWriter(f, fieldnames=parse_errors[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(parse_errors)
|
||||
logger.info(f"导出{len(parse_errors)}条解析错误记录到 migration_parse_errors.csv")
|
||||
|
||||
# 导出关联失败的记录
|
||||
sql_link_errors = f"""
|
||||
SELECT tp.student_no, tp.academic_year, tp.semester, tp.month,
|
||||
tp.score, tp.error_msg as link_error
|
||||
FROM {self.temp_parsed_table} tp
|
||||
WHERE tp.processing_status = 'error'
|
||||
"""
|
||||
|
||||
cursor.execute(sql_link_errors)
|
||||
link_errors = cursor.fetchall()
|
||||
|
||||
if link_errors:
|
||||
with open('migration_link_errors.csv', 'w', newline='', encoding='utf-8-sig') as f:
|
||||
if link_errors:
|
||||
writer = csv.DictWriter(f, fieldnames=link_errors[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(link_errors)
|
||||
logger.info(f"导出{len(link_errors)}条关联错误记录到 migration_link_errors.csv")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"导出错误详情失败: {e}")
|
||||
|
||||
def cleanup_temp_tables(self) -> bool:
|
||||
"""清理临时表"""
|
||||
if not self.cleanup:
|
||||
logger.info("跳过清理操作(使用--cleanup参数启用)")
|
||||
return True
|
||||
|
||||
try:
|
||||
logger.warning("开始清理临时表数据...")
|
||||
with self.connection.cursor() as cursor:
|
||||
# 删除临时表
|
||||
cursor.execute(f"DROP TABLE IF EXISTS {self.temp_parsed_table}")
|
||||
cursor.execute(f"DROP TABLE IF EXISTS {self.temp_import_table}")
|
||||
|
||||
# 可选:删除迁移日志表
|
||||
cursor.execute("DROP TABLE IF EXISTS temp_conduct_score_migration_log")
|
||||
|
||||
self.connection.commit()
|
||||
|
||||
logger.info("临时表清理完成")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"清理临时表失败: {e}")
|
||||
self.connection.rollback()
|
||||
return False
|
||||
|
||||
def run(self) -> bool:
|
||||
"""执行验证和清理流程"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
self.connect_db()
|
||||
|
||||
logger.info("开始迁移验证和清理")
|
||||
|
||||
# 生成报告
|
||||
report = self.generate_detailed_report()
|
||||
print("\n" + report + "\n")
|
||||
|
||||
# 保存报告到文件
|
||||
report_file = f"migration_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
|
||||
with open(report_file, 'w', encoding='utf-8') as f:
|
||||
f.write(report)
|
||||
logger.info(f"详细报告已保存到: {report_file}")
|
||||
|
||||
# 导出错误详情
|
||||
self.export_error_details()
|
||||
|
||||
# 清理临时表(如果指定了cleanup参数)
|
||||
cleanup_success = self.cleanup_temp_tables()
|
||||
|
||||
return cleanup_success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"验证和清理过程出现错误: {e}")
|
||||
return False
|
||||
|
||||
finally:
|
||||
self.disconnect_db()
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.info(f"验证总耗时: {elapsed_time:.1f}秒")
|
||||
def main():
|
||||
"""主函数"""
|
||||
parser = argparse.ArgumentParser(description='学生操行分历史数据 - 验证与清理')
|
||||
parser.add_argument('--cleanup', action='store_true',
|
||||
help='清理临时表数据(默认只验证不清理)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
logger.info("="*60)
|
||||
logger.info("学生操行分历史数据迁移 - 脚本5:验证与清理")
|
||||
if args.cleanup:
|
||||
logger.warning("⚠️ 已启用清理模式,将删除临时表数据")
|
||||
else:
|
||||
logger.info("ℹ️ 验证模式:只检查数据,不清理临时表")
|
||||
logger.info("="*60)
|
||||
|
||||
# 创建logs目录
|
||||
import os
|
||||
os.makedirs("logs", exist_ok=True)
|
||||
|
||||
# 执行验证和清理
|
||||
validator = MigrationValidator(cleanup=args.cleanup)
|
||||
success = validator.run()
|
||||
|
||||
if success:
|
||||
if args.cleanup:
|
||||
logger.info("✅ 验证和清理处理完成")
|
||||
else:
|
||||
logger.info("✅ 验证处理完成")
|
||||
sys.exit(0)
|
||||
else:
|
||||
logger.error("❌ 验证和清理处理失败")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,169 @@
|
||||
# Python版本兼容性说明
|
||||
|
||||
## 问题分析
|
||||
|
||||
原始脚本使用了Python 3.7+的语法特性,在Python 3.6环境运行报错:
|
||||
|
||||
```
|
||||
SyntaxError: invalid syntax
|
||||
```
|
||||
|
||||
### 具体问题
|
||||
1. **`from __future__ import annotations`** - Python 3.7+特性
|
||||
2. **类型注解语法** `-> None` - 在`__future__`导入后变为强制语法
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 文件版本说明
|
||||
|
||||
| 文件名 | Python版本 | 特性支持 | 适用环境 |
|
||||
|--------|-----------|----------|----------|
|
||||
| `02_parse_score_data_fixed.py` | 3.7+ | 类型注解 + f-string | 开发环境 |
|
||||
| `02_parse_score_data_py36.py` | 3.6+ | 移除类型注解 | 兼容版本 |
|
||||
| `02_parse_score_data_py36_compatible.py` | 3.6+ | 移除类型注解 + .format()格式 | 中等兼容环境 |
|
||||
| `02_parse_score_data_py34_compatible.py` | 3.4+ | 无typing模块 + .format()格式 | **超老环境推荐** |
|
||||
|
||||
### 常见错误及解决方案
|
||||
|
||||
#### 错误:`ImportError: No module named typing`
|
||||
**原因**: Python版本过低(< 3.5)或未安装typing模块
|
||||
**解决**: 使用 `02_parse_score_data_py34_compatible.py`
|
||||
|
||||
#### 错误:`SyntaxError: invalid syntax` (f-string)
|
||||
**原因**: Python版本不支持f-string(< 3.6)
|
||||
**解决**: 使用带`.format()`的兼容版本
|
||||
|
||||
#### 错误:`SyntaxError: invalid syntax` (类型注解)
|
||||
**原因**: Python版本不支持类型注解语法
|
||||
**解决**: 使用移除类型注解的版本
|
||||
|
||||
### 生产环境使用
|
||||
|
||||
#### 方法1:智能启动脚本(强烈推荐)
|
||||
```bash
|
||||
# 自动检测Python版本和可用模块,自动选择最合适的脚本版本
|
||||
./run_parser.sh
|
||||
|
||||
# 查看运行日志
|
||||
tail -f 2.log
|
||||
```
|
||||
|
||||
#### 方法2:手动选择版本
|
||||
```bash
|
||||
# 对于超老环境(Python < 3.6 或无typing模块)
|
||||
nohup python3 02_parse_score_data_py34_compatible.py > 2.log 2>&1 &
|
||||
|
||||
# 对于Python 3.6环境
|
||||
nohup python3 02_parse_score_data_py36_compatible.py > 2.log 2>&1 &
|
||||
```
|
||||
|
||||
#### 方法2:直接运行
|
||||
```bash
|
||||
# Python 3.6环境使用完全兼容版本
|
||||
nohup python3 02_parse_score_data_py36_compatible.py > 2.log 2>&1 &
|
||||
|
||||
# 查看运行日志
|
||||
tail -100f 2.log
|
||||
```
|
||||
|
||||
#### 进程管理
|
||||
```bash
|
||||
# 查看进程状态
|
||||
ps aux | grep python
|
||||
|
||||
# 停止进程
|
||||
kill $(cat parser.pid)
|
||||
|
||||
# 查看日志统计
|
||||
grep "解析成功" 2.log | wc -l # 成功记录数
|
||||
grep "解析失败" 2.log | wc -l # 失败记录数
|
||||
```
|
||||
|
||||
### 版本对比
|
||||
|
||||
#### 02_parse_score_data_py36_compatible.py (推荐用于生产环境)
|
||||
- ✅ 完全兼容Python 3.6
|
||||
- ✅ 使用 `.format()` 替代 f-string
|
||||
- ✅ 移除所有类型注解
|
||||
- ✅ 避免复杂的字符串嵌套表达式
|
||||
- ✅ 稳定的生产环境运行
|
||||
|
||||
### 代码修改内容
|
||||
|
||||
#### 移除的特性
|
||||
```python
|
||||
# 移除此行
|
||||
from __future__ import annotations
|
||||
|
||||
# 函数定义从:
|
||||
def connect_db(self) -> None:
|
||||
# 改为:
|
||||
def connect_db(self):
|
||||
```
|
||||
|
||||
#### 保持兼容的特性
|
||||
- f-string (Python 3.6+)
|
||||
- `pathlib` (Python 3.6+)
|
||||
- `dataclasses` (Python 3.7+但未使用)
|
||||
|
||||
## 版本检测脚本
|
||||
|
||||
```bash
|
||||
# 检查Python版本
|
||||
python3 --version
|
||||
|
||||
# 检查语法兼容性
|
||||
python3 -m py_compile 02_parse_score_data_py36.py
|
||||
```
|
||||
|
||||
## 环境升级建议
|
||||
|
||||
如果可能,建议升级生产环境到Python 3.8+,这样可以:
|
||||
- 使用更新的pandas版本
|
||||
- 获得更好的性能
|
||||
- 支持完整的类型注解语法
|
||||
- 使用现代的异步特性
|
||||
|
||||
## 故障排除指南
|
||||
|
||||
#### 问题1:`ImportError: No module named typing`
|
||||
```bash
|
||||
# 原因:Python版本过低(< 3.5)
|
||||
# 解决:使用无typing版本
|
||||
python3 02_parse_score_data_py34_compatible.py
|
||||
```
|
||||
|
||||
#### 问题2:`SyntaxError: invalid syntax` (f-string相关)
|
||||
```bash
|
||||
# 原因:Python版本不支持f-string(< 3.6)
|
||||
# 解决:使用.format()版本
|
||||
python3 02_parse_score_data_py36_compatible.py
|
||||
```
|
||||
|
||||
#### 问题3:依赖安装失败
|
||||
```bash
|
||||
# 检查依赖安装
|
||||
pip3 install --user -r requirements-prod.txt
|
||||
python3 test_dependencies.py
|
||||
```
|
||||
|
||||
#### 问题4:数据库连接问题
|
||||
```bash
|
||||
# 检查数据库配置
|
||||
python3 -c "from db_config import DB_CONFIG; print('配置正确')"
|
||||
```
|
||||
|
||||
#### 通用诊断命令
|
||||
```bash
|
||||
# 检查Python版本
|
||||
python3 --version
|
||||
|
||||
# 检查typing模块
|
||||
python3 -c "import typing; print('typing可用')" 2>/dev/null || echo "无typing模块"
|
||||
|
||||
# 检查依赖
|
||||
python3 -c "import pymysql, pandas, sqlalchemy; print('依赖正常')"
|
||||
|
||||
# 测试脚本语法
|
||||
python3 -m py_compile 02_parse_score_data_py34_compatible.py && echo "语法正确"
|
||||
```
|
||||
@@ -0,0 +1,82 @@
|
||||
# 生产环境依赖安装指南
|
||||
|
||||
## 问题分析
|
||||
|
||||
生产环境Python 3.6 + GCC 4.8.2存在以下兼容性问题:
|
||||
|
||||
1. **GCC版本过低**: GCC 4.8.2不支持C++11标准
|
||||
2. **greenlet编译失败**: SQLAlchemy 1.4.x需要greenlet,而greenlet需要C++11编译器
|
||||
3. **Python版本限制**: Python 3.6对新版本包的支持有限
|
||||
|
||||
## 解决方案对比
|
||||
|
||||
| 方案 | requirements.txt | requirements-prod.txt | 说明 |
|
||||
|------|------------------|----------------------|------|
|
||||
| **适用环境** | Python 3.8+ + GCC 5.0+ | Python 3.6 + GCC 4.8 | 当前生产环境 |
|
||||
| **pandas** | 1.3.0-2.0.0 | 1.1.5 | 固定版本,避免编译 |
|
||||
| **sqlalchemy** | 1.4.0-2.0.0 | 1.2.19 | 1.2.x无需greenlet |
|
||||
| **编译依赖** | 需要C++11编译器 | 无需编译 | ✅ **推荐** |
|
||||
|
||||
## 生产环境安装命令
|
||||
|
||||
```bash
|
||||
cd 学校学生操行历史数据迁移
|
||||
|
||||
# 推荐方案:使用预编译版本(无需编译)
|
||||
pip3 install --user -r requirements-prod.txt
|
||||
|
||||
# 验证安装
|
||||
python3 test_dependencies.py
|
||||
```
|
||||
|
||||
## 版本说明
|
||||
|
||||
### requirements-prod.txt (推荐用于生产环境)
|
||||
- **pandas==1.1.5**: 稳定的预编译版本
|
||||
- **sqlalchemy==1.2.19**: 不依赖greenlet,无需C++编译
|
||||
- **pymysql==0.9.3**: 纯Python实现
|
||||
- **xlrd==1.2.0**: Excel读取库
|
||||
- **openpyxl==2.6.4**: Excel写入库
|
||||
|
||||
### requirements.txt (需要现代化环境)
|
||||
- 需要Python 3.8+和GCC 5.0+
|
||||
- 如果生产环境满足条件,可使用此配置获得更好性能
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 如果仍有编译问题
|
||||
```bash
|
||||
# 检查GCC版本
|
||||
gcc --version
|
||||
|
||||
# 检查Python版本
|
||||
python3 --version
|
||||
|
||||
# 强制使用预编译版本
|
||||
pip3 install --user --only-binary=all -r requirements-prod.txt
|
||||
```
|
||||
|
||||
### 验证安装成功
|
||||
```bash
|
||||
python3 -c "
|
||||
import pandas as pd
|
||||
import sqlalchemy
|
||||
import pymysql
|
||||
import xlrd
|
||||
import openpyxl
|
||||
print('所有依赖安装成功!')
|
||||
"
|
||||
```
|
||||
|
||||
## 技术细节
|
||||
|
||||
- **greenlet问题**: 是SQLAlchemy异步功能依赖,但同步操作不需要
|
||||
- **C++11要求**: greenlet使用现代C++特性,旧编译器不支持
|
||||
- **版本选择**: 优先选择有wheel包的版本,避免源码编译
|
||||
|
||||
## 升级建议
|
||||
|
||||
如果可能,建议生产环境升级到:
|
||||
- Python 3.8+
|
||||
- GCC 7.0+
|
||||
- 这样可以使用更新的包版本获得更好性能
|
||||
@@ -0,0 +1,210 @@
|
||||
# 学生操行分历史数据迁移工具
|
||||
|
||||
## 📋 概述
|
||||
|
||||
本工具用于将学校历史操行分数据从Excel文件导入到校园管理系统数据库中。支持完整的迁移流程,包括数据导入、解析、关联、导入和验证。
|
||||
|
||||
## 🎯 迁移流程
|
||||
|
||||
### 数据处理流程
|
||||
```
|
||||
Excel文件 → 临时表导入 → 数据解析 → 学生关联 → 正式导入 → 验证清理
|
||||
```
|
||||
|
||||
### 脚本执行顺序
|
||||
1. **01_import_excel_to_temp.py** - Excel数据导入临时表
|
||||
2. **02_parse_score_data.py** - 数据解析处理
|
||||
3. **03_link_student_data.py** - 数据关联处理
|
||||
4. **04_import_to_production.py** - 正式导入处理
|
||||
5. **05_validate_and_cleanup.py** - 验证与清理
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 环境准备
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 确保Excel文件存在
|
||||
ls -la 操行.xls
|
||||
```
|
||||
|
||||
### 2. 创建临时表
|
||||
|
||||
```bash
|
||||
# 连接到数据库,执行建表脚本
|
||||
mysql -h 47.99.164.66 -u root -p hzdyjs_xg < create_temp_tables.sql
|
||||
```
|
||||
|
||||
### 3. 执行完整迁移
|
||||
|
||||
```bash
|
||||
# 一键执行所有步骤
|
||||
python import_conduct_score_history.py
|
||||
```
|
||||
|
||||
### 4. 分步骤执行(推荐用于调试)
|
||||
|
||||
```bash
|
||||
# 步骤1:Excel导入
|
||||
python 01_import_excel_to_temp.py
|
||||
|
||||
# 步骤2:数据解析
|
||||
python 02_parse_score_data.py
|
||||
|
||||
# 步骤3:数据关联
|
||||
python 03_link_student_data.py
|
||||
|
||||
# 步骤4:正式导入
|
||||
python 04_import_to_production.py
|
||||
|
||||
# 步骤5:验证清理
|
||||
python 05_validate_and_cleanup.py
|
||||
```
|
||||
|
||||
## 📊 数据格式说明
|
||||
|
||||
### Excel文件要求
|
||||
- 文件名:`操行.xls`
|
||||
- 必需列:`学号`、`学年`、`学期`、`平时分数`
|
||||
- 其他列:`姓名`、`性别`、`民族`、`最终分`、`等级`、`院系`、`专业`、`班级`、`年级`
|
||||
|
||||
### 数据格式转换
|
||||
|
||||
#### 学年格式
|
||||
- Excel: `2015-2016`
|
||||
- 数据库: `2015`
|
||||
|
||||
#### 学期格式
|
||||
- Excel: `第一学期` → 数据库: `1`
|
||||
- Excel: `第二学期` → 数据库: `2`
|
||||
|
||||
#### 平时分数格式
|
||||
- Excel: `一月:90,九月:85,十月:85,十一月:90,十二月:85`
|
||||
- 数据库: 多条月度记录,每条记录一个月份的分数
|
||||
|
||||
## 🛠️ 高级用法
|
||||
|
||||
### 从指定步骤开始执行
|
||||
|
||||
```bash
|
||||
# 从步骤3开始执行(适用于断点续传)
|
||||
python import_conduct_score_history.py --start-from 3
|
||||
```
|
||||
|
||||
### 仅执行验证
|
||||
|
||||
```bash
|
||||
# 只执行验证步骤,不执行迁移
|
||||
python import_conduct_score_history.py --validate-only
|
||||
```
|
||||
|
||||
### 清理临时数据
|
||||
|
||||
```bash
|
||||
# 验证完成后清理临时表
|
||||
python 05_validate_and_cleanup.py --cleanup
|
||||
```
|
||||
|
||||
## 📋 配置说明
|
||||
|
||||
### 数据库配置 (db_config.py)
|
||||
- 数据库连接信息
|
||||
- 表名映射
|
||||
- 常量配置
|
||||
- 月度和学期映射
|
||||
|
||||
### 日志文件
|
||||
- `logs/01_import_excel.log` - Excel导入日志
|
||||
- `logs/02_parse_data.log` - 数据解析日志
|
||||
- `logs/03_link_data.log` - 数据关联日志
|
||||
- `logs/04_import_production.log` - 正式导入日志
|
||||
- `logs/05_validate_cleanup.log` - 验证清理日志
|
||||
- `logs/migration_main.log` - 主控制脚本日志
|
||||
|
||||
## 🔍 验证与监控
|
||||
|
||||
### 自动验证
|
||||
脚本5会自动进行以下验证:
|
||||
- 数据完整性检查
|
||||
- 关联关系验证
|
||||
- 重复数据检查
|
||||
- 分数范围验证
|
||||
|
||||
### 错误处理
|
||||
- 解析失败的记录会标记为error状态
|
||||
- 关联失败的记录会记录错误信息
|
||||
- 导入失败的记录会回滚并记录
|
||||
|
||||
### 输出文件
|
||||
- `migration_report_YYYYMMDD_HHMMSS.txt` - 详细迁移报告
|
||||
- `migration_parse_errors.csv` - 解析错误详情
|
||||
- `migration_link_errors.csv` - 关联错误详情
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 数据安全
|
||||
- 导入前会检查重复数据,避免重复导入
|
||||
- 事务处理确保数据一致性
|
||||
- 支持断点续传,不会因为中断丢失进度
|
||||
|
||||
### 性能考虑
|
||||
- 大批量处理,自动分批提交
|
||||
- 索引优化,提升查询性能
|
||||
- 内存管理,避免大文件导致内存溢出
|
||||
|
||||
### 错误恢复
|
||||
- 每个步骤都可以独立重试
|
||||
- 错误记录详细,便于问题定位
|
||||
- 支持部分成功场景的继续处理
|
||||
|
||||
## 🐛 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **Excel文件不存在**
|
||||
- 检查文件路径和文件名
|
||||
- 确保文件在当前目录
|
||||
|
||||
2. **数据库连接失败**
|
||||
- 检查网络连接
|
||||
- 验证数据库配置
|
||||
- 确认用户权限
|
||||
|
||||
3. **数据解析失败**
|
||||
- 检查Excel数据格式
|
||||
- 查看错误日志详情
|
||||
- 手动修复数据后重试
|
||||
|
||||
4. **学生关联失败**
|
||||
- 检查学号是否存在于学生表
|
||||
- 确认学生状态为有效
|
||||
- 查看关联错误CSV文件
|
||||
|
||||
### 调试模式
|
||||
|
||||
```bash
|
||||
# 查看详细日志
|
||||
tail -f logs/migration_main.log
|
||||
|
||||
# 检查临时表数据
|
||||
mysql -e "SELECT COUNT(*) FROM temp_conduct_score_import;"
|
||||
|
||||
# 查看错误记录
|
||||
mysql -e "SELECT * FROM temp_conduct_score_parsed WHERE processing_status = 'error' LIMIT 5;"
|
||||
```
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
如遇到问题,请:
|
||||
1. 查看相关日志文件
|
||||
2. 检查错误输出文件
|
||||
3. 确认数据格式正确性
|
||||
4. 联系技术支持团队
|
||||
|
||||
## 📝 更新日志
|
||||
|
||||
- v1.0.0 (2025-01-XX): 初始版本,支持完整迁移流程
|
||||
- 支持断点续传和错误恢复
|
||||
- 自动验证和报告生成
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,95 @@
|
||||
-- 学生操行分历史数据迁移 - 临时表创建脚本
|
||||
-- 执行时间:2025-01-XX
|
||||
-- 执行人:系统管理员
|
||||
|
||||
-- ===========================================
|
||||
-- 临时表1:存储Excel原始数据
|
||||
-- ===========================================
|
||||
DROP TABLE IF EXISTS temp_conduct_score_import;
|
||||
CREATE TABLE temp_conduct_score_import (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
|
||||
student_no VARCHAR(50) NOT NULL COMMENT '学号',
|
||||
student_name VARCHAR(100) COMMENT '姓名',
|
||||
gender VARCHAR(10) COMMENT '性别',
|
||||
ethnicity VARCHAR(50) COMMENT '民族',
|
||||
academic_year_str VARCHAR(20) NOT NULL COMMENT '原始学年(2015-2016)',
|
||||
semester_str VARCHAR(20) NOT NULL COMMENT '原始学期(第一学期/第二学期)',
|
||||
monthly_scores TEXT NOT NULL COMMENT '平时分数(一月:90,九月:85...)',
|
||||
final_score DECIMAL(5,2) COMMENT '最终分',
|
||||
grade_level VARCHAR(20) COMMENT '等级',
|
||||
department VARCHAR(100) COMMENT '院系',
|
||||
major VARCHAR(100) COMMENT '专业',
|
||||
class_name VARCHAR(100) COMMENT '班级',
|
||||
grade VARCHAR(20) COMMENT '年级',
|
||||
import_status VARCHAR(20) DEFAULT 'pending' COMMENT '导入状态(pending/processing/parsed/error)',
|
||||
error_msg TEXT COMMENT '错误信息',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
INDEX idx_student_no (student_no),
|
||||
INDEX idx_import_status (import_status),
|
||||
INDEX idx_create_time (create_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生操行分Excel数据临时表';
|
||||
|
||||
-- ===========================================
|
||||
-- 临时表2:解析后的月度分数中间表
|
||||
-- ===========================================
|
||||
DROP TABLE IF EXISTS temp_conduct_score_parsed;
|
||||
CREATE TABLE temp_conduct_score_parsed (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
|
||||
temp_id BIGINT NOT NULL COMMENT '关联临时表ID',
|
||||
|
||||
-- Excel原始字段(用于追溯)
|
||||
student_no VARCHAR(50) NOT NULL COMMENT '学号',
|
||||
academic_year_str VARCHAR(20) NOT NULL COMMENT '原始学年',
|
||||
semester_str VARCHAR(20) NOT NULL COMMENT '原始学期',
|
||||
|
||||
-- 解析后字段
|
||||
academic_year INT NOT NULL COMMENT '学年',
|
||||
semester TINYINT NOT NULL COMMENT '学期(1/2)',
|
||||
month TINYINT NOT NULL COMMENT '月份(1-12)',
|
||||
score DECIMAL(5,2) NOT NULL COMMENT '月度分数',
|
||||
|
||||
-- 系统关联字段
|
||||
student_id BIGINT COMMENT '学生ID',
|
||||
class_id BIGINT COMMENT '班级ID',
|
||||
class_teacher_id BIGINT COMMENT '班主任ID',
|
||||
|
||||
-- 扩展字段
|
||||
remark VARCHAR(500) COMMENT '备注信息',
|
||||
processing_status VARCHAR(20) DEFAULT 'parsed' COMMENT '处理状态(parsed/linked/imported/error)',
|
||||
error_msg TEXT COMMENT '错误信息',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
-- 索引优化
|
||||
INDEX idx_temp_id (temp_id),
|
||||
INDEX idx_student_no (student_no),
|
||||
INDEX idx_processing_status (processing_status),
|
||||
INDEX idx_academic_year_semester (academic_year, semester),
|
||||
INDEX idx_student_year_semester_month (student_no, academic_year, semester, month),
|
||||
INDEX idx_create_time (create_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生操行分解析后中间表';
|
||||
|
||||
-- ===========================================
|
||||
-- 迁移日志表(可选,用于记录详细的迁移过程)
|
||||
-- ===========================================
|
||||
DROP TABLE IF EXISTS temp_conduct_score_migration_log;
|
||||
CREATE TABLE temp_conduct_score_migration_log (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
|
||||
script_name VARCHAR(100) NOT NULL COMMENT '脚本名称',
|
||||
operation VARCHAR(100) NOT NULL COMMENT '操作类型',
|
||||
record_count INT DEFAULT 0 COMMENT '处理记录数',
|
||||
success_count INT DEFAULT 0 COMMENT '成功数',
|
||||
error_count INT DEFAULT 0 COMMENT '失败数',
|
||||
error_details TEXT COMMENT '错误详情',
|
||||
start_time DATETIME NOT NULL COMMENT '开始时间',
|
||||
end_time DATETIME COMMENT '结束时间',
|
||||
duration_seconds INT COMMENT '耗时(秒)',
|
||||
status VARCHAR(20) DEFAULT 'running' COMMENT '状态(running/success/error)',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
|
||||
INDEX idx_script_operation (script_name, operation),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_create_time (create_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操行分迁移日志表';
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
数据库配置文件
|
||||
用于学生操行分历史数据迁移脚本
|
||||
"""
|
||||
|
||||
# 数据库配置信息
|
||||
DB_CONFIG = {
|
||||
"host": "47.99.164.66",
|
||||
"port": 3306,
|
||||
"database": "hzdyjs_xg",
|
||||
"user": "root",
|
||||
"password": "hn02le.34lkdLKD",
|
||||
"charset": "utf8mb4",
|
||||
"autocommit": False, # 手动控制事务
|
||||
"connect_timeout": 60, # 连接超时时间(秒)
|
||||
"read_timeout": 300, # 读取超时时间(秒)
|
||||
"write_timeout": 300, # 写入超时时间(秒)
|
||||
"max_allowed_packet": 67108864, # 64MB,增加包大小限制
|
||||
}
|
||||
|
||||
# 表名配置
|
||||
TABLE_CONFIG = {
|
||||
"temp_import": "temp_conduct_score_import", # Excel原始数据临时表
|
||||
"temp_parsed": "temp_conduct_score_parsed", # 解析后的月度分数中间表
|
||||
"production": "xg_conduct_score", # 正式操行分表
|
||||
"students": "xg_student_msg", # 学生信息表
|
||||
"classes": "xg_class_msg", # 班级信息表
|
||||
"users": "sys_user" # 用户表
|
||||
}
|
||||
|
||||
# 常量配置
|
||||
CONSTANTS = {
|
||||
"DEFAULT_TENANT_ID": "000000",
|
||||
"DEFAULT_CREATE_BY": 1,
|
||||
"DEFAULT_UPDATE_BY": 1,
|
||||
"BATCH_SIZE": 500, # 批量处理大小
|
||||
"DB_RETRY_MAX": 3, # 数据库重试次数
|
||||
"EXCEL_FILE": "操行.xls", # Excel文件名
|
||||
"SNOWFLAKE_EPOCH": 1288834974657, # 雪花ID纪元时间
|
||||
"WORKER_ID": 1, # 雪花ID工作节点ID
|
||||
"DATACENTER_ID": 1 # 雪花ID数据中心ID
|
||||
}
|
||||
|
||||
# 数据库连接重试配置(不添加到DB_CONFIG中,因为PyMySQL不支持此参数)
|
||||
# max_retries 在代码中单独使用
|
||||
|
||||
# 月度映射
|
||||
MONTH_MAPPING = {
|
||||
'一月': 1, '二月': 2, '三月': 3, '四月': 4, '五月': 5, '六月': 6,
|
||||
'七月': 7, '八月': 8, '九月': 9, '十月': 10, '十一月': 11, '十二月': 12
|
||||
}
|
||||
|
||||
# 学期映射
|
||||
SEMESTER_MAPPING = {
|
||||
'第一学期': 1,
|
||||
'第二学期': 2
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
生产环境诊断脚本
|
||||
检查Python版本、可用模块和推荐的脚本版本
|
||||
"""
|
||||
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
def check_python_version():
|
||||
"""检查Python版本"""
|
||||
version = sys.version_info
|
||||
version_str = "{}.{}".format(version.major, version.minor)
|
||||
print("Python版本: {}".format(version_str))
|
||||
|
||||
if version.major >= 3 and version.minor >= 7:
|
||||
return "full", version_str
|
||||
elif version.major >= 3 and version.minor >= 6:
|
||||
return "py36", version_str
|
||||
else:
|
||||
return "py34", version_str
|
||||
|
||||
def check_module(module_name):
|
||||
"""检查模块是否可用"""
|
||||
try:
|
||||
__import__(module_name)
|
||||
return True, "可用"
|
||||
except ImportError:
|
||||
return False, "不可用"
|
||||
|
||||
def check_dependencies():
|
||||
"""检查关键依赖"""
|
||||
deps = ['pymysql', 'pandas', 'sqlalchemy']
|
||||
results = {}
|
||||
for dep in deps:
|
||||
available, status = check_module(dep)
|
||||
results[dep] = (available, status)
|
||||
print("依赖 {}: {}".format(dep, status))
|
||||
return results
|
||||
|
||||
def recommend_script(python_level, has_typing):
|
||||
"""推荐使用的脚本"""
|
||||
if python_level == "full" and has_typing:
|
||||
return "02_parse_score_data_fixed.py", "完整功能版本"
|
||||
elif python_level in ["full", "py36"] and has_typing:
|
||||
return "02_parse_score_data_py36_compatible.py", "Python 3.6兼容版本"
|
||||
else:
|
||||
return "02_parse_score_data_py34_compatible.py", "超兼容版本(无typing)"
|
||||
|
||||
def test_script_syntax(script_name):
|
||||
"""测试脚本语法"""
|
||||
try:
|
||||
subprocess.check_call([sys.executable, "-m", "py_compile", script_name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return True, "语法正确"
|
||||
except subprocess.CalledProcessError:
|
||||
return False, "语法错误"
|
||||
|
||||
def main():
|
||||
print("=== 生产环境诊断工具 ===")
|
||||
print()
|
||||
|
||||
# 检查Python版本
|
||||
python_level, version_str = check_python_version()
|
||||
|
||||
# 检查typing模块
|
||||
has_typing, typing_status = check_module('typing')
|
||||
print("typing模块: {}".format(typing_status))
|
||||
|
||||
print()
|
||||
|
||||
# 检查依赖
|
||||
print("检查依赖包:")
|
||||
deps_status = check_dependencies()
|
||||
|
||||
print()
|
||||
|
||||
# 推荐脚本
|
||||
script_name, script_desc = recommend_script(python_level, has_typing)
|
||||
print("推荐脚本: {} ({})".format(script_name, script_desc))
|
||||
|
||||
# 测试推荐脚本语法
|
||||
syntax_ok, syntax_msg = test_script_syntax(script_name)
|
||||
print("脚本语法: {}".format(syntax_msg))
|
||||
|
||||
print()
|
||||
|
||||
# 给出建议
|
||||
print("=== 执行建议 ===")
|
||||
|
||||
if syntax_ok:
|
||||
print("✅ 环境检查通过")
|
||||
print("执行命令:")
|
||||
print(" nohup python3 {} > 2.log 2>&1 &".format(script_name))
|
||||
print(" tail -f 2.log")
|
||||
else:
|
||||
print("❌ 发现问题,请检查环境配置")
|
||||
|
||||
# 检查是否有缺失的依赖
|
||||
missing_deps = [dep for dep, (available, _) in deps_status.items() if not available]
|
||||
if missing_deps:
|
||||
print()
|
||||
print("缺失依赖,请安装:")
|
||||
print(" pip3 install --user -r requirements-prod.txt")
|
||||
|
||||
print()
|
||||
print("=== 版本信息 ===")
|
||||
print("Python: {}".format(version_str))
|
||||
print("Typing: {}".format(typing_status))
|
||||
print("推荐脚本: {}".format(script_name))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
学生操行分历史数据迁移 - 主控制脚本
|
||||
|
||||
功能说明:
|
||||
- 协调整个迁移流程的执行
|
||||
- 按顺序执行所有迁移脚本
|
||||
- 记录整体迁移过程和结果
|
||||
- 支持断点续传(可从指定步骤开始)
|
||||
|
||||
执行方式:
|
||||
python import_conduct_score_history.py [--start-from STEP] [--validate-only]
|
||||
|
||||
参数说明:
|
||||
- --start-from: 从指定步骤开始执行 (1-5)
|
||||
- --validate-only: 仅执行验证,不执行迁移
|
||||
|
||||
迁移步骤:
|
||||
1. Excel导入临时表 (01_import_excel_to_temp.py)
|
||||
2. 数据解析处理 (02_parse_score_data.py)
|
||||
3. 数据关联处理 (03_link_student_data.py)
|
||||
4. 正式导入处理 (04_import_to_production.py)
|
||||
5. 验证与清理 (05_validate_and_cleanup.py)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler("logs/migration_main.log", mode='a', encoding='utf-8')
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger("migration_main")
|
||||
|
||||
class MigrationController:
|
||||
"""迁移控制器"""
|
||||
|
||||
def __init__(self, start_from: int = 1, validate_only: bool = False):
|
||||
self.start_from = start_from
|
||||
self.validate_only = validate_only
|
||||
self.steps = [
|
||||
{
|
||||
'name': 'Excel导入临时表',
|
||||
'script': '01_import_excel_to_temp.py',
|
||||
'description': '读取操行.xls文件,导入到temp_conduct_score_import表'
|
||||
},
|
||||
{
|
||||
'name': '数据解析处理',
|
||||
'script': '02_parse_score_data.py',
|
||||
'description': '解析学年、学期、月度分数,生成月度记录'
|
||||
},
|
||||
{
|
||||
'name': '数据关联处理',
|
||||
'script': '03_link_student_data.py',
|
||||
'description': '通过学号关联学生、班级、班主任信息'
|
||||
},
|
||||
{
|
||||
'name': '正式导入处理',
|
||||
'script': '04_import_to_production.py',
|
||||
'description': '将验证后的数据导入到xg_conduct_score表'
|
||||
},
|
||||
{
|
||||
'name': '验证与清理',
|
||||
'script': '05_validate_and_cleanup.py',
|
||||
'description': '验证迁移结果,生成报告'
|
||||
}
|
||||
]
|
||||
|
||||
def check_prerequisites(self) -> bool:
|
||||
"""检查前置条件"""
|
||||
logger.info("检查前置条件...")
|
||||
|
||||
# 检查Python脚本是否存在
|
||||
for step in self.steps:
|
||||
script_path = step['script']
|
||||
if not Path(script_path).exists():
|
||||
logger.error(f"脚本文件不存在: {script_path}")
|
||||
return False
|
||||
|
||||
# 检查Excel文件是否存在(仅在执行第一步时检查)
|
||||
if self.start_from == 1 and not self.validate_only:
|
||||
excel_file = "操行.xls"
|
||||
if not Path(excel_file).exists():
|
||||
logger.error(f"Excel文件不存在: {excel_file}")
|
||||
return False
|
||||
|
||||
# 检查logs目录
|
||||
Path("logs").mkdir(exist_ok=True)
|
||||
|
||||
logger.info("前置条件检查通过")
|
||||
return True
|
||||
|
||||
def run_step(self, step_info: Dict[str, Any]) -> tuple[bool, str]:
|
||||
"""执行单个步骤"""
|
||||
step_name = step_info['name']
|
||||
script = step_info['script']
|
||||
description = step_info['description']
|
||||
|
||||
logger.info(f"开始执行步骤: {step_name}")
|
||||
logger.info(f"脚本: {script}")
|
||||
logger.info(f"说明: {description}")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# 构建命令
|
||||
cmd = [sys.executable, script]
|
||||
|
||||
# 如果是验证步骤,添加--cleanup参数(由用户决定是否清理)
|
||||
# 这里默认不清理,让用户手动决定
|
||||
|
||||
# 执行脚本
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
timeout=3600 # 1小时超时
|
||||
)
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
# 记录执行结果
|
||||
if result.returncode == 0:
|
||||
logger.info(f"步骤耗时: {step_elapsed:.1f}秒")
|
||||
return True, result.stdout + result.stderr
|
||||
else:
|
||||
logger.error(f"步骤耗时: {step_elapsed:.1f}秒")
|
||||
logger.error("标准输出:")
|
||||
logger.error(result.stdout)
|
||||
logger.error("标准错误:")
|
||||
logger.error(result.stderr)
|
||||
return False, result.stdout + result.stderr
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.error(f"步骤执行超时: {elapsed_time:.1f}秒")
|
||||
return False, "执行超时"
|
||||
|
||||
except Exception as e:
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.error(f"步骤执行出错: {elapsed_time:.1f}秒")
|
||||
return False, str(e)
|
||||
|
||||
def run_migration(self) -> bool:
|
||||
"""执行迁移流程"""
|
||||
logger.info("="*80)
|
||||
logger.info("学生操行分历史数据迁移开始")
|
||||
logger.info("="*80)
|
||||
logger.info(f"开始步骤: {self.start_from}")
|
||||
logger.info(f"验证模式: {self.validate_only}")
|
||||
logger.info(f"执行时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
logger.info("")
|
||||
|
||||
# 显示执行计划
|
||||
logger.info("执行计划:")
|
||||
for i, step in enumerate(self.steps, 1):
|
||||
status = "⏭️ 跳过" if i < self.start_from else "▶️ 执行"
|
||||
if self.validate_only and i < 5:
|
||||
status = "⏭️ 跳过"
|
||||
logger.info(f" {i}. {status} {step['name']}")
|
||||
logger.info("")
|
||||
|
||||
total_start_time = time.time()
|
||||
results = []
|
||||
|
||||
# 执行步骤
|
||||
for i, step in enumerate(self.steps, 1):
|
||||
step_start_time = time.time()
|
||||
|
||||
# 检查是否需要执行此步骤
|
||||
if i < self.start_from:
|
||||
logger.info(f"跳过步骤 {i}: {step['name']}")
|
||||
continue
|
||||
|
||||
if self.validate_only and i < 5:
|
||||
logger.info(f"验证模式跳过步骤 {i}: {step['name']}")
|
||||
continue
|
||||
|
||||
# 执行步骤
|
||||
success, output = self.run_step(step)
|
||||
|
||||
step_elapsed = time.time() - step_start_time
|
||||
results.append({
|
||||
'step': i,
|
||||
'name': step['name'],
|
||||
'success': success,
|
||||
'duration': step_elapsed,
|
||||
'output': output
|
||||
})
|
||||
|
||||
# 如果步骤失败,停止执行
|
||||
if not success:
|
||||
logger.error(f"步骤 {i} 执行失败,停止迁移流程")
|
||||
break
|
||||
|
||||
logger.info("")
|
||||
|
||||
# 输出总结报告
|
||||
total_elapsed = time.time() - total_start_time
|
||||
self.print_summary_report(results, total_elapsed)
|
||||
|
||||
# 检查整体结果
|
||||
all_success = all(result['success'] for result in results)
|
||||
return all_success
|
||||
|
||||
def print_summary_report(self, results: List[Dict[str, Any]], total_time: float) -> None:
|
||||
"""打印总结报告"""
|
||||
logger.info("="*80)
|
||||
logger.info("迁移执行总结报告")
|
||||
logger.info("="*80)
|
||||
|
||||
logger.info("各步骤执行结果:")
|
||||
for result in results:
|
||||
status = "✅ 成功" if result['success'] else "❌ 失败"
|
||||
duration = ".1f"
|
||||
logger.info("6")
|
||||
|
||||
logger.info("")
|
||||
logger.info(f"迁移总耗时: {total_time:.1f}秒")
|
||||
# 统计信息
|
||||
successful_steps = sum(1 for r in results if r['success'])
|
||||
total_steps = len(results)
|
||||
|
||||
logger.info(f"成功步骤: {successful_steps}/{total_steps}")
|
||||
|
||||
if successful_steps == total_steps:
|
||||
logger.info("🎉 迁移流程完全成功!")
|
||||
else:
|
||||
logger.info("⚠️ 迁移流程部分失败,请检查上述错误信息")
|
||||
|
||||
logger.info("="*80)
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
parser = argparse.ArgumentParser(description='学生操行分历史数据迁移 - 主控制脚本')
|
||||
parser.add_argument('--start-from', type=int, choices=range(1, 6), default=1,
|
||||
help='从指定步骤开始执行 (1-5)')
|
||||
parser.add_argument('--validate-only', action='store_true',
|
||||
help='仅执行验证步骤(步骤5),跳过前面的迁移步骤')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 如果是验证模式,从步骤5开始
|
||||
if args.validate_only:
|
||||
args.start_from = 5
|
||||
|
||||
# 创建控制器并执行
|
||||
controller = MigrationController(
|
||||
start_from=args.start_from,
|
||||
validate_only=args.validate_only
|
||||
)
|
||||
|
||||
# 检查前置条件
|
||||
if not controller.check_prerequisites():
|
||||
logger.error("前置条件检查失败,请解决上述问题后重试")
|
||||
sys.exit(1)
|
||||
|
||||
# 执行迁移
|
||||
success = controller.run_migration()
|
||||
|
||||
if success:
|
||||
logger.info("✅ 学生操行分历史数据迁移完成")
|
||||
sys.exit(0)
|
||||
else:
|
||||
logger.error("❌ 学生操行分历史数据迁移失败")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
55881
|
||||
@@ -0,0 +1 @@
|
||||
62198
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# 生产环境依赖安装脚本
|
||||
|
||||
echo "=== 生产环境依赖安装脚本 ==="
|
||||
echo "当前Python版本: $(python3 --version)"
|
||||
echo ""
|
||||
|
||||
# 方案1:安装编译工具(推荐)
|
||||
echo "方案1:安装编译工具后使用标准requirements.txt"
|
||||
echo "执行命令:"
|
||||
echo "yum install -y gcc gcc-c++ python3-devel # CentOS/RHEL"
|
||||
echo "# 或"
|
||||
echo "apt-get update && apt-get install -y build-essential python3-dev # Ubuntu/Debian"
|
||||
echo ""
|
||||
echo "然后执行:"
|
||||
echo "pip3 install --user -r requirements.txt"
|
||||
echo ""
|
||||
|
||||
# 方案2:使用预编译版本
|
||||
echo "方案2:使用预编译版本(如果方案1失败)"
|
||||
echo "执行命令:"
|
||||
echo "pip3 install --user -r requirements-prod.txt"
|
||||
echo ""
|
||||
|
||||
# 方案3:手动安装编译工具并重试
|
||||
echo "方案3:一键安装编译工具并安装依赖"
|
||||
echo "如果您同意,请输入 'yes' 继续:"
|
||||
read -r answer
|
||||
|
||||
if [ "$answer" = "yes" ] || [ "$answer" = "y" ]; then
|
||||
echo "检测操作系统类型..."
|
||||
|
||||
if command -v yum &> /dev/null; then
|
||||
echo "检测到CentOS/RHEL系统,安装编译工具..."
|
||||
yum install -y gcc gcc-c++ python3-devel
|
||||
elif command -v apt-get &> /dev/null; then
|
||||
echo "检测到Ubuntu/Debian系统,安装编译工具..."
|
||||
apt-get update && apt-get install -y build-essential python3-dev
|
||||
else
|
||||
echo "无法自动检测操作系统类型,请手动安装编译工具"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "安装Python依赖..."
|
||||
pip3 install --user -r requirements.txt
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "依赖安装成功!"
|
||||
python3 test_dependencies.py
|
||||
else
|
||||
echo "标准版本安装失败,尝试预编译版本..."
|
||||
pip3 install --user -r requirements-prod.txt
|
||||
fi
|
||||
else
|
||||
echo "请手动选择安装方案"
|
||||
fi
|
||||
@@ -0,0 +1,5 @@
|
||||
pandas==1.1.5
|
||||
sqlalchemy==1.2.19
|
||||
pymysql==0.9.3
|
||||
xlrd==1.2.0
|
||||
openpyxl==2.6.4
|
||||
@@ -0,0 +1,5 @@
|
||||
pandas>=1.3.0,<2.0.0
|
||||
sqlalchemy>=1.4.0,<2.0.0
|
||||
pymysql>=1.0.3
|
||||
xlrd>=2.0.1
|
||||
openpyxl>=3.0.0
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
# 生产环境数据解析脚本启动器
|
||||
|
||||
echo "=== 学生操行分数据解析器(智能版本选择) ==="
|
||||
echo "当前时间: $(date)"
|
||||
echo "Python版本: $(python3 --version)"
|
||||
echo ""
|
||||
|
||||
# 检查Python版本并选择合适的脚本
|
||||
python_version=$(python3 -c "import sys; print(sys.version_info.major * 10 + sys.version_info.minor)")
|
||||
echo "检测到Python版本: 3.$(($python_version % 10))"
|
||||
|
||||
# 检查是否有typing模块
|
||||
has_typing=$(python3 -c "import typing" 2>/dev/null && echo "yes" || echo "no")
|
||||
|
||||
if [ $python_version -ge 37 ] && [ "$has_typing" = "yes" ]; then
|
||||
SCRIPT_NAME="02_parse_score_data_fixed.py"
|
||||
echo "使用完整功能版本 (Python 3.7+)"
|
||||
elif [ $python_version -ge 36 ] && [ "$has_typing" = "yes" ]; then
|
||||
SCRIPT_NAME="02_parse_score_data_py36_compatible.py"
|
||||
echo "使用Python 3.6兼容版本"
|
||||
else
|
||||
SCRIPT_NAME="02_parse_score_data_py34_compatible.py"
|
||||
echo "使用Python 3.4+超兼容版本 (无typing模块)"
|
||||
fi
|
||||
|
||||
# 检查依赖是否安装
|
||||
echo "检查依赖安装..."
|
||||
python3 -c "
|
||||
try:
|
||||
import pymysql
|
||||
import pandas
|
||||
import sqlalchemy
|
||||
print('✓ 所有依赖已安装')
|
||||
except ImportError as e:
|
||||
print('✗ 缺少依赖:', e)
|
||||
print('请运行: pip3 install --user -r requirements-prod.txt')
|
||||
exit(1)
|
||||
"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "启动数据解析进程..."
|
||||
echo "日志文件: 2.log"
|
||||
echo "进程ID将保存在: parser.pid"
|
||||
echo ""
|
||||
|
||||
# 启动进程
|
||||
echo "启动脚本: $SCRIPT_NAME"
|
||||
nohup python3 "$SCRIPT_NAME" > 2.log 2>&1 &
|
||||
echo $! > parser.pid
|
||||
|
||||
echo "进程已启动 (PID: $(cat parser.pid))"
|
||||
echo ""
|
||||
echo "查看运行状态:"
|
||||
echo " tail -f 2.log # 查看实时日志"
|
||||
echo " ps aux | grep python # 查看进程"
|
||||
echo " kill \$(cat parser.pid) # 停止进程"
|
||||
echo ""
|
||||
echo "日志分析:"
|
||||
echo " grep '成功' 2.log | wc -l # 成功记录数"
|
||||
echo " grep '失败' 2.log | wc -l # 失败记录数"
|
||||
echo " grep '解析完成' 2.log # 完成状态"
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
测试依赖包是否正常工作
|
||||
"""
|
||||
|
||||
try:
|
||||
import pandas as pd
|
||||
print(f"✓ pandas {pd.__version__} 导入成功")
|
||||
except ImportError as e:
|
||||
print(f"✗ pandas 导入失败: {e}")
|
||||
|
||||
try:
|
||||
import sqlalchemy
|
||||
print(f"✓ sqlalchemy {sqlalchemy.__version__} 导入成功")
|
||||
except ImportError as e:
|
||||
print(f"✗ sqlalchemy 导入失败: {e}")
|
||||
|
||||
try:
|
||||
import pymysql
|
||||
print(f"✓ pymysql {pymysql.__version__} 导入成功")
|
||||
except ImportError as e:
|
||||
print(f"✗ pymysql 导入失败: {e}")
|
||||
|
||||
try:
|
||||
import xlrd
|
||||
print(f"✓ xlrd {xlrd.__version__} 导入成功")
|
||||
except ImportError as e:
|
||||
print(f"✗ xlrd 导入失败: {e}")
|
||||
|
||||
try:
|
||||
import openpyxl
|
||||
print(f"✓ openpyxl {openpyxl.__version__} 导入成功")
|
||||
except ImportError as e:
|
||||
print(f"✗ openpyxl 导入失败: {e}")
|
||||
|
||||
print("\n依赖检查完成!")
|
||||
Binary file not shown.
@@ -5,7 +5,7 @@
|
||||
# 处理类型: CLASS_CHANGE(换班级)
|
||||
# ===========================================
|
||||
|
||||
set -euo pipefail
|
||||
# set -euo pipefail # Temporarily disable to prevent script exit on errors
|
||||
|
||||
# Force C locale to ensure consistent behavior across systems
|
||||
export LANG=C
|
||||
@@ -261,54 +261,86 @@ main() {
|
||||
continue
|
||||
fi
|
||||
|
||||
# 检查是否有记录需要处理(过滤掉空行、MySQL警告信息和表头)
|
||||
# 检查是否有记录需要处理(过滤掉空行和MySQL警告信息)
|
||||
local clean_records
|
||||
# 过滤掉MySQL警告、空行,然后排除表头行(第一行通常是列名)
|
||||
local filtered_records
|
||||
filtered_records=$(echo "$records" | grep -v "^mysql:" | grep -v "^$" | sed '1d')
|
||||
clean_records=$(echo "$filtered_records" | wc -l)
|
||||
clean_records=$(echo "$records" | grep -v "^mysql:" | grep -v "^$" | wc -l)
|
||||
|
||||
# 去除可能的空白字符
|
||||
clean_records=$(echo "$clean_records" | tr -d ' ')
|
||||
|
||||
if [[ $clean_records -eq 0 ]]; then
|
||||
if [[ $clean_records -le 1 ]]; then
|
||||
log "INFO" "未找到未处理的记录,正在等待..."
|
||||
else
|
||||
log "INFO" "找到 $clean_records 条待处理记录"
|
||||
# 排除标题行后的实际记录数
|
||||
local actual_records=$((clean_records - 1))
|
||||
log "INFO" "找到 $actual_records 条待处理记录"
|
||||
|
||||
# 调试:显示前几条记录的信息
|
||||
if [[ $clean_records -gt 0 ]]; then
|
||||
log "DEBUG" "待处理记录预览:"
|
||||
echo "$filtered_records" | head -3 | while read -r line; do
|
||||
log "DEBUG" " $line"
|
||||
done
|
||||
if [[ $actual_records -gt 0 ]]; then
|
||||
log "DEBUG" "将处理 $actual_records 条记录"
|
||||
fi
|
||||
|
||||
local processed_count=0
|
||||
local failed_count=0
|
||||
|
||||
# Create a temporary file to store records for processing
|
||||
local temp_records_file="${SCRIPT_DIR}/temp_class_change_records_fixed_$$.txt"
|
||||
echo "$filtered_records" > "$temp_records_file"
|
||||
# Process records line by line using a simple loop
|
||||
local filtered_data
|
||||
filtered_data=$(echo "$records" | grep -v "^mysql:" | grep -v "^$")
|
||||
|
||||
# Process each record from the temporary file
|
||||
while IFS=$'\t' read -r record_id student_id student_no new_class_id operator_id; do
|
||||
# Skip empty lines or invalid records
|
||||
[[ -z "$record_id" || -z "$student_id" || -z "$student_no" || -z "$new_class_id" ]] && continue
|
||||
log "DEBUG" "过滤后数据行数: $(echo "$filtered_data" | wc -l)"
|
||||
log "DEBUG" "过滤后数据内容预览:"
|
||||
echo "$filtered_data" | head -3 | while read -r preview_line; do
|
||||
log "DEBUG" " DATA: $preview_line"
|
||||
done
|
||||
# Create temporary file for processing to avoid subshell variable scope issues
|
||||
local temp_file="/tmp/student_class_change_process_$$.txt"
|
||||
echo "$filtered_data" > "$temp_file"
|
||||
|
||||
# Log the record being processed for debugging
|
||||
log "INFO" "读取到记录 - ID: $record_id, 学生ID: $student_id, 学号: $student_no, 新班级ID: $new_class_id, 操作员: ${operator_id:-1}"
|
||||
# Process records from temporary file (variables will be in same shell)
|
||||
local is_first_line=true
|
||||
while IFS= read -r line; do
|
||||
# Parse the tab-separated values for processing
|
||||
IFS=$'\t' read -r record_id student_id student_no new_class_id operator_id <<< "$line"
|
||||
|
||||
# Skip completely empty lines
|
||||
if [[ -z "$record_id$student_id$student_no$new_class_id$operator_id" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Skip header line (first non-empty line that looks like column names)
|
||||
if [[ $is_first_line == true ]]; then
|
||||
is_first_line=false
|
||||
# Check if this looks like a header line
|
||||
if [[ "$record_id" == "id" || "$record_id" =~ ^[[:space:]]*id[[:space:]]*$ ]]; then
|
||||
log "INFO" "跳过标题行"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
# Validate required fields
|
||||
if [[ -z "$record_id" || -z "$student_id" || -z "$student_no" || -z "$new_class_id" ]]; then
|
||||
log "WARN" "跳过无效记录 - ID: '$record_id'"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Log the record being processed
|
||||
log "INFO" "处理记录 - ID: $record_id, 学号: $student_no"
|
||||
|
||||
local retry_count=0
|
||||
local success=false
|
||||
|
||||
while [[ $retry_count -lt $MAX_RETRY && $success == false ]]; do
|
||||
log "DEBUG" "开始调用process_student_class_change函数,参数: $record_id, $student_id, $student_no, $new_class_id, $operator_id"
|
||||
if process_student_class_change "$record_id" "$student_id" "$student_no" "$new_class_id" "$operator_id"; then
|
||||
success=true
|
||||
((processed_count++))
|
||||
log "INFO" "成功处理记录 $record_id (总计: $processed_count)"
|
||||
log "DEBUG" "函数调用成功,success=$success, processed_count=$processed_count"
|
||||
else
|
||||
((retry_count++))
|
||||
log "DEBUG" "函数调用失败,retry_count=$retry_count, MAX_RETRY=$MAX_RETRY"
|
||||
if [[ $retry_count -lt $MAX_RETRY ]]; then
|
||||
log "WARN" "处理失败,$RETRY_DELAY 秒后重试... (尝试 $retry_count/$MAX_RETRY)"
|
||||
log "WARN" "处理失败,重试中..."
|
||||
sleep $RETRY_DELAY
|
||||
fi
|
||||
fi
|
||||
@@ -316,13 +348,70 @@ main() {
|
||||
|
||||
if [[ $success == false ]]; then
|
||||
((failed_count++))
|
||||
log "ERROR" "$MAX_RETRY 次尝试后仍处理失败 - ID: $record_id"
|
||||
log "ERROR" "记录 $record_id 处理失败"
|
||||
fi
|
||||
((line_count++))
|
||||
echo "DEBUG: 处理第 $line_count 行: $line" >&2
|
||||
log "DEBUG" "处理第 $line_count 行: $line"
|
||||
# Parse the tab-separated values for processing
|
||||
IFS=$'\t' read -r record_id student_id student_no new_class_id operator_id <<< "$line"
|
||||
|
||||
# Skip completely empty lines
|
||||
if [[ -z "$record_id$student_id$student_no$new_class_id$operator_id" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
done < "$temp_records_file"
|
||||
# Skip header line (first non-empty line that looks like column names)
|
||||
if [[ $is_first_line == true ]]; then
|
||||
is_first_line=false
|
||||
if [[ "$record_id" == "id" || "$record_id" =~ ^[[:space:]]*id[[:space:]]*$ ]]; then
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
# Validate required fields
|
||||
if [[ -z "$record_id" || -z "$student_id" || -z "$student_no" || -z "$new_class_id" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Log the record being processed
|
||||
log "INFO" "处理记录 - ID: $record_id, 学号: $student_no"
|
||||
|
||||
local retry_count=0
|
||||
local success=false
|
||||
|
||||
while [[ $retry_count -lt $MAX_RETRY && $success == false ]]; do
|
||||
log "DEBUG" "开始调用process_student_class_change函数,参数: $record_id, $student_id, $student_no, $new_class_id, $operator_id"
|
||||
if process_student_class_change "$record_id" "$student_id" "$student_no" "$new_class_id" "$operator_id"; then
|
||||
success=true
|
||||
((processed_count++))
|
||||
log "INFO" "成功处理记录 $record_id (总计: $processed_count)"
|
||||
log "DEBUG" "函数调用成功,success=$success, processed_count=$processed_count"
|
||||
else
|
||||
((retry_count++))
|
||||
log "DEBUG" "函数调用失败,retry_count=$retry_count, MAX_RETRY=$MAX_RETRY"
|
||||
if [[ $retry_count -lt $MAX_RETRY ]]; then
|
||||
log "WARN" "处理失败,重试中..."
|
||||
sleep $RETRY_DELAY
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $success == false ]]; then
|
||||
((failed_count++))
|
||||
log "ERROR" "记录 $record_id 处理失败"
|
||||
fi
|
||||
|
||||
log "DEBUG" "完成处理记录 $record_id,继续下一条"
|
||||
|
||||
done < "$temp_file"
|
||||
|
||||
log "DEBUG" "所有记录处理完成,processed_count=$processed_count, failed_count=$failed_count"
|
||||
|
||||
# Clean up temporary file
|
||||
rm -f "$temp_records_file"
|
||||
rm -f "$temp_file"
|
||||
|
||||
log "INFO" "处理周期完成 - 已处理: $processed_count, 失败: $failed_count"
|
||||
|
||||
log "INFO" "处理周期完成 - 已处理: $processed_count, 失败: $failed_count"
|
||||
fi
|
||||
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "测试here string..."
|
||||
|
||||
data="line1
|
||||
line2
|
||||
line3"
|
||||
|
||||
echo "数据内容:"
|
||||
echo "$data"
|
||||
echo "---"
|
||||
|
||||
echo "使用here string:"
|
||||
count=0
|
||||
while IFS= read -r line; do
|
||||
((count++))
|
||||
echo "处理第 $count 行: $line"
|
||||
done <<< "$data"
|
||||
|
||||
echo "测试完成"
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "开始测试循环..."
|
||||
|
||||
data="line1
|
||||
line2
|
||||
line3"
|
||||
|
||||
echo "数据行数: $(echo "$data" | wc -l)"
|
||||
|
||||
echo "进入循环..."
|
||||
count=0
|
||||
while IFS= read -r line; do
|
||||
((count++))
|
||||
echo "处理第 $count 行: $line"
|
||||
if [[ $count -eq 1 ]]; then
|
||||
echo "只处理第一行,退出测试"
|
||||
break
|
||||
fi
|
||||
done <<< "$data"
|
||||
|
||||
echo "循环结束"
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "测试管道方式..."
|
||||
|
||||
data="line1
|
||||
line2
|
||||
line3"
|
||||
|
||||
echo "数据内容:"
|
||||
echo "$data"
|
||||
echo "---"
|
||||
|
||||
echo "使用管道:"
|
||||
count=0
|
||||
echo "$data" | while IFS= read -r line; do
|
||||
((count++))
|
||||
echo "处理第 $count 行: $line"
|
||||
done
|
||||
|
||||
echo "管道测试完成, count=$count"
|
||||
Reference in new issue
Block a user