fix(education): version collection membership replacements

This commit is contained in:
2026-07-31 06:21:55 +08:00
parent 08825ddb12
commit c6b482fa2b
5 changed files with 33 additions and 6 deletions

View File

@@ -32,4 +32,5 @@ public class QuestionCollectionDO extends CatalogScopeDO {
private String metadata;
private String publicationStatus;
private Integer authoringVersion;
private Integer membershipVersion;
}

View File

@@ -65,6 +65,7 @@ public interface QuestionCollectionMapper extends BaseMapperX<QuestionCollection
.eq(QuestionCollectionDO::getDeleted, false).eq(QuestionCollectionDO::getPublicationStatus, "DRAFT")
.eq(QuestionCollectionDO::getAuthoringVersion, expectedVersion)
.set(QuestionCollectionDO::getQuestionCount, count)
.setSql("membership_version = membership_version + 1")
.set(QuestionCollectionDO::getAuthoringVersion, expectedVersion + 1));
}

View File

@@ -37,6 +37,7 @@ public class QuestionCollectionAuthoringServiceImpl implements QuestionCollectio
QuestionCollectionDO collection = new QuestionCollectionDO(); apply(collection, command);
collection.setTenantId(tenantId); collection.setScope("TENANT_OWNED"); collection.setEntryId(node.getEntryId());
collection.setQuestionCount(0); collection.setPublicationStatus("DRAFT"); collection.setAuthoringVersion(0);
collection.setMembershipVersion(0);
collection.setIsActive(false); collection.setIsHidden(true); collectionMapper.insert(collection); return collection.getId();
}

View File

@@ -3,7 +3,8 @@
ALTER TABLE education_question_collection
ADD COLUMN publication_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
ADD COLUMN authoring_version INTEGER NOT NULL DEFAULT 0;
ADD COLUMN authoring_version INTEGER NOT NULL DEFAULT 0,
ADD COLUMN membership_version INTEGER NOT NULL DEFAULT 0;
UPDATE education_question_collection
SET publication_status = CASE WHEN is_active AND NOT is_hidden THEN 'ACTIVE' ELSE 'ARCHIVED' END,
@@ -15,6 +16,7 @@ SET publication_status = CASE WHEN is_active AND NOT is_hidden THEN 'ACTIVE' ELS
ALTER TABLE education_question_collection
ADD CONSTRAINT ck_education_question_collection_publication_status CHECK (publication_status IN ('DRAFT','ACTIVE','ARCHIVED')),
ADD CONSTRAINT ck_education_question_collection_authoring_version CHECK (authoring_version >= 0),
ADD CONSTRAINT ck_education_question_collection_membership_version CHECK (membership_version >= 0),
ADD CONSTRAINT ck_education_question_collection_lifecycle_consistent CHECK (
(publication_status = 'ACTIVE' AND is_active AND NOT is_hidden) OR
(publication_status IN ('DRAFT','ARCHIVED') AND NOT is_active AND is_hidden));
@@ -51,7 +53,7 @@ BEGIN
IF NEW.scope='PUBLIC' THEN
IF NEW.publication_status<>'ACTIVE' OR NOT NEW.is_active OR NEW.is_hidden OR NEW.authoring_version<>0 THEN
RAISE EXCEPTION 'PUBLIC collection writes are not tenant authoring' USING ERRCODE='23514'; END IF;
ELSIF NEW.scope='TENANT_OWNED' AND (NEW.collection_type<>'MANUAL' OR NEW.publication_status<>'DRAFT' OR NEW.is_active OR NOT NEW.is_hidden OR NEW.authoring_version<>0 OR NEW.question_count<>0) THEN
ELSIF NEW.scope='TENANT_OWNED' AND (NEW.collection_type<>'MANUAL' OR NEW.publication_status<>'DRAFT' OR NEW.is_active OR NOT NEW.is_hidden OR NEW.authoring_version<>0 OR NEW.membership_version<>0 OR NEW.question_count<>0) THEN
RAISE EXCEPTION 'tenant collection must start empty DRAFT' USING ERRCODE='23514'; END IF;
RETURN NEW;
END IF;
@@ -62,13 +64,17 @@ BEGIN
IF NEW.authoring_version<>OLD.authoring_version+1 THEN RAISE EXCEPTION 'collection authoring version must advance exactly once' USING ERRCODE='23514'; END IF;
IF OLD.publication_status<>'DRAFT' AND (NEW.entry_id IS DISTINCT FROM OLD.entry_id OR NEW.node_id IS DISTINCT FROM OLD.node_id OR
NEW.name IS DISTINCT FROM OLD.name OR NEW.title IS DISTINCT FROM OLD.title OR NEW.collection_type IS DISTINCT FROM OLD.collection_type OR
NEW.question_count IS DISTINCT FROM OLD.question_count OR NEW.duration_minutes IS DISTINCT FROM OLD.duration_minutes OR
NEW.question_count IS DISTINCT FROM OLD.question_count OR NEW.membership_version IS DISTINCT FROM OLD.membership_version OR NEW.duration_minutes IS DISTINCT FROM OLD.duration_minutes OR
NEW.access_rules IS DISTINCT FROM OLD.access_rules OR NEW.sort_order IS DISTINCT FROM OLD.sort_order OR NEW.metadata IS DISTINCT FROM OLD.metadata) THEN
RAISE EXCEPTION 'active collection content is immutable' USING ERRCODE='23514'; END IF;
IF NEW.publication_status IS NOT DISTINCT FROM OLD.publication_status AND OLD.publication_status='DRAFT'
AND NEW.question_count IS DISTINCT FROM OLD.question_count THEN
IF NEW.membership_version IS DISTINCT FROM OLD.membership_version THEN
IF OLD.publication_status<>'DRAFT' OR NEW.publication_status<>'DRAFT' OR NEW.membership_version<>OLD.membership_version+1 THEN
RAISE EXCEPTION 'collection membership version must advance exactly once in DRAFT' USING ERRCODE='23514';
END IF;
EXECUTE format('INSERT INTO %I.education_question_collection_membership_token VALUES ($1,$2,$3,$4)',TG_TABLE_SCHEMA)
USING pg_current_xact_id()::text::BIGINT,NEW.tenant_id,NEW.id,NEW.authoring_version;
ELSIF NEW.question_count IS DISTINCT FROM OLD.question_count THEN
RAISE EXCEPTION 'collection question_count requires membership version advance' USING ERRCODE='23514';
END IF;
IF NEW.publication_status IS DISTINCT FROM OLD.publication_status THEN
PERFORM pg_advisory_xact_lock(NEW.id);
@@ -174,7 +180,7 @@ CREATE TRIGGER trg_education_collection_membership_mutation BEFORE INSERT OR UPD
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_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 question_count ON education_question_collection DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION education_require_question_collection_membership_count();
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();
CREATE CONSTRAINT TRIGGER trg_education_question_collection_audit_required AFTER UPDATE OF publication_status ON education_question_collection DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION education_require_question_collection_audit();
DO $$ DECLARE installed INTEGER; BEGIN

View File

@@ -62,6 +62,24 @@ class QuestionCollectionAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbI
assertEquals(2L, auditMapper.selectCount());
}
@Test void sameSizeAndEmptyMembershipReplacementAdvanceSharedVersion() {
Long id = service.createDraft(command(null));
assertEquals(1, service.replaceMembership(id, List.of(publishedQuestionIds.get(0)), 0));
assertEquals(2, service.replaceMembership(id, List.of(publishedQuestionIds.get(1)), 1));
assertEquals(List.of(publishedQuestionIds.get(1)), rawMembership(id));
Long empty = service.createDraft(command(null));
assertEquals(1, service.replaceMembership(empty, List.of(), 0));
assertEquals(1, collectionMapper.selectTenantOwnedById(10L, empty).getMembershipVersion());
}
private List<Long> rawMembership(Long collectionId) {
return cn.iocoder.yudao.framework.tenant.core.util.TenantUtils.executeIgnore(() -> membershipMapper.selectList(
new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<QuestionCollectionQuestionDO>()
.eq(QuestionCollectionQuestionDO::getCollectionId, collectionId)
.orderByAsc(QuestionCollectionQuestionDO::getSortOrder))).stream()
.map(QuestionCollectionQuestionDO::getQuestionId).toList();
}
@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));