fix(education): guard collection question deletion
This commit is contained in:
@@ -103,6 +103,9 @@ public class QuestionCatalogServiceImpl implements QuestionCatalogService {
|
||||
public PracticeConfigPreviewRespVO previewPracticeConfig(PracticeConfigPreviewReqVO reqVO) {
|
||||
assertEnabled();
|
||||
|
||||
if (reqVO.getCollectionId() != null && reqVO.getNodeId() != null && !reqVO.getNodeId().isBlank()) {
|
||||
throw exception(INVALID_PRACTICE_CONFIG, "题集与目录节点不能同时指定");
|
||||
}
|
||||
int requestedCount = reqVO.getQuestionCount() != null ? reqVO.getQuestionCount() : 10;
|
||||
|
||||
CatalogPracticeBlueprintDTO blueprint = provider.getPracticeBlueprint(
|
||||
|
||||
@@ -117,6 +117,9 @@ CREATE FUNCTION education_block_active_collection_question_archive() RETURNS TRI
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
|
||||
DECLARE active_collection BIGINT;
|
||||
BEGIN
|
||||
IF NOT OLD.deleted AND NEW.deleted AND OLD.scope='TENANT_OWNED' THEN
|
||||
RAISE EXCEPTION 'tenant question cannot be logically deleted' USING ERRCODE='23514';
|
||||
END IF;
|
||||
IF OLD.status='PUBLISHED' AND NEW.status='ARCHIVED' THEN
|
||||
EXECUTE format('SELECT min(collection.id) FROM %I.education_question_collection_question membership JOIN %I.education_question_collection collection ON collection.id=membership.collection_id WHERE membership.question_id=$1 AND NOT membership.deleted AND collection.publication_status=''ACTIVE'' AND NOT collection.deleted',TG_TABLE_SCHEMA,TG_TABLE_SCHEMA)
|
||||
INTO active_collection USING NEW.id;
|
||||
@@ -177,7 +180,7 @@ REVOKE ALL ON FUNCTION education_require_question_collection_audit() FROM PUBLIC
|
||||
REVOKE ALL ON FUNCTION education_prevent_question_collection_audit_mutation() FROM PUBLIC;
|
||||
CREATE TRIGGER trg_education_question_collection_authoring BEFORE INSERT OR UPDATE ON education_question_collection FOR EACH ROW EXECUTE FUNCTION education_enforce_question_collection_authoring();
|
||||
CREATE TRIGGER trg_education_collection_membership_mutation BEFORE INSERT OR UPDATE OR DELETE ON education_question_collection_question FOR EACH ROW EXECUTE FUNCTION education_enforce_collection_membership_mutation();
|
||||
CREATE TRIGGER trg_education_question_active_collection_archive BEFORE UPDATE OF status ON education_question FOR EACH ROW EXECUTE FUNCTION education_block_active_collection_question_archive();
|
||||
CREATE TRIGGER trg_education_question_active_collection_archive BEFORE UPDATE OF status, deleted ON education_question FOR EACH ROW EXECUTE FUNCTION education_block_active_collection_question_archive();
|
||||
CREATE TRIGGER trg_education_question_collection_audit_validate BEFORE INSERT ON education_question_collection_lifecycle_audit FOR EACH ROW EXECUTE FUNCTION education_validate_question_collection_audit();
|
||||
CREATE TRIGGER trg_education_question_collection_audit_immutable BEFORE UPDATE OR DELETE ON education_question_collection_lifecycle_audit FOR EACH ROW EXECUTE FUNCTION education_prevent_question_collection_audit_mutation();
|
||||
CREATE CONSTRAINT TRIGGER trg_education_question_collection_membership_count AFTER UPDATE OF membership_version ON education_question_collection DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION education_require_question_collection_membership_count();
|
||||
|
||||
@@ -296,6 +296,17 @@ class QuestionControllerHttpTest {
|
||||
|
||||
// ========== Error scenarios ==========
|
||||
|
||||
@Test
|
||||
void shouldRejectPreviewWithCollectionAndNode() throws Exception {
|
||||
setLoginUser(100L);
|
||||
mockMvc.perform(get("/education/practice-config/preview")
|
||||
.param("collectionId", "col1")
|
||||
.param("nodeId", "node1"))
|
||||
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()))
|
||||
.andExpect(jsonPath("$.code").value(INVALID_PRACTICE_CONFIG.getCode()));
|
||||
verify(provider, never()).getPracticeBlueprint(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnQuestionNotFoundForMissingQuestion() throws Exception {
|
||||
setLoginUser(100L);
|
||||
|
||||
@@ -29,6 +29,7 @@ class QuestionCollectionAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbI
|
||||
@Resource ContentNodeMapper nodeMapper;
|
||||
@Resource cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService questionService;
|
||||
@Resource QuestionMapper questionMapper;
|
||||
@Resource javax.sql.DataSource dataSource;
|
||||
@Resource cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider catalogProvider;
|
||||
private Long activeNodeId;
|
||||
private List<Long> publishedQuestionIds;
|
||||
@@ -116,6 +117,16 @@ class QuestionCollectionAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbI
|
||||
assertEquals(QUESTION_COLLECTION_AUTHORING_NOT_FOUND.getCode(), notFound.getCode());
|
||||
}
|
||||
|
||||
@Test void logicalQuestionDeleteIsRejected() {
|
||||
assertThrows(java.sql.SQLException.class, () -> {
|
||||
try (var connection = dataSource.getConnection(); var statement = connection.prepareStatement(
|
||||
"UPDATE education_question SET deleted=true WHERE id=?")) {
|
||||
statement.setLong(1, publishedQuestionIds.get(0)); statement.executeUpdate();
|
||||
}
|
||||
});
|
||||
assertNotNull(questionMapper.selectPublishedById(10L, publishedQuestionIds.get(0)));
|
||||
}
|
||||
|
||||
@Test void activeCollectionBlocksQuestionArchiveUntilCollectionArchive() {
|
||||
Long id = service.createDraft(command(null));
|
||||
service.replaceMembership(id, List.of(publishedQuestionIds.get(0)), 0);
|
||||
|
||||
@@ -18,7 +18,7 @@ import java.util.List;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* QuestionCatalogServiceImpl 单元测试 — 测试安全字段剥离、fail-closed 可见性验证、真实总数。
|
||||
@@ -345,6 +345,16 @@ class QuestionCatalogServiceImplTest {
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void shouldRejectCollectionAndNodeTogether() {
|
||||
PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO();
|
||||
req.setCollectionId("col1");
|
||||
req.setNodeId("node1");
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.previewPracticeConfig(req));
|
||||
assertEquals(INVALID_PRACTICE_CONFIG.getCode(), ex.getCode());
|
||||
verify(provider, never()).getPracticeBlueprint(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnNormalizedPracticePreview() {
|
||||
CatalogPracticeBlueprintDTO bp = CatalogPracticeBlueprintDTO.builder()
|
||||
@@ -357,12 +367,11 @@ class QuestionCatalogServiceImplTest {
|
||||
.suggestedCount(20)
|
||||
.build();
|
||||
|
||||
when(provider.getPracticeBlueprint(eq("col1"), eq("node1"), eq("choice"), eq("easy")))
|
||||
when(provider.getPracticeBlueprint(eq("col1"), isNull(), eq("choice"), eq("easy")))
|
||||
.thenReturn(bp);
|
||||
|
||||
PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO();
|
||||
req.setCollectionId("col1");
|
||||
req.setNodeId("node1");
|
||||
req.setType("choice");
|
||||
req.setDifficulty("easy");
|
||||
req.setQuestionCount(10);
|
||||
|
||||
Reference in New Issue
Block a user