# 学校管理模块 - 技术方案

---

| 文档信息 | 内容 |
|---------|------|
| **文档版本** | 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~08（Service实现）
├── Day 3: BE-SCH-09~11（Controller、校验逻辑）
├── 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 |
| 年级/班级字典 | 年级班级选择依赖字典数据 | 初始化种子数据 |

---

## 评审检查项

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

---

## 审核签字

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

---

*文档结束*
