pangu-user-platform/docs/05-模块技术方案/学校管理/学校管理模块技术方案_v1.0.md

38 KiB
Raw Blame History

学校管理模块 - 技术方案


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

修订记录

版本 日期 修订人 修订内容
V1.0 2026-01-31 pangu 初稿

目录

  1. 模块概述
  2. 需求分析
  3. 前端技术方案
  4. 后端技术方案
  5. 数据库设计
  6. 接口设计
  7. 任务分解与排期
  8. 测试方案
  9. 风险评估

1. 模块概述

1.1 模块定位

学校管理模块是盘古用户平台的核心基础模块,负责管理学校、年级、班级的树形组织结构,为学生管理、会员管理等业务模块提供基础数据支撑。

1.2 功能范围

学校管理模块
├── 学校管理
│   ├── 学校列表查询(区域树 + 学校树 + 列表)
│   ├── 新增学校
│   ├── 编辑学校
│   └── 删除学校
├── 年级挂载
│   ├── 为学校挂载年级
│   └── 删除学校年级
└── 班级挂载
    ├── 为年级挂载班级
    └── 删除学校班级

1.3 角色权限

角色 查看所有学校 查看所属区域学校 新增/编辑学校 删除学校 管理年级/班级
超级管理员
分公司用户
学校用户

2. 需求分析

2.1 功能清单

功能编号 功能名称 功能描述 优先级
SCH-001 学校列表查询 按区域树形展示学校、年级、班级结构 P0
SCH-002 学校信息查询 按学校名称、状态筛选查询 P0
SCH-003 新增学校 创建新学校,自动生成学校编码 P0
SCH-004 编辑学校 修改学校名称、所属区域、状态 P0
SCH-005 删除学校 软删除学校(需检查关联数据) P0
SCH-006 新增年级 为学校挂载年级(从年级库选择) P0
SCH-007 新增班级 为年级挂载班级(从班级库选择) P0
SCH-008 删除年级/班级 软删除年级/班级(需检查关联数据) P1

2.2 业务规则

规则编号 规则描述
SCH-R01 学校编码由系统自动生成格式SCH + 年份(4位) + 序号(4位),如 SCH20260001
SCH-R02 所属地区、学校编码、学校名称为必填项
SCH-R03 新增学校时,所属地区默认带入列表页选择的区域
SCH-R04 删除学校前需检查是否有子级(年级/班级),有则提示
SCH-R05 删除学校前需检查是否被学生信息引用,有则不允许删除
SCH-R06 所有删除操作均为软删除,保留历史数据
SCH-R07 学校下新增年级为选择挂载,非新建年级,支持多选
SCH-R08 年级下新增班级为选择挂载,非新建班级,支持多选

2.3 页面原型

页面 描述 原型链接
学校列表页 左侧区域树 + 右侧学校树表格 墨刀原型-学校管理
学校编辑弹窗 新增/编辑学校信息 墨刀原型-编辑学校
年级选择弹窗 多选年级挂载 墨刀原型-新增年级
班级选择弹窗 多选班级挂载 墨刀原型-新增班级

3. 前端技术方案

3.1 技术选型

技术 版本 用途
Vue 3.5.x 前端框架
Element Plus 2.13.x UI组件库
Pinia 3.x 状态管理
Axios 1.x HTTP请求
Vite 7.x 构建工具

3.2 页面结构设计

views/school/
├── index.vue                    # 学校管理主页面
└── components/
    ├── RegionTree.vue           # 左侧区域树组件
    ├── SchoolTree.vue           # 右侧学校树表格组件
    ├── SchoolDialog.vue         # 新增/编辑学校弹窗
    ├── GradeSelectDialog.vue    # 年级选择弹窗
    └── ClassSelectDialog.vue    # 班级选择弹窗

3.3 组件设计

3.3.1 主页面index.vue

布局结构

<template>
  <div class="school-container">
    <!-- 左侧区域树 -->
    <div class="region-tree-wrapper">
      <RegionTree 
        @node-click="handleRegionSelect" 
        :default-selected="currentRegionId"
      />
    </div>
    
    <!-- 右侧内容区 -->
    <div class="main-content">
      <!-- 搜索栏 -->
      <el-form :model="queryParams" inline>
        <el-form-item label="学校名称">
          <el-input v-model="queryParams.schoolName" placeholder="请输入学校名称" />
        </el-form-item>
        <el-form-item label="状态">
          <el-select v-model="queryParams.status" placeholder="请选择状态">
            <el-option label="全部" value="" />
            <el-option label="启用" value="0" />
            <el-option label="禁用" value="1" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleQuery">搜索</el-button>
          <el-button @click="handleReset">重置</el-button>
        </el-form-item>
      </el-form>
      
      <!-- 操作栏 -->
      <div class="toolbar">
        <el-button type="primary" @click="handleAdd">新增学校</el-button>
      </div>
      
      <!-- 学校树表格 -->
      <SchoolTree 
        :data="schoolTreeData"
        :loading="loading"
        @edit-school="handleEdit"
        @delete-school="handleDelete"
        @add-grade="handleAddGrade"
        @delete-grade="handleDeleteGrade"
        @add-class="handleAddClass"
        @delete-class="handleDeleteClass"
      />
    </div>
    
    <!-- 弹窗组件 -->
    <SchoolDialog ref="schoolDialogRef" @success="handleQuery" />
    <GradeSelectDialog ref="gradeDialogRef" @success="handleQuery" />
    <ClassSelectDialog ref="classDialogRef" @success="handleQuery" />
  </div>
</template>

核心状态

// 查询参数
const queryParams = reactive({
  regionId: null,
  schoolName: '',
  status: ''
})

// 学校树数据
const schoolTreeData = ref([])

// 当前选中的区域ID
const currentRegionId = ref(null)

3.3.2 区域树组件RegionTree.vue

功能说明

  • 展示省-市-区三级区域树
  • 点击节点筛选右侧学校列表
  • 支持展开/收起操作
  • 高亮当前选中节点

组件Props

Prop 类型 必填 说明
defaultSelected Number 默认选中的区域ID

组件Events

Event 参数 说明
node-click regionId, regionData 点击区域节点

实现要点

// 加载区域树数据
const loadRegionTree = async () => {
  const res = await getRegionTree()
  regionTreeData.value = res.data
}

// 节点点击事件
const handleNodeClick = (data, node) => {
  emit('node-click', data.regionId, data)
}

3.3.3 学校树表格组件SchoolTree.vue

功能说明

  • 使用 el-table 的树形结构展示学校-年级-班级层级
  • 支持展开/收起
  • 不同层级显示不同操作按钮

数据结构

// 学校树节点结构
{
  id: 1,                        // 唯一标识
  type: 'school',               // 节点类型school/grade/class
  name: '武汉市第一中学',         // 显示名称
  code: 'SCH20260001',          // 编码
  status: '0',                  // 状态
  regionPath: '湖北省-武汉市-武昌区',
  children: [
    {
      id: 101,
      type: 'grade',
      name: '七年级',
      schoolGradeId: 1,          // 学校年级关联ID
      gradeId: 7,
      children: [
        {
          id: 1001,
          type: 'class',
          name: '1班',
          schoolClassId: 1,      // 学校班级关联ID
          classId: 1
        }
      ]
    }
  ]
}

表格列定义

列名 字段 宽度 说明
名称 name - 学校/年级/班级名称,树形展开
编码 code 150px 仅学校显示
区域 regionPath 200px 仅学校显示
状态 status 100px 启用/禁用
操作 - 250px 根据类型显示不同按钮

操作按钮规则

节点类型 操作按钮
school 编辑、新增年级、删除
grade 新增班级、删除
class 删除

3.3.4 学校编辑弹窗SchoolDialog.vue

功能说明

  • 支持新增和编辑两种模式
  • 新增时学校编码自动生成(后端返回)
  • 所属地区支持级联选择
  • 表单验证

表单字段

字段 类型 必填 说明
schoolName el-input 学校名称最大100字符
schoolCode el-input 学校编码,新增时自动生成,只读
schoolType el-select 学校类型:小学/初中/高中
regionId el-cascader 所属区域,级联选择
address el-input 详细地址
contactPerson el-input 联系人
contactPhone el-input 联系电话
status el-switch 状态

表单验证规则

const rules = {
  schoolName: [
    { required: true, message: '请输入学校名称', trigger: 'blur' },
    { max: 100, message: '学校名称长度不能超过100个字符', trigger: 'blur' }
  ],
  schoolType: [
    { required: true, message: '请选择学校类型', trigger: 'change' }
  ],
  regionId: [
    { required: true, message: '请选择所属区域', trigger: 'change' }
  ],
  contactPhone: [
    { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
  ]
}

核心方法

// 打开新增弹窗
const open = (regionId = null) => {
  dialogVisible.value = true
  isEdit.value = false
  resetForm()
  // 带入当前选中的区域
  if (regionId) {
    form.regionId = regionId
  }
}

// 打开编辑弹窗
const openEdit = async (schoolId) => {
  dialogVisible.value = true
  isEdit.value = true
  const res = await getSchool(schoolId)
  Object.assign(form, res.data)
}

// 提交表单
const handleSubmit = async () => {
  await formRef.value.validate()
  if (isEdit.value) {
    await updateSchool(form)
    ElMessage.success('修改成功')
  } else {
    await addSchool(form)
    ElMessage.success('新增成功')
  }
  dialogVisible.value = false
  emit('success')
}

3.3.5 年级选择弹窗GradeSelectDialog.vue

功能说明

  • 展示年级字典列表
  • 支持多选
  • 已挂载的年级默认禁用
  • 确认后批量挂载

组件交互

// 打开弹窗
const open = async (schoolId, existingGradeIds = []) => {
  currentSchoolId.value = schoolId
  existingGrades.value = existingGradeIds
  dialogVisible.value = true
  await loadGradeList()
}

// 加载年级列表
const loadGradeList = async () => {
  const res = await getGradeList({ status: '0' })
  gradeList.value = res.rows.map(item => ({
    ...item,
    disabled: existingGrades.value.includes(item.gradeId)
  }))
}

// 确认挂载
const handleConfirm = async () => {
  if (selectedGrades.value.length === 0) {
    ElMessage.warning('请至少选择一个年级')
    return
  }
  await bindGrades({
    schoolId: currentSchoolId.value,
    gradeIds: selectedGrades.value
  })
  ElMessage.success('年级挂载成功')
  dialogVisible.value = false
  emit('success')
}

3.3.6 班级选择弹窗ClassSelectDialog.vue

功能说明

  • 展示班级字典列表
  • 支持多选
  • 已挂载的班级默认禁用
  • 确认后批量挂载

组件交互

与年级选择弹窗类似,调用 bindClasses 接口完成班级挂载。

3.4 API接口封装

// api/school.js

import request from '@/utils/request'

// 获取学校树
export function getSchoolTree(regionId) {
  return request({
    url: '/api/school/tree',
    method: 'get',
    params: { regionId }
  })
}

// 获取学校列表
export function getSchoolList(query) {
  return request({
    url: '/api/school/list',
    method: 'get',
    params: query
  })
}

// 获取学校详情
export function getSchool(schoolId) {
  return request({
    url: `/api/school/${schoolId}`,
    method: 'get'
  })
}

// 新增学校
export function addSchool(data) {
  return request({
    url: '/api/school',
    method: 'post',
    data
  })
}

// 修改学校
export function updateSchool(data) {
  return request({
    url: '/api/school',
    method: 'put',
    data
  })
}

// 删除学校
export function deleteSchool(schoolId) {
  return request({
    url: `/api/school/${schoolId}`,
    method: 'delete'
  })
}

// 为学校挂载年级
export function bindGrades(data) {
  return request({
    url: '/api/school/bindGrades',
    method: 'post',
    data
  })
}

// 为年级挂载班级
export function bindClasses(data) {
  return request({
    url: '/api/school/bindClasses',
    method: 'post',
    data
  })
}

// 删除学校年级
export function deleteSchoolGrade(schoolGradeId) {
  return request({
    url: `/api/school/grade/${schoolGradeId}`,
    method: 'delete'
  })
}

// 删除学校班级
export function deleteSchoolClass(schoolClassId) {
  return request({
    url: `/api/school/class/${schoolClassId}`,
    method: 'delete'
  })
}

3.5 路由配置

// router/index.js

{
  path: '/school',
  component: Layout,
  meta: { title: '学校管理', icon: 'school' },
  children: [
    {
      path: '',
      name: 'School',
      component: () => import('@/views/school/index.vue'),
      meta: { title: '学校管理' }
    }
  ]
}

3.6 交互流程

3.6.1 查询学校流程

用户 ──► 点击区域树节点 ──► 更新 regionId ──► 调用 getSchoolTree(regionId)
                                                    │
                                                    ▼
用户 ◀── 渲染学校树表格 ◀── 返回学校树数据 ◀── 后端处理

3.6.2 新增学校流程

用户 ──► 点击"新增学校" ──► 打开弹窗(带入当前区域) ──► 填写表单
                                                        │
                                                        ▼
用户 ◀── 刷新列表 ◀── 关闭弹窗 ◀── 提示成功 ◀── 调用 addSchool()

3.6.3 挂载年级流程

用户 ──► 点击学校行"新增年级" ──► 打开年级选择弹窗 ──► 勾选年级
                                                        │
                                                        ▼
用户 ◀── 刷新列表 ◀── 关闭弹窗 ◀── 提示成功 ◀── 调用 bindGrades()

3.6.4 删除学校流程

用户 ──► 点击"删除" ──► 二次确认 ──► 调用 deleteSchool()
                                        │
                                        ├── 成功 ──► 提示成功 ──► 刷新列表
                                        │
                                        └── 失败(有关联数据) ──► 提示错误信息

4. 后端技术方案

4.1 技术选型

技术 版本 用途
Spring Boot 3.3.x 应用框架
Spring Security 6.x 安全框架
MyBatis Plus 3.5.x ORM框架
MySQL 8.0 数据库
Redis 7.x 缓存
Hutool 5.x 工具库

4.2 包结构设计

com.pangu.user/
├── controller/
│   └── SchoolController.java         # 学校管理控制器
├── service/
│   ├── ISchoolService.java           # 学校服务接口
│   └── impl/
│       └── SchoolServiceImpl.java    # 学校服务实现
├── mapper/
│   ├── SchoolMapper.java             # 学校Mapper
│   ├── SchoolGradeMapper.java        # 学校年级Mapper
│   └── SchoolClassMapper.java        # 学校班级Mapper
├── domain/
│   ├── entity/
│   │   ├── School.java               # 学校实体
│   │   ├── SchoolGrade.java          # 学校年级关联实体
│   │   └── SchoolClass.java          # 学校班级关联实体
│   ├── dto/
│   │   ├── SchoolQueryDTO.java       # 学校查询DTO
│   │   ├── SchoolCreateDTO.java      # 学校新增DTO
│   │   └── BindGradesDTO.java        # 年级挂载DTO
│   └── vo/
│       ├── SchoolVO.java             # 学校VO
│       └── SchoolTreeVO.java         # 学校树VO
└── common/
    └── enums/
        └── SchoolTypeEnum.java       # 学校类型枚举

4.3 核心代码设计

4.3.1 控制器层SchoolController.java

/**
 * 学校管理控制器
 * @author 湖北新华业务中台研发团队
 */
@RestController
@RequestMapping("/api/school")
public class SchoolController extends BaseController {

    @Autowired
    private ISchoolService schoolService;

    /**
     * 获取学校树形结构
     */
    @GetMapping("/tree")
    public AjaxResult tree(@RequestParam(required = false) Long regionId) {
        List<SchoolTreeVO> tree = schoolService.selectSchoolTree(regionId);
        return success(tree);
    }

    /**
     * 获取学校列表
     */
    @GetMapping("/list")
    public TableDataInfo list(SchoolQueryDTO query) {
        startPage();
        List<SchoolVO> list = schoolService.selectSchoolList(query);
        return getDataTable(list);
    }

    /**
     * 获取学校详情
     */
    @GetMapping("/{schoolId}")
    public AjaxResult getInfo(@PathVariable Long schoolId) {
        return success(schoolService.selectSchoolById(schoolId));
    }

    /**
     * 新增学校
     */
    @Log(title = "学校管理", businessType = BusinessType.INSERT)
    @PostMapping
    public AjaxResult add(@Validated @RequestBody SchoolCreateDTO dto) {
        return toAjax(schoolService.insertSchool(dto));
    }

    /**
     * 修改学校
     */
    @Log(title = "学校管理", businessType = BusinessType.UPDATE)
    @PutMapping
    public AjaxResult edit(@Validated @RequestBody School school) {
        return toAjax(schoolService.updateSchool(school));
    }

    /**
     * 删除学校
     */
    @Log(title = "学校管理", businessType = BusinessType.DELETE)
    @DeleteMapping("/{schoolId}")
    public AjaxResult remove(@PathVariable Long schoolId) {
        return toAjax(schoolService.deleteSchool(schoolId));
    }

    /**
     * 为学校挂载年级
     */
    @Log(title = "学校管理", businessType = BusinessType.UPDATE)
    @PostMapping("/bindGrades")
    public AjaxResult bindGrades(@Validated @RequestBody BindGradesDTO dto) {
        return toAjax(schoolService.bindGrades(dto.getSchoolId(), dto.getGradeIds()));
    }

    /**
     * 为年级挂载班级
     */
    @Log(title = "学校管理", businessType = BusinessType.UPDATE)
    @PostMapping("/bindClasses")
    public AjaxResult bindClasses(@Validated @RequestBody BindClassesDTO dto) {
        return toAjax(schoolService.bindClasses(dto.getSchoolGradeId(), dto.getClassIds()));
    }

    /**
     * 删除学校年级
     */
    @Log(title = "学校管理", businessType = BusinessType.DELETE)
    @DeleteMapping("/grade/{schoolGradeId}")
    public AjaxResult removeGrade(@PathVariable Long schoolGradeId) {
        return toAjax(schoolService.deleteSchoolGrade(schoolGradeId));
    }

    /**
     * 删除学校班级
     */
    @Log(title = "学校管理", businessType = BusinessType.DELETE)
    @DeleteMapping("/class/{schoolClassId}")
    public AjaxResult removeClass(@PathVariable Long schoolClassId) {
        return toAjax(schoolService.deleteSchoolClass(schoolClassId));
    }
}

4.3.2 服务接口ISchoolService.java

/**
 * 学校服务接口
 * @author 湖北新华业务中台研发团队
 */
public interface ISchoolService {

    /**
     * 查询学校树形结构
     * @param regionId 区域ID可选
     * @return 学校树
     */
    List<SchoolTreeVO> selectSchoolTree(Long regionId);

    /**
     * 查询学校列表
     * @param query 查询条件
     * @return 学校列表
     */
    List<SchoolVO> selectSchoolList(SchoolQueryDTO query);

    /**
     * 根据ID查询学校
     * @param schoolId 学校ID
     * @return 学校信息
     */
    SchoolVO selectSchoolById(Long schoolId);

    /**
     * 新增学校
     * @param dto 学校信息
     * @return 影响行数
     */
    int insertSchool(SchoolCreateDTO dto);

    /**
     * 修改学校
     * @param school 学校信息
     * @return 影响行数
     */
    int updateSchool(School school);

    /**
     * 删除学校(软删除)
     * @param schoolId 学校ID
     * @return 影响行数
     */
    int deleteSchool(Long schoolId);

    /**
     * 为学校挂载年级
     * @param schoolId 学校ID
     * @param gradeIds 年级ID列表
     * @return 影响行数
     */
    int bindGrades(Long schoolId, List<Long> gradeIds);

    /**
     * 为年级挂载班级
     * @param schoolGradeId 学校年级关联ID
     * @param classIds 班级ID列表
     * @return 影响行数
     */
    int bindClasses(Long schoolGradeId, List<Long> classIds);

    /**
     * 删除学校年级
     * @param schoolGradeId 学校年级关联ID
     * @return 影响行数
     */
    int deleteSchoolGrade(Long schoolGradeId);

    /**
     * 删除学校班级
     * @param schoolClassId 学校班级关联ID
     * @return 影响行数
     */
    int deleteSchoolClass(Long schoolClassId);
}

4.3.3 服务实现SchoolServiceImpl.java

/**
 * 学校服务实现
 * @author 湖北新华业务中台研发团队
 */
@Service
public class SchoolServiceImpl implements ISchoolService {

    @Autowired
    private SchoolMapper schoolMapper;
    
    @Autowired
    private SchoolGradeMapper schoolGradeMapper;
    
    @Autowired
    private SchoolClassMapper schoolClassMapper;
    
    @Autowired
    private StudentMapper studentMapper;
    
    @Autowired
    private RegionMapper regionMapper;

    @Override
    public List<SchoolTreeVO> selectSchoolTree(Long regionId) {
        // 1. 查询学校列表
        List<School> schools = schoolMapper.selectSchoolsByRegionId(regionId);
        
        // 2. 查询学校下的年级
        List<SchoolGrade> grades = schoolGradeMapper.selectBySchoolIds(
            schools.stream().map(School::getSchoolId).collect(Collectors.toList())
        );
        
        // 3. 查询年级下的班级
        List<SchoolClass> classes = schoolClassMapper.selectBySchoolGradeIds(
            grades.stream().map(SchoolGrade::getId).collect(Collectors.toList())
        );
        
        // 4. 组装树形结构
        return buildSchoolTree(schools, grades, classes);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public int insertSchool(SchoolCreateDTO dto) {
        School school = new School();
        BeanUtils.copyProperties(dto, school);
        
        // 生成学校编码SCH + 年份 + 4位序号
        String schoolCode = generateSchoolCode();
        school.setSchoolCode(schoolCode);
        
        // 设置区域路径
        String regionPath = regionMapper.selectRegionPath(dto.getRegionId());
        school.setRegionPath(regionPath);
        
        return schoolMapper.insert(school);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public int deleteSchool(Long schoolId) {
        // 1. 检查是否有年级/班级
        int gradeCount = schoolGradeMapper.countBySchoolId(schoolId);
        if (gradeCount > 0) {
            throw new ServiceException("该学校下存在年级数据,请先删除年级");
        }
        
        // 2. 检查是否被学生引用
        int studentCount = studentMapper.countBySchoolId(schoolId);
        if (studentCount > 0) {
            throw new ServiceException("该学校已被学生信息引用,无法删除");
        }
        
        // 3. 软删除
        return schoolMapper.deleteById(schoolId);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public int bindGrades(Long schoolId, List<Long> gradeIds) {
        int count = 0;
        for (Long gradeId : gradeIds) {
            // 检查是否已存在
            if (schoolGradeMapper.exists(schoolId, gradeId)) {
                continue;
            }
            SchoolGrade sg = new SchoolGrade();
            sg.setSchoolId(schoolId);
            sg.setGradeId(gradeId);
            count += schoolGradeMapper.insert(sg);
        }
        return count;
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public int bindClasses(Long schoolGradeId, List<Long> classIds) {
        int count = 0;
        for (Long classId : classIds) {
            // 检查是否已存在
            if (schoolClassMapper.exists(schoolGradeId, classId)) {
                continue;
            }
            SchoolClass sc = new SchoolClass();
            sc.setSchoolGradeId(schoolGradeId);
            sc.setClassId(classId);
            count += schoolClassMapper.insert(sc);
        }
        return count;
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public int deleteSchoolGrade(Long schoolGradeId) {
        // 1. 检查是否有班级
        int classCount = schoolClassMapper.countBySchoolGradeId(schoolGradeId);
        if (classCount > 0) {
            throw new ServiceException("该年级下存在班级数据,请先删除班级");
        }
        
        // 2. 检查是否被学生引用
        int studentCount = studentMapper.countBySchoolGradeId(schoolGradeId);
        if (studentCount > 0) {
            throw new ServiceException("该年级已被学生信息引用,无法删除");
        }
        
        // 3. 软删除
        return schoolGradeMapper.deleteById(schoolGradeId);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public int deleteSchoolClass(Long schoolClassId) {
        // 1. 检查是否被学生引用
        int studentCount = studentMapper.countBySchoolClassId(schoolClassId);
        if (studentCount > 0) {
            throw new ServiceException("该班级已被学生信息引用,无法删除");
        }
        
        // 2. 软删除
        return schoolClassMapper.deleteById(schoolClassId);
    }

    /**
     * 生成学校编码
     * 格式SCH + 年份(4位) + 序号(4位)
     */
    private String generateSchoolCode() {
        String year = String.valueOf(LocalDate.now().getYear());
        String prefix = "SCH" + year;
        
        // 查询当前年份最大编号
        String maxCode = schoolMapper.selectMaxCode(prefix);
        
        int seq = 1;
        if (maxCode != null) {
            seq = Integer.parseInt(maxCode.substring(7)) + 1;
        }
        
        return prefix + String.format("%04d", seq);
    }

    /**
     * 构建学校树形结构
     */
    private List<SchoolTreeVO> buildSchoolTree(List<School> schools, 
                                               List<SchoolGrade> grades, 
                                               List<SchoolClass> classes) {
        // 按学校ID分组年级
        Map<Long, List<SchoolGrade>> gradeMap = grades.stream()
            .collect(Collectors.groupingBy(SchoolGrade::getSchoolId));
        
        // 按学校年级ID分组班级
        Map<Long, List<SchoolClass>> classMap = classes.stream()
            .collect(Collectors.groupingBy(SchoolClass::getSchoolGradeId));
        
        return schools.stream().map(school -> {
            SchoolTreeVO vo = new SchoolTreeVO();
            vo.setId(school.getSchoolId());
            vo.setType("school");
            vo.setName(school.getSchoolName());
            vo.setCode(school.getSchoolCode());
            vo.setStatus(school.getStatus());
            vo.setRegionPath(school.getRegionPath());
            
            // 组装年级
            List<SchoolGrade> schoolGrades = gradeMap.getOrDefault(school.getSchoolId(), Collections.emptyList());
            vo.setChildren(schoolGrades.stream().map(sg -> {
                SchoolTreeVO gradeVO = new SchoolTreeVO();
                gradeVO.setId(sg.getId());
                gradeVO.setType("grade");
                gradeVO.setName(sg.getGradeName());
                gradeVO.setSchoolGradeId(sg.getId());
                gradeVO.setGradeId(sg.getGradeId());
                
                // 组装班级
                List<SchoolClass> gradeClasses = classMap.getOrDefault(sg.getId(), Collections.emptyList());
                gradeVO.setChildren(gradeClasses.stream().map(sc -> {
                    SchoolTreeVO classVO = new SchoolTreeVO();
                    classVO.setId(sc.getId());
                    classVO.setType("class");
                    classVO.setName(sc.getClassName());
                    classVO.setSchoolClassId(sc.getId());
                    classVO.setClassId(sc.getClassId());
                    return classVO;
                }).collect(Collectors.toList()));
                
                return gradeVO;
            }).collect(Collectors.toList()));
            
            return vo;
        }).collect(Collectors.toList());
    }
}

4.4 数据权限控制

/**
 * 数据权限注解
 * 分公司用户只能查看和管理所属区域的学校
 */
@DataScope(deptAlias = "d", userAlias = "u")
public List<SchoolVO> selectSchoolList(SchoolQueryDTO query) {
    // MyBatis拦截器自动拼接数据权限SQL
    return schoolMapper.selectSchoolList(query);
}

SQL示例

-- 原始SQL
SELECT * FROM pg_school WHERE del_flag = '0'

-- 分公司用户执行时自动拼接
SELECT * FROM pg_school s
LEFT JOIN sys_dept d ON s.region_id = d.dept_id
WHERE s.del_flag = '0'
AND d.dept_id IN (SELECT dept_id FROM sys_role_dept WHERE role_id = #{roleId})

5. 数据库设计

5.1 相关表结构

表名 说明
pg_region 区域表
pg_school 学校表
pg_grade 年级字典表
pg_class 班级字典表
pg_school_grade 学校年级关联表
pg_school_class 学校班级关联表

5.2 核心表结构(摘要)

pg_school学校表

字段 类型 说明
school_id bigint 学校ID主键
school_code varchar(32) 学校编码(唯一)
school_name varchar(100) 学校名称
school_type char(2) 学校类型
region_id bigint 所属区域ID
region_path varchar(200) 区域路径
status char(1) 状态
del_flag char(1) 删除标志

pg_school_grade学校年级关联表

字段 类型 说明
id bigint 主键ID
school_id bigint 学校ID
grade_id bigint 年级ID
del_flag char(1) 删除标志

pg_school_class学校班级关联表

字段 类型 说明
id bigint 主键ID
school_grade_id bigint 学校年级关联ID
class_id bigint 班级ID
del_flag char(1) 删除标志

6. 接口设计

6.1 接口清单

接口 方法 路径 说明
获取学校树 GET /api/school/tree 按区域获取学校树形结构
获取学校列表 GET /api/school/list 分页查询学校列表
获取学校详情 GET /api/school/{id} 获取单个学校信息
新增学校 POST /api/school 创建学校
修改学校 PUT /api/school 更新学校
删除学校 DELETE /api/school/{id} 软删除学校
挂载年级 POST /api/school/bindGrades 为学校挂载年级
挂载班级 POST /api/school/bindClasses 为年级挂载班级
删除年级 DELETE /api/school/grade/{id} 删除学校年级
删除班级 DELETE /api/school/class/{id} 删除学校班级

6.2 接口详情

详见《接口设计文档_v1.0.md》第3章 学校管理接口。


7. 任务分解与排期

7.1 前端开发任务

任务编号 任务名称 优先级 预估工时 依赖
FE-SCH-01 创建 school/index.vue 主页面框架 P0 2h -
FE-SCH-02 开发 RegionTree 区域树组件 P0 2h -
FE-SCH-03 开发 SchoolTree 学校树表格组件 P0 4h FE-SCH-01
FE-SCH-04 开发 SchoolDialog 新增/编辑弹窗 P0 3h FE-SCH-03
FE-SCH-05 开发 GradeSelectDialog 年级选择弹窗 P0 2h FE-SCH-03
FE-SCH-06 开发 ClassSelectDialog 班级选择弹窗 P0 2h FE-SCH-05
FE-SCH-07 封装 api/school.js 接口 P0 1h -
FE-SCH-08 主页面逻辑实现与联调 P0 3h FE-SCH-01~07
FE-SCH-09 样式优化与交互完善 P1 2h FE-SCH-08
FE-SCH-10 单元测试与Bug修复 P1 2h FE-SCH-09

前端总工时23h约3个工作日

7.2 后端开发任务

任务编号 任务名称 优先级 预估工时 依赖
BE-SCH-01 创建 School 实体类及相关DTO/VO P0 1h -
BE-SCH-02 创建 SchoolMapper 及 XML P0 2h BE-SCH-01
BE-SCH-03 创建 SchoolGradeMapper 及 XML P0 1h BE-SCH-01
BE-SCH-04 创建 SchoolClassMapper 及 XML P0 1h BE-SCH-01
BE-SCH-05 实现 ISchoolService 接口 P0 1h -
BE-SCH-06 实现学校CRUD方法 P0 3h BE-SCH-02, BE-SCH-05
BE-SCH-07 实现年级/班级挂载方法 P0 2h BE-SCH-03, BE-SCH-04
BE-SCH-08 实现学校树查询方法 P0 2h BE-SCH-02~04
BE-SCH-09 实现删除校验逻辑 P0 2h BE-SCH-06
BE-SCH-10 实现学校编码生成逻辑 P0 1h BE-SCH-06
BE-SCH-11 创建 SchoolController P0 2h BE-SCH-06~08
BE-SCH-12 数据权限控制实现 P1 2h BE-SCH-11
BE-SCH-13 单元测试编写 P1 3h BE-SCH-11
BE-SCH-14 接口联调与Bug修复 P1 2h BE-SCH-13

后端总工时25h约3.5个工作日)

7.3 开发排期

Week 1
├── Day 1: BE-SCH-01~04实体类、Mapper
├── Day 2: BE-SCH-05~08Service实现
├── Day 3: BE-SCH-09~11Controller、校验逻辑
├── Day 4: FE-SCH-01~04主页面、区域树、学校树
└── Day 5: FE-SCH-05~08弹窗组件、接口封装、联调

Week 2
├── Day 1: BE-SCH-12~14数据权限、测试
├── Day 2: FE-SCH-09~10样式优化、测试
└── Day 3: 集成测试、Bug修复

8. 测试方案

8.1 单元测试

后端单元测试

@SpringBootTest
public class SchoolServiceTest {

    @Autowired
    private ISchoolService schoolService;

    @Test
    public void testInsertSchool() {
        SchoolCreateDTO dto = new SchoolCreateDTO();
        dto.setSchoolName("测试学校");
        dto.setSchoolType("02");
        dto.setRegionId(111L);
        
        int result = schoolService.insertSchool(dto);
        
        assertEquals(1, result);
    }

    @Test
    public void testDeleteSchoolWithGrades() {
        // 准备:创建学校并挂载年级
        Long schoolId = createSchoolWithGrades();
        
        // 执行&断言:删除应抛出异常
        assertThrows(ServiceException.class, () -> {
            schoolService.deleteSchool(schoolId);
        });
    }

    @Test
    public void testBindGrades() {
        Long schoolId = 1L;
        List<Long> gradeIds = Arrays.asList(1L, 2L, 3L);
        
        int result = schoolService.bindGrades(schoolId, gradeIds);
        
        assertEquals(3, result);
    }
}

8.2 接口测试

测试场景 接口 预期结果
查询学校树 GET /api/school/tree?regionId=111 返回武昌区下所有学校树
新增学校 POST /api/school 返回成功,学校编码自动生成
编辑学校 PUT /api/school 返回成功,数据已更新
删除有子级的学校 DELETE /api/school/1 返回失败,提示有年级数据
删除无子级的学校 DELETE /api/school/2 返回成功
挂载年级 POST /api/school/bindGrades 返回成功
重复挂载年级 POST /api/school/bindGrades 返回成功(忽略重复)
挂载班级 POST /api/school/bindClasses 返回成功
删除被引用的班级 DELETE /api/school/class/1 返回失败,提示被学生引用

8.3 UI测试清单

测试项 测试步骤 预期结果
区域树展示 进入学校管理页面 左侧显示区域树,默认展开
区域筛选 点击区域树节点 右侧学校列表刷新
学校树展开 点击学校行展开图标 显示年级和班级
新增学校 点击新增按钮,填写表单 弹窗显示,提交后列表刷新
编辑学校 点击编辑按钮 弹窗显示并回填数据
删除学校确认 点击删除按钮 显示确认对话框
新增年级 点击学校行"新增年级" 弹出年级选择框,支持多选
新增班级 点击年级行"新增班级" 弹出班级选择框,支持多选
表单验证 必填项留空提交 显示验证提示

9. 风险评估

9.1 技术风险

风险 影响 概率 应对措施
树形数据量大导致性能问题 分页加载、懒加载子节点
数据权限逻辑复杂 复用RuoYi数据权限框架
前后端联调延迟 前端Mock数据先行开发

9.2 业务风险

风险 影响 概率 应对措施
需求变更频繁 预留扩展字段,模块化设计
删除校验遗漏 全面梳理关联关系,完善测试用例

9.3 依赖风险

依赖项 影响 应对措施
区域管理模块 学校必须选择区域 区域模块优先开发或提供Mock
年级/班级字典 年级班级选择依赖字典数据 初始化种子数据

评审检查项

检查项 状态
需求覆盖完整性
技术方案可行性
数据库设计合理性
接口设计规范性
任务分解粒度
工时估算合理性
测试方案完整性
风险识别充分性

审核签字

角色 姓名 日期 签字
技术负责人
前端负责人
后端负责人
产品负责人

文档结束