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

1430 lines
42 KiB
Markdown
Raw Permalink Normal View History

# 学生管理模块 - 后端技术方案
---
| 文档信息 | 内容 |
|---------|------|
| **文档版本** | V1.0 |
| **项目名称** | 盘古用户平台Pangu User Platform |
| **模块名称** | 学生管理模块 - 后端 |
| **编写团队** | pangu |
| **创建日期** | 2026-01-31 |
| **审核状态** | 待审核 |
---
## 目录
1. [技术栈说明](#1-技术栈说明)
2. [模块结构](#2-模块结构)
3. [数据库设计](#3-数据库设计)
4. [实体设计](#4-实体设计)
5. [接口设计](#5-接口设计)
6. [服务层设计](#6-服务层设计)
7. [批量导入设计](#7-批量导入设计)
8. [数据权限控制](#8-数据权限控制)
9. [异常处理](#9-异常处理)
10. [单元测试](#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
```sql
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 初始化数据
```sql
-- 学生示例数据
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 - 学生实体
```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 - 学生传输对象
```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 - 学生查询条件
```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 - 学生视图对象
```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 - 学生导入对象
```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 - 导入结果视图对象
```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
```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:student:list | 查询学生列表 |
| GET /api/student/{id} | system:student:query | 查询学生详情 |
| POST /api/student | system:student:add | 新增学生 |
| PUT /api/student | system:student:edit | 修改学生 |
| DELETE /api/student/{id} | system:student:remove | 删除学生 |
| GET /api/student/template | system:student:import | 下载导入模板 |
| POST /api/student/import | system:student:import | 批量导入 |
---
## 6. 服务层设计
### 6.1 IStudentService.java - 服务接口
```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 - 服务实现
```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 数据权限注解
```java
/**
* 数据权限注解
*/
@DataScope(deptAlias = "s", userAlias = "u")
public List<StudentVO> selectStudentList(StudentQueryDTO query) {
return studentMapper.selectStudentVOList(query);
}
```
### 8.2 Mapper SQL数据权限
```xml
<!-- 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 业务异常定义
```java
/**
* 学生业务异常
*/
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 全局异常处理
```java
/**
* 全局异常处理器
*/
@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 服务层测试
```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.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 控制器测试
```java
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% |
---
*文档结束*