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

---

| 文档信息 | 内容 |
|---------|------|
| **文档版本** | 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） |
| 初始密码 | 123456（BCrypt加密） |
| 注册来源 | 批量导入（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% |

---

*文档结束*
