生日提醒开发笔记

生日提醒开发笔记

这是笔者在大一下创新实践课程做的项目,即使还有很多上线刚需的功能没有实装,但是暂时的回顾和复盘是很有必要的。年后两个月左右我会将剩下的功能补齐,到时候再更新文档。

笔者表达能力堪忧,所以请结合源码阅读。

项目需求

在快节奏的现代生活中,我们忙于处理无数事务,重要的日子——比如亲友的生日——常常在指尖悄然溜走。即便记得日期,也可能因一时疏忽而错过那个送上祝福的最佳时刻。这种遗忘不仅留下遗憾,也可能让一份本该温暖的关系悄然降温。

为此,“生日提醒”应运而生。它不止是一个简单的生日记录工具,更是一位贴心的私人提醒助手。您只需提前设置好重要日期,应用便会在预设时间智能发出提醒,为您留出充足的时间准备礼物、构思祝福,或在那一刻准时送上问候。让我们帮助您,把每一个值得纪念的日子,都变成温暖人心的时刻。

核心功能

  • 亲友管理:记录亲友基本信息、生日日期、标签分类
  • 提醒计划:设置提前N天提醒,支持多种提醒时间
  • 自动邮件:生日当天自动发送个性化祝福邮件
  • 双模式提醒
    • 提醒用户模式:提前通知用户准备生日祝福
    • 自动祝福模式:生日当天自动发送祝福给亲友

技术栈

后端

类别 技术/组件 版本/说明
核心框架 Spring Boot 3.5.8
Web框架 Spring Boot Starter Web 内置于Spring Boot
ORM框架 MyBatis 3.0.3
分页插件 PageHelper 2.1.0
邮件服务 Spring Boot Starter Mail 内置于Spring Boot
数据库 MySQL 8.0.31
开发工具 Lombok 1.18.38 (用于简化POJO)
Spring Boot Devtools 内置于Spring Boot (热部署)
工具库 Hutool 5.8.38
系统监控 Spring Boot Actuator 内置于Spring Boot
定时任务 基于Spring Boot注解Schedule 内置于Spring Boot

前端

类别 技术/组件 说明
UI框架 Bootstrap 5
图标库 Font Awesome 阿里图标库
脚本语言 JavaScript 原生

项目构建与运行环境

类别 技术/组件
构建工具 Maven
JDK版本 JDK 25
数据库 MySQL 8.0+

项目架构与结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
com.Birthday_reminder
├── Birthday_reminder.java # Spring Boot启动类
├── controller/ # 控制器层
│ ├── HelloController.java # 测试控制器
│ ├── MailTestController.java # 邮件测试控制器
│ ├── RelationshipController.java # 亲友关系控制器
│ ├── RelationshipRecordController.java # 提醒记录控制器
│ └── RelationshipReminderController.java # 生日提醒控制器
├── domain/ # 实体层
│ ├── Relationship.java # 亲友实体类
│ ├── ReminderPlan.java # 提醒计划实体类
│ └── ReminderRecord.java # 提醒记录实体类
├── dto/ # 数据传输对象
│ ├── PageDTO.java # 分页数据传输对象基类
│ └── RelationshipPageDTO.java # 亲友分页数据传输对象
├── mapper/ # 数据访问层
│ ├── RelationshipMapper.java # 亲友数据访问接口
│ ├── ReminderPlanMapper.java # 提醒计划数据访问接口
│ └── ReminderRecordMapper.java # 提醒记录数据访问接口
├── service/ # 服务层接口
│ ├── RelationPlanService.java # 关系计划服务接口
│ ├── RelationshipService.java # 亲友服务接口
│ └── ReminderRecordService.java # 提醒记录服务接口
├── service/impl/ # 服务层实现类
│ ├── RelationPlanServiceImpl.java # 关系计划服务实现类
│ ├── RelationshipServiceImpl.java # 亲友服务实现类
│ └── ReminderRecordServiceImpl.java # 提醒记录服务实现类
├── task/ # 定时任务
│ ├── BirthdayReminderJob.java # 生日提醒定时任务
│ └── MailJob.java # 邮件发送定时任务
├── typehandler/ # 类型处理器
│ └── IntegerListJsonTypeHandler.java # JSON类型处理器
├── util/ # 工具类
│ └── MailUtil.java # 邮件工具类
└── vo/ # 视图对象
├── PageVO.java # 分页视图对象
├── RelationshipPageVO.java # 亲友分页视图对象
└── ResponseEntity.java # 响应实体类

架构说明

这是一个基于 *Spring Boot 的现代化单体应用程序,采用经典的*分层架构MyBatis 负责数据持久化,配合 PageHelper 处理分页。核心的生日提醒功能通过 Spring Mail 发送邮件。前端使用 Bootstrap 提供响应式界面,整个项目由 Maven 统一构建和管理。

本项目使用的依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>org.example</groupId>
<artifactId>Birthday_reminder</artifactId>
<version>1.0-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.8</version>
</parent>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!--springboot整合mybatis-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
<!--mybatis分页插件,已经包含了mybatis-->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>
<!--mysql驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.31</version>
</dependency>
<!--简化实体类-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.38</version>
</dependency>

<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.38</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- <dependency>-->
<!-- <groupId>org.springframework.boot</groupId>-->
<!-- <artifactId>spring-boot-starter-security</artifactId>-->
<!-- </dependency>-->
</dependencies>


<properties>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

</project>

表结构设计

我们需要三张表,一张是关系表,名为Relationship,作为主表记录主要信息。

一张是计划表,名为reminder_plan,关联主表记录提醒计划。

一张是记录表,名为reminder_record,关联主表,存储提醒记录,以便管理员查询和修改bug。

创建表结构的SQL如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

# 亲友关系表
-- table_birthday.relationship definition

CREATE TABLE `relationship` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`my_email` varchar(100) DEFAULT NULL,
`calendar_type` int DEFAULT '0',
`birthday` date DEFAULT NULL,
`birthday_month` int DEFAULT NULL,
`birthday_day` int DEFAULT NULL,
`tag` varchar(50) DEFAULT NULL,
`reminder_enabled` int DEFAULT '0',
`congratulate_enabled` int DEFAULT '0',
`relationship_email` varchar(100) DEFAULT NULL,
`greeting` varchar(200) DEFAULT NULL,
`self_call` varchar(50) DEFAULT NULL,
`notes` text,
`days_before` json DEFAULT NULL,
`created_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=38 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
# 提醒计划表
# 该表用于规划后面的发送邮件等任务
-- table_birthday.reminder_plan definition

CREATE TABLE `reminder_plan` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '计划ID,主键',
`relationship_id` int NOT NULL COMMENT '关联的亲友关系ID',
`reminder_date` date DEFAULT NULL COMMENT '提醒日期',
`days_before` int NOT NULL COMMENT '提前提醒天数',
`reminder_type` int DEFAULT '0' COMMENT '提醒类型 (0:生日提醒, 1:其他提醒)',
`execution_status` int DEFAULT '0' COMMENT '执行状态 (0:待执行, 1:已执行, 2:已取消)',
`created_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `relationship_id` (`relationship_id`),
CONSTRAINT `reminder_plan_ibfk_1` FOREIGN KEY (`relationship_id`) REFERENCES `relationship` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=32 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='提醒计划表';
# 提醒记录表
# 记录,用于保存系统日志供日后查询
-- table_birthday.reminder_record definition

CREATE TABLE `reminder_record` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '记录ID,主键',
`relationship_id` int NOT NULL COMMENT '关联的亲友关系ID',
`reminder_time` datetime NOT NULL COMMENT '实际提醒时间',
`receiver` varchar(100) DEFAULT NULL COMMENT '提醒接收方',
`reminder_type` int DEFAULT NULL COMMENT '提醒类型',
`reminder_content` text COMMENT '提醒内容',
`send_status` int DEFAULT '0' COMMENT '发送状态 (0:成功, 1:失败)',
`created_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `relationship_id` (`relationship_id`),
CONSTRAINT `reminder_record_ibfk_1` FOREIGN KEY (`relationship_id`) REFERENCES `relationship` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='提醒记录表';

值得一提的是,我在创建时间和修改时间使用的语句是

1
2
`created_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

使用了MySQL的原生功能—-自动填充变动时间戳,这意味着我们不再需要在项目中额外用代码配置。

跨域

这是在开发过程中一个比较重要的知识点。

跨域是浏览器的一种安全限制,它阻止你网页上的JavaScript代码,去请求另一个“来源”(域名、端口、协议不同)的服务器的数据。

也就是****默认情况下,只允许网页与“同源”的服务器自由通信。

同源指的是协议端口域名都必须相同,一旦出现以下任意一种情况,浏览器就会自动拦截操作

  • 协议不同:http://example.com 请求 https://example.com
  • 域名不同:http://a.com 请求 http://b.com
  • 端口不同:http://localhost:3000 请求 http://localhost:8080

作为一个简单的演示级项目,我们将前端三件套的文件(html,css,js)等与后端文件放在一起,实现逻辑上的分离但结构上的耦合。

使用MyBatisX插件快速生成实体类

MyBatisX插件是一个强大的IDEA插件,能够提供跳转、代码生成、提示等功能,优化基于MyBatis/MyBatis-Plus的开发体验。我们可以用它生成实体类甚至是Service层。

在Idea中配置数据库之后就能够用MybatisX插件生成实体类,具体教程请自行搜索。

请原谅,我不可能在这里用大量图片篇幅来解释这个过程。

项目结构层级作用

想半天也不知道怎么写,将就看吧。

有些层和其他层耦合严重,介绍层级就按照拓扑排序顺序来,先讲简单的。

项目一共有几个层级,分别是:

  • Controller控制器层:与前端直接交互,完成交互逻辑
  • Domain实体层:将数据库格式转化为java对象
  • DTO层:数据运输对象,封装某些参数在网络中传输,不带逻辑处理,作用是减少网络中传输次数。
  • Mapper数据访问层:Java的数据库接口,声明了数据库操作方法
  • Service服务层接口:针对三个界面写的三个接口,在Impl层用类实现并填充
  • Task层:基于@Schedule实现,负责执行定时任务。
  • TypeHandler层:数据转换器层将json类型转化为java对象,这个后面会提一嘴。
  • Util层:工具类,发送邮件
  • VO层:视图对象,为了美观地展示数据,我们需要将返回的数据封装成一个识图对象,才能与前端联动展示

所以,整体执行逻辑应该是:

4d57680833c09

DTO层

我们用了PageHelper插件来实现分页展示那参数就是页码page和每页要展示的数量size.

1
2
3
4
5
6
public class PageDTO implements Serializable {

private Integer page;
private Integer size;

}

那就集成序列化类,写一个简单的类就行了。

Domain层

这里有什么好写的?

使用MyBatisX插件生成的,你自己写的话注意和数据库的数据类型和顺序要完全对得上。

插件为了保证稳妥,甚至自动生成了操作方法,虽然这样的写法可读性不可理喻。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public boolean equals(Object that) {
if (this == that) {
return true;
}
if (that == null) {
return false;
}
if (getClass() != that.getClass()) {
return false;
}
Relationship other = (Relationship) that;
return (this.getId() == null ? other.getId() == null : this.getId().equals(other.getId()))
&& (this.getName() == null ? other.getName() == null : this.getName().equals(other.getName()))
&& (this.getCalendarType() == null ? other.getCalendarType() == null : this.getCalendarType().equals(other.getCalendarType()))
&& (this.getBirthday() == null ? other.getBirthday() == null : this.getBirthday().equals(other.getBirthday()))
&& (this.getBirthdayMonth() == null ? other.getBirthdayMonth() == null : this.getBirthdayMonth().equals(other.getBirthdayMonth()))
&& (this.getBirthdayDay() == null ? other.getBirthdayDay() == null : this.getBirthdayDay().equals(other.getBirthdayDay()))
&& (this.getTag() == null ? other.getTag() == null : this.getTag().equals(other.getTag()))
&& (this.getRemindEnabled() == null ? other.getRemindEnabled() == null : this.getRemindEnabled().equals(other.getRemindEnabled()))
&& (this.getCongratulateEnabled() == null ? other.getCongratulateEnabled() == null : this.getCongratulateEnabled().equals(other.getCongratulateEnabled()))
&& (this.getRelationshipEmail() == null ? other.getRelationshipEmail() == null : this.getRelationshipEmail().equals(other.getRelationshipEmail()))
&& (this.getGreeting() == null ? other.getGreeting() == null : this.getGreeting().equals(other.getGreeting()))
&& (this.getSelfCall() == null ? other.getSelfCall() == null : this.getSelfCall().equals(other.getSelfCall()))
&& (this.getNotes() == null ? other.getNotes() == null : this.getNotes().equals(other.getNotes()))
&& (this.getDaysBefore() == null ? other.getDaysBefore() == null : this.getDaysBefore().equals(other.getDaysBefore()));
}

Util层

一个工具类,没什么好说的,调用函数,传入该传的原子参数就行。

Mapper层

这一层就是声明数据库查询方法,具体实现看源码。

TypeHandler层

明确,在前端,提前天数是可选的:

image-20260102030446635

数据库中提前天数我使用的是

1
`days_before` json DEFAULT NULL,

这是一个非空的json数据类型,比较的“生僻”,所以使用MyBatisX插件的时候,他将数据库的提前天数给我生成了object类,这是java的一个很底层的基类,直接使用它可能会出现类型安全问题,代码可读性差,Lombok生成意外方法等问题,所以最好将他转化为java更常用的数据类型,我们单独写了TypeHandler来解决这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import cn.hutool.json.JSONUtil;  //hutool类型工具
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;

import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;

public class IntegerListJsonTypeHandler extends BaseTypeHandler<List<Integer>> {

@Override
public void setNonNullParameter(PreparedStatement ps, int i, List<Integer> parameter, JdbcType jdbcType) throws SQLException {
String json =JSONUtil.toJsonStr(parameter);
ps.setString(i,json);
}//这里只展示部分代码

也是使用了Java内置和hutools工具箱去转换,具体请移步到项目源码。

Service层

这里才是真正的业务大脑,在这里需要做到

  • 一个接口,一个实现
  • 处理复杂的业务规则
  • 协调多个Mapper完成一个功能

Relationship接口

增删改查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public Relationship getRelationship(Integer id) {
if (id == null || id <= 0) {
throw new RuntimeException("无效的ID");
}
Relationship relationship = relationshipMapper.selectById(id);
if (relationship == null) {
throw new RuntimeException("未找到对应的亲友关系");
}
return relationship;
}

@Override
public Relationship createRelationship(Relationship relationship) {
int result = relationshipMapper.insert(relationship);//具体insert方法怎么写就看源码,这里没什么奇淫技巧。
return relationship;
}

@Override
public Boolean updateRelationship(Relationship relationship) {
Relationship existing = relationshipMapper.selectById(relationship.getId());
if (existing == null) {
return false;
}
int result = relationshipMapper.update(relationship);
return result > 0;
}

@Override
public Boolean deleteRelationship(Integer id) {
int result = relationshipMapper.delete(id);
return result > 0;
}
}

分页效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 定义方法,接收一个RelationshipPageDTO参数,返回PageInfo<RelationshipPageVO>类型的分页结果
public PageInfo<RelationshipPageVO> queryRelationshipPage(RelationshipPageDTO dto) {

// 使用PageHelper插件开始分页查询,设置当前页码和每页大小
PageHelper.startPage(dto.getPage(), dto.getSize());

// 调用Mapper层查询所有符合条件的亲友关系列表
List<Relationship> relationships = relationshipMapper.selectAll(dto);

// 将查询结果封装到PageInfo对象中,包含分页相关信息
PageInfo<Relationship> pageInfo = new PageInfo<>(relationships);

// 将Relationship实体对象转换为RelationshipPageVO视图对象
// 使用Stream流处理,通过BeanUtils.copyProperties方法复制属性
List<RelationshipPageVO> vos = pageInfo.getList().stream().map(relationship -> {
RelationshipPageVO vo = new RelationshipPageVO();
BeanUtils.copyProperties(relationship, vo); // 复制属性从实体对象到视图对象
return vo;
}).collect(Collectors.toList()); // 收集为List

// 创建新的PageInfo<RelationshipPageVO>对象
PageInfo<RelationshipPageVO> result = new PageInfo<>(vos);

// 将原始pageInfo的分页属性复制到新的result对象中
result.setTotal(pageInfo.getTotal()); // 总记录数
result.setPages(pageInfo.getPages()); // 总页数
result.setPageNum(pageInfo.getPageNum()); // 当前页码
result.setPageSize(pageInfo.getPageSize()); // 每页大小
result.setSize(pageInfo.getSize()); // 当前页大小
result.setStartRow(pageInfo.getStartRow()); // 起始行
result.setEndRow(pageInfo.getEndRow()); // 结束行
result.setPrePage(pageInfo.getPrePage()); // 上一页页码
result.setNextPage(pageInfo.getNextPage()); // 下一页页码
result.setIsFirstPage(pageInfo.isIsFirstPage()); // 是否为第一页
result.setIsLastPage(pageInfo.isIsLastPage()); // 是否为最后一页
result.setHasPreviousPage(pageInfo.isHasPreviousPage()); // 是否有上一页
result.setHasNextPage(pageInfo.isHasNextPage()); // 是否有下一页
result.setNavigatePages(pageInfo.getNavigatePages()); // 导航页码数
result.setNavigatepageNums(pageInfo.getNavigatepageNums()); // 导航页码数组
result.setNavigateFirstPage(pageInfo.getNavigateFirstPage()); // 导航首页
result.setNavigateLastPage(pageInfo.getNavigateLastPage()); // 导航末页

// 返回封装好的分页结果
return result;
}

我觉得注释已经够详细了。

RelationRecord接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class ReminderRecordServiceImpl implements ReminderRecordService {

@Autowired
private ReminderRecordMapper mapper;

@Override
public PageInfo<ReminderRecord> getPageList(Integer page, Integer size, Integer relationshipId) {
PageHelper.startPage(page, size);
List<ReminderRecord> list = mapper.selectAll(relationshipId);
return new PageInfo<>(list);
}

@Override
public ReminderRecord getById(Integer id) {
return mapper.selectById(id);
}

@Override
public void markAsProcessed(Integer id) {
// 标记为已处理状态,未实装
}

@Override
public List<ReminderRecord> getPendingRecords()
{
return mapper.selectPendingRecords();
}
}

RelationPlan接口

该接口需要进行复杂的任务,请保持耐心

Plan方法

为指定的亲友关系创建提醒计划。

话说为什么答辩的时候脑子突然宕机了呢

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 定义方法,接收一个Relationship对象作为参数
public void plan(Relationship relationship) {
// 检查传入的relationship是否为null,如果是则直接返回
if (Objects.isNull(relationship)){
return;
}

// 删除该亲友ID关联的所有旧提醒计划
mapper.deleteByRelationshipId(relationship.getId());

// 验证必要参数(生日月份、日期和用户邮箱)
validateParams(relationship);

// 计算下一个生日日期,LocalDate是不带时间的日期类型。
LocalDate birthday = calculateBirthday(relationship);
// 获取当前日期
LocalDate now = LocalDate.now();
// 创建一个空的提醒计划列表
List<ReminderPlan> plans = new ArrayList<>();

// 1. 制定提醒用户的计划(reminderType=0)
// 检查是否启用了提醒功能(remindEnabled为1)
if (relationship.getRemindEnabled() != null && relationship.getRemindEnabled() == 1) {
// 获取提前提醒天数列表(例如:[1, 3, 7] 表示提前1天、3天、7天提醒)
List<Integer> daysBefore = relationship.getDaysBefore();
// 检查daysBefore列表是否不为空
if (CollectionUtil.isNotEmpty(daysBefore)) {
// 遍历每个提前提醒天数
for (Integer bf : daysBefore) {
// 计算提醒日期:生日日期减去提前提醒天数
LocalDate reminderDate = birthday.minusDays(bf);
// 如果当前日期已经过了提醒日期,则跳过该计划
if (now.isAfter(reminderDate)) {
continue;
}
// 创建基础提醒计划
ReminderPlan plan = buildBasicPlan(relationship);
plan.setReminderType(0); // 设置为提醒用户类型
plan.setReminderDate(reminderDate); // 设置提醒日期
plan.setDaysBefore(bf); // 设置提前提醒天数
plans.add(plan); // 将计划添加到列表
}
}
}

// 2. 制定自动祝贺计划(reminderType=1)
// 检查是否启用了自动祝贺功能(congratulateEnabled为1)
if (relationship.getCongratulateEnabled() != null && relationship.getCongratulateEnabled() == 1) {
// 检查亲友邮箱是否为空
if (StrUtil.isBlank(relationship.getRelationshipEmail())) {
// 如果邮箱为空,记录警告日志并跳过祝贺计划
log.warn("亲友 {} 启用了祝贺功能但没有设置邮箱,跳过祝贺计划", relationship.getName());
} else {
// 检查当前日期是否还没过生日
if (!now.isAfter(birthday)) {
// 创建祝贺计划
ReminderPlan congratulatePlan = buildBasicPlan(relationship);
congratulatePlan.setReminderType(1); // 设置为发送祝贺类型
congratulatePlan.setReminderDate(birthday); // 设置为生日当天
congratulatePlan.setDaysBefore(0); // 当天发送,提前提醒天数为0
plans.add(congratulatePlan); // 添加到计划列表
}
}
}

// 如果计划列表不为空,则批量保存到数据库
if (CollectionUtil.isNotEmpty(plans)) {
mapper.saveBatch(plans); // 批量保存所有计划
log.info("为亲友 {} 创建了 {} 条计划", relationship.getName(), plans.size()); // 记录日志
}
}

executePlan方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// 标记方法为重写方法,并使用事务管理
@Override
@Transactional
public void executePlan(LocalDate now) {
// 查询当天所有未执行的提醒计划
List<ReminderPlan> plans = mapper.listUndoPlans(now);
// 检查是否有待执行的计划
if (CollUtil.isEmpty(plans)) {
log.info("当前没有执行计划"); // 如果没有计划,记录日志并返回
return;
}

log.info("找到 {} 条待执行的提醒计划", plans.size()); // 记录找到的计划数量

// 遍历每个待执行的计划
for (ReminderPlan plan : plans) {
try {
log.info("开始执行计划:{}", plan); // 记录开始执行的计划信息

// 获取计划关联的亲友信息
Relationship relationship = relationshipMapper.selectById(plan.getRelationshipId());
// 检查亲友信息是否存在
if (relationship == null) {
log.warn("计划ID {} 关联的亲友信息不存在,跳过", plan.getId()); // 如果不存在,记录警告并跳过
continue;
}

// 检查用户邮箱是否存在
if (StrUtil.isBlank(relationship.getMyEmail())) {
log.warn("计划ID {} 关联的亲友 {} 没有邮箱,跳过", plan.getId(), relationship.getName()); // 如果邮箱为空,记录警告并跳过
continue;
}

// 创建提醒记录
ReminderRecord record = new ReminderRecord();
record.setRelationshipId(plan.getRelationshipId()); // 设置关联的亲友ID
record.setReminderTime(LocalDateTime.now()); // 设置提醒时间为当前时间
record.setReminderType(plan.getReminderType()); // 设置提醒类型

// 根据提醒类型设置不同的接收者邮箱
if (plan.getReminderType() == 0) {
// 0-提醒用户:发送到用户邮箱
record.setReceiver(relationship.getMyEmail());
} else if (plan.getReminderType() == 1) {
// 1-发送祝贺:发送到亲友邮箱
record.setReceiver(relationship.getRelationshipEmail());
}

// 保存提醒记录到数据库
reminderRecordMapper.insert(record);
log.info("已创建提醒记录:亲友 {}, 接收者 {}", relationship.getName(), relationship.getMyEmail());

// 更新计划状态为已执行
plan.setExecutionStatus(1); // 设置执行状态为1(已执行)
mapper.update(plan); // 更新数据库中的计划状态
log.info("计划ID {} 执行完成", plan.getId()); // 记录计划执行完成信息

} catch (Exception e) {
// 如果执行过程中出现异常,记录错误日志
log.error("执行计划ID {} 失败: {}", plan.getId(), e.getMessage(), e);
}
}

log.info("提醒计划执行完毕,共处理 {} 条计划", plans.size()); // 记录执行完成信息
}

剩下几个比较基础的增删改查就不赘述了。

Task层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class BirthdayReminderJob {

@Autowired
private RelationPlanService planService;

@Scheduled(cron = "0 0 9,12,20 * * ?")//cron表达式,代表执行频率

@Transactional
public void reminder() {
// 扫描当天未执行的计划
planService.executePlan(LocalDate.now());

}
}

cron表达式使用如下:

1
2
3
4
5
6
7
8
┌───────────── 秒(0-59
│ ┌─────────── 分钟(0-59
│ │ ┌───────── 小时(0-23
│ │ │ ┌─────── 日(1-31
│ │ │ │ ┌───── 月(1-12 或 JAN-DEC)
│ │ │ │ │ ┌─── 周几(0-7 或 SUN-SAT,0和7均代表周日)
│ │ │ │ │ │
* * * * * ? *(可选年字段,1970-2099

VO层

和前端连起来需要格式化输出和一个响应体类,VO层就是解决这个问题。

PageVO和RelationshipVO几乎是Domain层的复刻,但是我想说的是ResponseEntity。

后端需要响应到前端,就需要一个响应体,体内应该有状态码等信息,还需要封装基本的方法。

使用泛型T是因为响应体通用,处理的对象类型不应受限制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class ResponseEntity<T> implements Serializable {

/**
* 是否成功
*/
private Boolean success;

/**
* 提示信息
*/
private String message;

/**
* 状态码
*/
private String code;

/**
* 具体的数据
*/
private T data;

public static <T> ResponseEntity<T> success(T data){ //下面几个都是状态函数,用于检查运行状态和标注状态码等信息
ResponseEntity<T> response=new ResponseEntity<T>();
response.success=true;
response.data=data;
response.code="200"; //200表示成功
return response;
}

public static <T> ResponseEntity<T> success(){
return success(null);
}

public static <T> ResponseEntity<T> fail(T data,String message){
ResponseEntity<T> response=new ResponseEntity<T>();
response.success=false;
response.data=data;
response.code="500"; //500表示错误
response.message=message;
return response;
}
public static <T> ResponseEntity<T> fail(){
return fail(null);
}

public static <T> ResponseEntity<T> fail(String message){
return fail(null,message);
}
}

Controller层

Relationship控制器

该控制器和其他层耦合严重,请配合源码阅读。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// 标记为REST控制器,处理HTTP请求
@RestController
// 设置基础请求路径
@RequestMapping("/api/relationship")
public class RelationshipController {
// 注入关系计划服务
@Autowired
private RelationPlanService relationPlanService;

// 注入亲友服务
@Resource
private RelationshipService relationshipService;

// GET请求处理分页查询
@GetMapping("/page")
public ResponseEntity<PageVO<RelationshipPageVO>> page(RelationshipPageDTO dto){
// 验证并设置默认页码
if (dto.getPage() == null || dto.getPage() <= 0) {
dto.setPage(1);
}
// 验证并设置默认页面大小
if (dto.getSize() == null || dto.getSize() <= 0) {
dto.setSize(10);
}

// 调用服务层获取分页数据
PageInfo<RelationshipPageVO> pageInfo = relationshipService.queryRelationshipPage(dto);
// 构建分页响应对象
PageVO<RelationshipPageVO> pageVO = new PageVO<>(
pageInfo.getList(), // 数据列表
pageInfo.getPages(), // 总页数
pageInfo.getPageNum(), // 当前页码
pageInfo.getPageSize(), // 每页大小
pageInfo.getTotal() // 总记录数
);
// 返回成功响应
return ResponseEntity.success(pageVO);
}

// GET请求根据ID获取单个亲友信息
@GetMapping("/{id}")
public ResponseEntity<Relationship> get(@PathVariable Integer id) {
return ResponseEntity.success(relationshipService.getRelationship(id));
}

// POST请求创建新的亲友关系
@PostMapping
@Transactional // 启用事务管理
public ResponseEntity<Integer> create(@RequestBody Relationship relationship) {
try {
// 创建亲友关系
Relationship relation = relationshipService.createRelationship(relationship);
// 为新创建的亲友生成提醒计划
relationPlanService.plan(relation);
// 返回新创建的亲友ID
return ResponseEntity.success(relation.getId());
} catch (Exception e) {
e.printStackTrace();
// 返回失败响应
return ResponseEntity.fail("创建亲友关系失败: " + e.getMessage());
}
}

// PUT请求更新亲友信息
@PutMapping
@Transactional // 启用事务管理
public ResponseEntity<Boolean> update(@RequestBody Relationship relationship) {
// 更新亲友信息
Boolean b = relationshipService.updateRelationship(relationship);
// 重新生成提醒计划
relationPlanService.plan(relationship);
return ResponseEntity.success(b);
}

// DELETE请求删除亲友信息
@DeleteMapping("/{id}")
@Transactional // 启用事务管理
public ResponseEntity<Boolean> delete(@PathVariable Integer id) {
// 删除亲友信息
Boolean b = relationshipService.deleteRelationship(id);
// 删除关联的提醒计划
relationPlanService.deleteByRelationshipId(id);
return ResponseEntity.success(b);
}
}

RelationshipReminder控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@GetMapping
public ResponseEntity<PageVO<ReminderPlan>> getList(
@RequestParam(defaultValue = "0") Integer page,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) Integer relationshipId) {
PageInfo<ReminderPlan> pageInfo = relationPlanService.getPageList(page + 1, size, relationshipId);
PageVO<ReminderPlan> pageVO = new PageVO<>(
pageInfo.getList(),
pageInfo.getPages(),
pageInfo.getPageNum(),
pageInfo.getPageSize(),
pageInfo.getTotal()
);
return ResponseEntity.success(pageVO);
} //依旧PageHelper插件传参,如果没问题就返回一个响应体,响应体中带200状态码和内容

/**
* 根据ID获取提醒计划
*/
@GetMapping("/{id}")
public ResponseEntity<ReminderPlan> getById(@PathVariable Integer id) {
ReminderPlan plan = relationPlanService.getById(id);
return ResponseEntity.success(plan);
}

/**
* 创建提醒计划
*/
@PostMapping
public ResponseEntity<ReminderPlan> create(@RequestBody ReminderPlan reminderPlan) {
try {
relationPlanService.create(reminderPlan);
return ResponseEntity.success(reminderPlan);
} catch (Exception e) {
e.printStackTrace();
return ResponseEntity.fail("创建提醒计划失败: " + e.getMessage());
}
}

/**
* 更新提醒计划
*/
@PutMapping("/{id}")
public ResponseEntity<Void> update(@PathVariable Integer id, @RequestBody ReminderPlan reminderPlan) {
reminderPlan.setId(id);
relationPlanService.update(reminderPlan);
return ResponseEntity.success();
}

/**
* 删除提醒计划
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Integer id) {
relationPlanService.deleteById(id);
return ResponseEntity.success();
}
}
//这几个增删改查的方法在Service层中写有。

Yaml配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
server:
port: 8080 ##使用8080端口

spring:
application:
name: Birthday_reminder
datasource:
url: jdbc:mysql://127.0.0.1:3306/table_Birthday?useUnicode=true&characterEncoding=utf-8&useSSL=false&allowPublicKeyRetrieval=true ##数据库配置
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
mail:
host: smtp.qq.com
port: 587
username: example@qq.com
password: xxxxxxxxxxxxxx # 授权码,不是密码,怎么开启使用请自行查找
properties:
mail:
smtp:
auth: true
starttls:
enable: true
devtools:
restart:
enabled: true
additional-paths:
- src/main/java
- src/main/resources

mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.Birthday_reminder.domain

logging:
level:
com.Birthday_reminder: DEBUG
org.springframework.jdbc: DEBUG
org.springframework.boot.autoconfigure.jdbc: DEBUG

源码地址

Github仓库,如果有问题可以通过邮箱与我交流。