目录
竞赛列表
业务分析
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 id="selectExamList" 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.0
2.初始化数据库
#
# 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 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
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 class="exam-page flex-col">
<div class="exam-selected-section flex-col">
<div class="exam-option-group flex-row justify-between">
<div class="exam-option" v-for="option in options" :key="option.value"
@click="selectOption(option.value)"
:class="{ selected: selectedOption === option.value }">
{{ option.label }}
</div>
</div>
<div class="center-box">
<span class="exam-list-title">推荐竞赛</span>
<el-form inline="true" class="exam-navigation flex-row justify-between">
<el-form-item label="竞赛时间" prop="datetimerange" class="exam-navigation-box">
<el-date-picker v-model="datetimeRange" style="width: 240px" 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 class="exam-list-group flex-row">
<div class="exam-list-item flex-col" v-for="(exam, index) in examList" :key="index">
<div>
<img src="@/assets/images/exam.png">
</div>
<div class="right-info">
<span class="exam-title">{{ exam.title }}</span>
<div class="exam-content flex-col justify-between">
<span>开赛时间:{{ exam.startTime }}</span>
<span>结束时间:{{ exam.endTime }}</span>
</div>
</div>
</div>
</div>
<div class="exam-page-pagination flex-row">
<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缓存进行查询

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


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

转载自CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/mr_yuanshen/article/details/152884726






