基于Spring Boot的企业日报管理系统完整实战
又到了一年两季的课程设计和毕业设计高峰期后台私信里问得最多的就是“基于Spring Boot的XX管理系统”。这次准备把一个很经典、也特别适合本科生拿去做课设/毕设的选题——企业日报管理系统——从需求分析、数据库设计、核心代码实现到最后的部署和论文整理完整拆开讲一遍。我基于 Spring Boot 2.x 做了完整落地源码、数据库脚本、万字文档都整理过了这套东西不仅能直接跑通更重要的是你能在答辩时把每个模块的设计理由说清楚。不管你是只想要一份能交差的项目还是想踏踏实实把技术栈吃透这篇都能给你省下大量查资料的时间。先说一句实在话企业日报系统看着简单但“简单”只是表象。真正动手之后你会发现日报表结构怎么设计、提交后的状态怎么流转、员工只能看自己和本部门数据的权限怎么做、统计报表的 SQL 怎么写每一环都有不少门道。这篇文章我会把这些关键位置全部点透并把我在实际开发中踩过的坑也一并写上。1. 为什么选企业日报管理系统当课设/毕设选题逻辑与技术选型1.1 这个题目的真实业务场景很多同学的误区是把日报系统理解成“一个写文本的记事本”。真实企业里的日报系统没那么简单。企业日报的核心场景是员工每天填写当天工作内容、次日计划、遇到的问题提交给直属上级上级需要查看自己下属的日报可以对不合格的日报进行打回或者点评月末或周末管理层要能按部门、按时间、按人员统计出勤、工作量、问题分布情况。所以它本质上是一个带审批流的数据收集与统计系统而不是单纯的 CRUD。从答辩和评审角度这个题目有天然的优势业务逻辑清晰模块边界明确角色划分合理还能自然引入“状态机”“数据权限”“聚合统计”这些加分点。无论是课程设计的验收演示还是毕业设计的论文撰写都有内容可写不至于卡在“没东西讲”的尴尬境地。1.2 技术栈选型为什么是 Spring Boot Vue 前后端分离选题定了之后技术栈的选择同样影响后期的工作量和答辩深度。我这里用的组合是后端Spring Boot 2.7.x MyBatis-Plus MySQL 8.0权限Spring Security JWT前端Vue 2 Element UI如果对 Vue 3 熟练用 Vue 3 Element Plus 也可以构建Maven npm选 Spring Boot 的理由几乎不需要解释起步快、自动配置、生态成熟。但在做课设时我强烈建议在后端加一个 MyBatis-Plus它能在单表 CRUD 和分页查询上省掉大量重复的 XML 代码让 Mapper 层保持轻量同时又能保留自定义 SQL 的能力。很多同学在答辩时会被问到“数据库操作怎么实现的”用 MyBatis-Plus 答“封装了 BaseMapper内置通用方法复杂统计自己写 SQL”这个回答既显水平又不会挖坑。前端选择 Vue 的原因则是因为 Spring Boot 课程设计最常见的展示形态就是“管理系统 数据看板”。Vue 的组件化写法、Vue Router 做页面跳转、Axios 做接口请求这套组合如果你想在答辩现场临时调整演示路径也足够灵活。比较保守但稳妥的做法是后端不做页面渲染全部走 RESTful API 返回 JSON前端单独起一个工程调用接口。1.3 课程设计与毕业设计的不同侧重虽然同一个项目可以两头用但交付重心其实有区别。课程设计通常看重“功能是否完整、演示是否流畅”文档只占一小部分毕业设计则更看重“你做了什么设计、有没有自己的思考”文档占比明显提升。针对这个区别我在整体设计上做了兼容核心功能全部实现同时在文档和答辩中埋了几个值得展开讲的“技术点”比如 JWT 无状态认证、数据权限过滤、日报状态机流转、基于日期唯一键的防重复提交等。这些点写进论文里篇幅可以铺到三四章还不会让人觉得是凑字数。2. 数据库设计日报表不是简单的建一张表就行2.1 核心表结构规划企业日报系统的数据库设计至少需要六张核心表。我实际交付的脚本里包含的完整表如下表名作用关键字段sys_user用户表id, username, password, real_name, dept_id, role_id, statussys_dept部门表id, dept_name, parent_id, sort_ordersys_role角色表id, role_name, role_key, descriptionsys_menu菜单/权限表id, parent_id, menu_name, perms, path, componentbiz_report日报主表id, user_id, report_date, content, plan, problem, status, remark, create_time, update_timebiz_report_comment日报评论/点评表id, report_id, comment_user_id, content, create_time以 biz_report 为例建表的 SQL 核心部分我贴一下方便你对照CREATE TABLE biz_report ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, user_id bigint(20) NOT NULL COMMENT 提交人ID, report_date date NOT NULL COMMENT 日报日期, content text COMMENT 今日工作内容, plan text COMMENT 明日工作计划, problem text COMMENT 遇到的问题与建议, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态0草稿 1待审核 2已通过 3已打回, remark varchar(500) DEFAULT NULL COMMENT 审核意见, create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_user_date (user_id, report_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT日报表;这里有一个非常容易忽略的设计点唯一键 uk_user_date。没有这个约束用户同一天可能会提交出多条日报后期统计的时候要么用 distinct 去重、要么在业务代码里先查再插全是额外工作。加上唯一键后数据库层面直接挡住重复数据业务层只需要捕获 DuplicateKeyException 并返回友好提示即可。这个细节写进文档里可以让“数据库设计”这一章显得很扎实。2.2 状态字段为什么要设计成 tinyint 而不是字符串很多同学喜欢把日报状态直接存成字符串比如“草稿”“待审核”“已通过”“已打回”。这样的写法在演示的时候看着直观但扩容性很差一旦状态名称变化你就要写一堆 UPDATE 语句去改历史数据查询时如果写 where status 草稿索引基本也是废的。更合理的做法是存数字枚举然后在代码里定义一个枚举类public enum ReportStatus { DRAFT(0, 草稿), PENDING(1, 待审核), APPROVED(2, 已通过), REJECTED(3, 已打回); private final Integer code; private final String desc; ReportStatus(Integer code, String desc) { this.code code; this.desc desc; } }这样数据库里存的是 0、1、2、3Java 层通过枚举描述进行对应前端拿到数字后自己映射状态标签。既保证了查询效率又保留了可读性。查询草稿列表只需要status 0统计待审核数量只需要SUM(CASE WHEN status 1 THEN 1 ELSE 0 END)简单直接。2.3 统计报表的 SQL 设计思路日报系统做到后期一定会被问到一个功能部门日报统计。不要慌这个不是要你上 ClickHouse直接用 MySQL 就能做。常见的统计维度是按部门展示某段时间内日报提交人数、提交率、被驳回次数。我实现时用的核心 SQL 大致如下SELECT d.dept_name, COUNT(DISTINCT u.id) AS total_employee, COUNT(DISTINCT CASE WHEN r.id IS NOT NULL THEN u.id END) AS submitted_employee, COUNT(CASE WHEN r.status 3 THEN 1 END) AS rejected_count FROM sys_dept d LEFT JOIN sys_user u ON u.dept_id d.id LEFT JOIN biz_report r ON r.user_id u.id AND r.report_date BETWEEN #{startDate} AND #{endDate} WHERE d.id #{deptId} GROUP BY d.id, d.dept_name;这段 SQL 的关键是对 biz_report 做了“基于日期范围条件的 LEFT JOIN”避免把非查询区间的日报也算进来。写成 XML 里的动态 SQL 时要注意 where 条件的位置日期过滤条件必须放在子查询或 ON 子句里而不是放在外层 WHERE否则会因为 LEFT JOIN 的特性把未提交日报的部门行全部过滤掉。这个坑我在联调时就踩过统计数字突然少了很多人就是因为在 WHERE 里加了r.report_date BETWEEN ...结果没有日报的员工行被丢弃了。3. 后端分层实现Controller-Service-Mapper 的职责划分3.1 统一返回结果与全局异常处理Spring Boot 项目里如果没有统一返回结构前端对接接口会非常痛苦。今天这个接口返回一个 Map明天那个接口返回一个 List后端报错时返回的又是默认错误页联调效率极低。我的做法是定义统一的 Result 结构所有接口返回它public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }对应的全局异常处理器用RestControllerAdvice实现。我至少处理了以下几种异常参数校验异常MethodArgumentNotValidException业务异常BusinessException自定义认证授权异常数据库唯一键冲突异常DuplicateKeyException每个异常返回统一的 Result 结构前端 Axios 拦截器里只需要判断 code 是否为 200非 200 直接弹 message 即可。这套写法改动量不大但给项目带来的规范性和答辩卖点却是实打实的。3.2 日报状态流转的核心逻辑日报提交之后最核心的业务逻辑是状态流转。我定义的状态路径是草稿 → 待审核 → 已通过已打回 → 草稿修改后重新提交为待审核这个流转如果散落在 Service 各方法里后期维护会很难受。我是通过一个独立的ReportStateMachine组件来管理的public class ReportStateMachine { private static final MapReportStatus, SetReportStatus TRANSITIONS new EnumMap(ReportStatus.class); static { TRANSITIONS.put(ReportStatus.DRAFT, new HashSet(Arrays.asList( ReportStatus.PENDING ))); TRANSITIONS.put(ReportStatus.PENDING, new HashSet(Arrays.asList( ReportStatus.APPROVED, ReportStatus.REJECTED ))); TRANSITIONS.put(ReportStatus.REJECTED, new HashSet(Arrays.asList( ReportStatus.DRAFT, ReportStatus.PENDING ))); TRANSITIONS.put(ReportStatus.APPROVED, Collections.emptySet()); } public static void validateTransition(ReportStatus from, ReportStatus to) { SetReportStatus allowed TRANSITIONS.getOrDefault(from, Collections.emptySet()); if (!allowed.contains(to)) { throw new BusinessException(非法操作日报状态无法从 from.getDesc() 变更为 to.getDesc()); } } }这段代码的作用是任何状态变更操作都必须经过合法性校验避免 Service 层出现“待审核的日报被再提交一次”“已通过的日报被直接修改”之类的逻辑漏洞。把状态机独立出来还有一个好处就是写单元测试的时候可以直接对状态机做测试不需要启动整个项目。3.3 MyBatis-Plus 分页与多条件组合查询日报列表是典型的“多条件分页”场景按日期、按部门、按提交人、按状态。使用 MyBatis-Plus 的 LambdaQueryWrapper 组合条件很方便但需要注意分页插件的配置。分页插件配置Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }查询代码示例public PageResultReportVO pageReports(ReportQuery query) { LambdaQueryWrapperBizReport wrapper new LambdaQueryWrapper(); wrapper.eq(query.getStatus() ! null, BizReport::getStatus, query.getStatus()) .ge(query.getStartDate() ! null, BizReport::getReportDate, query.getStartDate()) .le(query.getEndDate() ! null, BizReport::getReportDate, query.getEndDate()) .eq(query.getDeptId() ! null, BizReport::getDeptId, query.getDeptId()) .orderByDesc(BizReport::getReportDate); PageBizReport page new Page(query.getPageNum(), query.getPageSize()); PageBizReport result bizReportMapper.selectPage(page, wrapper); return convertToPageResult(result); }这里有几个细节值得说一下。第一条件字段要判空否则查询条件里会出现WHERE report_date null导致查询结果为空。第二分页查询建议使用IPage而不是自己传 limit/offset这样能利用 MyBatis-Plus 自带的 count 查询数据量上来后也好优化。4. 权限控制与数据隔离员工不能乱看别人的日报4.1 RBAC 模型的落地方式日报系统的权限模型是典型的 RBACRole-Based Access Control。我建了三张基础表用户表、角色表、菜单权限表然后通过中间表实现关联。主要角色有三种普通员工只能填写和查看自己的日报部门主管可以查看本部门所有员工的日报并审核系统管理员拥有全部权限可以管理用户、部门、菜单用户登录后后端把用户的角色编码返回给前端。前端根据roleKey动态生成菜单只显示该角色能访问的页面。后端的接口层则用 Spring Security 的注解做二次拦截PreAuthorize(hasAuthority(report:audit)) PostMapping(/audit) public ResultVoid audit(RequestBody ReportAuditDTO dto) { reportService.audit(dto); return Result.success(null); }这里需要特别注意一个点在管理系统的开发里前端隐藏菜单只是界面层面的事情真正的安全控制必须放在后端接口上。如果后端不校验权限哪怕前端没有入口别人直接调用接口一样能审核日报。4.2 JWT 无状态认证方案登录认证我选择的是 JWT。整体流程是用户输入用户名密码后端校验通过后生成包含 userId、username、role 的 Token 返回给前端前端把 Token 存在 localStorage 或 Vuex 中Axios 请求时在拦截器里自动加上Authorization: Bearer token后端通过自定义过滤器解析 Token并把它写入 SecurityContext。JWT 的生成依赖是jjwt我用的是 0.9.1 版本注意不要盲目用最新版不同版本的 API 差异较大网上大多数教程是基于 0.9.x 的复现度更高。使用 JWT 有一个必须考虑的问题如何主动让用户的 Token 失效。因为 JWT 是无状态的一旦签发在有效期内无法从服务端主动撤销。最简单的折中方案是把 Token 有效期设置得短一些比如 2 小时过期如果要求更严格可以引入 Redis 存储 Token 黑名单或白名单。但对于课设/毕设来说设置合理过期时间就已经足够了答辩时可以主动提“为了简化我采用短期 Token 前端主动清除的方式”把这个取舍讲明白反而比硬上一套 Redis 显得更真实可信。4.3 按部门的数据权限过滤这个功能是日报系统里最隐蔽也最容易翻车的地方。部门主管查询下属员工日报时如果不做数据权限过滤直接查询全部日报那和系统管理员就没有区别了。我采用的方案是在 Service 层查询之前先从当前登录用户的上下文中取出 deptId然后拼接到查询条件中。public PageResultReportVO pageReportsForManager(ReportQuery query) { Long currentDeptId SecurityUtils.getCurrentUser().getDeptId(); query.setDeptId(currentDeptId); return reportQueryService.pageReports(query); }表面上看这只是一个查询条件的问题。但如果你把部门表设计成支持树形结构比如上级部门可以看到下级部门的数据问题就复杂了主管可能需要看到自己部门以及所有子部门的日报。这时候就要用递归查询子部门集合。WITH RECURSIVE dept_tree AS ( SELECT id FROM sys_dept WHERE id #{deptId} UNION ALL SELECT d.id FROM sys_dept d INNER JOIN dept_tree t ON d.parent_id t.id ) SELECT * FROM biz_report WHERE dept_id IN (SELECT id FROM dept_tree)MySQL 8.0 支持 WITH RECURSIVE直接用就好。如果你的数据库还是 5.7就只能用递归遍历部门列表拼接IN条件效果一样但代码会啰嗦一些。把“部门树 递归查询”这个点写进文档里答辩面整整高一个档次。5. 前端与后端联调Vue 页面接入 REST API 的实战细节5.1 动态路由和菜单权限前端在拿到用户的角色和菜单列表后需要动态注册路由。我是用router.addRoutes实现的在全局前置守卫里拉起用户信息router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (!token to.path ! /login) { next(/login); return; } if (token !store.state.userInfo) { store.dispatch(getUserInfo).then(() { next({ ...to, replace: true }); }); return; } next(); });这里有一个很常见的坑退出登录时只清掉 localStorage 里的 Token 还不够需要调用window.location.reload()或重置路由否则用户还能访问“已删除”的动态路由。我踩过这个坑之后直接在登出方法里强制刷新页面问题瞬间消失。5.2 跨域与文件上传前后端分离项目一定会遇到跨域问题。后端的处理方法最简单的是实现WebMvcConfigurer中的addCorsMappingsOverride public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); }日报系统一般还会有一个上报附件的需求我先用 MultipartFile 做文件上传存储到本地上传目录接口返回访问 URL。生产环境可以换 MinIO 或 OSS但课设阶段本地存储足够了。需要注意的是一件事文件上传接口的请求头不要手动加Content-Type: multipart/form-data让浏览器自动带上 boundary否则后端接收不到文件。5.3 联调时最容易遇到的 5 个问题我把五类高频问题列在下面遇到可以依次排查现象原因解决办法接口返回 200 但 data 为 null返回类型写成了 void 或 Result 泛型缺失检查 Controller 返回类型是否使用 Result提交日报后唯一键冲突报错同一天重复提交前端提交按钮置灰后端捕获 DuplicateKeyException 并提示Token 带上了但接口仍 401过滤器放行顺序问题确认 JWT 过滤器在 Spring Security 过滤器链之前执行日期查询少一天或边界不对前端传递的日期格式与后端 Date 类型转换不一致统一使用字符串yyyy-MM-dd后端 LocalDate 接收query 条件拼错了导致全表可见LambdaQueryWrapper 条件写错打印 SQL 日志核对 where 条件联调阶段建议把 MyBatis 的 SQL 日志打开application.yml里配置logging: level: com.example.report.mapper: debug这样每次执行 SQL 都能看到完整语句和参数排查条件拼接问题非常高效。6. 部署交付与论文撰写源码、数据库、文档怎么整理6.1 后端打包与前端构建后端打包我推荐直接打 jar 包用 Maven 的 package 插件Spring Boot 内置 Tomcat不需要额外装 Tomcat。打包命令mvn clean package -DskipTests打出来的 jar 包放服务器上一行命令启动java -jar report-system.jar --spring.profiles.activeprod前端构建用npm install npm run build构建产物在 dist 目录我会把这些静态文件放到服务器上用 Nginx 做反向代理把/api请求转发到后端 8080 端口。一个参考配置server { listen 80; server_name localhost; root /opt/report-frontend/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }Nginx 配置好后前端误刷新导致 404 的问题可以在 server 块里加一句try_files $uri $uri/ /index.html;解决 Vue Router 的 history 模式刷新空白问题。6.2 交付物整理的几个注意点标题里写到了“附源码、数据库、万字文档”但很多人的交付物只有一堆代码压缩包文档写得像用户手册数据库脚本还缺初始化数据。真正的交付整理应该包含四部分源码目录包括后端和前端两个子目录注意不要放 target、node_modules 这些垃圾目录压缩前清理掉。数据库脚本一个建库建表脚本一个初始化数据脚本。初始化数据至少要包含一个管理员账号、一个主管账号、几个员工账号方便评审老师登录后直接体验。部署说明文档写清楚环境要求、导入数据库步骤、启动命令、默认账号密码。设计文档/论文重点写需求分析、系统设计、数据库设计、核心功能实现、系统测试这几个章节。数据库脚本方面有一个常见问题字符集忘记设置导入后中文变成乱码。我建议在导出 SQL 的时候明确指定SET NAMES utf8mb4;并且建表语句也显示声明 utf8mb4避免排查乱码浪费一晚上。6.3 论文写作的核心章节与答辩提问准备系统设计类课程论文的结构基本是一致的我自己的实践下来占分最高的几块分别是需求分析把用例图和数据流图画清楚文字描述要区分普通用户、部门主管、系统管理员三类角色的功能边界。数据库设计把 ER 图画出来再配合核心表的字段说明。重点写日报表的状态字段设计、唯一键设计、部门树的递归查询设计。系统实现不要贴大段源码写核心实现思路和关键代码片段。日报状态机、JWT 认证过滤器、数据权限过滤这三处是最合适的代码展示点。系统测试至少要写各模块的功能测试用例表和测试结论展示你确实对项目做过验证。答辩时老师高频会问的问题提前准备好不会吃亏为什么用 Spring Boot它相比传统 SSM 有哪些优势日报重复提交怎么防止JWT 登录相比 Session 有什么优缺点主管能不能看到其他部门的日报你如何控制的统计报表的 SQL 性能如何优化每个问题其实都对应前面提到的设计点。只要在项目里真的做了回答时结合代码讲通常不会有大问题。我个人在完成这套项目后的体会是做一个管理系统并不难难的是把每个设计点考虑清楚并能在论文里讲出理由。企业日报管理系统之所以适合做课设或毕设正因为它在 CRUD 之上提供了足够多的“可设计空间”状态流转、权限隔离、统计聚合、防重复提交这些点一块块垒起来项目深度自然就出来了。如果你在搭建过程里卡在哪一步不妨按这篇文章的顺序对照检查数据库脚本建好后先把账号体系跑通再往日报模块和统计模块扩展整个项目的完成度会高很多。