Spring Boot时间管理系统毕设源码解析:从数据模型到部署优化
2026/9/12 8:23:33 网站建设 项目流程

简介:面向毕业设计的时间管理系统开发资料,采用Java与Spring Boot框架,使用MySQL存储数据,功能覆盖首页、个人中心、系统公告、用户管理、时间分类、事件数据、目标数据和用户日记等模块,适合计算机专业学生作为课题参考或二次开发起点。压缩包内主要包含项目源码、毕业论文、答辩PPT、部署操作文档和演示视频,整体约31MB,文件类型涵盖代码、文档和影音素材,可支持从环境搭建到功能演示的完整学习闭环。论文部分系统介绍了设计背景与研究目的、相关技术、功能分析、详细设计及开发心得,便于读者快速理解项目结构与实现思路。目前已有56人学习浏览,对于需要完成Spring Boot方向毕业设计或入门时间管理类系统开发的学习者具有实用参考价值,兼顾代码阅读与论文撰写需求,可作为系统性学习或扩展改造的基础。

1. 时间管理系统的模块底子,比想象中更适合做骨架

一个毕设级的时间管理系统,打包自带源码、论文、PPT、部署文档和演示视频,听起来像常规作业。但把代码跑起来后会看到,它的模块划分比想象中干净:首页、个人中心、系统公告管理、用户管理、时间分类管理、事件数据管理、目标数据管理、用户日记管理,本质上是“单用户数据隔离 + 基础内容发布”的完整样本。拿到这套 springboot 源码,可以当毕业设计交差,也能把它当成能快速改造成团队内部日程工具的骨架。适合两类人:一类是 Spring Boot 新手,想从完整项目里学 Controller/Service/Mapper 怎么串;另一类是准备面试的开发者,想找一个能说清楚业务表设计和权限控制的实际例子。这篇博文就按“数据模型 → 核心模块实现 → 部署排查 → 改造技巧”的顺序拆,全程给可抄的代码和参数,不绕弯子。

2. 数据模型先行:一张用户表如何撑起公告、事件、目标和日记

立项文档里提到的八个模块,压到数据库设计上其实只有六张核心表。用户表负责登录和身份,系统公告表是全局内容,时间分类表是用户维度的事件元数据,事件数据表、目标数据表、用户日记表则是三条互不干扰的业务线。理解这张模型,是后续改造所有功能的前提。

2.1 从功能清单反推表结构:五个业务表的关系

先看整体关系。用户表是归属主体,事件、目标、日记都要通过user_id和它关联;时间分类表本身也属于某个用户,但事件表再关联分类,就形成了“用户 → 分类 → 事件”的两层归属链;系统公告不按用户隔离,它是全站可见的。下面的表可以快速建立映射。

表名归属维度作用与 user 表的关系
user全局登录凭证、昵称、头像主表
system_announcement全局首页公告轮播记录发布者
time_category按用户事件分类,带颜色和排序多对一
event_data按用户具体时间段的事件多对一
target_data按用户目标值、当前值、截止日期多对一
user_diary按用户日记正文与心情多对一

用户表是这套系统的权限底座,设计上不要放业务字段。密码字段在毕设里常做成 MD5,但接手改造时建议改成 BCrypt,password字段长度也要从 32 扩到 100。建表脚本如下。

CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(100) NOT NULL COMMENT '密码,存 BCrypt 结果', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `avatar` varchar(255) DEFAULT NULL COMMENT '头像地址', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

建表关键点:用户名必须唯一,否则登录时无法确定唯一用户;create_time用数据库默认时间,不要在 Java 侧手动 new Date(),避免多台服务器时间不一致。时间分类表是事件表的父表,它决定了事件在页面上的颜色和排序。

CREATE TABLE `time_category` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT '归属用户', `name` varchar(20) NOT NULL COMMENT '分类名,如工作/学习/休息', `color` varchar(10) DEFAULT '#1890ff' COMMENT '事件标签颜色', `sort_order` int DEFAULT 0 COMMENT '排序值,小的靠前', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_sort` (`user_id`, `sort_order`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='时间分类表';

分类表为什么单独建而不是在事件表里存字符串?因为分类需要维护颜色和排序,如果存字符串,前端动态渲染标签颜色时只能靠 if/else 硬编码;建表后用user_id + sort_order一次查出,页面直接循环渲染。这也是毕设答辩时能讲清楚的设计点。

2.2 事件表与目标表的设计要点

事件表是整个系统数据量增长最快的表,索引设计直接决定查询速度。实际项目里我一般会加两个索引,一个用于用户按开始时间范围查列表,一个用于按分类筛选。

CREATE TABLE `event_data` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT '归属用户,水平越权拦截的依据', `category_id` bigint DEFAULT NULL COMMENT '关联 time_category.id', `title` varchar(200) NOT NULL COMMENT '事件标题', `description` text COMMENT '事件备注', `start_time` datetime NOT NULL COMMENT '计划开始时间', `end_time` datetime NOT NULL COMMENT '计划结束时间', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0-未开始 1-进行中 2-已完成', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_start` (`user_id`, `start_time`), KEY `idx_category` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='事件数据表';

status用 tinyint 而不是 varchar 是状态字段的标准做法。Java 侧对应定义枚举或常量类,例如0-未开始 1-进行中 2-已完成,这样数据库里不会出现“已完成”“完成”这种歧义数据。查询今天或本周事件时,SQL 条件必然是user_id = ? AND start_time BETWEEN ? AND ?,所以联合索引(user_id, start_time)要注意顺序,user_id 写在前面才能先做归属过滤。

目标数据表的难点是“进度”字段去留。很多毕设会在表里加一个progress字段,每次更新当前值再顺手更新进度,但这样会产生冗余数据。

CREATE TABLE `target_data` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `name` varchar(100) NOT NULL COMMENT '目标名称', `target_value` decimal(10,2) NOT NULL COMMENT '目标值', `current_value` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '当前值', `deadline` date DEFAULT NULL COMMENT '截止日期', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0-进行中 1-已完成 2-已放弃', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_deadline` (`user_id`, `deadline`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='目标数据表';

进度值是current_value / target_value的实时计算结果,不在表里冗余存放。原因是并发更新时冗余字段容易出现“当前值改了,进度没改”的中间态;而用 decimal 类型计算,即便目标值为 0.01 这类小数也能保持精度。(user_id, deadline)联合索引同时覆盖“当前用户未完成目标”的列表查询和截止日期排序。

2.3 application.yml 配置与工程分层

源码工程里关键配置在application.yml。毕设项目大多用 MyBatis 做持久层,配置时最容易踩的是 MySQL 8 的时区问题。下面是一份兼容开发环境的配置。

spring: datasource: url: jdbc:mysql://localhost:3306/time_management?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.timemanager.entity configuration: map-underscore-to-camel-case: true

连接串里的serverTimezone=Asia/Shanghai不是可选项。MySQL 8 驱动默认拿 UTC 时区,不配置时经常会报 “Server returns invalid timezone”,配置了也会在查询结果里看到固定 8 小时的偏差。map-underscore-to-camel-case负责把create_time自动映射成createTime,这样实体类里不用写一长串 @TableField 注解。

工程内部推荐保持经典的四层结构,源码里如果目录乱,接手第一步就重新分。

src/main/java/com/example/timemanager ├── controller # HTTP 层,参数接收与简单校验 ├── service # 业务事务层,核心逻辑 ├── mapper # MyBatis 数据访问接口 ├── entity # 与表对应的实体 └── common # 统一返回、异常处理、工具类 src/main/resources ├── mapper # XML 映射文件 └── application.yml

Controller 只做参数绑定,Service 管事务和状态机,Mapper 只写 SQL。这样分层的理由很直接:如果业务逻辑写在 Controller 里,接口一旦多起来,改一个校验规则要翻多个页面;把逻辑下沉到 Service,单元测试也能直接调用,不必启动 Web 容器。毕设答辩时,面试官最常问的也是这一层拆分,后面的事件状态流转就能用上这个结构。

3. 事件、目标、日记三个核心模块的前后端衔接

功能模块写得好不好,不看列表,要看三个典型的业务动作:事件的状态流转、目标的进度计算、日记的按月查询。这三个点分别对应页面展示中最常出 bug 的地方。

3.1 事件数据管理:按时间分类筛选与状态流转

事件数据管理模块的接口设计可以归纳成一张表,后面所有前端页面调用的都在这份清单里。

接口方法参数功能
/api/event/savePOSTEventData JSON新增或修改事件
/api/event/listGETpage, size, categoryId, status分页查询事件
/api/event/detailGETid事件详情
/api/event/statusPUTid, status更新事件状态
/api/event/deleteDELETEid删除事件

Controller 层写得干净,后续替换参数校验方式时就只动一个文件。保存接口的代码是这类项目最常见的写法。

@RestController @RequestMapping("/api/event") public class EventController { private final EventService eventService; public EventController(EventService eventService) { this.eventService = eventService; } @PostMapping("/save") public ResultDto<Long> save(@RequestBody EventData event) { // event.id 为空时新增,有值时更新 eventService.saveEvent(event); return ResultDto.success(event.getId()); } @GetMapping("/list") public ResultDto<PageResult<EventData>> list( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) Long categoryId, @RequestParam(required = false) Integer status) { return ResultDto.success(eventService.pageEvents(page, size, categoryId, status)); } }

@RequestBody会把 JSON 字符串自动反序列化成 EventData 对象,但日期格式需要额外约定。前端如果传"2025-06-01 10:00:00",实体类的时间字段要加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),否则 Jackson 默认按时间戳解析,接口直接报 400。分页参数 page 从 1 开始,service 层在 SQL 里转换成offset,不要在前端先减 1 再传。

状态流转是时间管理系统的核心行为。只允许顺序流转:未开始转进行中,进行中转已完成,不允许未开始直接完成,否则目标追踪的统计数据会失真。

public void changeStatus(Long id, Integer targetStatus) { EventData event = eventRepo.selectById(id); if (event == null) { throw new BizException("事件不存在"); } // 状态机约束:未开始的事件不能直接标记为已完成 if (event.getStatus() == 0 && targetStatus == 2) { throw new BizException("未开始的事件不能直接标记为已完成"); } event.setStatus(targetStatus); eventRepo.updateById(event); }

这段代码还有一个隐含的越权点:只校验了事件存在,没校验这个事件是不是当前用户的。后面第 5 章会专门补这部分,实际项目里所有状态变更都必须带userId条件,靠前端隐藏按钮挡不住攻击者直接调接口。

3.2 目标数据管理:进度计算不在前端做

目标数据管理页的进度条,很多同学喜欢在前端算:拿current_value除以target_value再乘以 100。这种写法在数据量大时会出现一个问题,列表页排序时需要按进度倒序,前端只能把整页数据取回来再在内存里排,翻页逻辑就会错乱。

正确做法是在 Service 层把百分比算好,再塞进返回对象。计算逻辑要单独抽方法。

public BigDecimal getProgress(Long targetId) { TargetData target = targetRepo.selectById(targetId); if (target == null) { return BigDecimal.ZERO; } // 目标值为 0 时进度无意义,统一返回 0 if (target.getTargetValue() == null || target.getTargetValue().compareTo(BigDecimal.ZERO) == 0) { return BigDecimal.ZERO; } BigDecimal progress = target.getCurrentValue() .divide(target.getTargetValue(), 4, RoundingMode.HALF_UP) .multiply(BigDecimal.valueOf(100)); // 超过 100% 时展示为 100% return progress.min(BigDecimal.valueOf(100)); }

除法用BigDecimal而不是 double,是因为浮点数在二进制里不能精确表示 0.1,分页查询里的进度排序会把诸如 59.999999 的结果排到错误位置。divide的第二个参数 4 是保留四位小数,计算足够精确后再交给前端展示时四舍五入。进度封顶到 100% 是为了防止超额完成时进度条溢出。

再回到隐藏点:百分比字段没有入库,也就不会出现“数据库存了 80%,前端显示 80,实际 current_value 已经变了”的不一致。目标的完成状态也不依赖进度是否到 100%,业务上有“目标值到了但还没交付”的场景,所以 status 单独维护。

3.3 用户日记管理:分页查询与时间线排序

用户日记管理是三个业务模块里查询模式最固定的,基本只有“按用户按月倒序”。这类固定查询不需要 MyBatis-Plus 的 QueryWrapper 硬拼条件,直接写 XML 可读性更好。

<select id="selectDiaryPage" resultType="com.example.timemanager.entity.UserDiary"> SELECT id, user_id, title, content, mood, create_time FROM user_diary WHERE user_id = #{userId} <if test="month != null and month != ''"> AND DATE_FORMAT(create_time, '%Y-%m') = #{month} </if> ORDER BY create_time DESC LIMIT #{offset}, #{limit} </select>

月份过滤采用DATE_FORMAT(create_time, '%Y-%m')在数据量小时够用,但数据量到十万条后这个条件无法走索引,因为对索引列做了函数运算。更稳的写法是前端传startTimeendTime,SQL 改成create_time BETWEEN #{startTime} AND #{endTime},这样能命中(user_id, create_time)联合索引。手写LIMIT #{offset}, #{limit}比依赖 PageHelper 更直观,也方便排查参数 bug。

日志查询的返回对象不要直接返回实体类,因为实体类里的user_id对前端没有意义。可以新建一个DiaryVO,只包含标题、内容、心情、创建时间。这么做还有一个好处,后面如果要加“字数统计”或“编辑时间”,只在 VO 里扩展,不影响表结构。

4. 部署阶段真正会踩的坑:从配置到启动的完整过程

源码附带部署文档和演示视频,但部署文档可能是几个月前写的,演示视频录制的环境也不一定和当前代码一致。实际跑起来会遇到文档里没提到的问题。下面按启动顺序排一遍,每个坑都给了排查命令。

4.1 拿到源码后的启动顺序

不要一上来就用 IDEA 直接运行,先用命令行把基础链路走通,这样能第一时间暴露数据库、端口、依赖三方面问题。典型流程如下。

mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS time_management DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -uroot -p time_management < sql/time_management.sql mvn clean package -DskipTests java -jar target/time-management.jar

第一步创建数据库时指定 utf8mb4 和排序规则,避免 Windows 下的默认字符集把中文注释变成乱码。第二步导入 SQL 前要先确认脚本开头没有USE test;之类的残留库名,否则数据会写进错误数据库。第三步跳过测试打包,因为测试类可能依赖本地 Redis 或短信服务,在当前环境根本跑不起来。如果target/下存在多个 jar,最后的启动命令要指定具体文件名,不要直接写*.jar,Linux 下通配符在有些 shell 配置里不会自动展开。

用 IDEA 启动时反而容易忽略一个细节:工作目录(Working directory)如果被误设成了$MODULE_DIR$以外的路径,mapper-locations里的classpath:mapper/*.xml仍然能扫到,因为 XML 在 resources 目录下会打进 classpath,真正受影响的是日志输出路径。如果启动后提示找不到日志目录,就把工作目录改回模块根目录。

4.2 常见启动失败的对照表

下面这张表收集的是部署文档里最常被忽略的报错,属于拿源码后第一天就会遇到的情况。

报错现象常见原因解决方案
Access denied for user 'root'@'localhost'密码错误或账号不允许本地登录在命令行执行 mysql -uroot -p 验证密码
Unknown database 'time_management'数据库还未创建执行 4.1 中的 create database 语句
Server returns invalid timezoneJDBC 连接串缺少时区URL 增加 serverTimezone=Asia/Shanghai
Port 8080 was already in use其他服务占用端口修改 server.port,或 lsof -i:8080 看进程
Invalid bound statement (not found)mapper.xml 没被扫描检查 MyBatis 配置和接口包名

端口占用是最频繁的问题。排查命令用lsof -i:8080,输出第二列是 PID,确认是自己的残留进程后执行kill -9 PID。如果线上环境不希望用 8080,直接在application.yml里改server.port,前端调用的端口也要跟着改。

Invalid bound statement (not found)这个报错容易误导人,它会提示 Service 类的位置有问题,实际上是 MyBatis 找不到 XML 里的方法。对照检查三点:mapper-locations是不是classpath:mapper/*.xml,XML 的 namespace 是不是完整的 Mapper 接口名,SQL 的 id 是不是和接口方法名完全一致。注意application.yml里如果写了mybatis.type-aliases-package,实体类批量注册为别名后,XML 的resultType可以直接写类名,写错类名也会报同样的错。

4.3 演示视频和文档对不上时怎么定位

演示视频里出现“系统公告管理”页面,但当前代码的菜单表里可能没有对应记录,这是毕设项目最常见的文档与实现不同步。遇到这种问题,先在数据库往公告表插一条测试数据,用最快路径验证接口是否还通。

INSERT INTO system_announcement (title, content, create_by, create_time) VALUES ('测试公告', '启动后首页应看到这条数据', 1, NOW());

插入后启动项目访问首页,如果能看到这条数据,说明公告的查询链路是通的,视频里展示是旧页面而已。如果看不到,就按“Mapper → Service → Controller”的顺序打断点,先确认 SQL 查出的结果,再确认接口返回。不要一上来改前端代码,问题大概率在查询条件里多加了status过滤,导致刚插入的数据状态不匹配。

登录相关的问题也常在这里暴露。视频里可能用的是 session 登录,代码里可能已经改成了 JWT 或 token。最简单的排查方法是打开 Chrome DevTools 的 Network 面板,登录一次后看请求头里的Authorization或 Cookie,不管装什么依赖都不要自己去逆 token 算法。以此判断接口返回 401 是 token 过期,还是登录接口根本没调到。

5. 把毕设代码改造成生产可用的小技巧

源码能跑通只是第一步,离生产可用还差数据权限、索引验证和缓存设计三个动作。下面只讲最小改动方案,不引入新的重量级框架。

5.1 所有列表查询都强制带 user_id

事件、目标、日记这三类数据都归属到用户,但很多毕设项目在 Service 层只按eventId查详情,攻击者把 id 改成别人的就能看到越权数据。最小修复是做一个用户上下文 ThreadLocal,把当前登录用户放进线程,并在任务结束时清理。

public class UserContext { private static final ThreadLocal<Long> CURRENT_USER = new ThreadLocal<>(); public static void set(Long userId) { CURRENT_USER.set(userId); } public static Long get() { return CURRENT_USER.get(); } public static void clear() { CURRENT_USER.remove(); } }

Service 里查询列表时强制取 userId,没有就抛异常,SQL 里所有where条件都改写成user_id = ?开头。注意 ThreadLocal 在线程池复用时不会自动清除,必须在拦截器的afterCompletion里调用clear(),否则下个请求读到上一个用户的 id。

5.2 用 explain 验证事件列表索引

索引加没加,不能只看建表脚本。事件表的idx_user_start用下面的语句验证。

ALTER TABLE event_data ADD INDEX idx_user_start (user_id, start_time); EXPLAIN SELECT * FROM event_data WHERE user_id = 1 AND start_time >= '2025-06-01 00:00:00' AND start_time < '2025-07-01 00:00:00';

如果 EXPLAIN 结果里possible_keys包含idx_user_start,并且type不是 ALL,说明索引生效。如果type是 ALL,检查查询条件里有没有对start_time做函数运算,比如DATE_FORMAT(start_time, '%Y-%m-%d') = ?会直接废掉索引。这个技巧能直接讲清楚“为什么事件列表页越翻越慢”。

5.3 系统公告用简单内存缓存

系统公告表是读多写少的典型,每次刷新首页都查一次数据库很不划算。不引 Redis 也能用ConcurrentHashMap做定时缓存,key 用版本号自增,公告更新时版本号加一,查询端自然读到新数据。

@Component public class AnnouncementCache { private final Map<Integer, List<Announcement>> cache = new ConcurrentHashMap<>(); private int version; @Scheduled(fixedDelay = 60_000) public void refresh() { List<Announcement> list = announcementMapper.selectLatest(20); cache.put(version++, list); } }

这段逻辑要生效,主启动类必须先加@EnableScheduling,否则@Scheduled不执行。缓存 key 用自增版本号而不是时间戳,是因为自增更容易在日志里确认“当前读取的是第几版”,排查缓存不刷新时也更快。等到公告的写入频率变高,再用 Redis 的@CacheEvict主动清缓存,但也要把数据权限过滤条件放进查询参数里,别让缓存把某个用户的数据泄露给其他人。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询