简介:面向毕业设计场景的SSM毕设管理系统BSManager完整项目包,后端整合Spring、SpringMVC、MyBatis三大框架,前端采用Bootstrap构建界面,面向教师端、学生端、管理员端提供后台运行能力,适合计算机专业毕业生参考或Java Web开发者学习框架整合。项目压缩包共2000个文件,包括1386个JavaScript脚本、210个HTML页面、112个Markdown文档、86个JSON配置、73个JSP视图、59个Java源码,以及CSS样式、XML映射、properties配置和sh脚本等,整体约32.62MB,目录结构完整清晰。资源自带数据库脚本文件,导入后配置Tomcat访问路径为/BSManager即可快速启动,可直接运行或二次开发,便于深入理解三个框架的协作方式、角色权限设计和前端组件调用。目前已有209人学习下载,是边用边学SSM项目搭建与后台管理流程的实用参考。
1. SSM 毕设管理系统:一个把选题流程讲清楚的后台,比一堆增删改查更有说服力
很多同学做毕业设计,把「SSM 毕设管理系统」做成了三个角色的 CRUD 拼盘,答辩时被老师一句「两个学生同时抢最后一个课题,系统怎么保证不超员」问得愣在台上。SSM 毕设管理系统真正的价值,不在 Spring、SpringMVC、MyBatis 三件套本身,而在它背后的业务模型:学生、教师、管理员三类角色,围绕课题申报、选题确认、进度提交、成绩录入这条链路各自拥有不同的操作权限和状态字段。这套系统既能覆盖 SSM 框架的全部核心知识点,又能让事务、并发控制、权限拦截这几个点成为答辩时的加分项。它适合没写过完整 Web 项目的本科生用来练手,也适合需要快速搭一套后台管理原型的开发者参考。做之前先想清楚一件事:这不是一个演示 Demo,而是一条完整可解释的业务链路。
2. 先把数据库设计立住:四张核心表与选题状态机的落法
SSM 项目里数据库设计决定了后面代码怎么写,尤其是表与表之间的职责边界。我通常先把用例图画清楚再建表:管理员维护用户和课题审核,教师发布课题、确认学生、批进度,学生浏览课题、发起选题、提交过程文件。这里最核心的设计是「选题状态机」:一条选题记录从待确认走到已通过、已拒绝或已退选,任何一次状态变更都要落库,不能只存在内存变量里。如果状态字段设计得含糊,后面写 Service 时每一处都要打补丁,这是很多同学做到一半推倒重来的原因。
2.1 角色权限与核心流程:三个角色各管一段什么
先明确角色边界,后面 Controller 的每个接口设计都从这里出发。
| 角色 | 主要操作 | 核心数据表 |
|---|---|---|
| 管理员 | 用户管理、课题审核、批次开关、成绩录入 | sys_user、topic |
| 教师 | 申报课题、查看选题、确认/拒绝学生、批进度 | topic、selection、progress |
| 学生 | 浏览课题、选题/退选、提交过程文件 | topic、selection、progress |
建议主流程采用「教师申报 → 管理员审核 → 学生选题 → 教师确认」,这样一条链路把三个角色全部串起来。状态设计上,topic 表用 status 表示课题当前能否被选,selection 表用 status 表示这条选题申请走到哪一步,progress 表用 review_status 表示过程文件是否合格。把三者串起来后,页面上的按钮显示和 SQL 的 where 条件都取决于这些状态值。
为什么角色权限表要单独写纯文字?因为毕设管理系统本质上是流程审批系统,不是文件管理系统。权限设计决定了哪些 JSP 页面由谁访问、哪些按钮在什么状态下可见、后端接口如何校验角色。如果一开始就没分清楚,后面会出现学生调用了管理员的接口、教师改了自己不该改的课题这类尴尬问题。权限矩阵表可以直接复制到论文设计章节里,也是答辩时老师最常看的一页。
2.2 四张核心表 DDL:sys_user、topic、selection、progress
建表时要注意几个细节:用户表不要用 user 这个表名,MySQL 里 user 与系统函数有语义冲突,兜底方案是加反引号,但更省事的是直接叫 sys_user;所有表统一 InnoDB + utf8mb4;状态字段用 TINYINT 配注释,而不是用 VARCHAR 存中文。下面这四张表是我验证过可以直接跑的业务核心。
CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL COMMENT '登录名', password VARCHAR(64) NOT NULL COMMENT '加密后的密码', real_name VARCHAR(32) NOT NULL COMMENT '姓名', role TINYINT NOT NULL COMMENT '1-管理员 2-教师 3-学生', student_no VARCHAR(32) DEFAULT NULL COMMENT '学号,仅学生有', teacher_no VARCHAR(32) DEFAULT NULL COMMENT '工号,仅教师有', department VARCHAR(64) DEFAULT NULL COMMENT '院系', created_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';CREATE TABLE topic ( id INT PRIMARY KEY AUTO_INCREMENT, teacher_id INT NOT NULL COMMENT 'sys_user.id', title VARCHAR(128) NOT NULL COMMENT '课题名称', description TEXT COMMENT '课题要求', direction VARCHAR(64) COMMENT '课题方向,便于学生筛选', capacity INT NOT NULL DEFAULT 1 COMMENT '可选学生人数', selected_count INT NOT NULL DEFAULT 0 COMMENT '已选人数', status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待审核 1-已通过 2-已关闭', created_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_teacher_id (teacher_id), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课题表';CREATE TABLE selection ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL COMMENT 'sys_user.id', topic_id INT NOT NULL COMMENT 'topic.id', status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待教师确认 1-已通过 2-已拒绝 3-已退选', select_time DATETIME NOT NULL, confirm_time DATETIME DEFAULT NULL, UNIQUE KEY uk_student_topic (student_id, topic_id), KEY idx_topic_id (topic_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选题记录表';CREATE TABLE progress ( id INT PRIMARY KEY AUTO_INCREMENT, selection_id INT NOT NULL COMMENT 'selection.id', stage TINYINT NOT NULL COMMENT '1-开题 2-中期 3-终稿', file_path VARCHAR(255) COMMENT '上传文件路径', content TEXT COMMENT '文字说明', submit_time DATETIME DEFAULT NULL, review_status TINYINT DEFAULT 0 COMMENT '0-未提交 1-待审核 2-已通过 3-打回', review_comment VARCHAR(255) COMMENT '审核意见', KEY idx_selection_id (selection_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='过程进度表';四张表的关联关系是:topic 挂在 teacher_id 下,selection 记录学生与课题的申请关系,progress 又挂在 selection 下。这条链保证了「一个学生的一个课题申请」对应一条进度流,不会出现进度不知道属于谁的混乱。selected_count 字段需要额外解释:它本身是冗余数据,可以用 count 查询替代,但放在 topic 表里是为了第 3 章并发控制能用一条 update 原子更新,这是这道题最容易拿分的设计点。
2.3 状态字段的取值口径:别把判断逻辑散落在 JSP 里
状态字段在代码里会反复出现,我强烈建议用一个枚举类统一定义,而不是在 Controller 里写魔法数字。比如 topic 的 status 字段,0 到底代表待审核还是未通过,不同人写的代码可能理解不一样。给一个简单的状态枚举,可以放到 common 包里。
public enum TopicStatus { PENDING(0, "待审核"), APPROVED(1, "已通过"), CLOSED(2, "已关闭"); private final int value; private final String desc; TopicStatus(int value, String desc) { this.value = value; this.desc = desc; } public int getValue() { return value; } public String getDesc() { return desc; } }枚举的好处是后端比较时写 TopicStatus.APPROVED.getValue() 而不是裸写 1,别人 review 代码时一眼能看出业务含义。前端 JSP 里判断状态时,建议只做展示,比如按状态显示「待审核」「已通过」按钮,不要在前端拼业务逻辑的 if 分支去判断课题能否删除。这样后面改状态流程时,只改一处枚举和 Service,不用满世界找魔法数字。还有一个注意点:删除数据使用物理 delete 还是逻辑删除,毕设管理系统建议用逻辑删除(加一个 deleted 字段),因为选题记录和历史成绩都可能需要回溯查证,物理删除后面做统计时会少数据。
3. 用三层架构把「学生选题」这条链路跑通:从 web.xml 到 Mapper XML
SSM 项目里配置文件是很多人照抄的,但并不知道每个文件的边界在哪里。我的习惯是按「谁管控制器、谁管数据源、谁管 SQL」来拆:spring-mvc.xml 只扫描 @Controller,applicationContext.xml 只扫描 Service、Mapper 和通用组件,二者不要混扫。混扫的后果是事务代理和 Controller 被重复实例化,项目启动后经常出现诡异的行为,比如同一个 bean 被创建两次、事务不生效。这一章把从配置文件到选题接口的完整链路走一遍,每一步都是可以直接抄进项目里的。
3.1 核心配置拆分:spring-mvc.xml 与 applicationContext.xml 各管什么
先看 spring-mvc.xml,它的核心任务是开启注解驱动、扫描控制器、配置视图解析器。
<context:component-scan base-package="com.example.bishe.controller"/> <mvc:annotation-driven/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/jsp/"/> <property name="suffix" value=".jsp"/> </bean>再看 applicationContext.xml,它负责数据源、MyBatis 的 SqlSessionFactory、Mapper 接口扫描和事务管理器。
<context:component-scan base-package="com.example.bishe.service"/> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/bishe?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="your_password"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.example.bishe.entity"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.bishe.mapper"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>这里几个参数值得说明:driverClassName 用 com.mysql.cj.jdbc.Driver 对应 MySQL 8.x,如果你还在用 MySQL 5.7,换成 com.mysql.jdbc.Driver 也行,但后者在新版驱动里已经废弃。数据库 URL 里的 serverTimezone=Asia/Shanghai 必须加,否则连接 MySQL 8 会报时区错误,本地时间也可能和数据库差 8 小时。mybatis 的 typeAliasesPackage 配了实体包后,Mapper XML 里写 resultType 可以只写类名不写全限定名,少打很多字。事务管理器配的是 Druid 数据源,这一点务必对应上,事务管理器拿不到正确数据源时,回滚就是空话。
3.2 选题接口完整链路:Controller、Service、Mapper XML 三段代码
学生选题是整个系统最核心的操作,需要考虑「课题是否审核通过」「人数是否已满」「重复选题」三个条件。我习惯把并发控制放在 Service 层而不是 Mapper 层,因为事务边界在这里。先看 Controller 的写法。
@Controller @RequestMapping("/student") public class SelectionController { @Autowired private SelectionService selectionService; @PostMapping("/choose") @ResponseBody public Result choose(Long topicId, HttpSession session) { User loginUser = (User) session.getAttribute("loginUser"); if (loginUser == null || loginUser.getRole() != 3) { return Result.error("请使用学生账号登录"); } try { selectionService.chooseTopic(loginUser.getId(), topicId); return Result.ok("选题成功,等待教师确认"); } catch (BizException e) { return Result.error(e.getMessage()); } } }Controller 只做角色校验、参数接收和异常转义,不写 SQL 业务。BizException 是自定义运行时异常,这样 Service 抛出的业务错误能统一被这里捕获转成 JSON 返回。
下面是 Service 的核心代码,这段是这套系统的灵魂。
@Service public class SelectionService { @Autowired private TopicMapper topicMapper; @Autowired private SelectionMapper selectionMapper; @Transactional(rollbackFor = Exception.class) public void chooseTopic(Long studentId, Long topicId) { Topic topic = topicMapper.selectByIdForUpdate(topicId); if (topic == null) { throw new BizException("课题不存在"); } if (topic.getStatus() != 1) { throw new BizException("课题未通过审核或已关闭"); } if (topic.getSelectedCount() >= topic.getCapacity()) { throw new BizException("课题人数已满"); } int rows = topicMapper.increaseSelectedCount(topicId, topic.getCapacity()); if (rows == 0) { throw new BizException("手慢了,课题刚被选完"); } selectionMapper.insert(studentId, topicId); } }selectByIdForUpdate 对应 Mapper XML 里的 select … for update,作用是在事务内对这条课题记录加行锁。两个学生同时点选题按钮时,后一个请求会等前一个事务提交后再读到最新的 selected_count,然后被「人数已满」拦下来。increaseSelectedCount 的 SQL 做了第二层保险,它的 update 条件里带上了 capacity 判断,即使前面忘了加锁,数据库也会挡住错误更新。
<select id="selectByIdForUpdate" resultType="com.example.bishe.entity.Topic"> select id, teacher_id, title, description, direction, capacity, selected_count, status from topic where id = #{id} for update </select><update id="increaseSelectedCount"> update topic set selected_count = selected_count + 1 where id = #{id} and selected_count < #{capacity} </update>这个双保险设计是为了应对两种并发场景:for update 保证「先查后更」的查询结果是最新值;条件 update 保证极端情况下(比如有人绕过了 Service 直接改库)也不可能超员。加锁和条件更新不是重复劳动,它们是两个不同层级的保护。这里还要注意一个细节:increaseSelectedCount 的返回值 rows 为 0 时,说明 update 影响行数为 0,也就是「应该满员了」,此时必须抛异常让事务回滚,否则 selectionMapper.insert 已经把申请记录插进去了,数据就出现「选题记录存在但人数没变」的不一致问题。
3.3 登录态与角色权限:用 HandlerInterceptor 就能扛住的小型权限模型
毕设管理系统不需要引入 Shiro 这种重量级框架,SpringMVC 自带的 HandlerInterceptor 足够。拦截器只负责两件事:未登录用户统一跳转登录页、已登录用户放行。
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String path = request.getServletPath(); if (path.startsWith("/login") || path.startsWith("/static/")) { return true; } Object loginUser = request.getSession().getAttribute("loginUser"); if (loginUser == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } return true; } }然后在 spring-mvc.xml 里注册拦截器,并设置排除路径。
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/static/**"/> <bean class="com.example.bishe.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>角色级别的权限校验我没有放进拦截器,因为三个角色的菜单差异较大,在拦截器里写 if 判断会越来越乱。更清晰的做法是 Controller 里用自定义注解标记接口角色,或者直接在方法入口用 if 判断当前登录用户的 role。毕设项目用 if 判断就够了,但要在论文里说明「生产环境用注解 + 切面更合适」,这个对比很容易在答辩时形成话题,让老师知道你想过方案取舍。
4. 高发翻车排查:事务失效、日期乱码、分页错位的修复方案
SSM 项目跑通容易,跑稳难。这一章我挑的五个问题都是我本地调试时反复踩过的坑,每条按「现象 → 原因 → 解决」写,希望能帮你少走弯路。这些问题有个共同点:它们都不会直接报编译错误,而是运行到某个特定场景才炸,排查起来特别费时间。我把它们整理成五个严格的排查记录,你可以直接当排查手册用。
4.1 事务失效:自调用和 try-catch 吞异常,数据写到一半就停手
现象:退选功能里先更新 selection 状态,再删除 progress 记录,中途抛了异常,但 selection 的状态依然更新成功。排查发现同一个 Service 内部方法互相调用,@Transactional 没生效;另一种场景是方法内部 try-catch 把异常吞掉后返回 success,事务感知不到异常,照常提交。
原因:Spring 的声明式事务基于 AOP 代理实现。内部自调用时走的是 this.method(),代理对象没有机会介入,事务注解被直接跳过;异常被 catch 后没有抛出,事务管理器认为方法正常结束,自然不触发回滚。把异常当玄学看是没用的,这两条都是代理机制的必然结果。
解决:自调用场景把事务方法拆到另一个 Service,或者注入自身代理。我用的比较多的是注入自身,改动最小。
@Service public class SelectionService { @Autowired @Lazy private SelectionService self; public void cancelSelection(Long selectionId) { // 业务前置检查 self.doCancel(selectionId); } @Transactional(rollbackFor = Exception.class) public void doCancel(Long selectionId) { selectionMapper.updateStatus(selectionId, 3); progressMapper.deleteBySelectionId(selectionId); } }吞异常场景,如果你确实需要捕获并记录日志,必须在 catch 块里调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() 标记事务回滚,或者直接把异常重新抛出。值得提醒的是:@Transactional 默认只回滚 RuntimeException,如果你 service 方法里声明了 throws Exception 并抛出 checked exception,事务不会回滚。我一般给所有事务方法写上 rollbackFor = Exception.class,省得后面踩坑。
4.2 JSON 日期变成时间戳:把日期格式化交给一个配置统一管
现象:接口返回的 created_time 是一串 13 位数字,前端拿到后显示在表格里完全不可读。如果只给实体的 Date 字段加 @JsonFormat 注解也能解决,但每个实体都要加,很容易漏掉新加的字段,日期格式还容易写得不一致。
原因:SpringMVC 默认使用 Jackson 序列化 JSON,Date 类型默认输出为时间戳毫秒值。这是框架约定,不是 bug,但显然不适合直接展示给用户。
解决:在 spring-mvc.xml 里配置一个全局 ObjectMapper,统一日期格式。
<mvc:annotation-driven> <mvc:message-converters> <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"> <property name="objectMapper" ref="jacksonObjectMapper"/> </bean> </mvc:message-converters> </mvc:annotation-driven> <bean id="jacksonObjectMapper" class="com.fasterxml.jackson.databind.ObjectMapper"> <property name="dateFormat"> <bean class="java.text.SimpleDateFormat"> <constructor-arg value="yyyy-MM-dd HH:mm:ss"/> </bean> </property> </bean>配置完成后,所有 java.util.Date 字段统一输出为 yyyy-MM-dd HH:mm:ss。这里还要提醒一个联动坑:前端拿到格式化后的字符串后,不要再做 new Date(str) 转换展示,因为 JS 的 Date 解析带时区,折腾一圈日期又偏了 8 小时。字符串原样展示是最稳妥的做法。如果你的实体里用了 java.sql.Timestamp,效果也相同,因为 Timestamp 继承自 Date。
4.3 PageHelper 分页污染:一次 startPage 隔了一条 SQL,后面全乱
现象:先调 PageHelper.startPage(2, 10),接着执行了一条统计 SQL,结果统计结果也被 LIMIT 限制,数据直接少了一截;更隐蔽的是,一个请求结束后线程回收到连接池,下一个请求复用这个线程,普通查询也被意外分页。
原因:PageHelper 通过 ThreadLocal 保存分页参数,startPage 之后碰到的第一个查询会被强制拼上 limit。如果中间插入了别的 SQL,参数就会作用在错误的查询上;请求结束后没有清理 ThreadLocal,线程复用时就污染了下一次查询。
解决:startPage 后面必须紧跟你要分页的查询语句,并且用 try-finally 保证线程结束前清理参数。
public PageInfo<Topic> pageQuery(int pageNum, int pageSize) { try { PageHelper.startPage(pageNum, pageSize); List<Topic> list = topicMapper.selectPageList(); return new PageInfo<>(list); } finally { PageHelper.clearPage(); } }这里尤其注意不要在 startPage 和查询之间执行其他 Mapper 方法。如果确实需要先查其他数据,那你要么把分页查询单独抽一个方法,要么先查完再做分页。另外,分页查询的 Mapper XML 里 count 语句如果要自己写,最好是简单的 count(*),不要在里面写子查询或 distinct,PageHelper 生成的 count 有时候和你预期不一致,容易造成总页数对不上的难题。
4.4 中文乱码:URL 参数、请求过滤器、表字符集三处必须一致
现象:学生提交课题名「基于深度学习的推荐系统」,数据库里存成了「????」,页面查询出来的也是乱码。改了代码重启还是老样子。
原因:乱码是链路问题,只要一个环节不一致就现原形。最常见的是三个地方:数据库连接 URL 少了 characterEncoding=utf8、Tomcat 请求编码没指定、表和字段的字符集不是 utf8mb4。如果是部署环境,还得检查数据库连接池初始化参数有没有被覆盖。
解决:分三步走。第一步把 jdbc URL 补全参数,第二步配置 CharacterEncodingFilter,第三步确认 DDL 里表和字段已经用 utf8mb4,这三步缺一不可。
<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>排查乱码有个笨但有效的办法:先直接在数据库客户端手工 insert 一条中文数据,如果能正常显示,说明表和字段没问题,问题出在代码或连接参数;如果手工插入就乱码,说明表结构字符集有问题,优先改 DDL。这个二分法能把排查范围缩一半。
5. 答辩前的三个加分动作:并发压测、密码升级和检查清单
5.1 用两个无痕窗口做并发选题压测
答辩时最怕老师问「并发怎么办」,而很多同学只是嘴上说用了锁。建议提前准备一个现场演示:造一个只剩一个名额的课题,开两个无痕窗口用两个学生账号登录,同时点选题按钮。观察结果:一个学生正常提交,另一个收到「课题人数已满」的提示;数据库里 selected_count 刚好等于 capacity,没有超员。这个演示一分钟完成,但能给老师留下「系统设计经得住推敲」的印象。如果演示结果不一致,优先检查 Service 事务是否生效,再检查 for update 有没有落到行锁。
5.2 密码加盐与选题名单导出
密码存储建议用 BCrypt 而不是 MD5 直接存,答辩时提到「加盐」「彩虹表」说明你有安全意识。Spring Security 自带 BCryptPasswordEncoder 可以直接用,或者引入一个轻量工具类,几十行代码就能替换完成。选题名单导出一个 Excel 汇总表是很多院内导师会问到的高频需求,POI 生成 xlsx 比 CSV 更正式,代码量也不大,值得花半小时做出来。POI 方案注意处理单元格公式注入问题,也就是以 = 开头的文本不要直接写入公式单元格,做一次前缀清洗即可。
5.3 上线前按这份清单检查
| 检查项 | 建议配置 |
|---|---|
| 数据库账号 | 不直接用 root,新建一个最小权限账号 |
| 日志级别 | 本地 dao 层 DEBUG,正式环境 INFO |
| session 超时 | 30 分钟无操作自动失效 |
| 配置文件 | 数据库密码外置到 jdbc.properties,不写死 |
| 数据备份 | 答辩前导出一份完整 SQL 备份到独立目录 |
我自己的习惯是每次改完代码跑一遍「选题-退选-再选题-教师确认」的回归脚本,确认五个核心状态流转没坏再往下走,这个习惯帮我避免过很多次答辩前一天晚上的翻车。这套 SSM 毕设管理系统只要你把业务链路想清楚、把并发和事务两个点做出亮点,它就不止是一个课程作业,而是一个能讲清楚设计取舍的真实后台系统。希望帮到你。
本文还有配套的精品资源,点击获取