feat(education): add manual question collections

This commit is contained in:
2026-07-31 03:16:58 +08:00
parent f445c61f92
commit 8861b6c8dc
22 changed files with 700 additions and 27 deletions

View File

@@ -0,0 +1,76 @@
package cn.iocoder.yudao.module.education.controller.admin.collection;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService;
import cn.iocoder.yudao.module.education.controller.admin.collection.vo.QuestionCollectionDraftReqVO;
import cn.iocoder.yudao.module.education.controller.admin.collection.vo.QuestionCollectionMembershipReqVO;
import cn.iocoder.yudao.module.education.service.collection.authoring.QuestionCollectionAuthoringCommand;
import cn.iocoder.yudao.module.education.service.collection.authoring.QuestionCollectionAuthoringService;
import org.junit.jupiter.api.*;
import org.springframework.context.annotation.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.junit.jupiter.api.extension.ExtendWith;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = QuestionCollectionAuthoringControllerContractTest.Config.class)
class QuestionCollectionAuthoringControllerContractTest {
@jakarta.annotation.Resource QuestionCollectionAuthoringController controller;
@jakarta.annotation.Resource RecordingService service;
@jakarta.annotation.Resource MutableSecurity security;
@BeforeEach void setUp() {
security.permissions.clear();
LoginUser user = new LoginUser(); user.setId(7L); user.setTenantId(10L);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, null, List.of()));
}
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
@Test void permissionsAreIndependentAndActorIsServerDerived() {
security.permissions.add("education:collection:author");
assertEquals(101L, controller.create(draft()).getData());
assertEquals(1, controller.replaceMembership(101L, membership()).getData());
assertThrows(AccessDeniedException.class, () -> controller.activate(101L, 1));
security.permissions.clear(); security.permissions.add("education:collection:publish");
assertThrows(AccessDeniedException.class, () -> controller.create(draft()));
assertEquals(2, controller.activate(101L, 1).getData());
assertEquals(7L, service.actorId);
security.permissions.clear(); security.permissions.add("education:collection:archive");
assertEquals(3, controller.archive(101L, 2).getData());
}
private QuestionCollectionDraftReqVO draft() {
QuestionCollectionDraftReqVO r = new QuestionCollectionDraftReqVO();
r.setNodeId(100L); r.setName("manual-set"); r.setTitle("Manual set"); r.setCollectionType("MANUAL");
return r;
}
private QuestionCollectionMembershipReqVO membership() {
QuestionCollectionMembershipReqVO r = new QuestionCollectionMembershipReqVO();
r.setExpectedAuthoringVersion(0); r.setQuestionIds(List.of(11L, 12L)); return r;
}
@Configuration(proxyBeanMethods = false) @EnableMethodSecurity static class Config {
@Bean("ss") MutableSecurity security() { return new MutableSecurity(); }
@Bean RecordingService service() { return new RecordingService(); }
@Bean QuestionCollectionAuthoringController controller(QuestionCollectionAuthoringService s) { return new QuestionCollectionAuthoringController(s); }
}
static class MutableSecurity implements SecurityFrameworkService {
final Set<String> permissions = new HashSet<>();
public boolean hasPermission(String p) { return permissions.contains(p); }
public boolean hasAnyPermissions(String... p) { return Arrays.stream(p).anyMatch(this::hasPermission); }
public boolean hasRole(String r) { return false; } public boolean hasAnyRoles(String... r) { return false; }
public boolean hasScope(String s) { return false; } public boolean hasAnyScopes(String... s) { return false; }
}
static class RecordingService implements QuestionCollectionAuthoringService {
Long actorId;
public Long createDraft(QuestionCollectionAuthoringCommand c) { return 101L; }
public int reviseDraft(Long id, QuestionCollectionAuthoringCommand c) { return c.expectedAuthoringVersion() + 1; }
public int replaceMembership(Long id, List<Long> q, int v) { return v + 1; }
public int activate(Long id, int v, Long actor) { actorId = actor; return v + 1; }
public int archive(Long id, int v, Long actor) { actorId = actor; return v + 1; }
}
}

View File

@@ -0,0 +1,119 @@
package cn.iocoder.yudao.module.education.service.collection.authoring;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.*;
import cn.iocoder.yudao.module.education.dal.mysql.catalog.*;
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.*;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.TestPropertySource;
import java.util.List;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
@Import({QuestionCollectionAuthoringServiceImpl.class, cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringServiceImpl.class, cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleServiceImpl.class, EducationProperties.class})
@TestPropertySource(properties = {"yudao.education.enabled=true", "yudao.education.catalog-mode=JAVA_READ"})
class QuestionCollectionAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest {
@Resource EducationProperties properties;
@Resource QuestionCollectionAuthoringService service;
@Resource cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringService nodeService;
@Resource QuestionCollectionMapper collectionMapper;
@Resource QuestionCollectionQuestionMapper membershipMapper;
@Resource QuestionCollectionLifecycleAuditMapper auditMapper;
@Resource ContentNodeLifecycleAuditMapper nodeAuditMapper;
@Resource ContentEntryMapper entryMapper;
@Resource ContentNodeMapper nodeMapper;
@Resource cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService questionService;
@Resource QuestionMapper questionMapper;
private Long activeNodeId;
private List<Long> publishedQuestionIds;
@BeforeEach void setUp() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
TenantContextHolder.setTenantId(10L);
ContentEntryDO entry = new ContentEntryDO(); entry.setId(100L); entry.setTenantId(10L); entry.setScope("TENANT_OWNED");
entry.setEntryKey("questions"); entry.setName("Questions"); entry.setEntryType("question"); entryMapper.insert(entry);
activeNodeId = nodeService.createDraft(new cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringCommand(
100L, null, "Active node", null, "category", null, true, 0, null, null));
nodeService.activate(activeNodeId, 0, 7L);
Long first = createPublishedQuestion();
Long second = createPublishedQuestion();
publishedQuestionIds = List.of(first, second);
}
@AfterEach void clear() { TenantContextHolder.clear(); }
@Test void draftMembershipActivationAndArchiveGateOnlyCollectionRoute() {
Long id = service.createDraft(command(null));
assertTrue(collectionMapper.selectActiveList(10L, 100L, activeNodeId, null, 10).isEmpty());
assertEquals(1, service.replaceMembership(id, List.of(publishedQuestionIds.get(1), publishedQuestionIds.get(0)), 0));
assertEquals(2, service.activate(id, 1, 7L));
assertEquals(List.of(publishedQuestionIds.get(1), publishedQuestionIds.get(0)), membershipMapper.selectByCollectionId(10L, id).stream().map(QuestionCollectionQuestionDO::getQuestionId).toList());
assertEquals(1, collectionMapper.selectActiveList(10L, 100L, activeNodeId, null, 10).size());
assertNotNull(collectionMapper.selectAvailableForStudent(10L, id));
assertEquals(2, collectionMapper.selectTenantOwnedById(10L, id).getQuestionCount());
assertEquals(3, service.archive(id, 2, 7L));
assertNull(collectionMapper.selectAvailableForStudent(10L, id));
assertNotNull(questionMapper.selectPublishedById(10L, publishedQuestionIds.get(0)));
assertEquals(2L, auditMapper.selectCount());
}
@Test void duplicateAndIneligibleMembershipFailClosedWithoutChangingVersion() {
Long id = service.createDraft(command(null));
ServiceException duplicate = assertThrows(ServiceException.class, () -> service.replaceMembership(id, List.of(publishedQuestionIds.get(0), publishedQuestionIds.get(0)), 0));
assertEquals(QUESTION_COLLECTION_MEMBERSHIP_DUPLICATE.getCode(), duplicate.getCode());
Long draftId = questionService.createDraft(new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand(
"Draft", "choice", "easy", List.of(
new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand.QuestionDraftOption("A", "Yes", 0.0),
new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand.QuestionDraftOption("B", "No", 1.0)),
"A", "Explanation", null));
ServiceException invalid = assertThrows(ServiceException.class, () -> service.replaceMembership(id, List.of(draftId), 0));
assertEquals(QUESTION_COLLECTION_MEMBERSHIP_INVALID.getCode(), invalid.getCode());
assertEquals(0, collectionMapper.selectTenantOwnedById(10L, id).getAuthoringVersion());
}
@Test void unsupportedProviderFailsBeforePersistence() {
properties.setCatalogMode(CatalogProviderMode.SCALAR_READ);
ServiceException unsupported = assertThrows(ServiceException.class, () -> service.createDraft(command(null)));
assertEquals(QUESTION_COLLECTION_PROVIDER_UNSUPPORTED.getCode(), unsupported.getCode());
assertEquals(0L, collectionMapper.selectCount());
}
@Test void otherTenantCannotManageCollection() {
Long id = service.createDraft(command(null));
TenantContextHolder.setTenantId(20L);
ServiceException notFound = assertThrows(ServiceException.class, () -> service.activate(id, 0, 8L));
assertEquals(QUESTION_COLLECTION_AUTHORING_NOT_FOUND.getCode(), notFound.getCode());
}
@Test void staleAndActiveMutationAreRejected() {
Long id = service.createDraft(command(null));
service.replaceMembership(id, List.of(publishedQuestionIds.get(0)), 0); service.activate(id, 1, 7L);
ServiceException active = assertThrows(ServiceException.class, () -> service.replaceMembership(id, List.of(publishedQuestionIds.get(1)), 2));
assertEquals(QUESTION_COLLECTION_AUTHORING_CONFLICT.getCode(), active.getCode());
ServiceException stale = assertThrows(ServiceException.class, () -> service.archive(id, 1, 7L));
assertEquals(QUESTION_COLLECTION_AUTHORING_CONFLICT.getCode(), stale.getCode());
}
private QuestionCollectionAuthoringCommand command(Integer version) {
return new QuestionCollectionAuthoringCommand(activeNodeId, "manual", "Manual", "MANUAL", 30, "{\"tier\":\"vip\"}", 0, null, version);
}
private Long createPublishedQuestion() {
Long id = questionService.createDraft(new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand(
"Question", "choice", "easy", List.of(
new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand.QuestionDraftOption("A", "Yes", 0.0),
new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand.QuestionDraftOption("B", "No", 1.0)),
"A", "Explanation", null));
questionService.place(id, new cn.iocoder.yudao.module.education.service.question.authoring.QuestionPlacementCommand(activeNodeId, 0));
questionService.publish(id, 7L);
return id;
}
private QuestionDO question(Long id, Long tenant, String scope, String status, boolean published) {
QuestionDO q = new QuestionDO(); q.setId(id); q.setTenantId(tenant); q.setScope(scope); q.setContentVersion(1);
q.setStem("Question " + id); q.setType("choice"); q.setOptions("[{\"label\":\"A\",\"content\":\"Yes\"}]");
q.setStatus(status); q.setIsPublished(published); q.setPlacementVersion(0); return q;
}
}

View File

@@ -119,7 +119,7 @@ class EducationFlywayMigrationIntegrationTest {
assertThat(queryStrings(schema,
"SELECT COALESCE(version, 'BASELINE') FROM flyway_schema_history ORDER BY installed_rank"))
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100");
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
"AND table_name = 'education_idempotency'"))
@@ -181,7 +181,7 @@ class EducationFlywayMigrationIntegrationTest {
assertThat(queryStrings(schema,
"SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank"))
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100");
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110");
assertThat(queryStrings(schema,
"SELECT table_name FROM information_schema.tables " +
"WHERE table_schema = current_schema() AND table_name IN (" +

View File

@@ -1,10 +1,13 @@
-- PostgreSQL persistence test cleanup.
-- Module-owned Flyway creates the disposable schema; this only isolates test data.
TRUNCATE TABLE
education_question_collection_lifecycle_audit,
education_content_node_lifecycle_audit,
education_question_lifecycle_transition_token,
education_question_lifecycle_audit,
education_question_version,
education_question_collection_question,
education_question_collection,
education_question,
education_idempotency,
education_favorite,