简介:这份资源是《基于Java的备忘录管理系统设计与实现》完整文档,面向计算机相关专业学生、Java初学者及需要完成课程设计或毕业设计的人群,帮助解决传统备忘录管理效率低、信息分散的问题。文档围绕SSM框架与MySQL数据库展开,涵盖系统用户管理、备忘录管理、日志管理、登录与退出等核心模块的设计思路与实现过程,并附有测试对比与结论分析。资源包共1个docx文件,约690KB,内容包含摘要、目录、技术选型说明及各模块功能描述,结构完整,可直接作为论文写作或项目开发的参考模板。目前已有81人学习下载,适合需要快速理解SSM项目架构、梳理系统功能模块划分或借鉴论文组织方式的读者,能有效节省从零搭建文档框架的时间。
1. 从一份 .docx 标题说起:Java 备忘录管理系统到底要解决什么
很多人第一次看到「基于java的备忘录管理系统设计与实现.docx」这个标题,第一反应是——这不就是课程设计吗?但如果你真在企业里待过,会发现一个扎心的事实:团队里真正高频使用的内部工具,往往不是那些花里胡哨的平台,而是一个能记事儿、能提醒、能按人按组隔离的轻量备忘录。它替代的是散落在聊天记录、邮件草稿和个人便签里的碎片信息。这个标题背后要落地的东西,本质是一套带用户体系、权限隔离、定时提醒和全文检索的服务端应用,技术栈通常锁定 Spring Boot + MyBatis-Plus + MySQL,前端可以是 Vue 也可以是服务端渲染。它适合两类人:一是想拿一个完整项目练手 Java 后端全链路的开发者,二是中小团队里需要快速搭一个内部信息记录工具的工程师。别被「管理系统」四个字骗了,真正难的不是增删改查,而是提醒调度、数据隔离和并发写入这三块。
2. 技术选型与数据模型:为什么是 Spring Boot + MyBatis-Plus 而不是裸 Servlet
2.1 选型理由与依赖清单
这个标题里「基于 Java」是唯一的技术约束,剩下的全靠自己定。我一般会直接上 Spring Boot 3.x + MyBatis-Plus + MySQL 8,理由很实际:备忘录系统的核心操作是单表 CRUD 加条件查询,MyBatis-Plus 的BaseMapper和LambdaQueryWrapper能把 80% 的样板代码干掉,省下来的时间拿去处理提醒调度和权限校验。如果你用裸 Servlet + JDBC,光是连接池配置和事务边界就能吃掉两天。
依赖清单如下,直接贴pom.xml关键部分:
<!-- Spring Boot 父依赖,锁定版本避免冲突 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> </parent> <dependencies> <!-- Web 层:REST 接口 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus:单表 CRUD 零 SQL --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.6</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- 参数校验:@NotBlank 等注解 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- 定时任务:提醒调度核心 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-quartz</artifactId> </dependency> </dependencies>这里有个参数要特别注意:MyBatis-Plus 3.5.6 对应 Spring Boot 3.x 必须用mybatis-plus-spring-boot3-starter,用错成mybatis-plus-boot-starter会在启动时报NoClassDefFoundError,这是血泪经验。Quartz 我选它而不是 Spring 自带的@Scheduled,原因是备忘录提醒需要持久化——服务器重启后未触发的提醒不能丢,@Scheduled是内存态的,重启即失效。
2.2 表结构设计与字段参数
备忘录系统的表不多,但字段设计直接决定后面查询顺不顺手。核心三张表:用户表、备忘录主表、提醒记录表。
-- 用户表:最小可用,别一上来就搞 RBAC CREATE TABLE `sys_user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(64) NOT NULL COMMENT '登录名,唯一', `password` VARCHAR(128) NOT NULL COMMENT 'BCrypt 加密后的密文', `nickname` VARCHAR(64) DEFAULT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 备忘录主表:user_id 是数据隔离的关键 CREATE TABLE `memo` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL COMMENT '归属用户,所有查询必须带此条件', `title` VARCHAR(200) NOT NULL, `content` TEXT DEFAULT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0未完成 1已完成 2已归档', `priority` TINYINT NOT NULL DEFAULT 1 COMMENT '1低 2中 3高', `remind_time` DATETIME DEFAULT NULL COMMENT '为空表示不提醒', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_status` (`user_id`, `status`), KEY `idx_remind` (`remind_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;idx_user_status这个联合索引是必须的,因为列表页最高频的查询就是「某用户的未完成备忘录」,没有它数据量上到十万级就开始翻车。idx_remind服务于提醒调度器的扫描任务。content用 TEXT 不用 VARCHAR,因为备忘录正文长度不可控,VARCHAR(65535) 在 utf8mb4 下会超行限制。
提示:
remind_time允许为 NULL,表示这条备忘录不需要提醒。调度器扫描时用WHERE remind_time IS NOT NULL AND remind_time <= NOW(),避免全表扫。
2.3 实体类与 MyBatis-Plus 映射
实体类用 Lombok 简化,关键是@TableField的fill策略要配好,否则create_time永远是 null。
@Data @TableName("memo") public class Memo { @TableId(type = IdType.AUTO) private Long id; private Long userId; private String title; private String content; private Integer status; private Integer priority; private LocalDateTime remindTime; // 插入时自动填充 @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; // 插入和更新时都填充 @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }填充逻辑要自己写一个MetaObjectHandler实现类,否则注解不生效:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }参数说明:strictInsertFill只在字段为 null 时填充,不会覆盖你手动设置的值,这个行为在批量导入场景下很重要。如果你用setFieldValByName就会强制覆盖,导入历史数据时时间戳全变成当前时间,这是个隐蔽的坑。
3. 核心功能落地:从备忘录 CRUD 到提醒调度
3.1 数据隔离:为什么每个查询都必须带 user_id
备忘录系统最容易被忽视的安全问题就是越权。用户 A 通过改 URL 里的 id 就能看到用户 B 的备忘录,这在课程设计里经常被放过,但真上线就是事故。我的做法是在 Service 层强制拼user_id,不依赖 Controller 传参。
@Service public class MemoService { @Resource private MemoMapper memoMapper; /** * 查询当前用户的备忘录列表 * @param userId 从登录态获取,绝不从前端传 */ public List<Memo> listByUser(Long userId, Integer status) { LambdaQueryWrapper<Memo> wrapper = new LambdaQueryWrapper<>(); // 数据隔离的核心:user_id 条件永远在第一位 wrapper.eq(Memo::getUserId, userId); if (status != null) { wrapper.eq(Memo::getStatus, status); } wrapper.orderByDesc(Memo::getPriority) .orderByDesc(Memo::getUpdateTime); return memoMapper.selectList(wrapper); } /** * 更新备忘录:先校验归属,再更新 */ public boolean updateMemo(Long memoId, Long userId, Memo update) { Memo exist = memoMapper.selectById(memoId); // 归属校验,不匹配直接拒绝 if (exist == null || !exist.getUserId().equals(userId)) { throw new BizException("备忘录不存在或无权限"); } update.setId(memoId); update.setUserId(userId); // 防止前端篡改归属 return memoMapper.updateById(update) > 0; } }逻辑说明:userId的来源必须是后端从 token 或 session 里解析出来的,绝不能信任前端传的userId参数。updateMemo里先selectById再校验归属,虽然多一次查询,但这是防越权的必要成本。如果追求性能可以用UPDATE memo SET ... WHERE id = ? AND user_id = ?一条语句搞定,但 MyBatis-Plus 的updateById不支持额外条件,得自己写 XML 或UpdateWrapper。
参数说明:orderByDesc(Memo::getPriority)让高优先级排前面,配合updateTime倒序,保证最近改过的浮上来。这个排序组合在列表页体验最好,比单纯按创建时间排要合理。
3.2 提醒调度:Quartz 持久化任务的配置与触发
提醒功能是这个系统里唯一有技术含量的部分。用 Quartz 做持久化调度,核心是两张 Quartz 自带的表QRTZ_TRIGGERS和QRTZ_JOB_DETAILS,Spring Boot 会自动建表(需要配spring.quartz.jdbc.initialize-schema=always,生产环境改成never手动初始化)。
@Configuration public class QuartzConfig { /** * 提醒任务的 JobDetail * 用 JobDataMap 传递 memoId,避免任务里再查一次 */ @Bean public JobDetail remindJobDetail() { return JobBuilder.newJob(RemindJob.class) .withIdentity("remindJob") .storeDurably() // 没有触发器时也保留 .build(); } }Job 实现类:
@DisallowConcurrentExecution // 防止同一任务并发执行 public class RemindJob implements Job { @Resource private MemoMapper memoMapper; @Override public void execute(JobExecutionContext context) { Long memoId = context.getJobDetail() .getJobDataMap().getLong("memoId"); Memo memo = memoMapper.selectById(memoId); if (memo == null || memo.getStatus() == 1) { return; // 已删除或已完成,跳过 } // 这里对接实际通知渠道:站内信、邮件等 System.out.println("提醒: " + memo.getTitle()); } }动态注册触发器的逻辑:
@Service public class RemindScheduler { @Resource private Scheduler scheduler; /** * 为指定备忘录注册提醒 * @param memoId 备忘录 ID * @param remindTime 提醒时间,必须晚于当前时间 */ public void schedule(Long memoId, LocalDateTime remindTime) throws SchedulerException { if (remindTime == null || remindTime.isBefore(LocalDateTime.now())) { return; // 过去时间不注册 } JobKey jobKey = JobKey.jobKey("remind_" + memoId); // 先删旧的,避免重复注册 if (scheduler.checkExists(jobKey)) { scheduler.deleteJob(jobKey); } JobDetail job = JobBuilder.newJob(RemindJob.class) .withIdentity(jobKey) .usingJobData("memoId", memoId) .build(); Trigger trigger = TriggerBuilder.newTrigger() .withIdentity("trigger_" + memoId) .startAt(Date.from(remindTime .atZone(ZoneId.systemDefault()).toInstant())) .build(); scheduler.scheduleJob(job, trigger); } }参数说明:@DisallowConcurrentExecution保证同一个 Job 不会并发跑,避免用户收到重复提醒。startAt接收java.util.Date,从LocalDateTime转换时必须指定时区,用ZoneId.systemDefault()而不是硬编码,否则跨时区部署会差 8 小时。checkExists+deleteJob的组合是幂等注册的关键,用户反复修改提醒时间时不会堆积触发器。
3.3 全文检索:LIKE 撑不住时怎么加索引
备忘录的搜索需求通常是「标题或内容包含关键词」。数据量小的时候LIKE '%关键词%'能用,但上了十万条就开始全表扫。我的做法是分两步走:先用 MySQL 全文索引顶住,真不够再上 Elasticsearch。
-- 给 title 和 content 加全文索引 ALTER TABLE memo ADD FULLTEXT INDEX ft_title_content (title, content) WITH PARSER ngram;ngram解析器是中文全文检索的关键,默认解析器按空格分词,中文整段会被当成一个词,搜不到。ngram_token_size默认是 2,表示按 2 字切分,可以在my.cnf里调成 1 支持单字搜索,但索引会变大。
查询语句:
SELECT id, title, priority, update_time FROM memo WHERE user_id = ? AND MATCH(title, content) AGAINST(? IN BOOLEAN MODE) ORDER BY update_time DESC LIMIT 20;参数说明:IN BOOLEAN MODE支持+关键词(必须包含)、-关键词(必须排除)的语法,比自然语言模式可控。但要注意全文索引和user_id条件一起用时,MySQL 可能选错索引,建议用FORCE INDEX明确指定,或者干脆把搜索做成独立接口,先全文检索拿到 id 列表再回表过滤user_id。
注意:全文索引对短词(小于
ngram_token_size)无效,用户搜单个字时可能返回空结果。如果产品要求支持单字搜索,直接上 Elasticsearch 或者用LIKE兜底,别在 MySQL 上死磕。
4. 避坑与排查:那些让备忘录系统翻车的细节
4.1 时间字段的时区错乱
现象:用户设置提醒时间为「下午 3 点」,实际触发在「晚上 11 点」,差 8 小时。
原因:MySQL 连接串没指定时区,JDBC 驱动默认用服务器时区,而LocalDateTime不带时区信息,写入和读取时发生了隐式转换。
解决:在application.yml的 JDBC URL 里显式加serverTimezone=Asia/Shanghai,同时 JVM 启动参数加-Duser.timezone=Asia/Shanghai。两处都配,缺一不可。如果用的是Instant或ZonedDateTime可以规避,但团队里只要有人用LocalDateTime就会踩。
4.2 MyBatis-Plus 逻辑删除与唯一索引冲突
现象:删除一条备忘录后,想用同样的标题重新创建,报唯一键冲突。
原因:如果配了逻辑删除(@TableLogic),删除只是把deleted字段置 1,数据库里的唯一索引仍然包含这条记录。
解决:唯一索引要带上deleted字段,比如UNIQUE KEY uk_user_title (user_id, title, deleted)。但这样删除多次后deleted值重复又会冲突,所以逻辑删除的标记值不能用固定的 1,要用时间戳或自增 ID。我一般直接用deleted BIGINT DEFAULT 0,删除时写入当前时间戳。
4.3 Quartz 集群下的重复触发
现象:部署两个实例后,同一条提醒被触发两次,用户收到两条通知。
原因:Quartz 默认每个实例都会抢触发器,没有配置集群模式时,两个实例各自扫描到同一个待触发任务。
解决:配置 Quartz 集群模式,spring.quartz.properties.org.quartz.jobStore.isClustered=true,并确保instanceId用AUTO。集群模式下 Quartz 通过数据库行锁保证同一时刻只有一个实例执行任务。另外@DisallowConcurrentExecution只在单实例内生效,跨实例要靠集群配置。
4.4 批量导入时自动填充失效
现象:用saveBatch批量插入备忘录,create_time全是 null。
原因:MyBatis-Plus 的MetaObjectHandler在批量操作时对某些版本不生效,或者实体类字段没加@TableField(fill = ...)。
解决:先确认注解加了,再确认MetaObjectHandler被 Spring 扫描到(加@Component)。如果还不生效,在saveBatch前手动setCreateTime(LocalDateTime.now()),别跟框架较劲。批量场景下我一般直接手动设值,省得排查。
4.5 长文本导致列表接口变慢
现象:列表页加载要 3 秒以上,明明只查 20 条。
原因:SELECT *把content字段也查出来了,单条备忘录正文可能几万字,20 条就是几十万字符的传输量。
解决:列表查询用select(Memo.class, info -> !"content".equals(info.getColumn()))排除 content 字段,详情页再单独查。MyBatis-Plus 的LambdaQueryWrapper支持select指定字段,别偷懒用selectList一把梭。
5. 进阶技巧:用状态机管好备忘录的生命周期
备忘录的status字段看起来简单,但状态流转一多就容易乱。比如「未完成 → 已完成 → 归档」,还可能有「已完成 → 未完成」的回退。如果到处写if (status == 0)判断,代码很快就没法维护。我的习惯是引入一个轻量状态机,不一定要上 Spring StateMachine 那么重,一个枚举加转移表就够了。
public enum MemoStatus { TODO(0), DONE(1), ARCHIVED(2); private final int code; MemoStatus(int code) { this.code = code; } public int getCode() { return code; } // 允许的转移:当前状态 -> 可转移到的状态集合 private static final Map<MemoStatus, Set<MemoStatus>> TRANSITIONS = Map.of( TODO, Set.of(DONE, ARCHIVED), DONE, Set.of(TODO, ARCHIVED), ARCHIVED, Set.of(TODO) // 归档可以恢复到未完成 ); public static boolean canTransfer(MemoStatus from, MemoStatus to) { return TRANSITIONS.getOrDefault(from, Set.of()).contains(to); } }Service 层调用:
public void changeStatus(Long memoId, Long userId, int targetCode) { Memo memo = memoMapper.selectById(memoId); if (memo == null || !memo.getUserId().equals(userId)) { throw new BizException("无权限"); } MemoStatus from = Arrays.stream(MemoStatus.values()) .filter(s -> s.getCode() == memo.getStatus()) .findFirst().orElseThrow(); MemoStatus to = Arrays.stream(MemoStatus.values()) .filter(s -> s.getCode() == targetCode) .findFirst().orElseThrow(); if (!MemoStatus.canTransfer(from, to)) { throw new BizException("不允许从 " + from + " 转到 " + to); } memo.setStatus(targetCode); memoMapper.updateById(memo); }这样状态规则集中在一处,加新状态只改枚举,不用满项目找if。验证方法也简单:写单元测试遍历所有状态对,断言非法转移抛异常。我一般会写一个参数化测试,把TRANSITIONS里没有的组合全跑一遍,确保没有漏网之鱼。
另一个实用技巧是给提醒加「静默期」。用户可能把提醒设成每分钟一次来测试,结果被通知轰炸。在RemindJob里加一个判断:同一条备忘录两次提醒间隔小于 5 分钟就跳过。这个阈值我放在配置文件里,不同环境可以调。实现上可以用 Redis 存last_remind_time:{memoId},设置 5 分钟过期,存在就跳过。没有 Redis 就用数据库的update_time字段凑合,但精度差一些。
最后说个我自己的习惯:每次改完提醒相关的代码,我都会手动把系统时间调到提醒时间前一分钟,盯着日志看任务有没有按时触发。这个笨办法帮我抓过好几次时区配置错误和 Quartz 触发器没注册的问题。自动化测试覆盖不到时间相关的边界,手动验证虽然土,但靠谱。希望帮到你。
本文还有配套的精品资源,点击获取