[项目总结] 在线OJ刷题系统项目技术应用(下)

发布于:2025-04-10 ⋅ 阅读:(43) ⋅ 点赞:(0)

🌸个人主页:https://blog.csdn.net/2301_80050796?spm=1000.2115.3001.5343
🏵️热门专栏:
🧊 Java基本语法(97平均质量分)https://blog.csdn.net/2301_80050796/category_12615970.html?spm=1001.2014.3001.5482
🍕 Collection与数据结构 (93平均质量分)https://blog.csdn.net/2301_80050796/category_12621348.html?spm=1001.2014.3001.5482
🧀线程与网络(96平均质量分) https://blog.csdn.net/2301_80050796/category_12643370.html?spm=1001.2014.3001.5482
🍭MySql数据库(93平均质量分)https://blog.csdn.net/2301_80050796/category_12629890.html?spm=1001.2014.3001.5482
🍬算法(97平均质量分)https://blog.csdn.net/2301_80050796/category_12676091.html?spm=1001.2014.3001.5482
🍃 Spring(97平均质量分)https://blog.csdn.net/2301_80050796/category_12724152.html?spm=1001.2014.3001.5482
🎃Redis(97平均质量分)https://blog.csdn.net/2301_80050796/category_12777129.html?spm=1001.2014.3001.5482
🐰RabbitMQ(97平均质量分) https://blog.csdn.net/2301_80050796/category_12792900.html?spm=1001.2014.3001.5482
感谢点赞与关注~~~
在这里插入图片描述

10. 阿里云短信服务

首先我们需要对阿里云的短信服务进行配置,我们需要配置调用发送短信API的必要数据,accessKeyId,accessKeySecret,这连个是每个用户有且仅有一个,用来拿到阿里云账号的用户权限.之后配置的是endpoint,即要发送短信的地域集群(这封短信从那个城市集群发出).

@Configuration
public class AliSmsConfig {

    @Value("${sms.aliyun.accessKeyId:}")
    private String accessKeyId;

    @Value("${sms.aliyun.accessKeySecret:}")
    private String accessKeySecret;

    @Value("${sms.aliyun.endpoint:}")
    private String endpoint;

    @Bean("aliClient")
    public Client client() throws Exception {
        Config config = new Config()
                .setAccessKeyId(accessKeyId)
                .setAccessKeySecret(accessKeySecret)
                .setEndpoint(endpoint);
        return new Client(config);
    }
}

我们调用短信服务主要用来发送验证码,在发送验证码的方法中,我们在SendSmsRequest中定义了要发送的手机号phone,使用的签名,即在短信头的中括号中显示的签名singName,之后就是我们需要使用的短信模版idtemplateCode,之后就是我们需要往短信中填充的内容,短信的模版中有一些内容是可变的,即${ }中的内容,这个Map中就修改的是这其中的内容.

@Component
@Slf4j
public class AliSmsService {

    @Autowired
    private Client aliClient;

    //业务配置
    @Value("${sms.aliyun.templateCode:}")
    private String templateCode;

    @Value("${sms.aliyun.sing-name:}")
    private String singName;

    public boolean sendMobileCode(String phone, String code) {
        Map<String, String> params = new HashMap<>();
        params.put("code", code);
        return sendTempMessage(phone, singName, templateCode, params);
    }

    public boolean sendTempMessage(String phone, String singName, String templateCode,
                                   Map<String, String> params) {
        SendSmsRequest sendSmsRequest = new SendSmsRequest();
        sendSmsRequest.setPhoneNumbers(phone);
        sendSmsRequest.setSignName(singName);
        sendSmsRequest.setTemplateCode(templateCode);
        sendSmsRequest.setTemplateParam(JSON.toJSONString(params));
        try {
            SendSmsResponse sendSmsResponse = aliClient.sendSms(sendSmsRequest);
            SendSmsResponseBody responseBody = sendSmsResponse.getBody();
            if (!"OK".equalsIgnoreCase(responseBody.getCode())) {
                log.error("短信{} 发送失败,失败原因:{}.... ", JSON.toJSONString(sendSmsRequest), responseBody.getMessage());
                return false;
            }
            return true;
        }  catch (Exception e) {
            log.error("短信{} 发送失败,失败原因:{}.... ",  JSON.toJSONString(sendSmsRequest), e.getMessage());
            return false;
        }
    }
}

在这里插入图片描述

11. 阿里云OSS对象存储

从OSS配置的属性中,我们就可以看出OSS对象存储服务需要的字段,和上面的短信服务一样,我们仍然需要endpoint城市集群结点URL,用户唯一的权限校验id和密钥accessKeyIdaccessKeySecret,其次,我们的配置还需要比短信服务多出了bucketNameOSS对象存储的存储空间,即存储对象的容器.其次就是pathPrefix,表示的是对象在bucket中的存储路径.最后是region,表示的是对象存储的城市服务器集群.我们需要首先在DefaultCredentialProvideraccessKeyIdaccessKeySecret两个属性配置好.之后配置进OSSClientBuilder即可,之后我们还需要配置endpointRegion,即城市集群URL和城市集群地域信息.由于OSS对象存储服务是以数据流的方式对数据进行上上传的,所以我们在上传完成之后需要对数据流进行关闭closeOSSClient().

@Slf4j
@Configuration
public class OSSConfig {

    @Autowired
    private OSSProperties prop;

    public OSS ossClient;

    @Bean
    public OSS ossClient() throws ClientException {
        DefaultCredentialProvider credentialsProvider = CredentialsProviderFactory.newDefaultCredentialProvider(
                prop.getAccessKeyId(), prop.getAccessKeySecret());

        // 创建ClientBuilderConfiguration
        ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration();
        clientBuilderConfiguration.setSignatureVersion(SignVersion.V4);

        // 使用内网endpoint进行上传
        ossClient = OSSClientBuilder.create()
                .endpoint(prop.getEndpoint())
                .credentialsProvider(credentialsProvider)
                .clientConfiguration(clientBuilderConfiguration)
                .region(prop.getRegion())
                .build();
        return ossClient;
    }

    @PreDestroy
    public void closeOSSClient() {
        ossClient.shutdown();
    }
}
@Data
@Component
@ConfigurationProperties(prefix = "file.oss")
public class OSSProperties {

    private String endpoint;

    private String region;

    private String accessKeyId;

    private String accessKeySecret;

    private String bucketName;

    private String pathPrefix; 
}

我们在配置好属性之后,我们就可以对外提供一个用于向OSS上传文件的Service方法了, 在一个c端的用户想要对头像进行修改的时候,为了保证某些恶意用户浪费系统资源,我们对每个用户单日上传头像的此处做了一定的限制,把每个用户上传头像的次数保存在Redis中,在上传头像之前先去Redis中查询当前用户上传头像的次数,如果超过了一定的次数限制,那么直接限制,如果没有超过,直接对Redis中的当前缓存的Value++.同时在每天的凌晨1点的时候对缓存进行刷新.进行检查之后,就可以对文件进行上传了,首先需要指定好文件路径与文件名,也就是在OSS的bucket中,我们需要把文件上传到哪个路径之下,之后我们可以使用InputStream输入流对文件进行上传,最后记得在finally方法中关闭输入流.

public OSSResult uploadFile(MultipartFile file) throws Exception {
    if (!test) {
        checkUploadCount();
    }
    InputStream inputStream = null;
    try {
        String fileName;
        if (file.getOriginalFilename() != null) {
            fileName = file.getOriginalFilename().toLowerCase();
        } else {
            fileName = "a.png";
        }
        String extName = fileName.substring(fileName.lastIndexOf(".") + 1);
        inputStream = file.getInputStream();
        return upload(extName, inputStream);
    } catch (Exception e) {
        log.error("OSS upload file error", e);
        throw new ServiceException(ResultCode.FAILED_FILE_UPLOAD);
    } finally {
        if (inputStream != null) {
            inputStream.close();
        }
    }
}
private void checkUploadCount() {
    Long userId = ThreadLocalUtil.get(Constants.USER_ID, Long.class);
    Long times = redisService.getCacheMapValue(CacheConstants.USER_UPLOAD_TIMES_KEY, String.valueOf(userId), Long.class);
    if (times != null && times >= maxTime) {
        throw new ServiceException(ResultCode.FAILED_FILE_UPLOAD_TIME_LIMIT);
    }
    redisService.incrementHashValue(CacheConstants.USER_UPLOAD_TIMES_KEY, String.valueOf(userId), 1);
    if (times == null || times == 0) {
        long seconds = ChronoUnit.SECONDS.between(LocalDateTime.now(),
                LocalDateTime.now().plusDays(1).withHour(0).withMinute(0).withSecond(0).withNano(0));
        redisService.expire(CacheConstants.USER_UPLOAD_TIMES_KEY, seconds, TimeUnit.SECONDS);
    }
}

12. docker代码沙箱

在用户的id和用户的代码被提交到java判题功能的时候,首先需要根据用户提交的代码进行用户代码文件的构建,即createUserCodeFile,就是把用户提交的代码和主方法拼接起来,之后使用FileUtil工具类在指定的目录之下创建一个用户代码文件.之后就是初始化代码沙箱,在初始化代码沙箱的时候,我们使用的是容器池的技术,即池化技术,避免每一次提交代码都在创建容器上产生不必要的开销,直接从容器池中获取到一个docker容器并启动docker容器.创建并启动完成之后,把我们之前创建好的用户代码提交到docker容器中进行编译,如果编译不通过直接返回编译错误,并删除之前创建的docker容器和用户代码文件避免资源的浪费,如果编译通过,则把测试用例的输入带入到用户提交的代码中进行执行并得到返回结果.

@Override
public SandBoxExecuteResult exeJavaCode(Long userId, String userCode, List<String> inputList) {
    containerId = sandBoxPool.getContainer();
    //创建用户代码文件
    createUserCodeFile(userCode);
    //编译代码
    CompileResult compileResult = compileCodeByDocker();
    //编译是否通过,如果不通过,直接把容器归还给容器池,并删除用户代码路径
    if (!compileResult.isCompiled()) {
        sandBoxPool.returnContainer(containerId);
        deleteUserCodeFile();
        //返回一个失败的结果
        return SandBoxExecuteResult.fail(CodeRunStatus.COMPILE_FAILED, compileResult.getExeMessage());
    }
    //如果编译通过,则执行代码
    return executeJavaCodeByDocker(inputList);
}
//创建并返回用户代码的文件
private void createUserCodeFile(Long userId, String userCode) {
    //创建存放用户代码的目录
    String examCodeDir = System.getProperty("user.dir") + File.separator + JudgeConstants.EXAM_CODE_DIR;
    if (!FileUtil.exist(examCodeDir)) {
        FileUtil.mkdir(examCodeDir);
    }
    String time = LocalDateTimeUtil.format(LocalDateTime.now(), DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
    //拼接用户代码文件格式
    userCodeDir = examCodeDir + File.separator + userId + Constant.UNDERLINE_SEPARATOR + time;
    userCodeFileName = userCodeDir + File.separator + JudgeConstants.USER_CODE_JAVA_CLASS_NAME;
    FileUtil.writeString(userCode, userCodeFileName, Constant.UTF8);
}

在创建一个docker容器的时候,需要对当前的docker容器进行配置,首先就是创建DefaultDockerClientConfig配置类,采用其中的createDefaultConfigBuilder()docker默认配置即可,之后为配置类指定要创建在哪个端口上,即withDockerHost.创建好配置类之后,使用DockerClientBuilder为我们需要创建的docker容器指定配置.之后拉取镜像.之后为当前容器指定其他的一些核心配置,比如限制最大内存,限制内存最大交换次数,限制cpu可以使用的核心,禁用网络等.之后为我们要创建的容器指定一个名称,之后就可以为容器传入配置正式创建容器,拿到创建好的容器id就可以正式启动容器了.

private void initDockerSanBox(){
    //创建一个docker客户端配置,采用默认配置,并设置端口号
    DefaultDockerClientConfig clientConfig = DefaultDockerClientConfig.createDefaultConfigBuilder()
            .withDockerHost(dockerHost)
            .build();
    //构建docker容器
    dockerClient = DockerClientBuilder
            .getInstance(clientConfig) //传入docker配置
            .withDockerCmdExecFactory(new NettyDockerCmdExecFactory())
            .build();
    //拉取镜像
    pullJavaEnvImage();
    //获取到容器的配置
    HostConfig hostConfig = getHostConfig();
    //创建容器并指定容器的名称
    CreateContainerCmd containerCmd = dockerClient
            .createContainerCmd(JudgeConstants.JAVA_ENV_IMAGE)
            .withName(JudgeConstants.JAVA_CONTAINER_NAME);
    //配置容器参数
    CreateContainerResponse createContainerResponse = containerCmd
            .withHostConfig(hostConfig)//使用之前获取到的配置
            .withAttachStderr(true)
            .withAttachStdout(true)
            .withTty(true)
            .exec();
    //记录容器id
    containerId = createContainerResponse.getId();
    //启动容器
    dockerClient.startContainerCmd(containerId).exec();
}
private HostConfig getHostConfig() {
    HostConfig hostConfig = new HostConfig();
    //设置挂载目录,指定用户代码路径,这是为了让容器可以访问用户代码,同时限制容器只能访问这个特定目录
    hostConfig.setBinds(new Bind(userCodeDir, new Volume(JudgeConstants.DOCKER_USER_CODE_DIR)));
    //限制docker容器使用资源
    //限制内存资源
    hostConfig.withMemory(memoryLimit);//限制最大内存
    hostConfig.withMemorySwap(memorySwapLimit);//限制内存最大交换次数
    hostConfig.withCpuCount(cpuLimit);//限制cpu可以使用的核心
    hostConfig.withNetworkMode("none"); //禁用网络
    hostConfig.withReadonlyRootfs(true); //禁止在root目录写文件
    return hostConfig;
}

docker容器池的创建其实和线程池的原理差不多,都是一种池化技术,首先我们在构造方法中指定该docker容器池的配置,包括容器客户端,代码沙箱镜像,挂载目录,最大内存限制,最大内存交换次数限制,使用的最大的cpu核心数,容器池中的最大容器数量,容器前缀名,归还与获取容器的阻塞队列,initDockerPool就是使用我们前面提到的创建容器的方法,为当前容器池中创建容器.getContainer方法是从docker容器池的阻塞队列中获取到docker容器,returnContainer是把docker容器归还到阻塞队列中.

/**
 * 实现容器池,避免因为创建容器而产生的开销
 */
@Slf4j
public class DockerSandBoxPool {
    private DockerClient dockerClient;//容器客户端
    private String sandboxImage;//代码沙箱镜像
    private String volumeDir;//挂载目录,与宿主机中的目录进行关联
    private Long memoryLimit;//最大内存限制
    private Long memorySwapLimit;//最大内存交换次数限制
    private Long cpuLimit;//使用的最大的cpu核心数
    private int poolSize;//容器池中的最大容器数量
    private String containerNamePrefix;//容器前缀名
    private BlockingQueue<String> containerQueue;//归还与获取容器的阻塞队列
    private Map<String, String> containerNameMap;
    public DockerSandBoxPool(DockerClient dockerClient,
                             String sandboxImage,
                             String volumeDir, Long memoryLimit,
                             Long memorySwapLimit, Long cpuLimit,
                             int poolSize, String containerNamePrefix) {
        this.dockerClient = dockerClient;
        this.sandboxImage = sandboxImage;
        this.volumeDir = volumeDir;
        this.memoryLimit = memoryLimit;
        this.memorySwapLimit = memorySwapLimit;
        this.cpuLimit = cpuLimit;
        this.poolSize = poolSize;
        this.containerQueue = new ArrayBlockingQueue<>(poolSize);
        this.containerNamePrefix = containerNamePrefix;
        this.containerNameMap = new HashMap<>();
    }
    public void initDockerPool() {
        log.info("------  创建容器开始  -----");
        for(int i = 0; i < poolSize; i++) {
            createContainer(containerNamePrefix + "-" + i);
        }
        log.info("------  创建容器结束  -----");
    }
    public String getContainer() {
        try {
            return containerQueue.take();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
    public void returnContainer(String containerId) {
        containerQueue.add(containerId);
    }

13. xxl-job定时任务

13.1 历史竞赛与完赛的竞赛

在每天的凌晨一点,都需要对竞赛的列表进行刷新,我们需要先从数据库中查询到结束时间早于当前时间的竞赛和晚于当前时间的竞赛,由于c端用户获取到竞赛列表的时候是首先从Redis中拿到的,所以我们需要对缓存中的竞赛列表进行刷新,即refreshCache(unFinishList, CacheConstants.EXAM_UNFINISHED_LIST);refreshCache(historyList, CacheConstants.EXAM_HISTORY_LIST);.

@XxlJob("examListOrganizeHandler")
public void examListOrganizeHandler() {
    log.info("*** 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, Constants.TRUE)
            .orderByDesc(Exam::getCreateTime));
    refreshCache(unFinishList, CacheConstants.EXAM_UNFINISHED_LIST);

    List<Exam> historyList = examMapper.selectList(new LambdaQueryWrapper<Exam>()
            .select(Exam::getExamId, Exam::getTitle, Exam::getStartTime, Exam::getEndTime)
            .le(Exam::getEndTime, LocalDateTime.now())
            .eq(Exam::getStatus, Constants.TRUE)
            .orderByDesc(Exam::getCreateTime));

    refreshCache(historyList, CacheConstants.EXAM_HISTORY_LIST);
    log.info("*** examListOrganizeHandler 统计结束 ***");
}

13.2 竞赛结束之后发送站内信

还是在每天的固定时间,从数据库中查询当天结束的竞赛,针对参加这些竞赛的用户创建站内信,通知用户竞赛结束并公布排名.

@XxlJob("examResultHandler")
public void examResultHandler() {
    LocalDateTime now = LocalDateTime.now();
    LocalDateTime minusDateTime = now.minusDays(1);//从当前时间中减去一天
    List<Exam> examList = examMapper.selectList(new LambdaQueryWrapper<Exam>()
            .select(Exam::getExamId, Exam::getTitle)
            .eq(Exam::getStatus, Constants.TRUE)
            .ge(Exam::getEndTime, minusDateTime)// 结束时间 >= minusDateTime(前一天)
            .le(Exam::getEndTime, now));// 结束时间 <= now(当前时间)
    if (CollectionUtil.isEmpty(examList)) {
        return;
    }
    Set<Long> examIdSet = examList.stream().map(Exam::getExamId).collect(Collectors.toSet());
    List<UserScore> userScoreList = userSubmitMapper.selectUserScoreList(examIdSet);
    Map<Long, List<UserScore>> userScoreMap = userScoreList.stream().collect(Collectors.groupingBy(UserScore::getExamId));
    createMessage(examList, userScoreMap);
}

14. OpenFeign

用户在c端调用Submit接口提交代码的时候,由于判题服务和代码提交服务是在两个不同的服务当中,需要通过OpenFeign的方式来把friend服务构造好的JudgeSubmitDTO参数(其中包含用户提交的代码)提交到判题服务中,由代码沙箱进行代码运行之后,得到判题的结果.

@FeignClient(contextId = "RemoteJudgeService",value = Constant.JUDGE_SERVICE)
public interface RemoteJudgeService {
    @PostMapping("/judge/doJudgeJavaCode")
    R<UserQuestionResultVO> doJudgeJavaCode(@RequestBody JudgeSubmitDTO judgeSubmitDTO);
}

15. TransmittableThreadLocal

15.1 技术原理

见线程与网络专栏"线程池,定时器,ThreadLocal".需要注意的一点是,之所以在当前项目中不直接使用ThreadLocal,是因为,ThreadLocal在一些场景下会出现问题,比如在线程池进行线程复用的时候会出现上下文污染,上一个线程中的信息回被下一个线程读取到,在消息异步处理的时候可能会导致子线程无法拿到在父线程中设置的信息.

15.2 项目应用

由于我们在某些时候需要在程序中用到当前用户的信息,所以我们需要在ThreadLocal中设置当前用户的userId以及userKey,但是需要注意的是,我们不可以在网关的服务中对ThreadLocal的信息进行设置,因为网关属于一个单独的服务,与其他的服务属于不同的进程,在网关设置的信息无法在其他的服务拿到,所以我们需要在拦截器中对其进行设置.拦截器与网关不同,只要那个服务调用了拦截器,当前拦截器就属于这个服务.

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    String token = getToken(request);  //请求头中获取token
    if (StrUtil.isEmpty(token)) {
        return true;
    }
    Claims claims = tokenService.getClaims(token, secret);
    Long userId = tokenService.getUserId(claims);
    String userKey = tokenService.getUserKey(claims);
    ThreadLocalUtil.set(Constants.USER_ID, userId);
    ThreadLocalUtil.set(Constants.USER_KEY, userKey);
    tokenService.extendToken(claims);
    return true;
}

16. RabbitMQ异步通信

在提交用户代码的时候,我们除了使用OpenFeign,最优的方案还是使用RabbitMQ进行异步通信.
还是在提交功能和判题功能的交互中,可能同时会有很多用户提交代码,而且判题功能逻辑较为复杂,这时候我们就需要用到消息队列来对消息进行削峰处理和异步处理,保证消息准确从提交功能到达判题功能.
在提交功能中,我们把构造好的JudgeSubmitDTO提交到消息队列中.

@Override
public boolean rabbitSubmit(UserSubmitDTO submitDTO) {
    Integer programType = submitDTO.getProgramType();
    if (ProgramType.JAVA.getValue().equals(programType)) {
        //按照java逻辑处理
        JudgeSubmitDTO judgeSubmitDTO = assembleJudgeSubmitDTO(submitDTO);
        judgeProducer.produceMsg(judgeSubmitDTO);
        return true;
    }
    throw new ServiceException(ResultCode.FAILED_NOT_SUPPORT_PROGRAM);
}

在判题功能中,我们设置一个Listener对消息进行监听,确保判题服务可以拿到JudgeSubmitDTO.

@Slf4j
@Component
public class JudgeConsumer {

    @Autowired
    private IJudgeService judgeService;

    @RabbitListener(queues = RabbitMQConstants.OJ_WORK_QUEUE)
    public void consume(JudgeSubmitDTO judgeSubmitDTO) {
        log.info("收到消息为: {}", judgeSubmitDTO);
        judgeService.doJudgeJavaCode(judgeSubmitDTO);
    }
}

但是这里有一个问题,由于我们是通过消息队列来把代码提交给另一个服务的,所以我们无法从判题服务中把判题的结果再次返回到friend服务中,所以我们只能在数据库中再维护一张表tb_user_submit用来保存判题的结果,在判题服务中,我们把判题的结果保存到表中,在friend服务中,从数据库中获取到判题的结果即可.

private void saveUserSubmit(JudgeSubmitDTO judgeSubmitDTO, UserQuestionResultVO userQuestionResultVO) {
    UserSubmit userSubmit = new UserSubmit();
    BeanUtil.copyProperties(userQuestionResultVO, userSubmit);
    userSubmit.setUserId(judgeSubmitDTO.getUserId());
    userSubmit.setQuestionId(judgeSubmitDTO.getQuestionId());
    userSubmit.setExamId(judgeSubmitDTO.getExamId());
    userSubmit.setProgramType(judgeSubmitDTO.getProgramType());
    userSubmit.setUserCode(judgeSubmitDTO.getUserCode());
    userSubmit.setCaseJudgeRes(JSON.toJSONString(userQuestionResultVO.getUserExeResultList()));
    userSubmit.setCreateBy(judgeSubmitDTO.getUserId());
    userSubmitMapper.delete(new LambdaQueryWrapper<UserSubmit>()
            .eq(UserSubmit::getUserId, judgeSubmitDTO.getUserId())
            .eq(UserSubmit::getQuestionId, judgeSubmitDTO.getQuestionId())
            .isNull(judgeSubmitDTO.getExamId() == null, UserSubmit::getExamId)
            .eq(judgeSubmitDTO.getExamId() != null, UserSubmit::getExamId, judgeSubmitDTO.getExamId()));
    userSubmitMapper.insert(userSubmit);
}
@Override
public UserQuestionResultVO exeResult(Long examId, Long questionId, String currentTime) {
    Long userId = ThreadLocalUtil.get(Constants.USER_ID, Long.class);
    UserSubmit userSubmit = userSubmitMapper.selectCurrentUserSubmit(userId, examId, questionId, currentTime);
    UserQuestionResultVO resultVO = new UserQuestionResultVO();
    if (userSubmit == null) {
        resultVO.setPass(QuestionResType.IN_JUDGE.getValue());
    } else {
        resultVO.setPass(userSubmit.getPass());
        resultVO.setExeMessage(userSubmit.getExeMessage());
        if (StrUtil.isNotEmpty(userSubmit.getCaseJudgeRes())) {
            resultVO.setUserExeResultList(JSON.parseArray(userSubmit.getCaseJudgeRes(), UserExeResult.class));
        }
    }
    return resultVO;
}

17. 数据库表设计

在这里插入图片描述
我们在涉及站内信的数据库表设计的时候,我们设计的是分开两张表的方式,之所以我们需要这样设计,是因为我们向不同的用户发送的消息可能是相同的,如果全部存在一张表中,会浪费很大的空间,所以我们选择把消息内容和发送人接收人内容分开存储.

18. Gateway网关

网关主要是对用户的权限做一些校验,我们采用了自定义过滤器的方式,其中自定义过滤器类中实现了GlobalFilter接口,证明是全局过滤器,会应用到所有路由请求上,实现Order用于指定过滤器的优先级.想要从网络请求中获取到相关的请求信息,首先我们需要从ServerWebExchange获取到网络请求.

ServerHttpRequest request = exchange.getRequest();

当然我们有一些接口是不需要经过网关权限验证的,比如用户登录功能,用户注册功能,游客可浏览页面,我们可以配置一个白名单,对白名单中的值不进行权限校验.继续执行下一个过滤器.由于网关中只有这一个自定义过滤器,所以相当于直接进入了后端服务中.
其中,白名单类IgnoreWhiteProperties中,我们使用@RefreshScope热更新,当Nacos中的配置更新之后,不需要重新读取配置,会立即进行配置更新,使用@ConfigurationProperties注解从配置文件中直接读取配置相关信息,对类中的属性进行注入.无需使用@Value注入.其中private List<String> whites = new ArrayList<>();中存放的就是从Nacos中读取出来的白名单路由信息.

@Autowired
private IgnoreWhiteProperties ignoreWhite;
// 跳过不需要验证的路径(白名单中的路径,比如登录功能)
if (matches(url, ignoreWhite.getWhites())) {
    return chain.filter(exchange);
}

@Configuration
@RefreshScope //配置热更新,无需刷新,配置更新之后自动更新
@ConfigurationProperties(prefix = "security.ignore")//在配置文件中找到security.ignore,为响应属性注入值
public class IgnoreWhiteProperties {
    /**
     * 放行白名单配置,网关不校验此处的白名单,比如登录接口
     */
    private List<String> whites = new ArrayList<>();
    public List<String> getWhites() {
        return whites;
    }
    public void setWhites(List<String> whites) {
        this.whites = whites;
    }
}

我们在if条件中使用match函数对请求的路径进行校验,ignoreWhite.getWhites()是我们从Nacos中获取到的白名单配置,如果url符合白名单中的通配符表达式,那么就返回一个true,证明这个URL无需进行校验.其中AntPathMatcher.match方法就是专门用来做通配符校验的.

private boolean matches(String url, List<String> patternList) {
    if (StrUtil.isEmpty(url) || patternList.isEmpty()) {
        return false;
    }
    for (String pattern : patternList) {
        if (isMatch(pattern, url)) {
            return true;
        }
    }
    return false;
}
private boolean isMatch(String pattern, String url) {
    AntPathMatcher matcher = new AntPathMatcher();
    return matcher.match(pattern, url);
}

由于我们的用户token是保存在header中的,所以我们需要从header中获取到用户的token信息.由于Header中是一些key-value形式的信息,其中HttpConstants.AUTHENTICATION是我们在header中保存用户token的key,拿着这个key,就可以获取到value,即用户令牌.*由于OAuth 2.0和JWT规范,Authorization头的Token通常以Bearer开头,所以如果字符串中有Bearer前缀,我们需要把这个前缀去掉,获取到纯净的token信息.

//从http请求头中获取token
String token = getToken(request);
if (StrUtil.isEmpty(token)) {
    return unauthorizedResponse(exchange, "令牌不能为空");
}
/**
 * 从请求头中获取请求token
 */
private String getToken(ServerHttpRequest request) {
    String token =
            request.getHeaders().getFirst(HttpConstants.AUTHENTICATION);
    // 如果前端设置了令牌前缀,则裁剪掉前缀
    if (StrUtil.isNotEmpty(token) &&
            token.startsWith(HttpConstants.PREFIX)) {
        token = token.replaceFirst(HttpConstants.PREFIX, StrUtil.EMPTY);
    }
    return token;
}

之后我们就可以从获取到的token信息中解析用户详细信息.其中我们需要传入我们在Nacos中配置好的签名密钥.
在解析JWT的方法中.我们传入用户令牌和签名密钥,我们就可以拿到Body信息.即用户信息.如果没有解析出用户的详细信息,即if (claims == null),我们就判断用户传入的令牌不正确或者已经过期.
判断令牌不正确之后,我们就需要给前端返回一个错误信息,其中webFluxResponseWriter方法封装一个JSON类型的响应数据,其中包括设置http请求的状态码,设置header,设置返回结果,并通过response.writeWith封装进入Mono(异步的、零或一个结果的流式数据)异步返回.

Claims claims;
try {
    claims = JWTUtils.parseToken(token, secret); //获取令牌中信息 解析payload中信息
    if (claims == null) {
        return unauthorizedResponse(exchange, "令牌已过期或验证不正确!");
    }
} catch (Exception e) {
    return unauthorizedResponse(exchange, "令牌已过期或验证不正确!");
}
public static Claims parseToken(String token, String secret) {
    return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
private Mono<Void> unauthorizedResponse(ServerWebExchange exchange, String
        msg) {
    log.error("[鉴权异常处理]请求路径:{}", exchange.getRequest().getPath());
    return webFluxResponseWriter(exchange.getResponse(), msg,
            ResultCode.FAILED_UNAUTHORIZED.getCode());
}
//拼装webflux模型响应
private Mono<Void> webFluxResponseWriter(ServerHttpResponse response,
                                         String msg, int code) {
    response.setStatusCode(HttpStatus.OK);//设置http响应的状态码
    response.getHeaders().add(HttpHeaders.CONTENT_TYPE,
            MediaType.APPLICATION_JSON_VALUE);//设置header
    R<?> result = R.fail(code, msg);
    DataBuffer dataBuffer =
            response.bufferFactory().wrap(JSON.toJSONString(result).getBytes());
    return response.writeWith(Mono.just(dataBuffer));//异步返回result
}

之后我们就可以从解析出的用户信息Claims中获取到UserKey,之后从redis查询当前的userKey是否存在,如果不存在,则证明登录状态已经过期.和上面一样,校验不同过之后,我们就构造一个http响应数据(JSON响应数据),使用Mono进行异步返回.

String userKey = JWTUtils.getUserKey(claims); //获取jwt中的key
boolean isLogin = redisService.hasKey(getTokenKey(userKey));//判断Redis中是否还存在当前用户的UserKey
if (!isLogin) {
    return unauthorizedResponse(exchange, "登录状态已过期");
}

之后需要校验Claim数据的完整性,确保userId也存在,方便我们进行之后的操作.

String userId = JWTUtils.getUserId(claims); //判断jwt中的信息是否完整
if (StrUtil.isEmpty(userId)) {
    return unauthorizedResponse(exchange, "令牌验证失败");
}

如果进行了上面的校验之后,均没有返回错误信息,则证明redis中的token信息是正确的,我们就根据UserKey把用户信息从redis中拿出来(即拿出UserKey的value信息),之后我们需要对当前用户的身份进行校验,看看当前用户是管理员还是普通用户,如果身份对不上,还是和上面一样,通过Mono异步方式返回错误信息.
具体的校验方式,是首先拿到我们之前的从ServerWebExchange获取到的URL信息,看看URL中包含/system还是/friend,如果是/system,则只有管理员才可以访问,如果是/friend,只有普通用户才可以访问,我们可以从redis中获取到的用户信息中获取到用户的身份信息,以此来作比较.

LoginUser user = redisService.getCacheObject(getTokenKey(userKey),LoginUser.class);//把该用户的身份信息从Redis中拿出来
if (url.contains(HttpConstants.SYSTEM_URL_PREFIX) &&
        !UserIdentity.ADMIN.getValue().equals(user.getIdentity())) {
    //如果获取URL中的前缀发现是system前缀,但是当前用户的身份不是管理员
    return unauthorizedResponse(exchange, "令牌验证失败");
}
if (url.contains(HttpConstants.FRIEND_URL_PREFIX) &&
        !UserIdentity.ORDINARY.getValue().equals(user.getIdentity())) {
    //从URL中获取前缀发现是friend,但是当前用户不是普通用户
    return unauthorizedResponse(exchange, "令牌验证失败");
}

最后进行返回,把当前请求交给下一个过滤器.

return chain.filter(exchange);