接上回--------------->《预约优化方案全链路优化实践》
已预约印章为例,讲解一下分时间段的预约代码具体实现
一、数据库设计
表格结构(Markdown 格式)
sys_holiday(节假日表)
| 字段名 | 字段中文名 | 类型 | 注释 | 约束 |
|---|---|---|---|---|
| id | 主键 ID | bigint | 唯一标识 | 自增、非空、主键 |
| date | 日期 | date | 预约相关日期(如 2024-10-01) | 非空、唯一索引(uk_date) |
| is_holiday | 是否节假日 | tinyint | 0 - 非节假日,1 - 节假日 | 非空,默认 0 |
| is_work | 是否调休上班 | tinyint | 0 - 不上班,1 - 调休上班 | 非空,默认 0 |
seal_time_slot(印章时段配置表)
| 字段名 | 字段中文名 | 类型 | 注释 | 约束 |
|---|---|---|---|---|
| id | 主键 ID | bigint | 唯一标识 | 自增、非空、主键 |
| time_slot_start | 时段开始时间 | time | 如 09:00、10:00 | 非空 |
| time_slot_end | 时段结束时间 | time | 如 09:59、10:59 | 非空 |
| max_num | 最大可预约数 | int | 每个时段固定最多 5 人预约 | 非空,默认 5 |
seal_stock(印章库存表)
| 字段名 | 字段中文名 | 类型 | 注释 | 约束 |
|---|---|---|---|---|
| id | 主键 ID | bigint | 唯一标识 | 自增、非空、主键 |
| date | 预约日期 | date | 关联的预约日期 | 非空 |
| time_slot_id | 时段 ID | bigint | 关联 seal_time_slot.id | 非空,外键 |
| remaining_num | 剩余可预约数 | int | 初始为 5,预约成功则减 1 | 非空,默认 5 |
| version | 乐观锁版本号 | int | 防并发更新冲突 | 非空,默认 0 |
索引:联合索引 idx_date_slot(date, time_slot_id)
seal_appointment(印章预约订单表)
| 字段名 | 字段中文名 | 类型 | 注释 | 约束 |
|---|---|---|---|---|
| id | 主键 ID | bigint | 唯一标识 | 自增、非空、主键 |
| user_id | 用户 ID | bigint | 预约用户的唯一标识 | 非空 |
| seal_id | 印章 ID | bigint | 被预约印章的唯一标识 | 非空 |
| appointment_date | 预约日期 | date | 用户选择的预约日期 | 非空 |
| time_slot_id | 预约时段 ID | bigint | 关联 seal_time_slot.id | 非空,外键 |
| status | 订单状态 | tinyint | 0 - 待确认,1 - 已确认,2 - 已取消 | 非空,默认 0 |
| create_time | 创建时间 | datetime | 订单生成时间 | 非空,默认当前时间 |
索引:唯一索引 uk_user_seal_date(user_id, seal_id, appointment_date)
二、前置准备:配置类 & 工具类
1. 核心依赖(pom.xml 关键部分)
<!-- SpringBoot核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 数据库 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.32</version>
</dependency>
<!-- MyBatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.0</version>
</dependency>
<!-- Lombok(简化实体类) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- RabbitMQ -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
2. 配置类
(1)Redis 配置(RedisConfig.java)
package com.seal.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis配置类:解决Redis序列化问题,方便存储对象
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 字符串序列化器(Key用String)
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
// JSON序列化器(Value用JSON)
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}
(2)RabbitMQ 配置(RabbitMQConfig.java)
package com.seal.config;
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* RabbitMQ配置类:声明队列(库存更新+预约通知)
*/
@Configuration
public class RabbitMQConfig {
// 库存更新队列:处理Redis库存同步到MySQL的异步任务
public static final String QUEUE_STOCK_UPDATE = "seal.stock.update";
// 预约通知队列:处理预约成功后的通知+订单状态更新
public static final String QUEUE_APPOINT_NOTICE = "seal.notice";
// 声明库存更新队列(durable=true:队列持久化,重启MQ不丢失)
@Bean
public Queue stockUpdateQueue() {
return new Queue(QUEUE_STOCK_UPDATE, true);
}
// 声明预约通知队列
@Bean
public Queue appointNoticeQueue() {
return new Queue(QUEUE_APPOINT_NOTICE, true);
}
}
(3)MyBatis 配置(MyBatisConfig.java)
package com.seal.config;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis配置类:扫描Mapper接口所在包
*/
@Configuration
@MapperScan("com.seal.mapper") // Mapper接口统一放在com.seal.mapper包下
public class MyBatisConfig {
}
3. 工具类
(1)日期工具类(DateUtils.java)
package com.seal.utils;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
/**
* 日期工具类:处理日期格式化、获取未来日期等
*/
public class DateUtils {
// 标准日期格式(yyyy-MM-dd)
public static final SimpleDateFormat SDF_DATE = new SimpleDateFormat("yyyy-MM-dd");
/**
* 日期转字符串(yyyy-MM-dd)
*/
public static String formatDate(Date date) {
if (date == null) return null;
return SDF_DATE.format(date);
}
/**
* 字符串转日期(yyyy-MM-dd)
*/
public static Date parseDate(String dateStr) throws ParseException {
if (dateStr == null || dateStr.isEmpty()) return null;
return SDF_DATE.parse(dateStr);
}
/**
* 获取未来N天的日期列表(从明天开始)
*/
public static List<Date> getNextNDays(int n) {
List<Date> dateList = new ArrayList<>();
Calendar calendar = Calendar.getInstance();
for (int i = 1; i <= n; i++) {
calendar.add(Calendar.DAY_OF_YEAR, 1); // 每天加1天
dateList.add(calendar.getTime());
}
return dateList;
}
}
(2)接口统一返回结果(Result.java)
package com.seal.utils;
import lombok.Data;
/**
* 统一接口返回结果:前后端交互用,避免返回格式混乱
*/
@Data
public class Result<T> {
// 状态码:200-成功,500-失败
private Integer code;
// 提示信息
private String msg;
// 返回数据(成功时携带)
private T data;
// 成功(无数据)
public static <T> Result<T> success() {
Result<T> result = new Result<>();
result.setCode(200);
result.setMsg("操作成功");
return result;
}
// 成功(有数据)
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMsg("操作成功");
result.setData(data);
return result;
}
// 失败(自定义消息)
public static <T> Result<T> fail(String msg) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMsg(msg);
return result;
}
}
三、实体类(Entity,用 Lombok 简化)
1. 节假日实体(SysHoliday.java)
package com.seal.entity;
import lombok.Data;
import java.util.Date;
/**
* 节假日实体:对应sys_holiday表
*/
@Data // Lombok注解:自动生成getter/setter/toString等
public class SysHoliday {
private Long id; // 主键ID
private Date date; // 日期
private Integer isHoliday; // 是否节假日(0-否,1-是)
private Integer isWork; // 是否调休上班(0-否,1-是)
}
2. 时段配置实体(SealTimeSlot.java)
package com.seal.entity;
import lombok.Data;
import java.time.LocalTime;
/**
* 时段配置实体:对应seal_time_slot表(每个时段最多5人)
*/
@Data
public class SealTimeSlot {
private Long id; // 主键ID
private LocalTime timeSlotStart; // 时段开始时间(如09:00)
private LocalTime timeSlotEnd; // 时段结束时间(如09:59)
private Integer maxNum; // 最大可预约数(固定5)
}
3. 库存实体(SealStock.java)
package com.seal.entity;
import lombok.Data;
import java.util.Date;
/**
* 库存实体:对应seal_stock表(乐观锁控制并发)
*/
@Data
public class SealStock {
private Long id; // 主键ID
private Date date; // 预约日期
private Long timeSlotId; // 关联时段ID
private Integer remainingNum;// 剩余可预约数(初始5)
private Integer version; // 乐观锁版本号
}
4. 预约订单实体(SealAppointment.java)
package com.seal.entity;
import lombok.Data;
import java.util.Date;
/**
* 预约订单实体:对应seal_appointment表
*/
@Data
public class SealAppointment {
private Long id; // 主键ID
private Long userId; // 用户ID
private Long sealId; // 印章ID
private Date appointmentDate; // 预约日期
private Long timeSlotId; // 预约时段ID
private Integer status; // 订单状态(0-待确认,1-已确认,2-已取消)
private Date createTime; // 创建时间
}
四、Mapper 层(数据访问层,MyBatis)
1. 节假日 Mapper(SysHolidayMapper.java)
package com.seal.mapper;
import com.seal.entity.SysHoliday;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.Date;
import java.util.List;
/**
* 节假日Mapper:操作sys_holiday表
*/
@Mapper // MyBatis注解:标识为Mapper接口,自动扫描
public interface SysHolidayMapper {
/**
* 批量插入/更新节假日(同步阿里云API时用)
* 存在则更新(基于date唯一索引),不存在则插入
*/
void batchUpsert(@Param("list") List<SysHoliday> holidayList);
/**
* 查询所有节假日(筛选可预约日期时用)
*/
List<SysHoliday> selectAll();
/**
* 根据日期查询单个节假日(缓存未命中时用)
*/
SysHoliday selectByDate(@Param("date") Date date);
}
对应的 XML(SysHolidayMapper.xml)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.seal.mapper.SysHolidayMapper">
<!-- 批量插入/更新节假日 -->
<insert id="batchUpsert">
INSERT INTO sys_holiday (date, is_holiday, is_work)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.date}, #{item.isHoliday}, #{item.isWork})
</foreach>
ON DUPLICATE KEY UPDATE
is_holiday = VALUES(is_holiday), -- 冲突时更新is_holiday
is_work = VALUES(is_work) -- 冲突时更新is_work
</insert>
<!-- 查询所有节假日 -->
<select id="selectAll" resultType="com.seal.entity.SysHoliday">
SELECT id, date, is_holiday, is_work
FROM sys_holiday
ORDER BY date ASC
</select>
<!-- 根据日期查询单个节假日 -->
<select id="selectByDate" parameterType="java.util.Date" resultType="com.seal.entity.SysHoliday">
SELECT id, date, is_holiday, is_work
FROM sys_holiday
WHERE date = #{date}
</select>
</mapper>
2. 时段配置 Mapper(SealTimeSlotMapper.java)
package com.seal.mapper;
import com.seal.entity.SealTimeSlot;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* 时段配置Mapper:操作seal_time_slot表
*/
@Mapper
public interface SealTimeSlotMapper {
/**
* 查询所有可预约时段(初始化库存时用)
*/
List<SealTimeSlot> selectAll();
/**
* 根据ID查询时段(获取最大预约数时用)
*/
SealTimeSlot selectById(Long id);
}
对应的 XML(SealTimeSlotMapper.xml)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.seal.mapper.SealTimeSlotMapper">
<!-- 查询所有时段 -->
<select id="selectAll" resultType="com.seal.entity.SealTimeSlot">
SELECT id, time_slot_start, time_slot_end, max_num
FROM seal_time_slot
ORDER BY time_slot_start ASC
</select>
<!-- 根据ID查询时段 -->
<select id="selectById" parameterType="java.lang.Long" resultType="com.seal.entity.SealTimeSlot">
SELECT id, time_slot_start, time_slot_end, max_num
FROM seal_time_slot
WHERE id = #{id}
</select>
</mapper>
3. 库存 Mapper(SealStockMapper.java)
package com.seal.mapper;
import com.seal.entity.SealStock;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.Date;
import java.util.List;
/**
* 库存Mapper:操作seal_stock表(乐观锁更新)
*/
@Mapper
public interface SealStockMapper {
/**
* 批量插入库存(每日20:00初始化时用)
*/
void batchInsert(@Param("list") List<SealStock> stockList);
/**
* 根据日期+时段ID查询库存(乐观锁更新前查版本号)
*/
SealStock selectByDateAndSlot(@Param("date") Date date, @Param("timeSlotId") Long timeSlotId);
/**
* 乐观锁扣减库存:仅版本号匹配时更新(防并发冲突)
* @return 受影响行数:1-成功,0-失败(版本号不匹配)
*/
int decreaseWithVersion(
@Param("date") Date date,
@Param("timeSlotId") Long timeSlotId,
@Param("version") Integer version
);
}
对应的 XML(SealStockMapper.xml)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.seal.mapper.SealStockMapper">
<!-- 批量插入库存 -->
<insert id="batchInsert">
INSERT INTO seal_stock (date, time_slot_id, remaining_num, version)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.date}, #{item.timeSlotId}, #{item.remainingNum}, #{item.version})
</foreach>
</insert>
<!-- 根据日期+时段ID查询库存 -->
<select id="selectByDateAndSlot" resultType="com.seal.entity.SealStock">
SELECT id, date, time_slot_id, remaining_num, version
FROM seal_stock
WHERE date = #{date}
AND time_slot_id = #{timeSlotId}
</select>
<!-- 乐观锁扣减库存:version匹配才更新 -->
<update id="decreaseWithVersion">
UPDATE seal_stock
SET
remaining_num = remaining_num - 1, -- 剩余数减1
version = version + 1 -- 版本号加1
WHERE
date = #{date}
AND time_slot_id = #{timeSlotId}
AND version = #{version} -- 关键:仅版本号匹配时更新
</update>
</mapper>
4. 预约订单 Mapper(SealAppointmentMapper.java)
package com.seal.mapper;
import com.seal.entity.SealAppointment;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 预约订单Mapper:操作seal_appointment表
*/
@Mapper
public interface SealAppointmentMapper {
/**
* 插入预约订单(用户提交预约时用)
*/
void insert(SealAppointment appointment);
/**
* 根据ID查询订单(通知时更新状态用)
*/
SealAppointment selectById(@Param("id") Long id);
/**
* 更新订单状态(如待确认→已确认)
*/
void updateStatus(@Param("id") Long id, @Param("status") Integer status);
}
对应的 XML(SealAppointmentMapper.xml)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.seal.mapper.SealAppointmentMapper">
<!-- 插入预约订单 -->
<insert id="insert" parameterType="com.seal.entity.SealAppointment">
INSERT INTO seal_appointment (
user_id, seal_id, appointment_date, time_slot_id, status, create_time
) VALUES (
#{userId}, #{sealId}, #{appointmentDate}, #{timeSlotId}, #{status}, #{createTime}
)
</insert>
<!-- 根据ID查询订单 -->
<select id="selectById" parameterType="java.lang.Long" resultType="com.seal.entity.SealAppointment">
SELECT
id, user_id, seal_id, appointment_date, time_slot_id, status
FROM
seal_appointment
WHERE
id = #{id}
</select>
<!-- 更新订单状态 -->
<update id="updateStatus">
UPDATE seal_appointment
SET status = #{status}
WHERE id = #{id}
</update>
</mapper>
五、Service 层(业务逻辑层,调用 Mapper)
1. 定时任务服务(SealScheduleService.java)
package com.seal.service;
import com.seal.entity.SealStock;
import com.seal.entity.SealTimeSlot;
import com.seal.mapper.SealStockMapper;
import com.seal.mapper.SealTimeSlotMapper;
import com.seal.utils.DateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* 定时任务服务:每日20:00同步节假日+初始化未来7天库存
*/
@Service // Spring注解:标识为业务层服务
public class SealScheduleService {
@Autowired
private SysHolidayService holidayService; // 注入节假日服务
@Autowired
private SealTimeSlotMapper timeSlotMapper; // 注入时段Mapper
@Autowired
private SealStockMapper stockMapper; // 注入库存Mapper
@Autowired
private RedisTemplate<String, Object> redisTemplate; // 注入Redis工具
// Redis库存缓存Key:seal:stock:日期:时段ID(如seal:stock:2024-10-01:1)
private static final String STOCK_CACHE_KEY = "seal:stock:%s:%s";
/**
* 每日20:00执行定时任务
* cron表达式:分 时 日 月 周 → 0 0 20 * * ? 表示每天20点整
*/
@Scheduled(cron = "0 0 20 * * ?")
public void dailyTask() {
// 1. 同步未来3个月节假日(调用阿里云API,由SysHolidayService实现)
holidayService.syncHolidayFromAliCloud();
// 2. 初始化未来7天库存(分批异步处理,避免阻塞主线程)
List<Date> next7Days = DateUtils.getNextNDays(7); // 获取未来7天日期
for (Date date : next7Days) {
// 异步处理单天库存初始化(CompletableFuture:不阻塞当前线程)
CompletableFuture.runAsync(() -> initStockForOneDay(date));
}
}
/**
* 初始化单天库存:为当天所有时段设置初始库存(每个时段5人)
*/
@Transactional // 事务注解:保证单天库存要么全成功,要么全回滚
public void initStockForOneDay(Date date) {
// ① 查询所有可预约时段(从数据库查,时段配置不常变)
List<SealTimeSlot> timeSlots = timeSlotMapper.selectAll();
if (timeSlots.isEmpty()) {
System.err.println("时段配置为空,跳过日期:" + DateUtils.formatDate(date) + "的库存初始化");
return;
}
// ② 构造单天库存列表(每个时段初始库存=max_num=5)
List<SealStock> stockList = new ArrayList<>();
for (SealTimeSlot slot : timeSlots) {
SealStock stock = new SealStock();
stock.setDate(date);
stock.setTimeSlotId(slot.getId());
stock.setRemainingNum(slot.getMaxNum()); // 初始库存=5
stock.setVersion(0); // 初始版本号=0
stockList.add(stock);
}
// ③ 批量插入库存到数据库
stockMapper.batchInsert(stockList);
System.out.println("库存初始化成功:日期=" + DateUtils.formatDate(date) + ",共" + stockList.size() + "个时段");
// ④ 同步库存到Redis(供前端快速查询,减轻数据库压力)
for (SealStock stock : stockList) {
String dateStr = DateUtils.formatDate(stock.getDate());
String cacheKey = String.format(STOCK_CACHE_KEY, dateStr, stock.getTimeSlotId());
redisTemplate.opsForValue().set(cacheKey, stock.getRemainingNum());
}
}
}
2. 节假日服务(SysHolidayService.java)
package com.seal.service;
import com.seal.entity.SysHoliday;
import com.seal.mapper.SysHolidayMapper;
import com.seal.utils.DateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
/**
* 节假日服务:同步阿里云API+筛选可预约日期
*/
@Service
public class SysHolidayService {
@Autowired
private SysHolidayMapper holidayMapper; // 注入节假日Mapper
@Autowired
private RedisTemplate<String, Object> redisTemplate; // 注入Redis工具
// Redis节假日缓存Key:seal:holiday:日期(如seal:holiday:2024-10-01)
private static final String HOLIDAY_CACHE_KEY = "seal:holiday:%s";
/**
* 同步阿里云万年历API数据到数据库和Redis
* 实际开发中替换为真实API调用(这里用模拟数据演示)
*/
@Transactional
public void syncHolidayFromAliCloud() {
// ① 调用阿里云API获取未来3个月节假日数据(模拟)
List<SysHoliday> holidayList = mockAliCloudHolidayData();
if (holidayList.isEmpty()) {
System.err.println("阿里云API返回空数据,同步失败");
return;
}
// ② 批量插入/更新到数据库(基于date唯一索引,避免重复)
holidayMapper.batchUpsert(holidayList);
System.out.println("节假日同步成功,共" + holidayList.size() + "条数据");
// ③ 同步数据到Redis(方便后续查询,减少数据库访问)
for (SysHoliday holiday : holidayList) {
String dateStr = DateUtils.formatDate(holiday.getDate());
String cacheKey = String.format(HOLIDAY_CACHE_KEY, dateStr);
redisTemplate.opsForValue().set(cacheKey, holiday);
}
}
/**
* 筛选可预约日期:非节假日 或 调休上班的日期
* @return 可预约日期列表
*/
public List<String> listAvailableDates() {
// ① 查询所有节假日数据(数据量小,全查效率高)
List<SysHoliday> allHolidays = holidayMapper.selectAll();
// ② 筛选规则:可预约 = 非节假日(is_holiday=0) OR 调休上班(is_work=1)
return allHolidays.stream()
.filter(holiday -> holiday.getIsHoliday() == 0 || holiday.getIsWork() == 1)
.map(holiday -> DateUtils.formatDate(holiday.getDate())) // 转成字符串格式返回
.collect(Collectors.toList());
}
/**
* 模拟阿里云API返回数据(实际开发替换为真实HTTP调用)
*/
private List<SysHoliday> mockAliCloudHolidayData() {
List<SysHoliday> list = new ArrayList<>();
try {
// 示例1:2024-10-01(国庆节,节假日,不上班)
SysHoliday h1 = new SysHoliday();
h1.setDate(DateUtils.parseDate("2024-10-01"));
h1.setIsHoliday(1);
h1.setIsWork(0);
list.add(h1);
// 示例2:2024-10-06(调休上班,非节假日)
SysHoliday h2 = new SysHoliday();
h2.setDate(DateUtils.parseDate("2024-10-06"));
h2.setIsHoliday(0);
h2.setIsWork(1);
list.add(h2);
// 示例3:2024-10-08(正常工作日)
SysHoliday h3 = new SysHoliday();
h3.setDate(DateUtils.parseDate("2024-10-08"));
h3.setIsHoliday(0);
h3.setIsWork(0);
list.add(h3);
} catch (Exception e) {
e.printStackTrace();
}
return list;
}
}
3. 库存服务(SealStockService.java)
package com.seal.service;
import com.seal.config.RabbitMQConfig;
import com.seal.entity.SealStock;
import com.seal.mapper.SealStockMapper;
import com.seal.utils.DateUtils;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Date;
/**
* 库存服务:查询库存+扣减库存(Redis原子操作+MQ异步落库)
*/
@Service
public class SealStockService {
@Autowired
private RedisTemplate<String, Object> redisTemplate; // 注入Redis工具
@Autowired
private RabbitTemplate rabbitTemplate; // 注入RabbitMQ工具
@Autowired
private SealStockMapper stockMapper; // 注入库存Mapper
// Redis库存缓存Key
private static final String STOCK_CACHE_KEY = "seal:stock:%s:%s";
/**
* 查询某日期+时段的剩余库存(优先查Redis,缓存未命中查数据库)
* @param date 预约日期
* @param timeSlotId 时段ID
* @return 剩余可预约数(0表示约满)
*/
public Integer getRemainingStock(Date date, Long timeSlotId) {
String dateStr = DateUtils.formatDate(date);
String cacheKey = String.format(STOCK_CACHE_KEY, dateStr, timeSlotId);
// ① 先查Redis缓存(快速,毫秒级响应)
Object cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (cacheValue != null) {
return (Integer) cacheValue;
}
// ② 缓存未命中,查数据库(并同步到Redis,下次直接查缓存)
SealStock stock = stockMapper.selectByDateAndSlot(date, timeSlotId);
int remaining = stock != null ? stock.getRemainingNum() : 0;
redisTemplate.opsForValue().set(cacheKey, remaining); // 同步缓存
return remaining;
}
/**
* 扣减库存:Redis原子操作保证高并发安全,MQ异步更新数据库
* @return true-扣减成功,false-库存不足
*/
public boolean decreaseStock(Date date, Long timeSlotId) {
String dateStr = DateUtils.formatDate(date);
String cacheKey = String.format(STOCK_CACHE_KEY, dateStr, timeSlotId);
// ① Redis原子扣减(DECR命令:线程安全,避免超卖)
Long remainAfterDecrease = redisTemplate.opsForValue().decrement(cacheKey);
if (remainAfterDecrease == null || remainAfterDecrease < 0) {
// 库存不足:回滚Redis(把刚才减的1加回来)
redisTemplate.opsForValue().increment(cacheKey);
return false;
}
// ② 发送MQ消息,异步更新数据库(不阻塞当前接口)
StockUpdateMsg msg = new StockUpdateMsg(date, timeSlotId);
rabbitTemplate.convertAndSend(RabbitMQConfig.QUEUE_STOCK_UPDATE, msg);
return true;
}
/**
* 库存更新消息体:用于MQ传递日期和时段ID(内部静态类,避免单独建类)
*/
public static class StockUpdateMsg {
private Date date; // 预约日期
private Long timeSlotId; // 时段ID
public StockUpdateMsg(Date date, Long timeSlotId) {
this.date = date;
this.timeSlotId = timeSlotId;
}
// Getter(MQ序列化需要)
public Date getDate() { return date; }
public Long getTimeSlotId() { return timeSlotId; }
}
}
4. 预约订单服务(SealAppointmentService.java)
package com.seal.service;
import com.seal.config.RabbitMQConfig;
import com.seal.entity.SealAppointment;
import com.seal.mapper.SealAppointmentMapper;
import com.seal.utils.DateUtils;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
/**
* 预约订单服务:处理用户预约提交(controller→service→mapper)
*/
@Service
public class SealAppointmentService {
@Autowired
private SealStockService stockService; // 注入库存服务
@Autowired
private SealAppointmentMapper appointmentMapper; // 注入订单Mapper
@Autowired
private RabbitTemplate rabbitTemplate; // 注入RabbitMQ工具
/**
* 提交印章预约
* @param userId 用户ID(登录后获取)
* @param sealId 印章ID(用户选择的印章)
* @param dateStr 预约日期(yyyy-MM-dd格式)
* @param timeSlotId 时段ID(用户选择的时段)
* @return 预约订单ID(成功时返回)
* @throws Exception 日期格式错误/库存不足等异常
*/
@Transactional // 事务注解:保证订单生成和库存扣减的一致性
public Long submitAppointment(
Long userId,
Long sealId,
String dateStr,
Long timeSlotId
) throws Exception {
// 1. 日期格式转换(前端传字符串,转成Date对象)
Date appointmentDate = DateUtils.parseDate(dateStr);
if (appointmentDate == null) {
throw new Exception("预约日期格式错误,需为yyyy-MM-dd");
}
// 2. 检查库存(调用库存服务,优先查Redis)
Integer remainingStock = stockService.getRemainingStock(appointmentDate, timeSlotId);
if (remainingStock <= 0) {
throw new Exception("该时段库存不足(最多5人),请选择其他时段");
}
// 3. 扣减库存(Redis原子操作+MQ异步落库)
boolean decreaseSuccess = stockService.decreaseStock(appointmentDate, timeSlotId);
if (!decreaseSuccess) {
throw new Exception("库存扣减失败,请重试");
}
// 4. 生成预约订单(同步写入数据库,保证订单实时性)
SealAppointment appointment = new SealAppointment();
appointment.setUserId(userId);
appointment.setSealId(sealId);
appointment.setAppointmentDate(appointmentDate);
appointment.setTimeSlotId(timeSlotId);
appointment.setStatus(0); // 0-待确认(后续通知后更新为1)
appointment.setCreateTime(new Date()); // 订单生成时间=当前时间
// 调用Mapper插入订单
appointmentMapper.insert(appointment);
System.out.println("预约订单生成成功:ID=" + appointment.getId());
// 5. 发送MQ消息,异步处理通知(不阻塞当前接口)
rabbitTemplate.convertAndSend(RabbitMQConfig.QUEUE_APPOINT_NOTICE, appointment.getId());
return appointment.getId(); // 返回订单ID给前端
}
}
六、Controller 层(接口层,调用 Service)
package com.seal.controller;
import com.seal.service.SealAppointmentService;
import com.seal.service.SysHolidayService;
import com.seal.utils.Result;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 印章预约Controller:前端调用的接口入口(遵循REST风格)
*/
@RestController // 组合注解:@Controller + @ResponseBody(返回JSON)
@RequestMapping("/api/seal/appointment") // 接口统一前缀,避免URL冲突
public class SealAppointmentController {
@Autowired
private SysHolidayService holidayService; // 注入节假日服务
@Autowired
private SealAppointmentService appointmentService; // 注入预约服务
/**
* 接口1:查询可预约日期(前端日期选择器用)
* 请求方式:GET
* 示例URL:http://localhost:8080/api/seal/appointment/available-dates
*/
@GetMapping("/available-dates")
public Result<List<String>> getAvailableDates() {
try {
// 调用Service查询可预约日期
List<String> availableDates = holidayService.listAvailableDates();
return Result.success(availableDates); // 返回成功结果(带数据)
} catch (Exception e) {
return Result.fail("查询可预约日期失败:" + e.getMessage()); // 返回失败结果
}
}
/**
* 接口2:提交预约(用户确认预约时调用)
* 请求方式:POST
* 示例URL:http://localhost:8080/api/seal/appointment/submit
* 示例参数:userId=1&sealId=2&dateStr=2024-10-08&timeSlotId=1
*/
@PostMapping("/submit")
public Result<Long> submitAppointment(
@RequestParam Long userId, // 用户ID(前端传参)
@RequestParam Long sealId, // 印章ID(前端传参)
@RequestParam String dateStr, // 预约日期(yyyy-MM-dd,前端传参)
@RequestParam Long timeSlotId // 时段ID(前端传参)
) {
try {
// 调用Service提交预约,返回订单ID
Long orderId = appointmentService.submitAppointment(userId, sealId, dateStr, timeSlotId);
return Result.success(orderId); // 返回成功结果(带订单ID)
} catch (Exception e) {
return Result.fail("预约失败:" + e.getMessage()); // 返回失败结果
}
}
}
七、RabbitMQ 消费者(异步处理,解耦业务)
1. 库存更新消费者(StockUpdateConsumer.java)
package com.seal.consumer;
import com.seal.config.RabbitMQConfig;
import com.seal.entity.SealStock;
import com.seal.mapper.SealStockMapper;
import com.seal.service.SealStockService;
import com.seal.utils.DateUtils;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 库存更新消费者:监听MQ队列,异步更新MySQL库存(解耦业务)
*/
@Component // Spring注解:标识为组件,自动扫描
public class StockUpdateConsumer {
@Autowired
private SealStockMapper stockMapper; // 注入库存Mapper
/**
* 监听库存更新队列(QUEUE_STOCK_UPDATE)
* 收到消息后,用乐观锁更新数据库库存
*/
@RabbitListener(queues = RabbitMQConfig.QUEUE_STOCK_UPDATE)
public void handleStockUpdate(SealStockService.StockUpdateMsg msg) {
try {
// ① 根据日期+时段ID查询当前库存(获取版本号)
SealStock stock = stockMapper.selectByDateAndSlot(msg.getDate(), msg.getTimeSlotId());
if (stock == null) {
System.err.println("库存不存在:日期=" + DateUtils.formatDate(msg.getDate()) + ", 时段ID=" + msg.getTimeSlotId());
return;
}
// ② 乐观锁更新库存(仅版本号匹配时成功,防并发冲突)
int affectedRows = stockMapper.decreaseWithVersion(
msg.getDate(),
msg.getTimeSlotId(),
stock.getVersion()
);
if (affectedRows == 1) {
System.out.println("数据库库存更新成功:日期=" + DateUtils.formatDate(msg.getDate()) + ", 时段ID=" + msg.getTimeSlotId());
} else {
// 更新失败(版本号不匹配,说明被其他线程修改)
System.err.println("数据库库存更新失败(乐观锁冲突):日期=" + DateUtils.formatDate(msg.getDate()) + ", 时段ID=" + msg.getTimeSlotId());
}
} catch (Exception e) {
e.printStackTrace();
System.err.println("库存更新异常:" + e.getMessage());
}
}
}
2. 预约通知消费者(NoticeConsumer.java)
package com.seal.consumer;
import com.seal.config.RabbitMQConfig;
import com.seal.entity.SealAppointment;
import com.seal.mapper.SealAppointmentMapper;
import com.seal.utils.DateUtils;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 预约通知消费者:监听MQ队列,异步发送通知+更新订单状态
*/
@Component
public class NoticeConsumer {
@Autowired
private SealAppointmentMapper appointmentMapper; // 注入订单Mapper
/**
* 监听预约通知队列(QUEUE_APPOINT_NOTICE)
* 收到消息后,发送通知+更新订单状态为“已确认”
*/
@RabbitListener(queues = RabbitMQConfig.QUEUE_APPOINT_NOTICE)
public void handleAppointNotice(Long appointmentId) {
try {
// ① 根据订单ID查询订单信息
SealAppointment appointment = appointmentMapper.selectById(appointmentId);
if (appointment == null) {
System.err.println("预约订单不存在:ID=" + appointmentId);
return;
}
// ② 模拟发送通知(实际项目替换为短信/站内信/邮件)
String noticeMsg = String.format(
"用户%d,您已成功预约印章%d,日期:%s,时段ID:%d,请按时办理",
appointment.getUserId(),
appointment.getSealId(),
DateUtils.formatDate(appointment.getAppointmentDate()),
appointment.getTimeSlotId()
);
System.out.println("发送预约通知:" + noticeMsg);
// ③ 更新订单状态为“已确认”(0→1)
appointmentMapper.updateStatus(appointmentId, 1);
System.out.println("订单状态更新成功:ID=" + appointmentId + ",状态:0→1");
} catch (Exception e) {
e.printStackTrace();
System.err.println("预约通知异常:" + e.getMessage());
}
}
}
八、配置文件(application.yml)
spring:
# 数据库配置
datasource:
url: jdbc:mysql://localhost:3306/seal_db?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
username: root # 你的MySQL用户名
password: 123456 # 你的MySQL密码
driver-class-name: com.mysql.cj.jdbc.Driver
# Redis配置
redis:
host: localhost # Redis地址(本地)
port: 6379 # Redis端口(默认)
password: # Redis密码(无则留空)
database: 0 # Redis数据库编号(默认0)
# RabbitMQ配置
rabbitmq:
host: localhost # RabbitMQ地址(本地)
port: 5672 # RabbitMQ端口(默认)
username: guest # RabbitMQ默认用户名
password: guest # RabbitMQ默认密码
listener:
simple:
acknowledge-mode: manual # 手动确认消息(避免消息丢失)
# MyBatis配置
mybatis:
mapper-locations: classpath:mapper/*.xml # Mapper XML文件路径
type-aliases-package: com.seal.entity # 实体类包(简化XML中的resultType)
configuration:
map-underscore-to-camel-case: true # 下划线转驼峰(如time_slot_start→timeSlotStart)
# 阿里云api
holiday:
ali-api:
url: "https://alidayu.market.aliyuncs.com/api/dayu/getholiday" # 阿里云API真实地址(需替换为你申请的接口地址)
app-code: "你的阿里云APPCODE" # 从阿里云控制台获取(API开通后在「我的API」中查看)
date-range: 90 # 同步未来90天(3个月)的节假日数据
# 服务端口
server:
port: 8080
核心流程总结
- 定时任务初始化:每日 20:00 同步节假日 + 初始化未来 7 天库存(每个时段 5 人),同步到 Redis;
- 前端查询可预约日期:调用
/api/seal/appointment/available-dates,后端返回非节假日 / 调休上班日期; - 用户提交预约:调用
/api/seal/appointment/submit,后端流程:
Controller 接收参数→Service 检查库存→Redis 原子扣减库存→生成订单(Mapper 插入)→发送 MQ 通知; - 异步处理:
库存消费者:监听 MQ,用乐观锁更新 MySQL 库存;
通知消费者:监听 MQ,发送通知 + 更新订单状态为 “已确认”。
浙公网安备 33010602011771号