pangu-user-platform/docs/05-模块技术方案/学生管理/03-后端技术方案.md

42 KiB
Raw Permalink Blame History

学生管理模块 - 后端技术方案


文档信息 内容
文档版本 V1.0
项目名称 盘古用户平台Pangu User Platform
模块名称 学生管理模块 - 后端
编写团队 pangu
创建日期 2026-01-31
审核状态 待审核

目录

  1. 技术栈说明
  2. 模块结构
  3. 数据库设计
  4. 实体设计
  5. 接口设计
  6. 服务层设计
  7. 批量导入设计
  8. 数据权限控制
  9. 异常处理
  10. 单元测试

1. 技术栈说明

技术 版本 用途
Spring Boot 3.3.x 应用框架
Spring Security 6.x 安全框架
MyBatis Plus 3.5.x ORM框架
EasyExcel 4.x Excel处理
Hutool 5.x 工具库
Lombok 1.18.x 代码简化
Validation 3.x 参数校验
JDK 17+ 运行环境

2. 模块结构

pangu-system/
├── src/main/java/com/pangu/system/
│   ├── controller/
│   │   └── StudentController.java          # 学生管理控制器
│   ├── domain/
│   │   ├── Student.java                    # 学生实体
│   │   ├── dto/
│   │   │   ├── StudentDTO.java             # 学生传输对象
│   │   │   ├── StudentQueryDTO.java        # 学生查询条件
│   │   │   └── StudentImportDTO.java       # 学生导入对象
│   │   └── vo/
│   │       ├── StudentVO.java              # 学生视图对象
│   │       ├── StudentDetailVO.java        # 学生详情视图对象
│   │       └── ImportResultVO.java         # 导入结果视图对象
│   ├── mapper/
│   │   └── StudentMapper.java              # 学生Mapper接口
│   ├── service/
│   │   ├── IStudentService.java            # 学生服务接口
│   │   └── impl/
│   │       └── StudentServiceImpl.java     # 学生服务实现
│   └── listener/
│       └── StudentImportListener.java      # Excel导入监听器
└── src/main/resources/
    └── mapper/
        └── StudentMapper.xml               # MyBatis映射文件

3. 数据库设计

3.1 学生表pg_student

CREATE TABLE `pg_student` (
  `student_id` bigint NOT NULL AUTO_INCREMENT COMMENT '学生ID',
  `student_name` varchar(50) NOT NULL COMMENT '学生姓名',
  `student_no` varchar(32) DEFAULT NULL COMMENT '学号',
  `gender` char(1) DEFAULT '0' COMMENT '性别0未知 1男 2女',
  `birthday` date DEFAULT NULL COMMENT '出生年月',
  `region_id` bigint NOT NULL COMMENT '所属区域ID',
  `region_path` varchar(200) DEFAULT NULL COMMENT '区域路径',
  `school_id` bigint NOT NULL COMMENT '所属学校ID',
  `school_grade_id` bigint NOT NULL COMMENT '所属学校年级ID',
  `school_class_id` bigint NOT NULL COMMENT '所属学校班级ID',
  `subject_id` bigint DEFAULT NULL COMMENT '学科ID',
  `member_id` bigint NOT NULL COMMENT '归属会员ID',
  `status` char(1) DEFAULT '0' COMMENT '状态0正常 1停用',
  `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `del_flag` char(1) DEFAULT '0' COMMENT '删除标志0存在 1删除',
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`student_id`),
  UNIQUE KEY `uk_student_no` (`student_no`),
  KEY `idx_member_id` (`member_id`),
  KEY `idx_school_id` (`school_id`),
  KEY `idx_school_class_id` (`school_class_id`),
  KEY `idx_student_name` (`student_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生表';

3.2 初始化数据

-- 学生示例数据
INSERT INTO pg_student (student_id, student_name, student_no, gender, birthday, region_id, region_path, school_id, school_grade_id, school_class_id, member_id, status, create_time) VALUES
(1, '张小明', 'STU20260001', '1', '2015-03-15', 111, '湖北省-武汉市-武昌区', 1, 1, 1, 1, '0', NOW()),
(2, '张小红', 'STU20260002', '2', '2017-06-20', 111, '湖北省-武汉市-武昌区', 3, 4, 1, 1, '0', NOW()),
(3, '李明明', 'STU20260003', '1', '2015-09-10', 111, '湖北省-武汉市-武昌区', 1, 1, 2, 2, '0', NOW());

4. 实体设计

4.1 Student.java - 学生实体

package com.pangu.system.domain;

import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;

import java.io.Serializable;
import java.time.LocalDate;
import java.time.LocalDateTime;

/**
 * 学生实体
 * 
 * @author pangu
 */
@Data
@TableName("pg_student")
public class Student implements Serializable {
    
    private static final long serialVersionUID = 1L;

    /** 学生ID */
    @TableId(type = IdType.AUTO)
    private Long studentId;

    /** 学生姓名 */
    private String studentName;

    /** 学号 */
    private String studentNo;

    /** 性别0未知 1男 2女 */
    private String gender;

    /** 出生年月 */
    @JsonFormat(pattern = "yyyy-MM")
    private LocalDate birthday;

    /** 所属区域ID */
    private Long regionId;

    /** 区域路径 */
    private String regionPath;

    /** 所属学校ID */
    private Long schoolId;

    /** 所属学校年级ID */
    private Long schoolGradeId;

    /** 所属学校班级ID */
    private Long schoolClassId;

    /** 学科ID */
    private Long subjectId;

    /** 归属会员ID */
    private Long memberId;

    /** 状态0正常 1停用 */
    private String status;

    /** 创建者 */
    @TableField(fill = FieldFill.INSERT)
    private String createBy;

    /** 创建时间 */
    @TableField(fill = FieldFill.INSERT)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;

    /** 更新者 */
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private String updateBy;

    /** 更新时间 */
    @TableField(fill = FieldFill.INSERT_UPDATE)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateTime;

    /** 删除标志0存在 1删除 */
    @TableLogic
    private String delFlag;

    /** 备注 */
    private String remark;
}

4.2 StudentDTO.java - 学生传输对象

package com.pangu.system.domain.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;

import java.time.LocalDate;
import java.util.List;

/**
 * 学生传输对象
 * 
 * @author pangu
 */
@Data
public class StudentDTO {

    /** 学生ID编辑时必填 */
    private Long studentId;

    /** 学生姓名 */
    @NotBlank(message = "学生姓名不能为空")
    @Size(max = 50, message = "学生姓名最大50个字符")
    private String studentName;

    /** 学号 */
    @Size(max = 32, message = "学号最大32个字符")
    private String studentNo;

    /** 性别0未知 1男 2女 */
    private String gender;

    /** 出生年月 */
    private LocalDate birthday;

    /** 区域ID数组级联选择器 */
    private List<Long> regionIds;

    /** 所属区域ID */
    @NotNull(message = "请选择区域")
    private Long regionId;

    /** 所属学校ID */
    @NotNull(message = "请选择学校")
    private Long schoolId;

    /** 所属学校年级ID */
    @NotNull(message = "请选择年级")
    private Long schoolGradeId;

    /** 所属学校班级ID */
    @NotNull(message = "请选择班级")
    private Long schoolClassId;

    /** 学科ID */
    private Long subjectId;

    /** 归属会员ID */
    @NotNull(message = "请选择归属用户")
    private Long memberId;

    /** 备注 */
    private String remark;
}

4.3 StudentQueryDTO.java - 学生查询条件

package com.pangu.system.domain.dto;

import lombok.Data;

/**
 * 学生查询条件
 * 
 * @author pangu
 */
@Data
public class StudentQueryDTO {

    /** 学生姓名(模糊查询) */
    private String studentName;

    /** 学号 */
    private String studentNo;

    /** 性别 */
    private String gender;

    /** 学校ID */
    private Long schoolId;

    /** 学校年级ID */
    private Long schoolGradeId;

    /** 学校班级ID */
    private Long schoolClassId;

    /** 学科ID */
    private Long subjectId;

    /** 归属用户手机号 */
    private String memberPhone;

    /** 页码 */
    private Integer pageNum = 1;

    /** 每页条数 */
    private Integer pageSize = 10;
}

4.4 StudentVO.java - 学生视图对象

package com.pangu.system.domain.vo;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;

import java.time.LocalDate;
import java.time.LocalDateTime;

/**
 * 学生视图对象
 * 
 * @author pangu
 */
@Data
public class StudentVO {

    /** 学生ID */
    private Long studentId;

    /** 学生姓名 */
    private String studentName;

    /** 学号 */
    private String studentNo;

    /** 性别0未知 1男 2女 */
    private String gender;

    /** 出生年月 */
    @JsonFormat(pattern = "yyyy-MM")
    private LocalDate birthday;

    /** 区域路径 */
    private String regionPath;

    /** 学校名称 */
    private String schoolName;

    /** 年级名称 */
    private String gradeName;

    /** 班级名称 */
    private String className;

    /** 学科名称 */
    private String subjectName;

    /** 归属会员ID */
    private Long memberId;

    /** 会员昵称 */
    private String memberNickname;

    /** 会员手机号(脱敏) */
    private String memberPhone;

    /** 创建时间 */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;
}

4.5 StudentImportDTO.java - 学生导入对象

package com.pangu.system.domain.dto;

import com.alibaba.excel.annotation.ExcelProperty;
import lombok.Data;

/**
 * 学生导入对象Excel解析
 * 
 * @author pangu
 */
@Data
public class StudentImportDTO {

    @ExcelProperty(value = "姓名", index = 0)
    private String studentName;

    @ExcelProperty(value = "学号", index = 1)
    private String studentNo;

    @ExcelProperty(value = "用户手机号", index = 2)
    private String memberPhone;

    @ExcelProperty(value = "区域", index = 3)
    private String regionPath;

    @ExcelProperty(value = "学校", index = 4)
    private String schoolName;

    @ExcelProperty(value = "年级", index = 5)
    private String gradeName;

    @ExcelProperty(value = "班级", index = 6)
    private String className;

    @ExcelProperty(value = "性别", index = 7)
    private String gender;

    @ExcelProperty(value = "出生年月", index = 8)
    private String birthday;

    /** 行号(用于错误提示) */
    private Integer rowIndex;
}

4.6 ImportResultVO.java - 导入结果视图对象

package com.pangu.system.domain.vo;

import lombok.Data;

import java.util.ArrayList;
import java.util.List;

/**
 * 导入结果视图对象
 * 
 * @author pangu
 */
@Data
public class ImportResultVO {

    /** 成功数量 */
    private int successCount = 0;

    /** 失败数量 */
    private int failCount = 0;

    /** 失败列表 */
    private List<FailItem> failList = new ArrayList<>();

    /**
     * 添加失败记录
     */
    public void addFail(int row, String reason) {
        this.failCount++;
        this.failList.add(new FailItem(row, reason));
    }

    /**
     * 增加成功数量
     */
    public void addSuccess() {
        this.successCount++;
    }

    /**
     * 失败项
     */
    @Data
    public static class FailItem {
        private int row;
        private String reason;

        public FailItem(int row, String reason) {
            this.row = row;
            this.reason = reason;
        }
    }
}

5. 接口设计

5.1 StudentController.java

package com.pangu.system.controller;

import com.pangu.common.core.controller.BaseController;
import com.pangu.common.core.domain.AjaxResult;
import com.pangu.common.core.page.TableDataInfo;
import com.pangu.system.domain.dto.StudentDTO;
import com.pangu.system.domain.dto.StudentQueryDTO;
import com.pangu.system.service.IStudentService;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

/**
 * 学生管理控制器
 * 
 * @author pangu
 */
@RestController
@RequestMapping("/api/student")
@RequiredArgsConstructor
@Validated
public class StudentController extends BaseController {

    private final IStudentService studentService;

    /**
     * 查询学生列表
     */
    @PreAuthorize("@ss.hasPermi('system:student:list')")
    @GetMapping("/list")
    public TableDataInfo list(StudentQueryDTO query) {
        startPage();
        return getDataTable(studentService.selectStudentList(query));
    }

    /**
     * 获取学生详情
     */
    @PreAuthorize("@ss.hasPermi('system:student:query')")
    @GetMapping("/{studentId}")
    public AjaxResult getInfo(@PathVariable Long studentId) {
        return success(studentService.selectStudentById(studentId));
    }

    /**
     * 新增学生
     */
    @PreAuthorize("@ss.hasPermi('system:student:add')")
    @PostMapping
    public AjaxResult add(@Valid @RequestBody StudentDTO dto) {
        return toAjax(studentService.insertStudent(dto));
    }

    /**
     * 修改学生
     */
    @PreAuthorize("@ss.hasPermi('system:student:edit')")
    @PutMapping
    public AjaxResult edit(@Valid @RequestBody StudentDTO dto) {
        return toAjax(studentService.updateStudent(dto));
    }

    /**
     * 删除学生
     */
    @PreAuthorize("@ss.hasPermi('system:student:remove')")
    @DeleteMapping("/{studentId}")
    public AjaxResult remove(@PathVariable Long studentId) {
        return toAjax(studentService.deleteStudentById(studentId));
    }

    /**
     * 下载导入模板
     */
    @PreAuthorize("@ss.hasPermi('system:student:import')")
    @GetMapping("/template")
    public void downloadTemplate(HttpServletResponse response) {
        studentService.downloadTemplate(response);
    }

    /**
     * 批量导入学生
     */
    @PreAuthorize("@ss.hasPermi('system:student:import')")
    @PostMapping("/import")
    public AjaxResult importData(MultipartFile file) {
        return success(studentService.importStudents(file));
    }
}

5.2 接口权限配置

接口 权限标识 说明
GET /api/student/list system🧑‍🎓list 查询学生列表
GET /api/student/{id} system🧑‍🎓query 查询学生详情
POST /api/student system🧑‍🎓add 新增学生
PUT /api/student system🧑‍🎓edit 修改学生
DELETE /api/student/{id} system🧑‍🎓remove 删除学生
GET /api/student/template system🧑‍🎓import 下载导入模板
POST /api/student/import system🧑‍🎓import 批量导入

6. 服务层设计

6.1 IStudentService.java - 服务接口

package com.pangu.system.service;

import com.pangu.system.domain.dto.StudentDTO;
import com.pangu.system.domain.dto.StudentQueryDTO;
import com.pangu.system.domain.vo.ImportResultVO;
import com.pangu.system.domain.vo.StudentDetailVO;
import com.pangu.system.domain.vo.StudentVO;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

/**
 * 学生服务接口
 * 
 * @author pangu
 */
public interface IStudentService {

    /**
     * 查询学生列表
     * 
     * @param query 查询条件
     * @return 学生列表
     */
    List<StudentVO> selectStudentList(StudentQueryDTO query);

    /**
     * 根据ID查询学生详情
     * 
     * @param studentId 学生ID
     * @return 学生详情
     */
    StudentDetailVO selectStudentById(Long studentId);

    /**
     * 新增学生
     * 
     * @param dto 学生数据
     * @return 影响行数
     */
    int insertStudent(StudentDTO dto);

    /**
     * 修改学生
     * 
     * @param dto 学生数据
     * @return 影响行数
     */
    int updateStudent(StudentDTO dto);

    /**
     * 删除学生
     * 
     * @param studentId 学生ID
     * @return 影响行数
     */
    int deleteStudentById(Long studentId);

    /**
     * 下载导入模板
     * 
     * @param response HTTP响应
     */
    void downloadTemplate(HttpServletResponse response);

    /**
     * 批量导入学生
     * 
     * @param file Excel文件
     * @return 导入结果
     */
    ImportResultVO importStudents(MultipartFile file);

    /**
     * 校验学号唯一性
     * 
     * @param studentNo 学号
     * @param studentId 学生ID编辑时排除自己
     * @return 是否唯一
     */
    boolean checkStudentNoUnique(String studentNo, Long studentId);
}

6.2 StudentServiceImpl.java - 服务实现

package com.pangu.system.service.impl;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.pangu.common.core.exception.ServiceException;
import com.pangu.common.security.utils.SecurityUtils;
import com.pangu.system.domain.Member;
import com.pangu.system.domain.Student;
import com.pangu.system.domain.dto.StudentDTO;
import com.pangu.system.domain.dto.StudentImportDTO;
import com.pangu.system.domain.dto.StudentQueryDTO;
import com.pangu.system.domain.vo.ImportResultVO;
import com.pangu.system.domain.vo.StudentDetailVO;
import com.pangu.system.domain.vo.StudentVO;
import com.pangu.system.mapper.StudentMapper;
import com.pangu.system.service.*;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;

/**
 * 学生服务实现
 * 
 * @author pangu
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class StudentServiceImpl extends ServiceImpl<StudentMapper, Student> implements IStudentService {

    private final StudentMapper studentMapper;
    private final IMemberService memberService;
    private final ISchoolService schoolService;
    private final IRegionService regionService;
    private final IGradeService gradeService;
    private final IClassService classService;

    /**
     * 查询学生列表
     */
    @Override
    public List<StudentVO> selectStudentList(StudentQueryDTO query) {
        return studentMapper.selectStudentVOList(query);
    }

    /**
     * 根据ID查询学生详情
     */
    @Override
    public StudentDetailVO selectStudentById(Long studentId) {
        return studentMapper.selectStudentDetailById(studentId);
    }

    /**
     * 新增学生
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int insertStudent(StudentDTO dto) {
        // 校验学号唯一性
        if (StrUtil.isNotBlank(dto.getStudentNo()) && !checkStudentNoUnique(dto.getStudentNo(), null)) {
            throw new ServiceException("学号已存在");
        }
        
        // 构建区域路径
        String regionPath = regionService.buildRegionPath(dto.getRegionId());
        
        Student student = new Student();
        BeanUtil.copyProperties(dto, student);
        student.setRegionPath(regionPath);
        student.setCreateBy(SecurityUtils.getUsername());
        
        return studentMapper.insert(student);
    }

    /**
     * 修改学生
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int updateStudent(StudentDTO dto) {
        // 校验学生存在
        Student existStudent = studentMapper.selectById(dto.getStudentId());
        if (existStudent == null) {
            throw new ServiceException("学生不存在");
        }
        
        // 校验学号唯一性
        if (StrUtil.isNotBlank(dto.getStudentNo()) && !checkStudentNoUnique(dto.getStudentNo(), dto.getStudentId())) {
            throw new ServiceException("学号已存在");
        }
        
        // 构建区域路径
        String regionPath = regionService.buildRegionPath(dto.getRegionId());
        
        Student student = new Student();
        BeanUtil.copyProperties(dto, student);
        student.setRegionPath(regionPath);
        student.setUpdateBy(SecurityUtils.getUsername());
        
        return studentMapper.updateById(student);
    }

    /**
     * 删除学生
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int deleteStudentById(Long studentId) {
        return studentMapper.deleteById(studentId);
    }

    /**
     * 下载导入模板
     */
    @Override
    public void downloadTemplate(HttpServletResponse response) {
        try {
            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
            response.setCharacterEncoding("utf-8");
            String fileName = URLEncoder.encode("学生导入模板", StandardCharsets.UTF_8).replace("\\+", "%20");
            response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
            
            // 生成模板数据
            List<StudentImportDTO> templateData = new ArrayList<>();
            StudentImportDTO example = new StudentImportDTO();
            example.setStudentName("张小明");
            example.setStudentNo("STU20260001");
            example.setMemberPhone("13812345678");
            example.setRegionPath("湖北省-武汉市-武昌区");
            example.setSchoolName("武汉市第一中学");
            example.setGradeName("七年级");
            example.setClassName("1班");
            example.setGender("男");
            example.setBirthday("2015-03");
            templateData.add(example);
            
            EasyExcel.write(response.getOutputStream(), StudentImportDTO.class)
                    .sheet("学生信息")
                    .doWrite(templateData);
        } catch (IOException e) {
            log.error("下载模板失败", e);
            throw new ServiceException("下载模板失败");
        }
    }

    /**
     * 批量导入学生
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public ImportResultVO importStudents(MultipartFile file) {
        ImportResultVO result = new ImportResultVO();
        
        try {
            List<StudentImportDTO> importList = new ArrayList<>();
            
            // 解析Excel
            EasyExcel.read(file.getInputStream(), StudentImportDTO.class, 
                new AnalysisEventListener<StudentImportDTO>() {
                    private int rowIndex = 1;
                    
                    @Override
                    public void invoke(StudentImportDTO data, AnalysisContext context) {
                        data.setRowIndex(++rowIndex);
                        importList.add(data);
                    }
                    
                    @Override
                    public void doAfterAllAnalysed(AnalysisContext context) {
                        log.info("解析完成,共{}条数据", importList.size());
                    }
                }).sheet().doRead();
            
            // 校验数据量
            if (importList.size() > 1000) {
                throw new ServiceException("单次导入数据量不能超过1000条");
            }
            
            // 逐条处理
            for (StudentImportDTO dto : importList) {
                try {
                    processImportRow(dto);
                    result.addSuccess();
                } catch (Exception e) {
                    result.addFail(dto.getRowIndex(), e.getMessage());
                }
            }
            
        } catch (IOException e) {
            log.error("导入失败", e);
            throw new ServiceException("文件解析失败");
        }
        
        return result;
    }

    /**
     * 处理导入的单行数据
     */
    private void processImportRow(StudentImportDTO dto) {
        // 1. 校验必填字段
        validateImportData(dto);
        
        // 2. 校验学号唯一性
        if (StrUtil.isNotBlank(dto.getStudentNo()) && !checkStudentNoUnique(dto.getStudentNo(), null)) {
            throw new ServiceException("学号已存在");
        }
        
        // 3. 解析区域
        Long regionId = regionService.getRegionIdByPath(dto.getRegionPath());
        if (regionId == null) {
            throw new ServiceException("区域信息不存在");
        }
        
        // 4. 解析学校
        Long schoolId = schoolService.getSchoolIdByName(dto.getSchoolName(), regionId);
        if (schoolId == null) {
            throw new ServiceException("学校信息不存在");
        }
        
        // 5. 解析年级
        Long schoolGradeId = schoolService.getSchoolGradeId(schoolId, dto.getGradeName());
        if (schoolGradeId == null) {
            throw new ServiceException("年级信息不存在");
        }
        
        // 6. 解析班级
        Long schoolClassId = schoolService.getSchoolClassId(schoolGradeId, dto.getClassName());
        if (schoolClassId == null) {
            throw new ServiceException("班级信息不存在");
        }
        
        // 7. 处理会员
        Long memberId = getOrCreateMember(dto.getMemberPhone());
        
        // 8. 保存学生
        Student student = new Student();
        student.setStudentName(dto.getStudentName());
        student.setStudentNo(dto.getStudentNo());
        student.setGender(parseGender(dto.getGender()));
        student.setBirthday(parseBirthday(dto.getBirthday()));
        student.setRegionId(regionId);
        student.setRegionPath(dto.getRegionPath());
        student.setSchoolId(schoolId);
        student.setSchoolGradeId(schoolGradeId);
        student.setSchoolClassId(schoolClassId);
        student.setMemberId(memberId);
        student.setCreateBy(SecurityUtils.getUsername());
        
        studentMapper.insert(student);
    }

    /**
     * 校验导入数据
     */
    private void validateImportData(StudentImportDTO dto) {
        if (StrUtil.isBlank(dto.getStudentName())) {
            throw new ServiceException("姓名不能为空");
        }
        if (StrUtil.isBlank(dto.getStudentNo())) {
            throw new ServiceException("学号不能为空");
        }
        if (StrUtil.isBlank(dto.getMemberPhone())) {
            throw new ServiceException("用户手机号不能为空");
        }
        if (StrUtil.isBlank(dto.getRegionPath())) {
            throw new ServiceException("区域不能为空");
        }
        if (StrUtil.isBlank(dto.getSchoolName())) {
            throw new ServiceException("学校不能为空");
        }
        if (StrUtil.isBlank(dto.getGradeName())) {
            throw new ServiceException("年级不能为空");
        }
        if (StrUtil.isBlank(dto.getClassName())) {
            throw new ServiceException("班级不能为空");
        }
    }

    /**
     * 获取或创建会员
     */
    private Long getOrCreateMember(String phone) {
        // 查询已有会员
        Member member = memberService.selectMemberByPhone(phone);
        if (member != null) {
            return member.getMemberId();
        }
        
        // 创建新会员身份家长初始密码123456
        return memberService.createMemberForImport(phone, "123456");
    }

    /**
     * 解析性别
     */
    private String parseGender(String gender) {
        if ("男".equals(gender)) return "1";
        if ("女".equals(gender)) return "2";
        return "0";
    }

    /**
     * 解析出生日期
     */
    private LocalDate parseBirthday(String birthday) {
        if (StrUtil.isBlank(birthday)) return null;
        try {
            return LocalDate.parse(birthday + "-01", DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * 校验学号唯一性
     */
    @Override
    public boolean checkStudentNoUnique(String studentNo, Long studentId) {
        LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(Student::getStudentNo, studentNo);
        if (studentId != null) {
            wrapper.ne(Student::getStudentId, studentId);
        }
        return count(wrapper) == 0;
    }
}

7. 批量导入设计

7.1 导入流程图

┌─────────────────────────────────────────────────────────────────┐
│                       批量导入处理流程                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  接收文件 ──► 校验文件类型 ──► 解析Excel ──► 校验数据量            │
│                                                │                 │
│                                                ▼                 │
│                                    ┌─────────────────────┐       │
│                                    │    逐行处理数据      │       │
│                                    └──────────┬──────────┘       │
│                                               │                  │
│      ┌────────────────────────────────────────┼────────────────┐│
│      ▼                                        ▼                ▼│
│  校验必填字段                            校验业务数据       处理会员关联│
│      │                                        │                ││
│      ▼                                        ▼                ▼│
│  字段格式校验                            区域/学校匹配      查询或创建会员│
│      │                                        │                ││
│      ▼                                        ▼                ▼│
│  学号唯一性校验                          年级/班级匹配      返回会员ID │
│      │                                        │                ││
│      └────────────────────────────────────────┼────────────────┘│
│                                               │                  │
│                                               ▼                  │
│                                       ┌─────────────┐           │
│                                       │  保存学生   │           │
│                                       └──────┬──────┘           │
│                                              │                   │
│                        ┌─────────────────────┼─────────────────┐│
│                        ▼                     ▼                 ▼│
│                     成功                  失败            异常捕获│
│                   计数+1               记录行号和原因    记录错误  │
│                        │                     │                 ││
│                        └─────────────────────┴─────────────────┘│
│                                              │                   │
│                                              ▼                   │
│                                       返回导入结果               │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

7.2 会员自动创建规则

场景 处理方式
手机号已存在 直接关联到已有会员
手机号不存在 自动创建家长会员

自动创建会员的属性:

属性
手机号 导入数据中的手机号
昵称 自动生成(用户+后4位手机号
身份类型 家长1
初始密码 123456BCrypt加密
注册来源 批量导入4
状态 正常0

7.3 错误处理策略

错误类型 处理方式 是否继续处理后续数据
文件格式错误 立即返回错误
数据量超限 立即返回错误
必填字段为空 记录错误,跳过当前行
学号重复 记录错误,跳过当前行
区域/学校不匹配 记录错误,跳过当前行
数据库异常 记录错误,跳过当前行

8. 数据权限控制

8.1 数据权限注解

/**
 * 数据权限注解
 */
@DataScope(deptAlias = "s", userAlias = "u")
public List<StudentVO> selectStudentList(StudentQueryDTO query) {
    return studentMapper.selectStudentVOList(query);
}

8.2 Mapper SQL数据权限

<!-- StudentMapper.xml -->
<select id="selectStudentVOList" resultType="StudentVO">
    SELECT 
        s.student_id,
        s.student_name,
        s.student_no,
        s.gender,
        s.birthday,
        s.region_path,
        sch.school_name,
        g.grade_name,
        c.class_name,
        sub.subject_name,
        s.member_id,
        m.nickname AS member_nickname,
        CONCAT(LEFT(m.phone, 3), '****', RIGHT(m.phone, 4)) AS member_phone,
        s.create_time
    FROM pg_student s
    LEFT JOIN pg_school sch ON s.school_id = sch.school_id
    LEFT JOIN pg_school_grade sg ON s.school_grade_id = sg.id
    LEFT JOIN pg_grade g ON sg.grade_id = g.grade_id
    LEFT JOIN pg_school_class sc ON s.school_class_id = sc.id
    LEFT JOIN pg_class c ON sc.class_id = c.class_id
    LEFT JOIN pg_subject sub ON s.subject_id = sub.subject_id
    LEFT JOIN pg_member m ON s.member_id = m.member_id
    WHERE s.del_flag = '0'
    <if test="studentName != null and studentName != ''">
        AND s.student_name LIKE CONCAT('%', #{studentName}, '%')
    </if>
    <if test="studentNo != null and studentNo != ''">
        AND s.student_no = #{studentNo}
    </if>
    <if test="gender != null and gender != ''">
        AND s.gender = #{gender}
    </if>
    <if test="schoolId != null">
        AND s.school_id = #{schoolId}
    </if>
    <if test="schoolGradeId != null">
        AND s.school_grade_id = #{schoolGradeId}
    </if>
    <if test="schoolClassId != null">
        AND s.school_class_id = #{schoolClassId}
    </if>
    <if test="subjectId != null">
        AND s.subject_id = #{subjectId}
    </if>
    <if test="memberPhone != null and memberPhone != ''">
        AND m.phone LIKE CONCAT('%', #{memberPhone}, '%')
    </if>
    <!-- 数据权限过滤 -->
    ${params.dataScope}
    ORDER BY s.create_time DESC
</select>

8.3 数据权限SQL生成规则

角色 生成的SQL条件
超级管理员 无附加条件
分公司用户 AND s.region_id IN (用户所属区域及子区域)
学校用户 AND s.school_id = 用户所属学校ID

9. 异常处理

9.1 业务异常定义

/**
 * 学生业务异常
 */
public class StudentException extends ServiceException {
    
    public StudentException(String message) {
        super(message);
    }
    
    public static StudentException studentNotFound() {
        return new StudentException("学生不存在");
    }
    
    public static StudentException studentNoExists() {
        return new StudentException("学号已存在");
    }
    
    public static StudentException importFailed(String reason) {
        return new StudentException("导入失败:" + reason);
    }
}

9.2 全局异常处理

/**
 * 全局异常处理器
 */
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(ServiceException.class)
    public AjaxResult handleServiceException(ServiceException e) {
        log.error("业务异常:{}", e.getMessage());
        return AjaxResult.error(e.getMessage());
    }
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public AjaxResult handleValidationException(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getFieldErrors().stream()
                .map(FieldError::getDefaultMessage)
                .findFirst()
                .orElse("参数校验失败");
        return AjaxResult.error(message);
    }
}

10. 单元测试

10.1 服务层测试

package com.pangu.system.service;

import com.pangu.system.domain.dto.StudentDTO;
import com.pangu.system.domain.dto.StudentQueryDTO;
import com.pangu.system.domain.vo.StudentVO;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

/**
 * 学生服务测试
 * 
 * @author pangu
 */
@SpringBootTest
@Transactional
class StudentServiceTest {
    
    @Autowired
    private IStudentService studentService;
    
    @Test
    void testSelectStudentList() {
        StudentQueryDTO query = new StudentQueryDTO();
        query.setPageNum(1);
        query.setPageSize(10);
        
        List<StudentVO> list = studentService.selectStudentList(query);
        assertNotNull(list);
    }
    
    @Test
    void testInsertStudent() {
        StudentDTO dto = new StudentDTO();
        dto.setStudentName("测试学生");
        dto.setStudentNo("TEST001");
        dto.setRegionId(111L);
        dto.setSchoolId(1L);
        dto.setSchoolGradeId(1L);
        dto.setSchoolClassId(1L);
        dto.setMemberId(1L);
        
        int result = studentService.insertStudent(dto);
        assertEquals(1, result);
    }
    
    @Test
    void testCheckStudentNoUnique_Unique() {
        boolean unique = studentService.checkStudentNoUnique("UNIQUE001", null);
        assertTrue(unique);
    }
    
    @Test
    void testCheckStudentNoUnique_Exists() {
        // 假设STU20260001已存在
        boolean unique = studentService.checkStudentNoUnique("STU20260001", null);
        assertFalse(unique);
    }
}

10.2 控制器测试

package com.pangu.system.controller;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

/**
 * 学生控制器测试
 * 
 * @author pangu
 */
@SpringBootTest
@AutoConfigureMockMvc
class StudentControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Test
    void testList() throws Exception {
        mockMvc.perform(get("/api/student/list")
                .param("pageNum", "1")
                .param("pageSize", "10")
                .header("Authorization", "Bearer xxx"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(200));
    }
    
    @Test
    void testGetInfo() throws Exception {
        mockMvc.perform(get("/api/student/1")
                .header("Authorization", "Bearer xxx"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(200));
    }
    
    @Test
    void testAdd() throws Exception {
        String json = """
            {
                "studentName": "测试学生",
                "studentNo": "TEST001",
                "regionId": 111,
                "schoolId": 1,
                "schoolGradeId": 1,
                "schoolClassId": 1,
                "memberId": 1
            }
            """;
        
        mockMvc.perform(post("/api/student")
                .contentType(MediaType.APPLICATION_JSON)
                .content(json)
                .header("Authorization", "Bearer xxx"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(200));
    }
}

10.3 测试覆盖率要求

模块 覆盖率要求
Service层 ≥ 80%
Controller层 ≥ 70%
工具类 ≥ 90%

文档结束