基于ThinkPHP和MySQL的教学管理系统后台设计与实现 简介这是一套面向PHP初学者与Web开发入门者的ThinkPHP教学管理系统源码可用于练习教务管理、学生信息维护、课程安排与成绩录入等常见业务场景帮助读者理解从URL路由、控制器调度、模型数据操作到视图渲染的完整请求链路。压缩包约11.04MB共2000个文件主体为PHP源码辅以HTML模板、JavaScript脚本、CSS样式和图片素材搭建页面并包含少量SQL、XML、YAML、JSON及Markdown说明文件便于本地部署、结构梳理与二次开发。目前已有1582人学习下载具备一定的参考热度。内容覆盖MVC分层、数据库表设计、路由配置、模板引擎、基于角色的权限管理、RESTful接口、异常处理与日志记录、SQL注入和XSS防护等知识点读者可对照项目目录理解登录、增删改查和权限校验的实现方式并在原有模块上扩展新功能积累可复用的ThinkPHP实战经验。1. 期末周的三份 Excel被这套后台收进了一个库期末周教务老师手里三份 Excel 互相打架一份选课名单、一份平时分、一份补考名单谁改了哪一版没人说得清。这套基于 ThinkPHP 的教学管理系统处理的就是这个场景——把学生、教师、课程、班级、成绩放进同一个 MySQL 库用一套带角色权限的后台把增删改查统一收进后台谁在什么时候改了什么数据库里有记录可查。它的定位很克制就是“够用的教务小后台”登录分管理员、教师、学生三种角色管理员维护基础数据教师只录自己课程的成绩学生只能查自己的。想拿它做课程设计、毕业设计或者团队内部要快速搭一个培训管理后台都能在一个下午跑起来再花两天改成自己要的样子。2. 技术选型与请求链路一个 /admin/course/save 从进来到落库走了哪些代码2.1 为什么选 ThinkPHP 而不是自己拼一套这套系统的技术栈是 PHP 7.4 ThinkPHP 6.0 MySQL 5.7前端是 Bootstrap 5 加 jQuery 的服务端渲染模板没有 Node 构建环节。选型理由基本围绕“改起来快”和“部署成本低”两个点。框架挑 ThinkPHP是因为它把这种 CRUD 密集型系统最常用的东西全内置了路由集中在一个文件里改 URL 不用动控制器Db 查询构造器的写法贴近 SQLwhereLike(name|code, ...)这种写法对刚接触框架的人几乎零学习成本中间件做权限比在每个控制器方法开头写if (!session(admin_id))更清晰模板引擎直接渲染{$vo.name}改完刷新页面就能看到效果。对比下来Laravel 功能更完整但服务容器、门面、Eloquent 关联这一套概念在一个只有十来张表的教务系统里属于过度设计完全用原生 PHP 手写十几张表的增删改查会变成纯体力劳动而且表单校验、分页、异常处理都得自己实现。代价也要说清楚ThinkPHP 5.1 到 6.0 的改动不小命名空间、门面、中间件签名都变了。网上能搜到的老代码十有八九是 5.1 的直接粘过来会出问题——这是我在这个项目上最大的一次失误后面章节还会再提。2.2 一个请求的完整链路浏览器请求/admin/course/save之后实际发生的是这几步public/index.php作为唯一入口加载 Composer 自动加载器实例化应用多应用模式解析出admin应用全局中间件和路由中间件依次执行路由把请求分发到app\admin\controller\Course::save()控制器做参数校验、写模型、返回 JSON 或跳转。先看路由定义这是整条链路的起点?php // route/app.php use think\facade\Route; // 后台分组整组挂上登录校验中间件省得每个控制器里重复写 Route::group(admin, function () { // 登录相关不挂中间件否则会死循环跳回登录页 Route::get(login, admin/Login/index); Route::post(login, admin/Login/doLogin); Route::get(index, admin/Index/index); Route::get(welcome, admin/Index/welcome); // resource 会自动注册 index/create/save/read/edit/update/delete 七个动作 Route::resource(course, admin/Course); Route::resource(student, admin/Student); // 成绩批量保存单独一条语义比 resource 的 save 更清楚 Route::post(score/batch, admin/Score/batchSave); })-middleware(\app\middleware\AdminAuth::class);Route::resource是这里最省事的一行它按约定一次性注册七个路由对应控制器里的七个方法URL 和方法的映射关系是固定的。参数说明第一个参数是 URL 前缀第二个参数是「应用/控制器」路径。要注意resource生成的read路由是/course/:id如果你的控制器里没有read方法访问会报方法不存在所以要么实现它要么用-except([read])排除掉。再看中间件权限校验都集中在这里?php declare(strict_types1); namespace app\middleware; use Closure; use think\facade\Session; use think\Request; use think\Response; class AdminAuth { public function handle(Request $request, Closure $next): Response { // 1. 未登录AJAX 请求和页面请求返回的东西不一样得分开处理 if (!Session::has(admin_id)) { if ($request-isAjax()) { return json([code 401, msg 登录已过期请重新登录]); } return redirect((string) url(/admin/login)); } // 2. 学生账号不允许进后台即使 session 校验通过 if (Session::get(role) student) { return json([code 403, msg 无权限访问后台]); } // 3. 把当前用户挂到 request 上控制器里用 $this-request-user 取 $request-user [ id Session::get(admin_id), name Session::get(admin_name), role Session::get(role), ]; return $next($request); } }逻辑说明拦截点在$next($request)之前没登录直接返回业务代码根本不会执行。参数说明$request-user是动态属性赋值ThinkPHP 的 Request 对象支持这么挂载好处是控制器里不用反复读 Session也不需要靠全局变量传递。这里有个容易出问题的地方中间件返回的 401 是 HTTP 200 加业务码前端 jQuery 靠res.code判断而不是靠 HTTP 状态码。如果哪天要改造成 REST 风格记得两边同时改。2.3 三种角色的权限怎么卡住角色模型很简单admin能做所有事teacher只能管自己的课和自己课上的学生成绩student只能查自己的成绩。菜单层面按角色渲染教师的侧边栏没有「教师管理」学生的侧边栏只有「我的成绩」。但必须提醒一句菜单藏起来不等于接口拦住了。前端隐藏只是体验真正的边界在数据层。教师访问/admin/score/index?course_id5时即使课程 5 不是他开的只要不加条件就会把别人班的数据全查出来。下面的写法在查询层就带上条件// app/admin/controller/Score.php 片段 public function index() { $user $this-request-user; $courseId (int) $this-request-get(course_id, 0); $query \app\model\Score::with([student, course]) -where(course_id, $courseId); // 教师只能看自己开的课管理员不加限制 if ($user[role] teacher) { $query-whereIn(course_id, function ($sub) use ($user) { $sub-table(edu_course) -where(teacher_id, $user[id]) -field(id); }); } $list $query-order(id, desc)-paginate([ list_rows 20, query $this-request-get(), // 关键把搜索条件带进分页链接 ]); return view(, [list $list, course_id $courseId]); }参数说明里最值得记的是query $this-request-get()。少了这一行翻到第二页时搜索条件和course_id全部丢失页面会悄无声息地退回全量列表——这个现象很难一眼看出是 bug很容易被当成“分页本来就这样”。3. 目录结构与数据表五张表撑起最小骨架3.1 目录约定项目是标准的多应用结构站点根目录必须指向public这是第一个安全前提edu-admin/ ├── app/ │ ├── admin/ # 后台应用 │ │ ├── controller/ # Login / Index / Student / Course / Score / Teacher │ │ ├── model/ # Student / Course / Score / Teacher / Classes │ │ ├── validate/ # CourseValidate、ScoreValidate │ │ └── middleware.php # 应用级中间件注册 │ ├── middleware/ │ │ └── AdminAuth.php # 后台登录校验 │ ├── common.php # 公共函数如成绩等级换算 │ └── provider.php ├── config/ # app / database / session / route / view ├── public/ │ ├── index.php # 唯一入口 │ └── static/ # bootstrap、jquery、自定义 js/css ├── route/app.php # 路由集中定义 ├── view/admin/ # 模板按控制器名分目录 ├── runtime/ # 日志、缓存、编译后的模板 ├── sql/edu.sql # 建表脚本 初始管理员 ├── .env # 数据库账号密码不进版本库 └── composer.json几个位置要特别留意runtime目录必须可写否则模板编译失败、日志也写不进去出问题时连错误信息都看不到.env绝对不能提交到代码仓库里面是明文的数据库密码view/admin下的目录名要和控制器名对应ThinkPHP 默认按「控制器/方法」找模板文件比如Course/index对应view/admin/course/index.html。多应用模式需要在config/app.php里确认auto_multi_app处于开启状态否则app/admin不会被识别成一个独立应用访问/admin/login会提示控制器不存在。3.2 五张核心表与字段设计整个系统的最小骨架由班级、学生、教师、课程、成绩五张表撑起来。字段设计上有三条通用原则关联一律存 id 不存名字分数和学分用 decimal 不用 float所有业务编号加唯一索引。-- 班级 CREATE TABLE edu_class ( id int unsigned NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL COMMENT 如 2023级软件1班, grade smallint NOT NULL DEFAULT 2023 COMMENT 入学年份, teacher_id int unsigned NOT NULL DEFAULT 0 COMMENT 班主任, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT班级; -- 学生学号同时是登录账号 CREATE TABLE edu_student ( id int unsigned NOT NULL AUTO_INCREMENT, student_no varchar(20) NOT NULL COMMENT 学号, password varchar(255) NOT NULL COMMENT password_hash 结果, name varchar(30) NOT NULL, class_id int unsigned NOT NULL DEFAULT 0, gender tinyint NOT NULL DEFAULT 0 COMMENT 0未知 1男 2女, phone varchar(20) NOT NULL DEFAULT , status tinyint NOT NULL DEFAULT 1 COMMENT 1在读 0离校, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_no (student_no), KEY idx_class (class_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生; -- 课程 CREATE TABLE edu_course ( id int unsigned NOT NULL AUTO_INCREMENT, code varchar(20) NOT NULL COMMENT 课程编号, name varchar(80) NOT NULL, teacher_id int unsigned NOT NULL DEFAULT 0, class_id int unsigned NOT NULL DEFAULT 0, credit decimal(3,1) NOT NULL DEFAULT 0.0, term varchar(20) NOT NULL DEFAULT COMMENT 2024-2025-1, max_student smallint NOT NULL DEFAULT 60, PRIMARY KEY (id), UNIQUE KEY uk_code_term (code, term) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程; -- 成绩唯一索引是防重复录入的第一道闸 CREATE TABLE edu_score ( id int unsigned NOT NULL AUTO_INCREMENT, course_id int unsigned NOT NULL, student_id int unsigned NOT NULL, usual decimal(5,2) NOT NULL DEFAULT 0.00 COMMENT 平时分, exam decimal(5,2) NOT NULL DEFAULT 0.00 COMMENT 期末分, total decimal(5,2) NOT NULL DEFAULT 0.00 COMMENT 总评, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_course_student (course_id, student_id), KEY idx_student (student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT成绩;教师表edu_teacher结构类似学生表只是把student_no换成username另外多了title职称和dept所属院系。重点解释两处设计。成绩表的uk_course_student唯一索引不是可选项它是防止重复提交的最后一道保险——用户手抖点两次保存按钮或者网络超时重试只要走的是replace into结果都是覆盖而不是新增两条。课程表的uk_code_term同理同一学期不允许出现两个编号相同的课程。分数用decimal(5,2)而不用float是因为浮点数求和会出现89.99999999这种结果导出成绩单时会被教务处打回来。这个坑我踩过一次后来所有涉及分数、金额的字段一律 decimal。3.3 配置数据库并首次启动配置写在.env里框架启动时自动加载不写进代码APP_DEBUG true [APP] DEFAULT_TIMEZONE Asia/Shanghai [DATABASE] TYPE mysql HOSTNAME 127.0.0.1 DATABASE edu_admin USERNAME edu_user PASSWORD 换成你自己的密码 HOSTPORT 3306 CHARSET utf8mb4 PREFIX edu_PREFIX edu_这一项要和建表脚本里的表名前缀一致改错了会报「表不存在」。CHARSET必须是utf8mb4不是utf8——后者只有三字节存不下四字节字符。初始化步骤# 1. 装依赖vendor 目录不存在时必须先跑这一步 composer install # 2. 建库并导入表结构和初始数据 mysql -u root -p -e CREATE DATABASE edu_admin DEFAULT CHARSET utf8mb4; mysql -u root -p edu_admin sql/edu.sql # 3. 本地开发用内置服务器起正式环境换 nginx php-fpm php think run -H 0.0.0.0 -p 8000起来之后访问/admin/login初始管理员账号在sql/edu.sql里通常是admin加一个初始密码。第一次登录强制改密码是这类系统的常见做法实现方式是在中间件里判断Session::get(need_reset)为真就重定向到改密页。提示php think run只适合本地调试它的并发能力和错误处理都不适合正式使用。正式部署时 nginx 的root要指到public目录并且配上伪静态规则否则除首页外全部 404。4. 核心模块拆解登录、课程 CRUD、成绩录入4.1 登录与 Session 鉴权登录是唯一一个不挂AdminAuth的控制器因为挂上就死循环了。控制器继承一个 Base 基类基类里做公共初始化登录控制器单独放行。?php namespace app\admin\controller; use think\facade\Db; use think\facade\Session; class Login extends Base { public function index() { return view(); } public function doLogin() { $username trim((string) $this-request-post(username, )); $password (string) $this-request-post(password, ); // 1. 空值和超长先挡掉别让它进到 SQL 层 if ($username || $password ) { return json([code 1, msg 账号和密码不能为空]); } if (mb_strlen($username) 20 || mb_strlen($password) 64) { return json([code 1, msg 账号或密码格式不正确]); } // 2. 同一 IP 一分钟超过 10 次失败就暂时拒绝防撞库 $failKey login_fail_ . $this-request-ip(); if ((int) cache($failKey) 10) { return json([code 1, msg 尝试过于频繁请一分钟后再试]); } // 3. 账号可能来自教师表也可能来自学生表按顺序查 $user Db::name(teacher)-where(username, $username)-find(); $role teacher; if (!$user) { $user Db::name(student)-where(student_no, $username)-find(); $role student; } // 4. 统一走 password_verify不比对明文也不做 md5 拼接 if (!$user || !password_verify($password, $user[password])) { cache($failKey, (int) cache($failKey) 1, 60); return json([code 1, msg 账号或密码错误]); } // 5. 登录成功清失败计数、重置会话 id、写入角色 cache($failKey, null); Session::regenerate(); Session::set(admin_id, $user[id]); Session::set(admin_name, $user[name]); Session::set(role, $role); return json([code 0, msg ok, url url(/admin/index)-build()]); } public function logout() { Session::clear(); return redirect((string) url(/admin/login)); } }逻辑说明Session::regenerate()是关键一步它换掉会话 id防止会话固定攻击——攻击者提前把已知的 session id 塞给受害者等对方登录后就能复用这个 id。参数说明cache($failKey, ..., 60)的第三个参数是过期秒数60 秒后自动清零不需要额外的定时任务。密码字段长度给到varchar(255)因为password_hash目前产出 60 个字符但算法升级或加盐参数变化后长度会变留足余量省得以后改表。老项目常见md5($password . $salt)的存法从那种系统迁移过来时不能直接拿新算法比对得写一段一次性升级先用旧算法验通过再用password_hash生成新值写回数据库下次登录就走新逻辑。4.2 课程管理的增删改查课程是使用频次最高的模块列表要支持关键词和学期两个维度的筛选新增和编辑共用一个保存接口删除前要检查有没有关联成绩。?php namespace app\admin\controller; use app\model\Course; use app\validate\CourseValidate; use think\exception\ValidateException; use think\facade\Db; class Course extends Base { // 列表关键词模糊匹配课程名或编号学期精确匹配 public function index() { $keyword trim((string) $this-request-get(keyword, )); $term (string) $this-request-get(term, ); $list Course::with([teacher]) -when($keyword ! , function ($q) use ($keyword) { $q-whereLike(name|code, %{$keyword}%); }) -when($term ! , function ($q) use ($term) { $q-where(term, $term); }) -order(id, desc) -paginate([ list_rows 15, query $this-request-get(), ]); return view(, [list $list, keyword $keyword, term $term]); } // 新增和编辑共用靠 id 是否存在判断 public function save() { $data $this-request-only([ id, code, name, teacher_id, class_id, credit, term, max_student, ], post); try { validate(CourseValidate::class)-check($data); } catch (ValidateException $e) { return json([code 1, msg $e-getError()]); } $id (int) ($data[id] ?? 0); unset($data[id]); if ($id 0) { Course::update($data, [id $id]); } else { $data[create_time] date(Y-m-d H:i:s); $id (int) Course::create($data)-id; } return json([code 0, msg 保存成功, id $id]); } // 删除前先查有没有成绩避免留下孤儿数据 public function delete() { $id (int) $this-request-post(id, 0); if ($id 0) { return json([code 1, msg 参数错误]); } if (Db::name(score)-where(course_id, $id)-count() 0) { return json([code 1, msg 该课程已有成绩记录不能删除]); } Course::destroy($id); return json([code 0, msg 已删除]); } }whereLike(name|code, ...)是 ThinkPHP 的简写等价于WHERE (name LIKE ... OR code LIKE ...)注意这里必须用括号包住不然和后面的学期条件 AND 起来会变成「课程名匹配 OR 编号匹配 AND 学期匹配」筛选结果会多出一截。验证器单独一个文件规则集中管理?php namespace app\admin\validate; use think\Validate; class CourseValidate extends Validate { protected $rule [ code require|alphaDash|length:2,20, name require|length:2,80, teacher_id require|integer|gt:0, credit require|float|between:0.5,20, term require|regex:/^\d{4}-\d{4}-[12]$/, max_student integer|between:1,300, ]; protected $message [ code.require 课程编号不能为空, code.alphaDash 课程编号只能是字母、数字、下划线和破折号, term.regex 学期格式应为 2024-2025-1, credit.between 学分应在 0.5 到 20 之间, ]; }参数说明alphaDash只允许字母、数字、下划线和破折号正好能挡掉把课程编号写成中文的情况between:0.5,20是闭区间term用正则卡死格式因为学期字符串后面要用来分组统计格式一乱统计就废了。4.3 成绩批量录入与事务边界成绩录入是整个系统里唯一需要事务的地方。一页可能同时提交几十条中途一条失败必须全部回滚否则会出现“录了一半”的状态教师自己都不知道哪些进去了。// app/admin/controller/Score.php 片段 public function batchSave() { $courseId (int) $this-request-post(course_id, 0); $rows $this-request-post(rows/a, []); // /a 后缀强制转成数组 if ($courseId 0 || empty($rows)) { return json([code 1, msg 参数缺失]); } $user $this-request-user; $course \app\model\Course::find($courseId); if (!$course) { return json([code 1, msg 课程不存在]); } // 教师只能录自己课的成绩管理员不受限 if ($user[role] teacher (int) $course-teacher_id ! (int) $user[id]) { return json([code 1, msg 只能录入自己课程的成绩]); } Db::startTrans(); try { $now date(Y-m-d H:i:s); $insert []; foreach ($rows as $row) { $studentId (int) ($row[student_id] ?? 0); $usual round((float) ($row[usual] ?? 0), 2); $exam round((float) ($row[exam] ?? 0), 2); if ($studentId 0) { throw new \InvalidArgumentException(存在无效的学生记录); } if ($usual 0 || $usual 100 || $exam 0 || $exam 100) { throw new \InvalidArgumentException(分数必须在 0 到 100 之间); } // 总评 平时 30% 期末 70%权重要改只改这一行 $total round($usual * 0.3 p a hrefhttps://download.csdn.net/download/dhywjx/10704152 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p