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

1344 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 学校管理模块 - 技术方案
---
| 文档信息 | 内容 |
|---------|------|
| **文档版本** | V1.0 |
| **项目名称** | 盘古用户平台Pangu User Platform |
| **模块名称** | 学校管理模块 |
| **编写团队** | pangu |
| **创建日期** | 2026-01-31 |
| **审核状态** | 待评审 |
---
## 修订记录
| 版本 | 日期 | 修订人 | 修订内容 |
|------|------|--------|----------|
| V1.0 | 2026-01-31 | pangu | 初稿 |
---
## 目录
1. [模块概述](#1-模块概述)
2. [需求分析](#2-需求分析)
3. [前端技术方案](#3-前端技术方案)
4. [后端技术方案](#4-后端技术方案)
5. [数据库设计](#5-数据库设计)
6. [接口设计](#6-接口设计)
7. [任务分解与排期](#7-任务分解与排期)
8. [测试方案](#8-测试方案)
9. [风险评估](#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
**布局结构**
```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>
```
**核心状态**
```javascript
// 查询参数
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 | 点击区域节点 |
**实现要点**
```javascript
// 加载区域树数据
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 的树形结构展示学校-年级-班级层级
- 支持展开/收起
- 不同层级显示不同操作按钮
**数据结构**
```javascript
// 学校树节点结构
{
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 | ✓ | 状态 |
**表单验证规则**
```javascript
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' }
]
}
```
**核心方法**
```javascript
// 打开新增弹窗
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
**功能说明**
- 展示年级字典列表
- 支持多选
- 已挂载的年级默认禁用
- 确认后批量挂载
**组件交互**
```javascript
// 打开弹窗
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接口封装
```javascript
// 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 路由配置
```javascript
// 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
```java
/**
* 学校管理控制器
* @author pangu
*/
@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
```java
/**
* 学校服务接口
* @author pangu
*/
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
```java
/**
* 学校服务实现
* @author pangu
*/
@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 数据权限控制
```java
/**
* 数据权限注解
* 分公司用户只能查看和管理所属区域的学校
*/
@DataScope(deptAlias = "d", userAlias = "u")
public List<SchoolVO> selectSchoolList(SchoolQueryDTO query) {
// MyBatis拦截器自动拼接数据权限SQL
return schoolMapper.selectSchoolList(query);
}
```
**SQL示例**
```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 单元测试
**后端单元测试**
```java
@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 |
| 年级/班级字典 | 年级班级选择依赖字典数据 | 初始化种子数据 |
---
## 评审检查项
| 检查项 | 状态 |
|-------|:----:|
| 需求覆盖完整性 | ☐ |
| 技术方案可行性 | ☐ |
| 数据库设计合理性 | ☐ |
| 接口设计规范性 | ☐ |
| 任务分解粒度 | ☐ |
| 工时估算合理性 | ☐ |
| 测试方案完整性 | ☐ |
| 风险识别充分性 | ☐ |
---
## 审核签字
| 角色 | 姓名 | 日期 | 签字 |
|-----|------|------|------|
| 技术负责人 | | | |
| 前端负责人 | | | |
| 后端负责人 | | | |
| 产品负责人 | | | |
---
*文档结束*