
简介面向毕业设计的时间管理系统开发资料采用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但接手改造时建议改成 BCryptpassword字段长度也要从 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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连接串里的serverTimezoneAsia/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.ymlController 只做参数绑定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 ResultDtoLong save(RequestBody EventData event) { // event.id 为空时新增有值时更新 eventService.saveEvent(event); return ResultDto.success(event.getId()); } GetMapping(/list) public ResultDtoPageResultEventData 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 idselectDiaryPage resultTypecom.example.timemanager.entity.UserDiary SELECT id, user_id, title, content, mood, create_time FROM user_diary WHERE user_id #{userId} if testmonth ! 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)在数据量小时够用但数据量到十万条后这个条件无法走索引因为对索引列做了函数运算。更稳的写法是前端传startTime和endTimeSQL 改成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最后的启动命令要指定具体文件名不要直接写*.jarLinux 下通配符在有些 shell 配置里不会自动展开。用 IDEA 启动时反而容易忽略一个细节工作目录Working directory如果被误设成了$MODULE_DIR$以外的路径mapper-locations里的classpath:mapper/*.xml仍然能扫到因为 XML 在 resources 目录下会打进 classpath真正受影响的是日志输出路径。如果启动后提示找不到日志目录就把工作目录改回模块根目录。4.2 常见启动失败的对照表下面这张表收集的是部署文档里最常被忽略的报错属于拿源码后第一天就会遇到的情况。报错现象常见原因解决方案Access denied for user rootlocalhost密码错误或账号不允许本地登录在命令行执行 mysql -uroot -p 验证密码Unknown database time_management数据库还未创建执行 4.1 中的 create database 语句Server returns invalid timezoneJDBC 连接串缺少时区URL 增加 serverTimezoneAsia/ShanghaiPort 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/*.xmlXML 的 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 ThreadLocalLong 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 MapInteger, ListAnnouncement cache new ConcurrentHashMap(); private int version; Scheduled(fixedDelay 60_000) public void refresh() { ListAnnouncement list announcementMapper.selectLatest(20); cache.put(version, list); } }这段逻辑要生效主启动类必须先加EnableScheduling否则Scheduled不执行。缓存 key 用自增版本号而不是时间戳是因为自增更容易在日志里确认“当前读取的是第几版”排查缓存不刷新时也更快。等到公告的写入频率变高再用 Redis 的CacheEvict主动清缓存但也要把数据权限过滤条件放进查询参数里别让缓存把某个用户的数据泄露给其他人。本文还有配套的精品资源点击获取