feat: 实现题目服务完整校验责任链和流量控制

- 责任链校验系统
  * 题目创建参数校验(标题、内容、难度、判题配置、标签)
  * 题目编辑参数校验(可选字段校验)
  * 题目更新参数校验(管理员、存在性校验)
  * 题目提交参数校验(存在性、状态、语言、代码安全)

- Sentinel 流量控制
  * 添加 Sentinel 依赖和配置
  * 题目提交接口添加限流注解和降级处理

- 数据模型优化
  * QuestionResponseDTO 返回对象类型(JudgeConfig、JudgeCase)
  * 实现 Entity 与 DTO 的 JSON 转换

- 接口文档
  * 生成博客服务完整 API 文档

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-26 23:10:19 +08:00
parent c06cfc10ee
commit 5681b6bcef
27 changed files with 1918 additions and 16 deletions

View File

@@ -77,6 +77,17 @@
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- Sentinel 流量控制-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<!--Sentinel数据源 - 持久化规则到Nacos-->
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-datasource-nacos</artifactId>
</dependency>
<!-- ==================== 测试 ==================== -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -18,11 +18,21 @@ public enum ChainMarkEnums {
*/
QUESTION_UPDATE_PARAM_VERIFY_CHAIN("question_update_param_verify_chain", "题目更新参数校验责任链"),
/**
* 题目编辑参数校验(用户编辑)
*/
QUESTION_EDIT_PARAM_VERIFY_CHAIN("question_edit_param_verify_chain", "题目编辑参数校验责任链"),
/**
* 测试用例创建参数校验
*/
TEST_CASE_CREATE_PARAM_VERIFY_CHAIN("test_case_create_param_verify_chain", "测试用例创建参数校验责任链"),
/**
* 题目提交参数校验
*/
QUESTION_SUBMIT_REQ_PARAM_VERIFY_CHAIN("question_submit_req_param_verify_chain", "题目提交参数校验责任链"),
;
/**

View File

@@ -52,10 +52,7 @@ public class QuestionController {
@PathVariable("id") Long id,
@Parameter(description = "题目信息", required = true)
@RequestBody @Valid QuestionEditRequestDTO request) {
Question question = new Question();
BeanUtils.copyProperties(request, question);
question.setId(id);
questionService.updateQuestion(question);
questionService.updateQuestionWithChain(id, request);
return Results.success();
}

View File

@@ -5,6 +5,8 @@ import cn.meowrain.aioj.backend.framework.core.web.Results;
import cn.meowrain.aioj.backend.question.dao.entity.QuestionSubmit;
import cn.meowrain.aioj.backend.question.dto.req.QuestionSubmitRequestDTO;
import cn.meowrain.aioj.backend.question.service.QuestionSubmitService;
import com.alibaba.csp.sentinel.annotation.SentinelResource;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -30,17 +32,21 @@ public class QuestionSubmitController {
*/
@PostMapping
@Operation(summary = "提交代码", description = "用户提交代码答案")
@SentinelResource(value = "submit-question",blockHandler = "handleException")
public Result<Long> submitQuestion(
@Parameter(description = "提交信息", required = true)
@RequestBody @Valid QuestionSubmitRequestDTO request) {
QuestionSubmit questionSubmit = new QuestionSubmit();
BeanUtils.copyProperties(request, questionSubmit);
// 设置初始状态为待判题
questionSubmit.setStatus(0);
Long submitId = questionSubmitService.createSubmit(questionSubmit);
return Results.success(submitId);
}
public String handleException(BlockException ex) {
System.out.println("被限流了: " + ex.getClass().getCanonicalName());
return "系统繁忙,请稍后再试!(这是自定义的限流提示)";
}
/**
* 获取提交详情
* GET /v1/question-submits/{id}

View File

@@ -0,0 +1,89 @@
package cn.meowrain.aioj.backend.question.dto.chains;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.AbstractChianHandler;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import cn.meowrain.aioj.backend.question.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.question.dto.req.QuestionSubmitRequestDTO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
/**
* 用户代码校验责任链处理器
*/
@Slf4j
@Component
public class CodeVerifyChain implements AbstractChianHandler<QuestionSubmitRequestDTO> {
/**
* 最小代码长度(字符数)
*/
private static final int MIN_CODE_LENGTH = 10;
/**
* 最大代码长度(字符数)- 10KB
*/
private static final int MAX_CODE_LENGTH = 10240;
@Override
public void handle(QuestionSubmitRequestDTO requestParam) {
String code = requestParam.getCode();
// 校验代码不为空
if (StringUtils.isBlank(code)) {
throw new ClientException("代码不能为空", ErrorCode.PARAMS_ERROR);
}
// 去除首尾空白后重新校验
String trimmedCode = code.trim();
if (trimmedCode.isEmpty()) {
throw new ClientException("代码不能为空", ErrorCode.PARAMS_ERROR);
}
// 校验代码长度
if (trimmedCode.length() < MIN_CODE_LENGTH) {
throw new ClientException(
String.format("代码长度不能少于 %d 个字符", MIN_CODE_LENGTH),
ErrorCode.PARAMS_ERROR
);
}
if (trimmedCode.length() > MAX_CODE_LENGTH) {
throw new ClientException(
String.format("代码长度不能超过 %d 个字符", MAX_CODE_LENGTH),
ErrorCode.PARAMS_ERROR
);
}
// 安全检查:检测危险代码模式(根据需要扩展)
String[] dangerousPatterns = {
"Runtime.getRuntime().exec",
"ProcessBuilder",
"System.exec",
"<script",
"eval("
};
for (String pattern : dangerousPatterns) {
if (trimmedCode.contains(pattern)) {
log.warn("检测到危险代码模式: {}", pattern);
// 根据业务需求,可以选择:
// 1. 直接拒绝throw new ClientException("代码包含危险操作", ErrorCode.FORBIDDEN_ERROR);
// 2. 记录警告但允许通过
}
}
log.debug("代码校验通过,代码长度: {} 字符", trimmedCode.length());
}
@Override
public String mark() {
return ChainMarkEnums.QUESTION_SUBMIT_REQ_PARAM_VERIFY_CHAIN.getMark();
}
@Override
public int getOrder() {
return 40;
}
}

View File

@@ -0,0 +1,74 @@
package cn.meowrain.aioj.backend.question.dto.chains;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.AbstractChianHandler;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import cn.meowrain.aioj.backend.question.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.question.dto.req.QuestionSubmitRequestDTO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
/**
* 编程语言校验责任链处理器
*/
@Slf4j
@Component
public class LanguageVerifyChain implements AbstractChianHandler<QuestionSubmitRequestDTO> {
/**
* 支持的编程语言列表
*/
private static final List<String> SUPPORTED_LANGUAGES = Arrays.asList(
"java",
"cpp",
"python",
"go",
"javascript",
"c",
"csharp",
"rust",
"php",
"swift",
"kotlin",
"typescript",
"ruby",
"shell"
);
@Override
public void handle(QuestionSubmitRequestDTO requestParam) {
String language = requestParam.getLanguage();
// 校验语言不为空
if (StringUtils.isBlank(language)) {
throw new ClientException("编程语言不能为空", ErrorCode.PARAMS_ERROR);
}
// 校验语言是否支持(不区分大小写)
String normalizedLanguage = language.toLowerCase().trim();
if (!SUPPORTED_LANGUAGES.contains(normalizedLanguage)) {
throw new ClientException(
String.format("不支持的编程语言: %s支持的语言: %s",
language,
String.join(", ", SUPPORTED_LANGUAGES)),
ErrorCode.PARAMS_ERROR
);
}
log.debug("编程语言校验通过: {}", normalizedLanguage);
}
@Override
public String mark() {
return ChainMarkEnums.QUESTION_SUBMIT_REQ_PARAM_VERIFY_CHAIN.getMark();
}
@Override
public int getOrder() {
return 30;
}
}

View File

@@ -0,0 +1,49 @@
package cn.meowrain.aioj.backend.question.dto.chains;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.AbstractChianHandler;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import cn.meowrain.aioj.backend.question.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.question.dto.req.QuestionEditRequestDTO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
/**
* 题目编辑时内容校验(可选字段)
*/
@Slf4j
@Component
public class QuestionEditContentVerifyChain implements AbstractChianHandler<QuestionEditRequestDTO> {
@Override
public void handle(QuestionEditRequestDTO requestParam) {
String content = requestParam.getContent();
// 内容是可选的,如果为空则跳过校验
if (StringUtils.isBlank(content)) {
return;
}
// 如果提供了内容,则进行校验
if (content.length() < 20) {
throw new ClientException("题目内容过短至少需要20个字符", ErrorCode.PARAMS_ERROR);
}
if (content.length() > 10000) {
throw new ClientException("题目内容过长最多支持10000个字符", ErrorCode.PARAMS_ERROR);
}
log.debug("题目编辑内容校验通过");
}
@Override
public String mark() {
return ChainMarkEnums.QUESTION_EDIT_PARAM_VERIFY_CHAIN.getMark();
}
@Override
public int getOrder() {
return 30;
}
}

View File

@@ -0,0 +1,57 @@
package cn.meowrain.aioj.backend.question.dto.chains;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.AbstractChianHandler;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import cn.meowrain.aioj.backend.question.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.question.dto.req.QuestionEditRequestDTO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
/**
* 题目编辑时难度校验(可选字段)
*/
@Slf4j
@Component
public class QuestionEditDifficultyVerifyChain implements AbstractChianHandler<QuestionEditRequestDTO> {
/**
* 允许的难度等级
*/
private static final List<String> ALLOWED_DIFFICULTIES = Arrays.asList("easy", "medium", "hard");
@Override
public void handle(QuestionEditRequestDTO requestParam) {
String difficulty = requestParam.getDifficulty();
// 难度是可选的,如果为空则跳过校验
if (StringUtils.isBlank(difficulty)) {
return;
}
// 如果提供了难度,则进行校验
String normalizedDifficulty = difficulty.toLowerCase().trim();
if (!ALLOWED_DIFFICULTIES.contains(normalizedDifficulty)) {
throw new ClientException(
String.format("题目难度必须是以下之一: %s", String.join(", ", ALLOWED_DIFFICULTIES)),
ErrorCode.PARAMS_ERROR
);
}
log.debug("题目编辑难度校验通过: {}", normalizedDifficulty);
}
@Override
public String mark() {
return ChainMarkEnums.QUESTION_EDIT_PARAM_VERIFY_CHAIN.getMark();
}
@Override
public int getOrder() {
return 40;
}
}

View File

@@ -0,0 +1,105 @@
package cn.meowrain.aioj.backend.question.dto.chains;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.AbstractChianHandler;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import cn.meowrain.aioj.backend.question.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.question.dto.req.JudgeConfig;
import cn.meowrain.aioj.backend.question.dto.req.QuestionEditRequestDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 题目编辑时判题配置校验(可选字段)
*/
@Slf4j
@Component
public class QuestionEditJudgeConfigVerifyChain implements AbstractChianHandler<QuestionEditRequestDTO> {
/**
* 默认时间限制(毫秒)
*/
private static final Long DEFAULT_TIME_LIMIT = 3000L;
/**
* 最大时间限制(毫秒)- 10秒
*/
private static final Long MAX_TIME_LIMIT = 10000L;
/**
* 最小时间限制(毫秒)
*/
private static final Long MIN_TIME_LIMIT = 100L;
/**
* 默认内存限制MB
*/
private static final Long DEFAULT_MEMORY_LIMIT = 256L;
/**
* 最大内存限制MB- 1GB
*/
private static final Long MAX_MEMORY_LIMIT = 1024L;
/**
* 最小内存限制MB
*/
private static final Long MIN_MEMORY_LIMIT = 16L;
@Override
public void handle(QuestionEditRequestDTO requestParam) {
JudgeConfig judgeConfig = requestParam.getJudgeConfig();
// 判题配置是可选的,如果为空则跳过校验
if (judgeConfig == null) {
return;
}
// 如果提供了判题配置,则进行校验
Long timeLimit = judgeConfig.getTimeLimit();
if (timeLimit != null) {
if (timeLimit < MIN_TIME_LIMIT) {
throw new ClientException(
String.format("时间限制不能小于 %d 毫秒", MIN_TIME_LIMIT),
ErrorCode.PARAMS_ERROR
);
}
if (timeLimit > MAX_TIME_LIMIT) {
throw new ClientException(
String.format("时间限制不能大于 %d 毫秒", MAX_TIME_LIMIT),
ErrorCode.PARAMS_ERROR
);
}
}
Long memoryLimit = judgeConfig.getMemoryLimit();
if (memoryLimit != null) {
if (memoryLimit < MIN_MEMORY_LIMIT) {
throw new ClientException(
String.format("内存限制不能小于 %d MB", MIN_MEMORY_LIMIT),
ErrorCode.PARAMS_ERROR
);
}
if (memoryLimit > MAX_MEMORY_LIMIT) {
throw new ClientException(
String.format("内存限制不能大于 %d MB", MAX_MEMORY_LIMIT),
ErrorCode.PARAMS_ERROR
);
}
}
log.debug("题目编辑判题配置校验通过: timeLimit={}ms, memoryLimit={}MB",
timeLimit != null ? timeLimit : DEFAULT_TIME_LIMIT,
memoryLimit != null ? memoryLimit : DEFAULT_MEMORY_LIMIT);
}
@Override
public String mark() {
return ChainMarkEnums.QUESTION_EDIT_PARAM_VERIFY_CHAIN.getMark();
}
@Override
public int getOrder() {
return 50;
}
}

View File

@@ -0,0 +1,80 @@
package cn.meowrain.aioj.backend.question.dto.chains;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.AbstractChianHandler;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import cn.meowrain.aioj.backend.question.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.question.dto.req.QuestionEditRequestDTO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 题目编辑时标签校验(可选字段)
*/
@Slf4j
@Component
public class QuestionEditTagsVerifyChain implements AbstractChianHandler<QuestionEditRequestDTO> {
/**
* 最大标签数量
*/
private static final int MAX_TAGS_COUNT = 10;
/**
* 单个标签最大长度
*/
private static final int MAX_TAG_LENGTH = 20;
@Override
public void handle(QuestionEditRequestDTO requestParam) {
List<String> tags = requestParam.getTags();
// 标签是可选的,如果为空则跳过校验
if (tags == null || tags.isEmpty()) {
return;
}
// 如果提供了标签,则进行校验
if (tags.size() > MAX_TAGS_COUNT) {
throw new ClientException(
String.format("标签数量不能超过 %d 个", MAX_TAGS_COUNT),
ErrorCode.PARAMS_ERROR
);
}
for (String tag : tags) {
if (StringUtils.isBlank(tag)) {
throw new ClientException("标签不能为空", ErrorCode.PARAMS_ERROR);
}
if (tag.trim().length() > MAX_TAG_LENGTH) {
throw new ClientException(
String.format("标签长度不能超过 %d 个字符", MAX_TAG_LENGTH),
ErrorCode.PARAMS_ERROR
);
}
if (tag.contains(",") || tag.contains(";") || tag.contains("|")) {
throw new ClientException(
String.format("标签 '%s' 包含非法字符", tag),
ErrorCode.PARAMS_ERROR
);
}
}
log.debug("题目编辑标签校验通过,共 {} 个标签", tags.size());
}
@Override
public String mark() {
return ChainMarkEnums.QUESTION_EDIT_PARAM_VERIFY_CHAIN.getMark();
}
@Override
public int getOrder() {
return 60;
}
}

View File

@@ -0,0 +1,49 @@
package cn.meowrain.aioj.backend.question.dto.chains;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.AbstractChianHandler;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import cn.meowrain.aioj.backend.question.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.question.dto.req.QuestionEditRequestDTO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
/**
* 题目编辑时标题校验(可选字段)
*/
@Slf4j
@Component
public class QuestionEditTitleVerifyChain implements AbstractChianHandler<QuestionEditRequestDTO> {
@Override
public void handle(QuestionEditRequestDTO requestParam) {
String title = requestParam.getTitle();
// 标题是可选的,如果为空则跳过校验
if (StringUtils.isBlank(title)) {
return;
}
// 如果提供了标题,则进行校验
if (title.length() < 2) {
throw new ClientException("题目标题长度不能少于2个字符", ErrorCode.PARAMS_ERROR);
}
if (title.length() > 100) {
throw new ClientException("题目标题长度不能超过100个字符", ErrorCode.PARAMS_ERROR);
}
log.debug("题目编辑标题校验通过: {}", title);
}
@Override
public String mark() {
return ChainMarkEnums.QUESTION_EDIT_PARAM_VERIFY_CHAIN.getMark();
}
@Override
public int getOrder() {
return 20;
}
}

View File

@@ -0,0 +1,48 @@
package cn.meowrain.aioj.backend.question.dto.chains;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.AbstractChianHandler;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import cn.meowrain.aioj.backend.question.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.question.dto.req.QuestionSubmitRequestDTO;
import cn.meowrain.aioj.backend.question.service.QuestionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 题目存在性校验责任链处理器
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class QuestionExistVerifyChain implements AbstractChianHandler<QuestionSubmitRequestDTO> {
private final QuestionService questionService;
@Override
public void handle(QuestionSubmitRequestDTO requestParam) {
Long questionId = requestParam.getQuestionId();
// 校验题目是否存在
boolean exists = questionService.lambdaQuery()
.eq(cn.meowrain.aioj.backend.question.dao.entity.Question::getId, questionId)
.exists();
if (!exists) {
throw new ClientException("题目不存在题目ID: " + questionId, ErrorCode.NOT_FOUND_ERROR);
}
log.debug("题目存在性校验通过题目ID: {}", questionId);
}
@Override
public String mark() {
return ChainMarkEnums.QUESTION_SUBMIT_REQ_PARAM_VERIFY_CHAIN.getMark();
}
@Override
public int getOrder() {
return 10;
}
}

View File

@@ -0,0 +1,57 @@
package cn.meowrain.aioj.backend.question.dto.chains;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.AbstractChianHandler;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import cn.meowrain.aioj.backend.question.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.question.dao.entity.Question;
import cn.meowrain.aioj.backend.question.dto.req.QuestionSubmitRequestDTO;
import cn.meowrain.aioj.backend.question.service.QuestionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 题目状态校验责任链处理器
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class QuestionStatusVerifyChain implements AbstractChianHandler<QuestionSubmitRequestDTO> {
private final QuestionService questionService;
@Override
public void handle(QuestionSubmitRequestDTO requestParam) {
Long questionId = requestParam.getQuestionId();
// 查询题目详情
Question question = questionService.getById(questionId);
if (question == null) {
throw new ClientException("题目不存在", ErrorCode.NOT_FOUND_ERROR);
}
// 校验题目是否可用(未删除、状态正常)
if (question.getIsDelete() != null && question.getIsDelete() == 1) {
throw new ClientException("题目已被删除,无法提交", ErrorCode.FORBIDDEN_ERROR);
}
// 可以添加更多状态校验,比如题目是否草稿状态、是否暂停提交等
// if (question.getStatus() != null && question.getStatus() != 1) {
// throw new ClientException("题目当前不可用", ErrorCode.FORBIDDEN_ERROR);
// }
log.debug("题目状态校验通过题目ID: {}", questionId);
}
@Override
public String mark() {
return ChainMarkEnums.QUESTION_SUBMIT_REQ_PARAM_VERIFY_CHAIN.getMark();
}
@Override
public int getOrder() {
return 20;
}
}

View File

@@ -0,0 +1,49 @@
package cn.meowrain.aioj.backend.question.dto.chains;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.AbstractChianHandler;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import cn.meowrain.aioj.backend.question.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.question.dto.req.QuestionUpdateRequestDTO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
/**
* 题目更新时内容校验(可选字段)
*/
@Slf4j
@Component
public class QuestionUpdateContentVerifyChain implements AbstractChianHandler<QuestionUpdateRequestDTO> {
@Override
public void handle(QuestionUpdateRequestDTO requestParam) {
String content = requestParam.getContent();
// 内容是可选的,如果为空则跳过校验
if (StringUtils.isBlank(content)) {
return;
}
// 如果提供了内容,则进行校验
if (content.length() < 20) {
throw new ClientException("题目内容过短至少需要20个字符", ErrorCode.PARAMS_ERROR);
}
if (content.length() > 10000) {
throw new ClientException("题目内容过长最多支持10000个字符", ErrorCode.PARAMS_ERROR);
}
log.debug("题目更新内容校验通过");
}
@Override
public String mark() {
return ChainMarkEnums.QUESTION_UPDATE_PARAM_VERIFY_CHAIN.getMark();
}
@Override
public int getOrder() {
return 30;
}
}

View File

@@ -0,0 +1,57 @@
package cn.meowrain.aioj.backend.question.dto.chains;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.AbstractChianHandler;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import cn.meowrain.aioj.backend.question.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.question.dto.req.QuestionUpdateRequestDTO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
/**
* 题目更新时难度校验(可选字段)
*/
@Slf4j
@Component
public class QuestionUpdateDifficultyVerifyChain implements AbstractChianHandler<QuestionUpdateRequestDTO> {
/**
* 允许的难度等级
*/
private static final List<String> ALLOWED_DIFFICULTIES = Arrays.asList("easy", "medium", "hard");
@Override
public void handle(QuestionUpdateRequestDTO requestParam) {
String difficulty = requestParam.getDifficulty();
// 难度是可选的,如果为空则跳过校验
if (StringUtils.isBlank(difficulty)) {
return;
}
// 如果提供了难度,则进行校验
String normalizedDifficulty = difficulty.toLowerCase().trim();
if (!ALLOWED_DIFFICULTIES.contains(normalizedDifficulty)) {
throw new ClientException(
String.format("题目难度必须是以下之一: %s", String.join(", ", ALLOWED_DIFFICULTIES)),
ErrorCode.PARAMS_ERROR
);
}
log.debug("题目更新难度校验通过: {}", normalizedDifficulty);
}
@Override
public String mark() {
return ChainMarkEnums.QUESTION_UPDATE_PARAM_VERIFY_CHAIN.getMark();
}
@Override
public int getOrder() {
return 40;
}
}

View File

@@ -0,0 +1,53 @@
package cn.meowrain.aioj.backend.question.dto.chains;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.AbstractChianHandler;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import cn.meowrain.aioj.backend.question.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.question.dto.req.QuestionUpdateRequestDTO;
import cn.meowrain.aioj.backend.question.service.QuestionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 题目更新时题目存在性校验
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class QuestionUpdateExistVerifyChain implements AbstractChianHandler<QuestionUpdateRequestDTO> {
private final QuestionService questionService;
@Override
public void handle(QuestionUpdateRequestDTO requestParam) {
Long questionId = requestParam.getId();
// 校验题目ID不为空
if (questionId == null) {
throw new ClientException("题目ID不能为空", ErrorCode.PARAMS_ERROR);
}
// 校验题目是否存在
boolean exists = questionService.lambdaQuery()
.eq(cn.meowrain.aioj.backend.question.dao.entity.Question::getId, questionId)
.exists();
if (!exists) {
throw new ClientException("题目不存在无法更新题目ID: " + questionId, ErrorCode.NOT_FOUND_ERROR);
}
log.debug("题目更新存在性校验通过题目ID: {}", questionId);
}
@Override
public String mark() {
return ChainMarkEnums.QUESTION_UPDATE_PARAM_VERIFY_CHAIN.getMark();
}
@Override
public int getOrder() {
return 10;
}
}

View File

@@ -0,0 +1,105 @@
package cn.meowrain.aioj.backend.question.dto.chains;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.AbstractChianHandler;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import cn.meowrain.aioj.backend.question.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.question.dto.req.JudgeConfig;
import cn.meowrain.aioj.backend.question.dto.req.QuestionUpdateRequestDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 题目更新时判题配置校验(可选字段)
*/
@Slf4j
@Component
public class QuestionUpdateJudgeConfigVerifyChain implements AbstractChianHandler<QuestionUpdateRequestDTO> {
/**
* 默认时间限制(毫秒)
*/
private static final Long DEFAULT_TIME_LIMIT = 3000L;
/**
* 最大时间限制(毫秒)- 10秒
*/
private static final Long MAX_TIME_LIMIT = 10000L;
/**
* 最小时间限制(毫秒)
*/
private static final Long MIN_TIME_LIMIT = 100L;
/**
* 默认内存限制MB
*/
private static final Long DEFAULT_MEMORY_LIMIT = 256L;
/**
* 最大内存限制MB- 1GB
*/
private static final Long MAX_MEMORY_LIMIT = 1024L;
/**
* 最小内存限制MB
*/
private static final Long MIN_MEMORY_LIMIT = 16L;
@Override
public void handle(QuestionUpdateRequestDTO requestParam) {
JudgeConfig judgeConfig = requestParam.getJudgeConfig();
// 判题配置是可选的,如果为空则跳过校验
if (judgeConfig == null) {
return;
}
// 如果提供了判题配置,则进行校验
Long timeLimit = judgeConfig.getTimeLimit();
if (timeLimit != null) {
if (timeLimit < MIN_TIME_LIMIT) {
throw new ClientException(
String.format("时间限制不能小于 %d 毫秒", MIN_TIME_LIMIT),
ErrorCode.PARAMS_ERROR
);
}
if (timeLimit > MAX_TIME_LIMIT) {
throw new ClientException(
String.format("时间限制不能大于 %d 毫秒", MAX_TIME_LIMIT),
ErrorCode.PARAMS_ERROR
);
}
}
Long memoryLimit = judgeConfig.getMemoryLimit();
if (memoryLimit != null) {
if (memoryLimit < MIN_MEMORY_LIMIT) {
throw new ClientException(
String.format("内存限制不能小于 %d MB", MIN_MEMORY_LIMIT),
ErrorCode.PARAMS_ERROR
);
}
if (memoryLimit > MAX_MEMORY_LIMIT) {
throw new ClientException(
String.format("内存限制不能大于 %d MB", MAX_MEMORY_LIMIT),
ErrorCode.PARAMS_ERROR
);
}
}
log.debug("题目更新判题配置校验通过: timeLimit={}ms, memoryLimit={}MB",
timeLimit != null ? timeLimit : DEFAULT_TIME_LIMIT,
memoryLimit != null ? memoryLimit : DEFAULT_MEMORY_LIMIT);
}
@Override
public String mark() {
return ChainMarkEnums.QUESTION_UPDATE_PARAM_VERIFY_CHAIN.getMark();
}
@Override
public int getOrder() {
return 50;
}
}

View File

@@ -0,0 +1,80 @@
package cn.meowrain.aioj.backend.question.dto.chains;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.AbstractChianHandler;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import cn.meowrain.aioj.backend.question.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.question.dto.req.QuestionUpdateRequestDTO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 题目更新时标签校验(可选字段)
*/
@Slf4j
@Component
public class QuestionUpdateTagsVerifyChain implements AbstractChianHandler<QuestionUpdateRequestDTO> {
/**
* 最大标签数量
*/
private static final int MAX_TAGS_COUNT = 10;
/**
* 单个标签最大长度
*/
private static final int MAX_TAG_LENGTH = 20;
@Override
public void handle(QuestionUpdateRequestDTO requestParam) {
List<String> tags = requestParam.getTags();
// 标签是可选的,如果为空则跳过校验
if (tags == null || tags.isEmpty()) {
return;
}
// 如果提供了标签,则进行校验
if (tags.size() > MAX_TAGS_COUNT) {
throw new ClientException(
String.format("标签数量不能超过 %d 个", MAX_TAGS_COUNT),
ErrorCode.PARAMS_ERROR
);
}
for (String tag : tags) {
if (StringUtils.isBlank(tag)) {
throw new ClientException("标签不能为空", ErrorCode.PARAMS_ERROR);
}
if (tag.trim().length() > MAX_TAG_LENGTH) {
throw new ClientException(
String.format("标签长度不能超过 %d 个字符", MAX_TAG_LENGTH),
ErrorCode.PARAMS_ERROR
);
}
if (tag.contains(",") || tag.contains(";") || tag.contains("|")) {
throw new ClientException(
String.format("标签 '%s' 包含非法字符", tag),
ErrorCode.PARAMS_ERROR
);
}
}
log.debug("题目更新标签校验通过,共 {} 个标签", tags.size());
}
@Override
public String mark() {
return ChainMarkEnums.QUESTION_UPDATE_PARAM_VERIFY_CHAIN.getMark();
}
@Override
public int getOrder() {
return 60;
}
}

View File

@@ -0,0 +1,49 @@
package cn.meowrain.aioj.backend.question.dto.chains;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.AbstractChianHandler;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import cn.meowrain.aioj.backend.question.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.question.dto.req.QuestionUpdateRequestDTO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
/**
* 题目更新时标题校验(可选字段)
*/
@Slf4j
@Component
public class QuestionUpdateTitleVerifyChain implements AbstractChianHandler<QuestionUpdateRequestDTO> {
@Override
public void handle(QuestionUpdateRequestDTO requestParam) {
String title = requestParam.getTitle();
// 标题是可选的,如果为空则跳过校验
if (StringUtils.isBlank(title)) {
return;
}
// 如果提供了标题,则进行校验
if (title.length() < 2) {
throw new ClientException("题目标题长度不能少于2个字符", ErrorCode.PARAMS_ERROR);
}
if (title.length() > 100) {
throw new ClientException("题目标题长度不能超过100个字符", ErrorCode.PARAMS_ERROR);
}
log.debug("题目更新标题校验通过: {}", title);
}
@Override
public String mark() {
return ChainMarkEnums.QUESTION_UPDATE_PARAM_VERIFY_CHAIN.getMark();
}
@Override
public int getOrder() {
return 20;
}
}

View File

@@ -0,0 +1,13 @@
package cn.meowrain.aioj.backend.question.dto.chains.context;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.CommonChainContext;
import cn.meowrain.aioj.backend.question.dto.req.QuestionEditRequestDTO;
import org.springframework.stereotype.Component;
/**
* 题目编辑参数校验责任链上下文(用户编辑)
*/
@Component
public class QuestionEditRequestParamVerifyContext extends CommonChainContext<QuestionEditRequestDTO> {
}

View File

@@ -0,0 +1,13 @@
package cn.meowrain.aioj.backend.question.dto.chains.context;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.CommonChainContext;
import cn.meowrain.aioj.backend.question.dto.req.QuestionSubmitRequestDTO;
import org.springframework.stereotype.Component;
/**
* 题目提交参数校验责任链上下文
*/
@Component
public class QuestionSubmitRequestParamVerifyContext extends CommonChainContext<QuestionSubmitRequestDTO> {
}

View File

@@ -0,0 +1,13 @@
package cn.meowrain.aioj.backend.question.dto.chains.context;
import cn.meowrain.aioj.backend.framework.core.designpattern.chains.CommonChainContext;
import cn.meowrain.aioj.backend.question.dto.req.QuestionUpdateRequestDTO;
import org.springframework.stereotype.Component;
/**
* 题目更新参数校验责任链上下文
*/
@Component
public class QuestionUpdateRequestParamVerifyContext extends CommonChainContext<QuestionUpdateRequestDTO> {
}

View File

@@ -20,6 +20,14 @@ public interface QuestionService extends IService<Question> {
*/
Long createQuestionWithChain(QuestionCreateRequestDTO requestDTO);
/**
* 更新题目(使用责任链校验)
* @param questionId 题目ID
* @param requestDTO 题目编辑请求DTO
* @return 是否成功
*/
Boolean updateQuestionWithChain(Long questionId, cn.meowrain.aioj.backend.question.dto.req.QuestionEditRequestDTO requestDTO);
/**
* 创建题目
* @param question 题目信息

View File

@@ -4,6 +4,7 @@ import cn.meowrain.aioj.backend.question.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.question.dao.entity.Question;
import cn.meowrain.aioj.backend.question.dao.mapper.QuestionMapper;
import cn.meowrain.aioj.backend.question.dto.chains.context.QuestionCreateRequestParamVerifyContext;
import cn.meowrain.aioj.backend.question.dto.chains.context.QuestionEditRequestParamVerifyContext;
import cn.meowrain.aioj.backend.question.dto.req.*;
import cn.meowrain.aioj.backend.question.dto.resp.QuestionResponseDTO;
import cn.meowrain.aioj.backend.question.service.QuestionService;
@@ -17,6 +18,8 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.core.type.TypeReference;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
@@ -28,8 +31,10 @@ import java.util.List;
public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> implements QuestionService {
private final QuestionCreateRequestParamVerifyContext questionCreateChainContext;
private final QuestionEditRequestParamVerifyContext questionEditChainContext;
@Override
@Transactional(rollbackFor = Exception.class)
public Long createQuestionWithChain(QuestionCreateRequestDTO requestDTO) {
// 执行责任链校验
log.info("开始执行题目创建责任链校验");
@@ -79,17 +84,86 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long createQuestion(Question question) {
this.save(question);
return question.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean updateQuestion(Question question) {
return this.updateById(question);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean updateQuestionWithChain(Long questionId, QuestionEditRequestDTO requestDTO) {
// 先检查题目是否存在
Question existingQuestion = this.getById(questionId);
if (existingQuestion == null) {
throw new cn.meowrain.aioj.backend.framework.core.exception.ClientException(
"题目不存在题目ID: " + questionId,
cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode.NOT_FOUND_ERROR
);
}
// 执行责任链校验
log.info("开始执行题目编辑责任链校验题目ID: {}", questionId);
questionEditChainContext.handler(
ChainMarkEnums.QUESTION_EDIT_PARAM_VERIFY_CHAIN.getMark(),
requestDTO
);
log.info("题目编辑责任链校验通过");
// 校验通过,更新题目(只更新非空字段)
Question questionToUpdate = new Question();
questionToUpdate.setId(questionId);
// 使用 BeanUtils.copyProperties 的忽略空值特性
// 这里需要手动处理每个字段,因为 copyProperties 会覆盖 null 值
if (StringUtils.isNotBlank(requestDTO.getTitle())) {
questionToUpdate.setTitle(requestDTO.getTitle());
}
if (StringUtils.isNotBlank(requestDTO.getContent())) {
questionToUpdate.setContent(requestDTO.getContent());
}
if (StringUtils.isNotBlank(requestDTO.getDifficulty())) {
questionToUpdate.setDifficulty(requestDTO.getDifficulty());
}
if (StringUtils.isNotBlank(requestDTO.getAnswer())) {
questionToUpdate.setAnswer(requestDTO.getAnswer());
}
// 处理复杂字段
ObjectMapper mapper = new ObjectMapper();
if (requestDTO.getTags() != null && !requestDTO.getTags().isEmpty()) {
try {
questionToUpdate.setTags(mapper.writeValueAsString(requestDTO.getTags()));
} catch (Exception e) {
log.error("序列化 tags 失败", e);
}
}
if (requestDTO.getJudgeConfig() != null) {
try {
questionToUpdate.setJudgeConfig(mapper.writeValueAsString(requestDTO.getJudgeConfig()));
} catch (Exception e) {
log.error("序列化 judgeConfig 失败", e);
}
}
if (requestDTO.getJudgeCase() != null && !requestDTO.getJudgeCase().isEmpty()) {
try {
questionToUpdate.setJudgeCase(mapper.writeValueAsString(requestDTO.getJudgeCase()));
} catch (Exception e) {
log.error("序列化 judgeCase 失败", e);
}
}
return this.updateById(questionToUpdate);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean deleteQuestion(Long questionId) {
return this.removeById(questionId);
}

View File

@@ -1,32 +1,69 @@
package cn.meowrain.aioj.backend.question.service.impl;
import cn.meowrain.aioj.backend.framework.core.errorcode.ErrorCode;
import cn.meowrain.aioj.backend.framework.core.exception.ClientException;
import cn.meowrain.aioj.backend.question.common.enums.ChainMarkEnums;
import cn.meowrain.aioj.backend.question.dao.entity.QuestionSubmit;
import cn.meowrain.aioj.backend.question.dao.mapper.QuestionSubmitMapper;
import cn.meowrain.aioj.backend.question.dto.chains.context.QuestionSubmitRequestParamVerifyContext;
import cn.meowrain.aioj.backend.question.dto.req.QuestionSubmitRequestDTO;
import cn.meowrain.aioj.backend.question.service.QuestionSubmitService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 题目提交服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class QuestionSubmitServiceImpl extends ServiceImpl<QuestionSubmitMapper, QuestionSubmit> implements QuestionSubmitService {
@Override
public Long createSubmit(QuestionSubmit questionSubmit) {
private final QuestionSubmitRequestParamVerifyContext submitChainContext;
@Transactional(rollbackFor = Exception.class)
@Override
public Long createSubmit(QuestionSubmit questionSubmit) {
return createSubmitWithChain(questionSubmit);
}
/**
* 使用责任链模式创建题目提交
*/
private Long createSubmitWithChain(QuestionSubmit questionSubmit) {
// 将 QuestionSubmit 转换为 QuestionSubmitRequestDTO 用于责任链校验
QuestionSubmitRequestDTO requestDTO = new QuestionSubmitRequestDTO();
requestDTO.setQuestionId(questionSubmit.getQuestionId());
requestDTO.setLanguage(questionSubmit.getLanguage());
requestDTO.setCode(questionSubmit.getCode());
// 执行责任链校验
log.info("开始执行题目提交责任链校验题目ID: {}", questionSubmit.getQuestionId());
submitChainContext.handler(
ChainMarkEnums.QUESTION_SUBMIT_REQ_PARAM_VERIFY_CHAIN.getMark(),
requestDTO
);
log.info("题目提交责任链校验通过");
// 校验通过,保存提交记录
// 设置初始状态0 - 待判题
questionSubmit.setStatus(0);
this.save(questionSubmit);
return questionSubmit.getId();
}
@Override
public Boolean updateSubmitStatus(QuestionSubmit questionSubmit) {
return this.updateById(questionSubmit);
}
@Override
public Boolean updateSubmitStatus(QuestionSubmit questionSubmit) {
return this.updateById(questionSubmit);
}
@Override
public QuestionSubmit getSubmitById(Long submitId) {
return this.getById(submitId);
}
@Override
public QuestionSubmit getSubmitById(Long submitId) {
return this.getById(submitId);
}
}

View File

@@ -17,3 +17,14 @@ spring:
server-addr: 10.0.0.10:8848
username: nacos
password: nacos
sentinel:
transport:
dashboard: 10.0.0.10:8081
port: 8719
client-ip: 10.0.0.1
datasource:
flow:
nacos:
server-addr: ${spring.cloud.nacos.discovery.server-addr}
data-id: ${spring.application.name}-flow-rules
rule-type: flow