This commit is contained in:
zlwu committed 2026-01-04 10:00:08 +08:00
1 parent 48f15745be
commit e421030ada
72 files changed
+17064 -35

No files matched your search

@@ -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());
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
@@ -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();
}
@@ -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. 插入新的班主任关联记录
@@ -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);
// 构建部门树
@@ -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) {
@@ -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
**兼容性**: 向后兼容
+125
View File
@@ -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): 初始版本,支持完整迁移流程
- 支持断点续传和错误恢复
- 自动验证和报告生成
@@ -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
+56
View File
@@ -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
+66
View File
@@ -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
+20
View File
@@ -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 "测试完成"
+24
View File
@@ -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 "循环结束"
+20
View File
@@ -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"