励志从零打造LeetCode平台之C端竞赛列表

目录
竞赛列表
业务分析
C端与B端竞赛列表的差异主要体现在以下三个方面:
显示方式存在差异(可通过前端实现调整)
B端竞赛列表:
C端竞赛列表:
2.每个竞赛展示的数据不同
“C端只用显示竞赛标题、开始时间、结束时间”
而后端除此之外需要展示创建人,竞赛状态......
(可以通过修改sql语句进行修改)
3.C端支持游客使用,B端必须先完成登录
(使用网关白名单跳过即可)
通过业务分析可知,我们只需基于B端代码进行修改即可。
代码结构变阵

Controller
@RestController @RequestMapping("/exam") public class ExamController extends BaseController { @Autowired private IExamService examService; @GetMapping("/semiLogin/list") public List<ExamVO> list(ExamQueryDTO examQueryDTO) { return examService.list(examQueryDTO); } }Service
@Service @Slf4j public class ExamServiceImpl implements IExamService { @Autowired private ExamMapper examMapper; @Override public List<ExamVO> list(ExamQueryDTO examQueryDTO) { PageHelper.startPage(examQueryDTO.getPageNum(), examQueryDTO.getPageSize()); return examMapper.selectExamList(examQueryDTO); } } Mapper
public interface ExamMapper extends BaseMapper<Exam> { List<ExamVO> selectExamList(ExamQueryDTO examQueryDTO); }DTO
这里相比之前B端竞赛列表DTO多了一个类型type,因为我们查找的时候可能选择未完赛和历史竞赛
@Getter @Setter public class ExamQueryDTO extends pageDomain { private String title; private String startTime; private String endTime; private Integer type; //0 未完善 1 历史竞赛 }VO
由于我们前端只显示标题,开始时间,结束时间,所以我们这里减少非必要参数
@Getter @Setter public class ExamVO { @JsonSerialize(using = ToStringSerializer.class) private Long examId; private String title; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime startTime; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime endTime; } ExamMapper.xml
修改sql语句,以满足前端返回值的需求以及搜索要求的改变
<?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.bite.friend.mapper.exam.ExamMapper"> <select resultType="com.bite.friend.model.exam.vo.ExamVO"> SELECT te.exam_id, te.title, te.start_time, te.end_time FROM tb_exam te where status = 1 <if test="title !=null and title !='' "> AND te.title LIKE CONCAT('%',#{title},'%') </if> <if test="startTime != null and startTime != '' "> AND te.start_time >= #{startTime} </if> <if test="endTime != null and endTime != ''"> AND te.end_time <= #{endTime} </if> <if test="type == 0"> And te.end_time > NOW() </if> <if test="type == 1"> And te.end_time <= NOW() </if> ORDER BY te.create_time DESC </select> </mapper>postman测试
数据库里面搜索历史竞赛
select * from tb_exam where status = 1

Redis优化竞赛
业务分析
因为我们致力于打造一个leetCode级别的竞赛系统,那我们就得注重我们竞赛平台的效率。但是现在每一个用户查询竞赛列表的时候,我们都得从数据库里面进行查找,这样一方面速度上可能受到影响,另一方法数据库服务器也可能会因为各种原因导致崩溃,所以我们就引入Redis作为缓存去优化我们得查找功能
什么时候将 C 端竞赛数据存储到 redis 当中?
在发布竞赛的时候应该将 C 端的竞赛数据存储到 redis 里。取消发布时候还需要将 C 端的竞赛数据从 redis 当中移除。
选择怎样的数据结构来存储 C 端的竞赛数据?
选择:list 选择两个 list 结构,分别存储未完赛的竞赛列表 和 历史竞赛列表。
key: e:t:l e:h:l
value: examid
此外,我们还需要再将竞赛信息单独储存一份
key : examid
value : json{竞赛信息}
代码优化
为了简化我们后续操作,我们将Redis相关的操作封装为一个类
@Component public class ExamCacheManager { @Autowired private RedisService redisService; public void addCache(Exam exam) { redisService.leftPushForList(getExamListKey(), exam.getExamId()); redisService.setCacheObject(getDetailKey(exam.getExamId()), exam); } public void deleteCache(Long examId) { redisService.removeForList(getExamListKey(), examId); redisService.deleteObject(getDetailKey(examId)); } private String getExamListKey() { //得到竞赛列表key return CacheConstants.EXAM_UNFINISHED_LIST; } private String getDetailKey(Long examId) { //得到详细信息key return CacheConstants.EXAM_DETAIL + examId; } }add:将未完赛/历史竞赛对应的examid以及竞赛信息存储到redis
delete: 将未完赛/历史竞赛对应的examid以及竞赛相关信息从redis中删除
第一步:在我们发布竞赛得时候,将竞赛储存到redis中
@Override public int publish(Long examId) { Exam exam = getExam(examId); if (exam.getEndTime().isBefore(LocalDateTime.now())) { throw new ServiceException(ResultCode.EXAM_IS_FINISH); } Long count = examQuestionMapper .selectCount(new LambdaQueryWrapper<ExamQuestion>() .eq(ExamQuestion::getExamId, examId)); if (count == null || count <= 0) { throw new ServiceException(ResultCode.EXAM_NOT_HAS_QUESTION); } exam.setStatus(Contants.TRUE); examCacheManager.addCache(exam); return examMapper.updateById(exam); }第二步:在撤销竞赛的时候,将竞赛从redis中删除
@Override public int cancelPublish(Long examId) { Exam exam = getExam(examId); checkExam(exam); if (exam.getEndTime().isBefore(LocalDateTime.now())) { throw new ServiceException(ResultCode.EXAM_IS_FINISH); } exam.setStatus(Contants.FALSE); examCacheManager.deleteCache(examId); return examMapper.updateById(exam); }第三步:使用接口调用redis查询竞赛列表
首先再引入一个管理类:将竞赛相关操作封装为一个类,接下来我们来详细剖析一下代码作用
@Component public class ExamCacheManager { @Autowired private ExamMapper examMapper; @Autowired private RedisService redisService; public Long getListSize(Integer examListType) { String examListKey = getExamListKey(examListType); return redisService.getListSize(examListKey); } public List<ExamVO> getExamVOList(ExamQueryDTO examQueryDTO) { int start = (examQueryDTO.getPageNum() - 1) * examQueryDTO.getPageSize(); int end = start + examQueryDTO.getPageSize() - 1; //下标需要 -1 String examListKey = getExamListKey(examQueryDTO.getType()); List<Long> examIdList = redisService.getCacheListByRange(examListKey, start, end, Long.class); List<ExamVO> examVOList = assembleExamVOList(examIdList); if (CollectionUtil.isEmpty(examVOList)) { //说明redis中数据可能有问题 从数据库中查数据并且重新刷新缓存 examVOList = getExamListByDB(examQueryDTO); //从数据库中获取数据 refreshCache(examQueryDTO.getType()); } return examVOList; } //刷新缓存逻辑 public void refreshCache(Integer examListType) { List<Exam> examList = new ArrayList<>(); if (ExamListType.EXAM_UN_FINISH_LIST.getValue().equals(examListType)) { //查询未完赛的竞赛列表 examList = examMapper.selectList(new LambdaQueryWrapper<Exam>() .select(Exam::getExamId, Exam::getTitle, Exam::getStartTime, Exam::getEndTime) .gt(Exam::getEndTime, LocalDateTime.now()) .eq(Exam::getStatus, Contants.TRUE) .orderByDesc(Exam::getCreateTime)); } else if (ExamListType.EXAM_HISTORY_LIST.getValue().equals(examListType)) { //查询历史竞赛 examList = examMapper.selectList(new LambdaQueryWrapper<Exam>() .select(Exam::getExamId, Exam::getTitle, Exam::getStartTime, Exam::getEndTime) .le(Exam::getEndTime, LocalDateTime.now()) .eq(Exam::getStatus, Contants.TRUE) .orderByDesc(Exam::getCreateTime)); } if (CollectionUtil.isEmpty(examList)) { return; } Map<String, Exam> examMap = new HashMap<>(); List<Long> examIdList = new ArrayList<>(); for (Exam exam : examList) { examMap.put(getDetailKey(exam.getExamId()), exam); examIdList.add(exam.getExamId()); } redisService.multiSet(examMap); //批量将竞赛信息存入 redisService.deleteObject(getExamListKey(examListType));//因为下面是rightPushAll,所以我们需要先清楚,而上面是set可以直接覆盖,所以不用 redisService.rightPushAll(getExamListKey(examListType), examIdList); //刷新列表缓存 } private List<ExamVO> getExamListByDB(ExamQueryDTO examQueryDTO) { PageHelper.startPage(examQueryDTO.getPageNum(), examQueryDTO.getPageSize()); //查询C端的竞赛列表 return examMapper.selectExamList(examQueryDTO); } private List<ExamVO> assembleExamVOList(List<Long> examIdList) { if (CollectionUtil.isEmpty(examIdList)) { //说明redis当中没数据 从数据库中查数据并且重新刷新缓存 return null; } //拼接redis当中key的方法 并且将拼接好的key存储到一个list中 List<String> detailKeyList = new ArrayList<>(); for (Long examId : examIdList) { detailKeyList.add(getDetailKey(examId)); } List<ExamVO> examVOList = redisService.multiGet(detailKeyList, ExamVO.class); CollUtil.removeNull(examVOList); if (CollectionUtil.isEmpty(examVOList) || examVOList.size() != examIdList.size()) { //说明redis中数据有问题 从数据库中查数据并且重新刷新缓存 return null; } return examVOList; } private String getExamListKey(Integer examListType) { if (ExamListType.EXAM_UN_FINISH_LIST.getValue().equals(examListType)) { return CacheConstants.EXAM_UNFINISHED_LIST; } else { return CacheConstants.EXAM_HISTORY_LIST; } } private String getDetailKey(Long examId) { return CacheConstants.EXAM_DETAIL + examId; } } 
getExamListKey:通过传入的type类型返回是哪个列表(未完赛列表还是历史列表)getDetailKey :通过传入的竞赛id,或者竞赛id的修饰key

它的作用是刷新缓存

分类讨论,如果查询未完赛的话就去数据库里面查找未完赛

如果数据库中也为空,则返回,否则将查找到的数据存储到map里面以便后续使用

首先批量将我们数据库中查到的竞赛信息存入redis,然后将未完赛/历史竞赛对应的examid以及竞赛相关信息从redis中删除(为了后面的重新pushAll)

从数据库中获得竞赛数据

去redis中获取数据,如果不包含数据或者数据的数量不符合我们预期的数量,则返回空。否则返回查询到的数据列表

查询竞赛列表
首先获取要查询的页数和个数(分页功能),然后判断assemble方法获取的返回值是否为空如果为空,则直接去数据库中查找,然后刷新缓存,否则直接返回查询到的examVoList
Controller
@GetMapping("/semiLogin/redis/list") public TableDataInfo redisList(ExamQueryDTO examQueryDTO) { return examService.redisList(examQueryDTO); }Service
@Override public TableDataInfo redisList(ExamQueryDTO examQueryDTO) { Long total = examCacheManager.getListSize(examQueryDTO.getType()); List<ExamVO> examVOList; if (total == null || total <= 0) { examVOList=list(examQueryDTO); examCacheManager.refreshCache(examQueryDTO.getType()); total=new PageInfo<>(examVOList).getTotal(); }else { examVOList=examCacheManager.getExamVOList(examQueryDTO); total=examCacheManager.getListSize(examQueryDTO.getType()); } if(CollectionUtil.isEmpty(examVOList)){ return TableDataInfo.empty(); } return TableDataInfo.success(examVOList,total); }测试代码


虽然效率有所提升,但目前我们遇到一个棘手的问题:
Redis中存储的竞赛信息并非实时更新,随着时间的推移,这些数据可能会出现偏差。
解决方法:
我们可以协商一个固定的缓存刷新时间点,及时清理过时的竞赛信息。
我们采用xxl-job组件来实现任务调度功能。
xxl-job
引⼊项⽬
1.通过Docker 镜像⽅式搭建调度中⼼:
docker pull xuxueli/xxl-job-admin:2.4.02.初始化数据库
# # XXL-JOB # Copyright (c) 2015-present, xuxueli. CREATE database if NOT EXISTS `xxl_job` default character set utf8mb4 collate utf8mb4_unicode_ci; use `xxl_job`; SET NAMES utf8mb4; ## —————————————————————— job group and registry —————————————————— CREATE TABLE `xxl_job_group` ( `id` int(11) NOT NULL AUTO_INCREMENT, `app_name` varchar(64) NOT NULL COMMENT '执行器AppName', `title` varchar(12) NOT NULL COMMENT '执行器名称', `address_type` tinyint(4) NOT NULL DEFAULT '0' COMMENT '执行器地址类型:0=自动注册、1=手动录入', `address_list` text COMMENT '执行器地址列表,多地址逗号分隔', `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; CREATE TABLE `xxl_job_registry` ( `id` int(11) NOT NULL AUTO_INCREMENT, `registry_group` varchar(50) NOT NULL, `registry_key` varchar(255) NOT NULL, `registry_value` varchar(255) NOT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `i_g_k_v` (`registry_group`, `registry_key`, `registry_value`) USING BTREE ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; ## —————————————————————— job info —————————————————— CREATE TABLE `xxl_job_info` ( `id` int(11) NOT NULL AUTO_INCREMENT, `job_group` int(11) NOT NULL COMMENT '执行器主键ID', `job_desc` varchar(255) NOT NULL, `add_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, `author` varchar(64) DEFAULT NULL COMMENT '作者', `alarm_email` varchar(255) DEFAULT NULL COMMENT '报警邮件', `schedule_type` varchar(50) NOT NULL DEFAULT 'NONE' COMMENT '调度类型', `schedule_conf` varchar(128) DEFAULT NULL COMMENT '调度配置,值含义取决于调度类型', `misfire_strategy` varchar(50) NOT NULL DEFAULT 'DO_NOTHING' COMMENT '调度过期策略', `executor_route_strategy` varchar(50) DEFAULT NULL COMMENT '执行器路由策略', `executor_handler` varchar(255) DEFAULT NULL COMMENT '执行器任务handler', `executor_param` varchar(512) DEFAULT NULL COMMENT '执行器任务参数', `executor_block_strategy` varchar(50) DEFAULT NULL COMMENT '阻塞处理策略', `executor_timeout` int(11) NOT NULL DEFAULT '0' COMMENT '任务执行超时时间,单位秒', `executor_fail_retry_count` int(11) NOT NULL DEFAULT '0' COMMENT '失败重试次数', `glue_type` varchar(50) NOT NULL COMMENT 'GLUE类型', `glue_source` mediumtext COMMENT 'GLUE源代码', `glue_remark` varchar(128) DEFAULT NULL COMMENT 'GLUE备注', `glue_updatetime` datetime DEFAULT NULL COMMENT 'GLUE更新时间', `child_jobid` varchar(255) DEFAULT NULL COMMENT '子任务ID,多个逗号分隔', `trigger_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '调度状态:0-停止,1-运行', `trigger_last_time` bigint(13) NOT NULL DEFAULT '0' COMMENT '上次调度时间', `trigger_next_time` bigint(13) NOT NULL DEFAULT '0' COMMENT '下次调度时间', PRIMARY KEY (`id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; CREATE TABLE `xxl_job_logglue` ( `id` int(11) NOT NULL AUTO_INCREMENT, `job_id` int(11) NOT NULL COMMENT '任务,主键ID', `glue_type` varchar(50) DEFAULT NULL COMMENT 'GLUE类型', `glue_source` mediumtext COMMENT 'GLUE源代码', `glue_remark` varchar(128) NOT NULL COMMENT 'GLUE备注', `add_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; ## —————————————————————— job log and report —————————————————— CREATE TABLE `xxl_job_log` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `job_group` int(11) NOT NULL COMMENT '执行器主键ID', `job_id` int(11) NOT NULL COMMENT '任务,主键ID', `executor_address` varchar(255) DEFAULT NULL COMMENT '执行器地址,本次执行的地址', `executor_handler` varchar(255) DEFAULT NULL COMMENT '执行器任务handler', `executor_param` varchar(512) DEFAULT NULL COMMENT '执行器任务参数', `executor_sharding_param` varchar(20) DEFAULT NULL COMMENT '执行器任务分片参数,格式如 1/2', `executor_fail_retry_count` int(11) NOT NULL DEFAULT '0' COMMENT '失败重试次数', `trigger_time` datetime DEFAULT NULL COMMENT '调度-时间', `trigger_code` int(11) NOT NULL COMMENT '调度-结果', `trigger_msg` text COMMENT '调度-日志', `handle_time` datetime DEFAULT NULL COMMENT '执行-时间', `handle_code` int(11) NOT NULL COMMENT '执行-状态', `handle_msg` text COMMENT '执行-日志', `alarm_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '告警状态:0-默认、1-无需告警、2-告警成功、3-告警失败', PRIMARY KEY (`id`), KEY `I_trigger_time` (`trigger_time`), KEY `I_handle_code` (`handle_code`), KEY `I_jobid_jobgroup` (`job_id`,`job_group`), KEY `I_job_id` (`job_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; CREATE TABLE `xxl_job_log_report` ( `id` int(11) NOT NULL AUTO_INCREMENT, `trigger_day` datetime DEFAULT NULL COMMENT '调度-时间', `running_count` int(11) NOT NULL DEFAULT '0' COMMENT '运行中-日志数量', `suc_count` int(11) NOT NULL DEFAULT '0' COMMENT '执行成功-日志数量', `fail_count` int(11) NOT NULL DEFAULT '0' COMMENT '执行失败-日志数量', `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `i_trigger_day` (`trigger_day`) USING BTREE ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; ## —————————————————————— lock —————————————————— CREATE TABLE `xxl_job_lock` ( `lock_name` varchar(50) NOT NULL COMMENT '锁名称', PRIMARY KEY (`lock_name`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; ## —————————————————————— user —————————————————— CREATE TABLE `xxl_job_user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '账号', `password` varchar(100) NOT NULL COMMENT '密码加密信息', `token` varchar(100) DEFAULT NULL COMMENT '登录token', `role` tinyint(4) NOT NULL COMMENT '角色:0-普通用户、1-管理员', `permission` varchar(255) DEFAULT NULL COMMENT '权限:执行器ID列表,多个逗号分割', PRIMARY KEY (`id`), UNIQUE KEY `i_username` (`username`) USING BTREE ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; ## —————————————————————— for default data —————————————————— INSERT INTO `xxl_job_group`(`id`, `app_name`, `title`, `address_type`, `address_list`, `update_time`) VALUES (1, 'xxl-job-executor-sample', '通用执行器Sample', 0, NULL, now()), (2, 'xxl-job-executor-sample-ai', 'AI执行器Sample', 0, NULL, now()); INSERT INTO `xxl_job_info`(`id`, `job_group`, `job_desc`, `add_time`, `update_time`, `author`, `alarm_email`, `schedule_type`, `schedule_conf`, `misfire_strategy`, `executor_route_strategy`, `executor_handler`, `executor_param`, `executor_block_strategy`, `executor_timeout`, `executor_fail_retry_count`, `glue_type`, `glue_source`, `glue_remark`, `glue_updatetime`, `child_jobid`) VALUES (1, 1, '示例任务01', now(), now(), 'XXL', '', 'CRON', '0 0 0 * * ? *', 'DO_NOTHING', 'FIRST', 'demoJobHandler', '', 'SERIAL_EXECUTION', 0, 0, 'BEAN', '', 'GLUE代码初始化', now(), ''), (2, 2, 'Ollama示例任务01', now(), now(), 'XXL', '', 'NONE', '', 'DO_NOTHING', 'FIRST', 'ollamaJobHandler', '{ "input": "慢SQL问题分析思路", "prompt": "你是一个研发工程师,擅长解决技术类问题。", "model": "qwen3:0.6b" }', 'SERIAL_EXECUTION', 0, 0, 'BEAN', '', 'GLUE代码初始化', now(), ''), (3, 2, 'Dify示例任务', now(), now(), 'XXL', '', 'NONE', '', 'DO_NOTHING', 'FIRST', 'difyWorkflowJobHandler', '{ "inputs":{ "input":"查询班级各学科前三名" }, "user": "xxl-job", "baseUrl": "http://localhost/v1", "apiKey": "app-OUVgNUOQRIMokfmuJvBJoUTN" }', 'SERIAL_EXECUTION', 0, 0, 'BEAN', '', 'GLUE代码初始化', now(), ''); INSERT INTO `xxl_job_user`(`id`, `username`, `password`, `role`, `permission`) VALUES (1, 'admin', '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92', 1, NULL); INSERT INTO `xxl_job_lock` (`lock_name`) VALUES ('schedule_lock'); commit;赋予权限
以拥有足够权限的用户登录数据库
mysql -u root授予 ojtest 用户对 xxl_job 数据库的所有权限(包括 SELECT、INSERT、UPDATE、DELETE 等),使用以下 SQL 语句:
GRANT ALL PRIVILEGES ON xxl_job.* TO 'ojtest'@'%';刷新权限
FLUSH PRIVILEGES;启动docker容器注意:将数据源配置为上⼀步所创建的。
docker run -e PARAMS="--spring.datasource.url=jdbc:mysql://172.17.0.2:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai --spring.datasource.username=ojtest --spring.datasource.password=123456" -p 8080:8080 --name xxl-job-admin -d xuxueli/xxl-job-admin:2.4.0进⼊调度中⼼
调度中⼼访问地址:http://localhost:8080/xxl-job-admin

默认账号密码:admin/123456

新增执行器


新增任务

配置部署“执⾏器项⽬”
引入依赖
<dependency> <groupId>com.bite</groupId> <artifactId>oj-common-core</artifactId> <version>${oj-common-core.version}</version> </dependency> <dependency> <groupId>com.bite</groupId> <artifactId>oj-common-redis</artifactId> <version>${oj-common-redis.version}</version> </dependency> <!-- xxl-job-core --> <dependency> <groupId>com.xuxueli</groupId> <artifactId>xxl-job-core</artifactId> <version>${xxl-job.version}</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> </dependency>
配置xxl-job
nacos上增加配置:
server: port: 9203 spring: datasource: url: jdbc:mysql://localhost:3306/bitoj_dev?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowMultiQueries=true username: ojtest password: 123456 data: redis: host: localhost port: 6379 password: 123456 xxl: job: admin: addresses: http://localhost:8080/xxl-job-admin accessToken: default_token executor: appname: ${spring.application.name}-executor新增配置⽂件
package com.bite.job.config; import com.xxl.job.core.executor.impl.XxlJobSpringExecutor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration @Slf4j public class XxlJobConfig { @Value("${xxl.job.admin.addresses}") private String adminAddresses; @Value("${xxl.job.accessToken}") private String accessToken; @Value("${xxl.job.executor.appname}") private String appname; @Bean public XxlJobSpringExecutor xxlJobExecutor() { log.info(">>>>>>>>>>> xxl-job config init."); XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor(); xxlJobSpringExecutor.setAdminAddresses(adminAddresses); xxlJobSpringExecutor.setAppname(appname); xxlJobSpringExecutor.setAccessToken(accessToken); return xxlJobSpringExecutor; } }通过Bean来创建一个执行器对象,作为任务执行器的核心组件。
增加“handler”⽅法
分别将未完赛竞赛和历史竞赛从数据库中取出,然后刷新缓存
@Component public class ExamXxlJob { @Autowired private ExamMapper examMapper; @Autowired private RedisService redisService; @XxlJob("examListOrganizeHandler") public void examListOrganizeHandler() { // 未完赛竞赛列表 List<Exam> unFinishList = examMapper.selectList(new LambdaQueryWrapper<Exam>() .select(Exam::getExamId, Exam::getTitle, Exam::getStartTime, Exam::getEndTime) .gt(Exam::getEndTime, LocalDateTime.now()) .eq(Exam::getStatus, Contants.TRUE) .orderByDesc(Exam::getCreateTime)); refreshCache(CacheConstants.EXAM_UNFINISHED_LIST, unFinishList); // 历史竞赛列表 List<Exam> historyExamList = examMapper.selectList(new LambdaQueryWrapper<Exam>() .select(Exam::getExamId, Exam::getTitle, Exam::getStartTime, Exam::getEndTime) .le(Exam::getEndTime, LocalDateTime.now()) .eq(Exam::getStatus, Contants.TRUE) .orderByDesc(Exam::getCreateTime)); refreshCache(CacheConstants.EXAM_HISTORY_LIST, historyExamList); } public void refreshCache(String examListKey, List<Exam> examList) { if (CollectionUtil.isEmpty(examList)) { return; } Map<String, Exam> examMap = new HashMap<>(); List<Long> examIdList = new ArrayList<>(); for (Exam exam : examList) { examMap.put(getDetailKey(exam.getExamId()), exam); examIdList.add(exam.getExamId()); } redisService.multiSet(examMap); // 刷新详情缓存 redisService.deleteObject(examListKey); redisService.rightPushAll(examListKey, examIdList); // 刷新列表缓存 } private String getDetailKey(Long examId) { return CacheConstants.EXAM_DETAIL + examId; } } 

这俩个是相互对应的
启动任务


前端开发:
配置路由
import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path:"/", redirect:'/c-oj/home/question', }, { path: "/c-oj/home", name: "home", component: () => import("@/views/Home.vue"), children: [ { path: 'question', name: 'question', component: () => import('@/views/Question.vue'), meta: {showBanner: true} }, { path: 'exam', name: 'exam', component: () => import('@/views/Exam.vue'), meta: {showBanner: true} }, ] }, { path: "/c-oj/login", name: "login", component: () => import("@/views/Login.vue"), }, ] }) export default router前后端交互请求
import service from "@/utils/request"; export function getExamListService(params) { return service({ url: "/exam/semiLogin/redis/list", method: "get", params, }); } export function getDbExamListService(params) { return service({ url: "/exam/semiLogin/list", method: "get", params, }); } }Exam.vue(竞赛)
<template> <div> <div> <div> <div v-for="option in options" :key="option.value" @click="selectOption(option.value)" :class="{ selected: selectedOption === option.value }"> {{ option.label }} </div> </div> <div> <span>推荐竞赛</span> <el-form inline="true"> <el-form-item label="竞赛时间" prop="datetimerange"> <el-date-picker v-model="datetimeRange" type="datetimerange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期"></el-date-picker> </el-form-item> <el-form-item> <el-button @click="onSearch" plain type="primary">搜索</el-button> <el-button @click="onReset" plain type="info">重置</el-button> </el-form-item> </el-form> <div> <div v-for="(exam, index) in examList" :key="index"> <div> <img src="@/assets/images/exam.png"> </div> <div> <span>{{ exam.title }}</span> <div> <span>开赛时间:{{ exam.startTime }}</span> <span>结束时间:{{ exam.endTime }}</span> </div> </div> </div> </div> <div> <el-pagination background layout="total, sizes, prev, pager, next, jumper" :total="total" v-model:current-page="params.pageNum" v-model:page-size="params.pageSize" :page-sizes="[3, 6, 9, 12]" @size-change="handleSizeChange" @current-change="handleCurrentChange" /> </div> </div> </div> </div> </template> <script setup> import { reactive, ref } from 'vue' import { getExamListService ,getDbExamListService} from '@/api/exam' //参数定义 const examList = ref([]) //题目列表 const total = ref(0) const selectedOption = ref(0); // 初始化选中的文本 const options = ref([ { label: '未完赛', value: 0 }, { label: '历史竞赛', value: 1 }, ]) const params = reactive({ pageNum: 1, pageSize: 9, startTime:'', endTime:'', title: '', type: 0, }) const datetimeRange = ref([]) //竞赛列表 async function getExamList() { if (datetimeRange.value[0] instanceof Date) { console.log("startTime"+datetimeRange.value[0]) params.startTime = datetimeRange.value[0].toISOString() console.log("startTime"+params.startTime) } if (datetimeRange.value[1] instanceof Date) { params.endTime = datetimeRange.value[1].toISOString() } console.log("最终参数"+params) params.type = selectedOption.value const result = await getExamListService(params) examList.value = result.rows total.value = result.total } getExamList() async function selectOption(type) { selectedOption.value = type params.pageNum = 1 params.type = type getExamList() } async function getDbExamList(){ if (datetimeRange.value[0] instanceof Date) { console.log("startTime"+datetimeRange.value[0]) params.startTime = datetimeRange.value[0].toISOString() console.log("startTime"+params.startTime) } if (datetimeRange.value[1] instanceof Date) { params.endTime = datetimeRange.value[1].toISOString() } params.type = selectedOption.value const result = await getDbExamListService(params) examList.value = result.rows total.value = result.total } function onSearch() { params.pageNum = 1 params.pageSize = 3 console.log(params) getDbExamList() } function onReset() { params.pageNum = 1 params.pageSize = 3 params.title = '' params.startTime = '' params.endTime = '' datetimeRange.value.length = 0 getExamList() } // 分页 function handleSizeChange(newSize) { params.pageNum = 1 getExamList() } function handleCurrentChange(newPage) { getExamList() } </script> <style lang="scss" scoped> .exam-page { background-color: rgba(247, 247, 247, 1); position: relative; overflow: hidden; display: flex; .center-box { max-width: 1520px; margin: 0 auto; width: 100%; min-height: 368px; background: #FFFFFF; border-radius: 16px; padding: 0 20px; padding-top: 30px; margin-top: 10px; } .exam-selected-section { margin: 0 auto; margin-bottom: 20px; position: relative; padding-top: 50px; max-width: 1520px; width: 100%; .exam-option-group { width: fit-content; height: 50px; position: absolute; top: 0; left: 0; .exam-option { cursor: pointer; padding: 10px; border-bottom: 2px solid transparent; transition: all 0.3s ease; font-family: PingFangSC, PingFang SC; font-weight: 600; font-size: 18px; color: #222; height: 50px; width: fit-content; display: flex; justify-content: center; align-items: center; margin-right: 20px; } .exam-option.selected { color: #32C5FF; border-bottom: 2px solid #32C5FF; } } .exam-list-title { height: 24px; font-family: PingFangSC, PingFang SC; font-weight: 600; font-size: 18px; color: #222222; line-height: 25px; text-align: left; margin-bottom: 30px; display: block; } :deep(.exam-navigation) { width: 554px; height: 40px; font-size: 100px; margin-bottom: 30px; .el-form-item { margin-right: 20px; } .el-form-item__label { background: #fff; } .exam-navigation-box { background-color: rgba(242, 243, 244, 1); border-radius: 6px; height: 30px; font-weight: 700; } } .exam-list-group { flex-wrap: wrap; @media screen and (min-width: 1420px) { .exam-list-item { width: 32%; &:nth-of-type(3n) { margin-right: 0; } } } @media screen and (max-width: 1419px) { .exam-list-item { width: 48%; margin-right: 2%; &:nth-of-type(2n) { margin-right: 0; } } } @media screen and (max-width: 970px) { .exam-list-item { width: 100%; margin-right: 0; } } .exam-list-item { height: 220px; background: #F9F9F9; border-radius: 10px; margin-right: 2%; margin-bottom: 20px; padding: 20px; box-sizing: border-box; display: flex; align-items: center; flex-direction: row; justify-content: space-between; cursor: pointer; .right-info { width: calc(100% - 146px); } .exam-title { height: 26px; font-family: PingFangSC, PingFang SC; font-weight: 600; font-size: 18px; color: #222222; line-height: 26px; text-align: left; max-width: 90%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 16px; display: block; } .exam-content { margin-bottom: 26px; span { height: 22px; font-family: PingFangSC, PingFang SC; font-weight: 400; font-size: 16px; color: #666666; line-height: 22px; display: block; margin-bottom: 12px; } } img { width: 126px; height: 180px; border-radius: 6px; } &:hover { background: #fff; box-shadow: 0px 0px 6px 0px rgba(0, 0, 0, 0.1); .exam-title { color: #32C5FF; } .el-button { background: #f7f7f7; } } .exam-hash-entry { float: right; font-size: 18px; font-family: PingFangSC-Regular, PingFang SC; font-weight: 400; color: #999; } .exam-button-container { display: flex; justify-content: space-between; align-items: center; .el-button { width: 120px; height: 44px; background: #F7F7F7; border-radius: 4px; border: 1px solid #32C5FF; font-family: PingFangSC, PingFang SC; font-weight: 400; font-size: 18px; color: #32C5FF; line-height: 44px; text-align: center; } } } } .exam-page-pagination { width: 594px; height: 40px; margin: 30px 0 73px 800px; } } } </style>由于Redis难以对查询条件进行有效约束,在按时间范围查询时存在局限性,因此我们仍沿用原有的数据库查询方案。



但是为了提高效率,我们的竞赛列表初始展示以及重置功能我们还是使用Redis缓存进行查询

这样我们就解决了查询全部和按时间查询了
目前效果展示


赠人玫瑰,手有余香!如果文章内容对你有所帮助,请不要吝啬你的点赞评论和关注,你的每一次支持都是我不断创作的最大动力。当然如果你发现了文章中存在错误或者有更好的解决方法,也欢迎评论私信告诉我哦!