
1. 一个毕设标题背后的双技术栈真相JavaSSM和Django为什么会同时出现我拿到这个项目标题的第一反应是这个题目大概率是毕设市场上标准的“一题双卖”——同一个网上选课系统的业务需求分别用JavaSSM和PythonDjango各做了一套实现源码、论文、调试文档配套齐全学生拍到之后根据自己的技术基础选其中一套来跑、来改、来答辩就可以了。为什么我敢这么判断因为一个正常的企业级项目根本不需要同时用SSM和Django实现同一套逻辑那是重复造轮子。但在毕业设计这个场景里双技术栈的选题有非常现实的意义学生背景差异大有的只学过Java Web有的只学过Python Web双版本可以覆盖更多人群。答辩老师偏好不同有的教研室以Java为主有的以Python数据分析方向为主提供双版本等于上了双保险。扩展性好学生拿到源码后如果某套跑不起来还能切换到另一套不至于“死在一个坑里”。从标题本身看“网上选课系统”这个业务在所以毕设选题里属于难度适中、演示效果好、逻辑清晰的典型题目。它不像电商系统那样要处理支付和库存也不像社交平台那样要处理复杂的关系链但它具备一个完整信息系统的所有核心要素多角色权限、时间窗口状态、并发冲突、一对多/多对多关系。选课系统中“一门课被很多人抢选”、“一个学生选多门课”、“选课截止后状态切换”这些业务规则恰到好处地覆盖了数据库设计、事务控制、状态管理等关键知识点用来答辩完全撑得住场面。因此这篇文章我不打算只讲某个版本的代码怎么跑而是把这两个技术栈的实现逻辑、核心表设计、选课冲突处理、交付物组织方式、调试经验全部拆开讲一遍。无论你最终选择的是SSM还是Django版本都能在这篇文章里找到自己需要的东西。2. 网上选课系统的核心业务骨架先搞懂需求再谈技术很多同学拿到毕设源码的第一件事就是启动Tomcat跑起来然后对着页面发呆——“这个系统到底有哪些功能表结构为什么这么设计选课的流程到底是什么”我建议你反过来先理业务再看代码。因为理解了业务骨架代码里那些看似绕弯的写法立刻就通透了。2.1 三类角色的权限边界网上选课系统最基本的参与者有三类学生、教师、管理员。每一类角色管的事完全不同这也是系统里所有权限判断的出发点。学生端是最核心的使用方。学生登录后要做的事情包括浏览本学期开放的课程列表、查看课程详情上课时间、授课教师、剩余容量、已选人数、提交选课申请、退选已选课程、查看自己的课表、查看成绩。这里有一个关键的业务规则学生能看到的课必须是“选课周期内”开放的课学生能退选的课必须是“还没过退选截止时间”的课。这些判断散落在不同接口里千万别指望前端帮我挡住后端每个请求都要校验。教师端相对简单教师可以查看自己负责的课程列表、查看选课学生名单、录入成绩平时分期末分最终按权重合成总评。有些系统还允许教师维护课程的基本信息比如上课地点和教学大纲但这属于扩展功能核心是查看选课名单和录成绩。管理员端是整个系统的“上帝视角”。管理员负责维护基础数据——学生信息、教师信息、课程信息的增删改查维护开课计划——哪个学期开哪些课、哪个老师教哪门课、课程容量是多少维护选课窗口——什么时候开始选、什么时候截止、能不能补选、能不能退选。管理员是唯一能直接操作选课状态的角色学生和教师都不行。这三类角色对应到数据库设计上典型做法是建一张user表用role字段区分身份0管理员1学生2教师同时把学生、教师的详细信息分别放在student和teacher表里通过user_id外键关联。这个设计很朴素但完全够用——比建三张独立的登录表要合理得多因为角色的公共字段账号、密码、姓名、邮箱只需要一份存储。提示有些系统会把角色做成role表 user_role关联表用来支持“一个用户多个角色”。但选课系统的实际业务里一个用户几乎不可能既是学生又是教师所以直接在user表里放role字段就够了别过度设计。2.2 核心数据表学生、课程、选课记录三张表如何联动把需求翻译成数据库结构最核心的表其实是三张用户表user用户ID、账号、密码建议MD5或加盐哈希存储、角色、姓名、邮箱/手机号。课程表course课程ID、课程名称、课程编号、学分、授课教师ID外键到teacher、上课时间通常用星期节次描述比如周一 1-2节、上课地点、课程容量、已选人数、开课学期、课程状态0停用1启用。选课记录表course_selection选课ID、学生ID外键到student、课程ID外键到course、选课时间、成绩默认NULL教师录入、退选标记0正常1已退选。为什么说这三张表是核心因为整个系统的所有业务逻辑都围绕它们转学生提交选课 往course_selection插入一条记录 更新course表的selected_count字段。学生退选 更新course_selection的is_deleted标记 更新course表的selected_count字段。教师查询选课名单 通过course_id查course_selection再关联student表拿学生信息。管理员开课 往course表插入记录初始selected_count0。这里有一个设计细节值得展开退选为什么用逻辑删除而不是物理删除因为如果直接DELETE掉选课记录那门课的选课历史、学生的最终成绩如果已录就全没了。而且学校查选课流水时需要“选过又退了”的完整记录。用is_deleted字段打标记既能保留历史又能在统计“实际在选人数”时统一用WHERE is_deleted 0过滤一举两得。这个设计在毕设答辩时也是一个可以直接讲给评委听的亮点。2.3 选课状态机选课窗口如何控制系统的打开与关闭很多同学忽略了一个关键点选课系统不是7x24小时都能选课的。现实中学校的选课系统只在规定的时间窗口内开放比如“2024年9月1日 8:00 —— 2024年9月10日 23:59”过了这个窗口学生就不能再提交或退选课程了。这意味着系统里必须有一个“选课状态控制”的机制。我的建议是在admin_config表或system_config表里存两个字段——start_time和end_time管理员通过后台设置后端代码在每次处理选课/退选请求时先比对当前时间是否在窗口内。这个判断我在两种技术栈里的写法都不太一样后面详述但核心逻辑就一段伪代码当前时间是否在 startTime 和 endTime 之间 是 - 允许操作 否 - 返回“当前不在选课时间范围内”别小看这个窗口判断它实际上定义了一个选课状态机未开始选课按钮置灰→ 进行中可选可退→ 已结束所有选课/退选接口拒绝服务。在SSM版本里这个判断可能写在一个CheckTimeInterceptor或AOP切面里在Django版本里可能写成视图里的一小段判断逻辑或一个自定义的decorator。无论放哪里业务规则只有一个——过了截止时间谁都别想改选课记录。注意有的系统还设计了“补选阶段”统计漏选学生后再开放一次窗口本质上就是把这个状态机扩展成“选课阶段-补选阶段-查询阶段”三态。如果你的系统有这个需求就再增加一个phase字段标记当前所处阶段比单纯用时间戳判断更灵活。3. SSM版本落地实录从配置文件到选课请求的完整链路如果你最终选了Java这套SSMSpring SpringMVC MyBatis是老牌经典的组合网上资料多、遇到问题好搜。但正因为经典很多同学对它的理解停留在“会用注解”层面不理解一条请求从Tomcat进来后到底经过了哪些环节。下面按一条“学生提交选课”的请求链路把SSM的落地过程从头到尾讲清楚。3.1 项目结构和依赖配置SSM的骨架搭建SSM项目的基础结构是标准的Maven Web工程pom.xml src/main/java ├── com.example.controller # Controller层 ├── com.example.service # Service层接口 实现类 ├── com.example.dao # MyBatis的Mapper接口 ├── com.example.entity # 实体类对应数据库表 ├── com.example.utils # 工具类 └── com.example.interceptor # 拦截器可选 src/main/resources ├── jdbc.properties # 数据库连接配置 ├── spring-mvc.xml # SpringMVC配置 ├── spring-mybatis.xml # Spring整合MyBatis配置 └── mapper # MyBatis的XML映射文件 src/main/webapp/WEB-INF └── web.xml在pom.xml里需要引入的依赖主要是这些spring-webmvc、spring-jdbc、mybatis、mybatis-spring、mysql-connector-java或druid连接池、javax.servlet-apiTomcat编译期用、jstlJSP页面用、jackson-databindJSON序列化。spring-mybatis.xml是最容易让人懵的地方。它的作用是把数据源、事务管理器、MyBatis的SqlSessionFactory、Mapper扫描器全部整合进Spring容器。核心配置我这么写context:property-placeholder locationclasspath:jdbc.properties/ bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean tx:annotation-driven transaction-managertransactionManager/ bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean mybatis:scan base-packagecom.example.dao/这里有几个关键点需要理解否则你就是“配对了但不知道为什么”mapperLocations告诉MyBatis去哪里找SQL映射XML文件如果路径写错了启动时Mapper接口和SQL文件关联不上会直接报BindingException。mybatis:scan把dao包下的接口全部注册成Spring的Bean。扫描成功之后Controller、Service里才能用Autowired注入Mapper接口。**DruidDataSource**负责数据库连接池管理比直接用DriverManager的方式性能好得多而且可以在jdbc.properties里配置初始连接数、最大连接数、超时时间等参数。3.2 选课请求的请求链路Controller到Mapper的四层递进以“学生提交选课”为例子这条请求在SSM里会经历以下四个环节。第一步Controller接收前端请求Controller只做三件事接收参数、调用Service、根据Service返回结果决定跳转页面或返回JSON。我习惯用RequestMappingResponseBody的方式让接口直接返回JSON方便前后端分离如果项目使用JSP则返回视图名称由InternalResourceViewResolver解析。Controller RequestMapping(/selection) public class SelectionController { Autowired private CourseSelectionService selectionService; RequestMapping(value /select, method RequestMethod.POST) ResponseBody public Result selectCourse(RequestParam(courseId) Integer courseId, RequestParam(studentId) Integer studentId) { try { selectionService.selectCourse(studentId, courseId); return Result.success(选课成功); } catch (BusinessException e) { return Result.error(e.getMessage()); } catch (Exception e) { return Result.error(系统异常请稍后重试); } } }注意Result是一个自定义的统一返回对象包含code、message、data三个字段。毕设系统里用统一返回结构前端拿JSON时逻辑统一不会出现“这个接口返回字符串、那个接口返回Map”的混乱。第二步Service处理业务规则Service是业务逻辑的主战场。在selectCourse方法里必须依次完成这些校验当前时间是否在选课窗口内课程是否存在、是否启用该学生是否已经选过这堂课防止重复提交课程当前已选人数是否达到容量上限插入选课记录同时更新selected_count。这里最容易犯的错误是把所有校验写在Controller里。虽然功能也能跑但一旦多个Controller都要用到“判断课程是否可选”的逻辑就出现重复代码了。正确做法是Controller只收参业务规则全部下沉到Service里。第三步Service实现类完成事务控制选课操作涉及插入选课记录和更新课程已选人数两步必须放在同一个数据库事务里保证原子性。MySQL的InnoDB引擎默认支持事务但代码层面还要加上Transactional注解才能让Spring帮我们管理事务边界Service Transactional public class CourseSelectionServiceImpl implements CourseSelectionService { Autowired private CourseSelectionDao courseSelectionDao; Autowired private CourseDao courseDao; Override public void selectCourse(Integer studentId, Integer courseId) { // 1. 校验选课窗口 // 2. 校验课程状态与容量 // 3. 判断是否重复选课 // 4. 插入选课记录 // 5. 更新课程已选人数 selected_count selected_count 1 } }Transactional注解放在类上表示类中所有public方法都会被事务拦截。Spring通过AOP在方法开始前开启事务、方法成功后提交、方法抛出异常时回滚。这个特性在我们稍后要讲的并发冲突处理中至关重要。第四步MyBatis Mapper执行SQL最后一步是数据访问层。MyBatis的Mapper接口只定义方法签名真正的SQL写在XML里我用sql标签和if标签可以很灵活地拼条件select idcountByStudentAndCourse resultTypeint SELECT COUNT(*) FROM course_selection WHERE student_id #{studentId} AND course_id #{courseId} AND is_deleted 0 /select update idincreaseSelectedCount UPDATE course SET selected_count selected_count 1 WHERE id #{courseId} AND selected_count lt; capacity /update提示selected_count capacity这个条件加在UPDATE语句里非常有价值——它能防止两个学生同时选最后一门课时数据库层面出现“超选”的错误。即使两个请求同时进入Service层并都通过了校验到了UPDATE语句时InnoDB的行锁会让其中一个请求的UPDATE匹配不到行因为另一个请求已经让selected_count达到capacity从而让这个请求的影响行数为0。代码里只需要检查updateResult 0就知道是否还有容量这也是下面“并发冲突”章节的地基。3.3 SSM版本的坑事务失效、路径扫描、JSON乱码的避坑办法SSM这套组合虽然经典但坑也多我把这几年带毕设常见的几个问题列出来如果你启动或运行时遇到类似症状可以直接对照排查。坑一事务注解不生效。典型症状是Transactional明明写了但插入成功后课程人数没更新成功数据不一致。原因通常是spring-mybatis.xml里没有启用tx:annotation-driven或者WebApplicationContext根本没加载到transactionManager这个Bean。另一个隐蔽原因是事务方法被同类内部调用——AOP代理只拦截外部入口调用如果this.selectCourse()在同类里被另一个方法调用事务不会生效。解决办法是把事务方法放在独立的Bean里调用或者自己注入自身代理。坑二静态资源被DispatcherServlet拦截。前端页面里的CSS、JS、图片突然全部404。原因很可能是web.xml里把DispatcherServlet映射到了/导致所有请求都走SpringMVC静态资源被当成Controller请求处理。解决办法是在spring-mvc.xml里加mvc:default-servlet-handler/或单独配置资源映射路径。坑三JSON输出乱码。接口返回的中文全部变成???。这个问题的根源是SpringMVC的StringHttpMessageConverter默认使用ISO-8859-1编码。两个修法一是RequestMapping里加produces application/json; charsetutf-8二是在spring-mvc.xml里定义一个StringHttpMessageConverter并设置为UTF-8我推荐第二种一劳永逸mvc:annotation-driven mvc:message-converters bean classorg.springframework.http.converter.StringHttpMessageConverter property namesupportedMediaTypes list valuetext/plain;charsetUTF-8/value valuetext/html;charsetUTF-8/value valueapplication/json;charsetUTF-8/value /list /property /bean /mvc:message-converters /mvc:annotation-driven4. Django版本的核心差异ORM思维、Admin后台、认证系统如果你选了Python这套恭喜你Django在“做管理类信息系统”这件事上确实比SSM省事不少。尤其是有自带的Admin后台和用户认证模块很多SSM里要手写的东西Django开箱即用。但省事不等于没逻辑下面把Django版本里那些和SSM“同业务不同写法”的地方逐一对比。4.1 Django项目结构与SSM的对应关系Django项目的典型结构长这样manage.py myproject/ ├── settings.py # 对应SSM的spring-mybatis.xml spring-mvc.xml ├── urls.py # 对应SpringMVC的RequestMapping路由分发 ├── wsgi.py course_system/ # 一个app对应SSM里的一个模块如com.example.selection包 ├── models.py # 对应数据库表用ORM类描述 ├── views.py # 对应Controller ├── urls.py # 该app自己的路由表 ├── admin.py # 注册Admin后台管理模型 └── forms.py # 表单校验对应SSM里的Valid或手动校验一句话总结Django的models.py MyBatis的mapper XML entity类views.py Controlleradmin.py 半个现成的管理员后台。4.2 用ORM实现选课关系模型定义与事务写法Django的连接数据库、建表方式比MyBatis更自动化。在models.py里定义好模型类后跑makemigrations和migrate两条命令Django就自动生成对应的数据库表不需要写CREATE TABLE。选课系统的三张核心表用Django定义是这样的from django.db import models class User(models.Model): ROLE_CHOICES ( (0, 管理员), (1, 学生), (2, 教师), ) username models.CharField(max_length50, uniqueTrue) password models.CharField(max_length128) # 存hash值 role models.IntegerField(choicesROLE_CHOICES, default1) name models.CharField(max_length50) email models.EmailField(blankTrue) class Course(models.Model): name models.CharField(max_length100) course_no models.CharField(max_length20, uniqueTrue) credit models.FloatField(default2.0) teacher models.ForeignKey(Teacher, on_deletemodels.CASCADE) schedule models.CharField(max_length50) # 例如周一 1-2节 location models.CharField(max_length50) capacity models.IntegerField(default60) selected_count models.IntegerField(default0) semester models.CharField(max_length20) status models.IntegerField(default1) # 0停用 1启用 class CourseSelection(models.Model): student models.ForeignKey(Student, on_deletemodels.CASCADE) course models.ForeignKey(Course, on_deletemodels.CASCADE) select_time models.DateTimeField(auto_now_addTrue) score models.FloatField(nullTrue, blankTrue) is_deleted models.IntegerField(default0) # 0正常 1退选这里有一点必须提醒on_deletemodels.CASCADE在你删除教师或课程时会级联删除相关的选课记录。对于选课系统来说删除课程通常意味着“这门课不开”那它的选课记录确实应该清理掉级联删除是合理的。但如果删除的是学生他的历史选课记录也一并没了成绩也就没了。这个规则按学校教务处的要求来定有的学校选择SET_NULL保留历史那就得把外键字段设成nullTrue否则执行迁移时会报错。Django的ORM在事务控制上比SSM更简洁——用一个with transaction.atomic()装饰器或上下文管理器就能把代码块包进统一事务from django.db import transaction from django.utils import timezone def select_course(student_id, course_id): # 所有的校验逻辑时间窗口、课程状态、容量、重复选课 with transaction.atomic(): # 插入选课记录 CourseSelection.objects.create(student_idstudent_id, course_idcourse_id, select_timetimezone.now()) # 更新课程已选人数加一个条件防止超选 updated Course.objects.filter(idcourse_id, selected_count__ltmodels.F(capacity) ).update(selected_countmodels.F(selected_count) 1) if updated 0: raise ValueError(课程已满员)关键点在于F(selected_count)的用法——它让“从当前值基础上1”这个操作在数据库层面完成而不是先在Python里读出selected_count值、加1、再写回去。用F表达式的好处是避免“读-改-写”三步之间的并发间隙和SSM版本里UPDATE course SET selected_count selected_count 1 WHERE ...是一个道理都是为了防超选。4.3 为什么Django的Admin后台能帮你省掉一半工作量SSM版本里管理员端的课程管理、学生管理、教师管理页面要写Controller、JSP、JS一大堆代码。但Django自带一个功能全面的Admin后台注册模型后自动生成增删改查界面。在admin.py里写from django.contrib import admin from .models import Course, Student, Teacher, CourseSelection admin.register(Course) class CourseAdmin(admin.ModelAdmin): list_display (name, course_no, teacher, capacity, selected_count, status) search_fields (name, course_no) list_filter (semester, status)这段代码就给管理员生成了一个带搜索、筛选、分页的课程管理页面。如果你是个人开发者给学校做内部系统Admin后台几乎直接就是管理端了连前端页面都不用写。但有两个坑必须提醒坑一Admin后台的权限体系。Django的Admin默认只有is_staffTrue且登录后的用户才能访问。你的学生、教师账号如果走普通注册流程默认is_staffFalse是进不了Admin页面的。所以毕设答辩时如果要演示Admin功能需要创建一个is_staffTrue的管理员账号createsuperuser命令。坑二Admin后台不代表全部管理功能。毕设演示时如果你只用Admin后台管理数据course_selection的选课记录、成绩录入这些操作虽然也能在Admin里做但界面不够贴近“教务管理”的业务场景。现实中我建议至少把“选课管理”按课程查看选课名单、按学生查看课表和“成绩录入”这两个页面单独实现Admin后台只用来做基础数据维护。这样既展示了Django的ORM开发效率又能让评委看到你写了核心业务代码而不是光靠自带后台撑场面。4.4 Django做选课系统时的登录鉴权方案Django自带一套用户认证体系对选课系统来说最佳实践是复用auth.User再通过OneToOneField扩展学生/教师信息。from django.contrib.auth.models import User from django.db import models class Student(models.Model): user models.OneToOneField(User, on_deletemodels.CASCADE) student_no models.CharField(max_length20, uniqueTrue) major models.CharField(max_length100) grade models.CharField(max_length20)这样做的最大好处是直接使用Django的login、logout、login_required装饰器做登录状态管理不用自己写Session或Token逻辑。比如学生选课接口加一个login_required就能保证只有登录用户能访问from django.contrib.auth.decorators import login_required login_required def select_course(request): # request.user 就是当前登录用户 ...几行代码免去了自己在Session里存用户ID、每次取出来判断登录状态的工作量。对比SSM版本要写一个LoginInterceptor确实省事很多。但要注意一点auth.User的username字段直接复用学生学号里经常有特殊字符需要在创建账号时做转换或校验避免和密码格式冲突。5. 选课并发与业务冲突最容易翻车也最值得写进论文的地方我刚拿到这个项目时把注意力放在“怎么把页面跑起来”上觉得选课嘛就是往表里插一行数据。直到有朋友让我帮他的毕设压测才发现并发选课才是这个系统技术含量最高的地方。两个学生同时点击“选课”按钮恰好只剩下最后一个名额如果处理不当就会出现“都选上了”的数据不一致。这个场景最适合写进毕业论文因为它既有业务价值又有技术深度。5.1 超选问题的本质读改写三步的竞态条件超选的本质是“读改写”三步操作之间存在竞态窗口。伪代码是这样的1. 查SELECT COUNT(*) FROM course_selection WHERE course_id ? 2. 判断count capacity 吗 3. 插入INSERT INTO course_selection ...如果两个请求同时在步骤1读到count 59而capacity是60都通过了步骤2的判断然后在步骤3都插入成功数据库里就有61条选课记录了。这就是“超选”。解决办法有两个层面我在代码里是两层防护一起做的防御一限制性UPDATE。这也是前面反复提过的做法核心是在更新课程表时带上容量条件UPDATE course SET selected_count selected_count 1 WHERE id ? AND selected_count capacity如果影响行数为0说明这条UPDATE请求到达数据库时课程已经满了哪怕Java/Python层通过SELECT读到“未满”也没用。数据库的行锁让UPDATE串行化这是兜底。防御二事务隔离。在SSM里用Transactional把“查重-插记录-更新人数”包在一个事务里在Django里用transaction.atomic()做同样的事。配合数据库默认的REPEATABLE READMySQL或READ COMMITTEDPostgreSQL能保证同一事务内多次查询看到一致的快照。但要提醒你一个陷阱只靠Transactional是不够的。如果两个事务同时开始都SELECT到“未满”然后同时UPDATEInnoDB的行锁会让第二个UPDATE阻塞等待最终因为selected_count capacity条件不满足而影响0行。但如果你的UPDATE语句不带容量条件两个事务都能成功更新行数——这时候就出乱子了。所以关键不是加锁本身而是把业务校验条件压进UPDATE语句。5.2 乐观锁 vs 悲观锁选课系统该选哪种关于锁的选型很多教材喜欢抽象地讲乐观锁和悲观锁的区别但放到选课系统里结论其实很明确。悲观锁SELECT ... FOR UPDATE会给选中的行加锁其他事务要等锁释放才能操作。它的优点是数据一致性绝对可靠缺点是并发能力差。选课场景的特点是高频短事务锁等待时间短但请求量巨大悲观锁容易成为性能瓶颈。乐观锁版本号或条件更新不加锁而是在提交时检查冲突。我们前面的selected_count capacity本质上就是一种乐观锁方案——不锁课程记录只是在UPDATE时校验条件。它的优点是吞吐量高缺点是有冲突时靠重试或直接报错。我的实际建议是选课写入用条件更新式的乐观锁插入前查询用普通校验就够了。不要为了显得高级去用SELECT FOR UPDATE锁课程行——那样确实不会超选但会让并发时的其他事务排队等待在“抢课”这样的高并发场景下体验很差。这个取舍在答辩时可以讲清楚能展示你对并发控制有真实思考而不是背概念。5.3 友好的冲突提示容量不足、重复选课、窗口关闭的差异化处理并发冲突在业务层的表现不能只有“系统异常”一句话。我在设计时就区分了三种不同的拒绝原因让前端能给出不同提示异常类型判断逻辑用户提示选课窗口已关闭当前时间 开始时间 或 结束时间“当前不在选课时间范围内”课程已满员限制性UPDATE影响行数为0“该课程已满员请选择其他课程”重复选课同一学生该课程存在未删除记录“您已选择过该课程”在SSM里我自定义了BusinessException继承RuntimeException带message字段Service校验不通过就throw new BusinessException(...)Controller捕获后在Result对象里带上提示信息。在Django里可以直接开一个全局异常处理后让不同Exception返回不同的JSON错误码。这比所有失败都返回“失败”两个字体验好很多答辩演示时也更有说服力。6. 交付物地图源码、LW、调试文档、讲解如何配套组织这个项目标题里明确写了“源码LW调试文档讲解等”。很多同学不知道这些交付物分别是什么、怎么配套使用拿到手后经常是“源码能跑但论文不知道怎么改”。我把这一整套东西的实际使用方法理清楚。6.1 源码的结构与启动方式两种技术栈各自的运行前提SSM版本拿到源码后第一件事不是启动Tomcat而是先改jdbc.properties里的数据库连接信息然后在MySQL里执行项目附带的init.sql脚本建库建表并插入初始演示数据。之后用IDEA打开Maven工程让它自动下载依赖首次会比较慢配好本地的Tomcat或直接用tomcat7-maven-plugin插件一键启动把Artifact部署到Tomcat的webapps目录启动后访问http://localhost:8080/即可。Django版本先pip install -r requirements.txt装依赖然后修改settings.py里的DATABASES配置执行python manage.py makemigrations和python manage.py migrate建表再执行python manage.py createsuperuser创建管理员账号。最后python manage.py runserver就能在浏览器打开。如果项目里附带了data.json这类初始数据文件用loaddata命令导入即可。提示两个版本共用一个MySQL实例是可以的但数据库结构不同Django建的表名是应用名_模型名这种格式所以千万别把两个版本的建表脚本同时跑在同一个库上。建议分别建两个数据库比如course_system_ssm和course_system_django。6.2 LW论文/设计文档该怎么和源码对应起来“LW”在毕设圈里指的是论文或设计说明书。这个文档通常包含引言背景与意义、相关技术介绍、需求分析、系统设计架构图数据库设计、系统实现核心代码与截图、系统测试、总结致谢。很多同学拿到LW后直接改个名字就交了结果答辩时老师随便问两句就露馅——因为他根本不了解论文里写了什么。我的建议是花一天时间做这样几件事读一遍LW的“需求分析”章节对照源码里的功能清单确认每个功能点对应到哪个Controller/View、哪个页面。读“数据库设计”章节把ER图里每张表和源码里的Model/Entity类对照起来。能答出“为什么这张表有is_deleted字段”比背一百遍概念都有用。写一段“技术难点”的口述稿。论文里写了并发控制、事务管理等内容的就对着源码里的Transactional或transaction.atomic()准备一段“我在实现时如何解决超选”的阐述。6.3 调试文档的价值别把它当成摆设调试文档通常记录的是“项目启动过程中可能遇到的问题及解决方法”。这份东西在项目配好环境、正常跑起来之后确实显得“没用”但只要你换一台电脑、换一个数据库版本它立刻变成救命稻草。我拿到调试文档后的固定流程是先按文档列出的顺序走一遍环境配置每遇到一个错误就对照文档排查。如果文档里没写某个错误就把解决方案补充进去。如果你将来要把这个项目发给同学或学弟学妹这份文档的价值就体现出来了——它能大幅减少“为什么我这跑不起来”这样的重复问题。6.4 讲解演示时最容易出彩的三个片段从引导答辩准备来看选课系统的演示不需要把每个功能都点一遍而是要有节奏地突出亮点。我建议重点讲这三个片段角色切换分别以管理员、教师、学生身份登录演示同一套系统在不同视角下的功能差异。这能展示你对权限模型的理解。选课状态控制先展示学生选课成功再去Admin后台把选课窗口关闭再演示学生选课被拒。这展示了状态机设计的完整性。防超选演示临时把课程容量改成1开两个浏览器窗口分别登录不同学生账号几乎同时点选课看系统如何拒绝其中之一。这比讲一万字并发理论都直观。7. 我在调试这类系统时踩过的坑给后来者的一份实操排雷清单这块算是我个人经验里的“血泪史”了。各种花式报错都碰过整理一份排雷清单按技术栈分类方便你遇到问题直接来对照。7.1 版本兼容性问题SSM的版本玄学与Django的版本陷阱SSM这套里Spring、MyBatis、JDK、Tomcat这四者之间存在隐性版本依赖。我的经验是JDK 8 Spring 5.x MyBatis 3.5.x Tomcat 8.5/9是多年验证过的稳定组合。如果你用JDK 11或17Spring的CGLIB代理反射机制在某些版本里会有兼容问题。如果用了Spring 6那要求JDK 17而且SpringMVC的核心包、拦截器命名都变了网上旧教程的很多写法直接失效。拿到源码第一件事先看pom.xml里的spring版本和你的JDK版本是否匹配别盲启动否则报错会报得你怀疑人生。Django的版本坑主要在Django 4.x/5.x上urls.py的写法已经全面使用path()而非过时的url()。如果你在代码里看到from django.conf.urls import url这篇代码大概率是Django 2.x时代的跑在Django 4上会直接报错。同样Django 3.0以后中间件配置从MIDDLEWARE_CLASSES改成了MIDDLEWARE旧代码需要修改后才能跑起来。7.2 中文乱码与环境编码大一统的UTF-8方案做完这个项目我对“编码问题”总结出一条铁律全链路UTF-8一个环节都不能漏。包括四个环节数据库连接URL加?useUnicodetruecharacterEncodingUTF-8JSP页面顶部加% page contentTypetext/html; charsetUTF-8 pageEncodingUTF-8%Java源码文件保持UTF-8编码IDEA里在Settings File Encodings全设为UTF-8Tomcat的server.xml里Connector加URIEncodingUTF-8。Django这边简单一些因为Django本身默认UTF-8。但有一个坑MySQL表如果建库时用了latin1之类的旧编码Django写入中文时也会乱。建库时强制指定utf8mb4CREATE DATABASE course_system_django DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;7.3 演示数据的重要性生产环境没有问题演示时才翻车毕设项目里最容易被低估的是初始演示数据的质量。很多源码自带的init.sql里只有两三个测试用户、四五门课程“跑起来”没问题但答辩演示时就很尴尬——老师想看“选课名单”页面上只有两个人想分页看课程列表翻两页就到头了。我建议你花半小时扩充一份完整的演示数据学生至少20个教师至少5个课程至少15门覆盖不同学分、不同时间、不同容量选课记录至少30条包括正常选课、已退选、已录成绩的状态。这样无论从哪个入口演示数据都足够撑场面。这个习惯也适用于你以后接任何外包项目——给用户交付的空数据库和充满合理演示数据的数据库体验是完全不同的。7.4 数据库连接池超时Django里一个容易被忽视的定时任务最后提一个不算热门但实际困扰我的问题Django MySQL默认连接超过8小时空闲会被MySQL主动断开然后程序报“MySQL server has gone away”。如果你只做毕设演示这个问题无所谓但如果挂机测试时长超过8小时或用了定时任务比如每天固定时间自动关闭选课窗口就会踩到这个坑。解决办法是在settings.py里设置DATABASES { default: { ENGINE: django.db.backends.mysql, CONN_MAX_AGE: 3600, # 1小时小于MySQL的wait_timeout OPTIONS: { init_command: SET sql_modeSTRICT_TRANS_TABLES, }, } }或者用一次性connection.close()成本更低的手段定时任务每次执行前检查连接是否可用不可用就重连。这不难但能避免线上演示时突然白屏的尴尬。我在实际带这个项目过程的体会是选课系统虽然看起来是个“老掉牙”的毕设题目但它的业务完整性、技术覆盖面权限、事务、并发、状态机、交付组织决定了它非常适合作为学习Web开发的一个综合练手项目。无论SSM还是Django本质都是“把业务规则落到数据操作上”这一件事。把核心逻辑吃透再遇到任何管理信息系统你都能触类旁通。