fix(education): harden manual collection authoring

This commit is contained in:
2026-07-31 04:52:20 +08:00
parent 8861b6c8dc
commit de7fe04c0c
10 changed files with 85 additions and 12 deletions

View File

@@ -40,7 +40,7 @@ public class QuestionCollectionAuthoringController {
return success(service.archive(id, expectedAuthoringVersion, getLoginUserId()));
}
private QuestionCollectionAuthoringCommand toCommand(QuestionCollectionDraftReqVO r, Integer version) {
return new QuestionCollectionAuthoringCommand(r.getNodeId(), r.getName(), r.getTitle(), r.getCollectionType(),
return new QuestionCollectionAuthoringCommand(r.getNodeId(), r.getName(), r.getTitle(),
r.getDurationMinutes(), r.getAccessRules(), r.getSortOrder(), r.getMetadata(), version);
}
}

View File

@@ -8,7 +8,6 @@ public class QuestionCollectionDraftReqVO {
@NotNull private Long nodeId;
@NotBlank @Size(max = 200) private String name;
@Size(max = 200) private String title;
@NotBlank @Size(max = 50) private String collectionType;
@Positive private Integer durationMinutes;
private String accessRules;
private Integer sortOrder;

View File

@@ -1,11 +1,12 @@
package cn.iocoder.yudao.module.education.controller.admin.collection.vo;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
@Data
public class QuestionCollectionMembershipReqVO {
@NotNull private Integer expectedAuthoringVersion;
@NotNull private List<@NotNull Long> questionIds;
@NotNull @Size(max = 1000) private List<@NotNull Long> questionIds;
}

View File

@@ -123,4 +123,5 @@ public interface ErrorCodeConstants {
ErrorCode QUESTION_COLLECTION_NODE_UNAVAILABLE = new ErrorCode(1_005_002_033, "题集内容节点不存在或不可用");
ErrorCode QUESTION_COLLECTION_MEMBERSHIP_DUPLICATE = new ErrorCode(1_005_002_034, "题集成员包含重复题目");
ErrorCode QUESTION_COLLECTION_MEMBERSHIP_INVALID = new ErrorCode(1_005_002_035, "题集成员必须是当前租户已发布题目");
ErrorCode QUESTION_COLLECTION_MEMBERSHIP_TOO_LARGE = new ErrorCode(1_005_002_036, "题集成员数量不能超过 {}");
}

View File

@@ -1,6 +1,6 @@
package cn.iocoder.yudao.module.education.service.collection.authoring;
public record QuestionCollectionAuthoringCommand(Long nodeId, String name, String title, String collectionType,
public record QuestionCollectionAuthoringCommand(Long nodeId, String name, String title,
Integer durationMinutes, String accessRules, Integer sortOrder,
String metadata, Integer expectedAuthoringVersion) {
}

View File

@@ -15,6 +15,7 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
@Service
public class QuestionCollectionAuthoringServiceImpl implements QuestionCollectionAuthoringService {
private static final int MAX_MEMBERSHIP_SIZE = 1000;
private final EducationProperties properties;
private final QuestionCollectionMapper collectionMapper;
private final QuestionCollectionQuestionMapper membershipMapper;
@@ -52,6 +53,7 @@ public class QuestionCollectionAuthoringServiceImpl implements QuestionCollectio
public int replaceMembership(Long id, List<Long> questionIds, int expectedVersion) {
assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId(); requireDraft(tenantId, id, expectedVersion);
List<Long> ids = questionIds != null ? questionIds : List.of();
if (ids.size() > MAX_MEMBERSHIP_SIZE) throw exception(QUESTION_COLLECTION_MEMBERSHIP_TOO_LARGE, MAX_MEMBERSHIP_SIZE);
if (new HashSet<>(ids).size() != ids.size()) throw exception(QUESTION_COLLECTION_MEMBERSHIP_DUPLICATE);
if (!ids.isEmpty() && TenantUtils.executeIgnore(() -> questionMapper.countTenantOwnedPublishedByIds(tenantId, ids)) != ids.size()) {
throw exception(QUESTION_COLLECTION_MEMBERSHIP_INVALID);
@@ -90,7 +92,7 @@ public class QuestionCollectionAuthoringServiceImpl implements QuestionCollectio
if (node == null) throw exception(QUESTION_COLLECTION_NODE_UNAVAILABLE); return node;
}
private void apply(QuestionCollectionDO c, QuestionCollectionAuthoringCommand x) {
c.setNodeId(x.nodeId()); c.setName(x.name()); c.setTitle(x.title()); c.setCollectionType(x.collectionType());
c.setNodeId(x.nodeId()); c.setName(x.name()); c.setTitle(x.title()); c.setCollectionType("MANUAL");
c.setDurationMinutes(x.durationMinutes()); c.setAccessRules(x.accessRules()); c.setSortOrder(x.sortOrder() != null ? x.sortOrder() : 0); c.setMetadata(x.metadata());
}
private void assertMode() {

View File

@@ -50,11 +50,12 @@ 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.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.question_count<>0) THEN
RAISE EXCEPTION 'tenant collection must start empty DRAFT' USING ERRCODE='23514'; END IF;
RETURN NEW;
END IF;
IF NEW.scope='PUBLIC' AND NEW IS DISTINCT FROM OLD THEN RAISE EXCEPTION 'PUBLIC collection writes are not tenant authoring' USING ERRCODE='23514'; END IF;
IF OLD.scope='TENANT_OWNED' AND NEW.collection_type IS DISTINCT FROM OLD.collection_type THEN RAISE EXCEPTION 'tenant collection type is immutable MANUAL' USING ERRCODE='23514'; END IF;
IF OLD.scope='TENANT_OWNED' AND NEW.deleted IS DISTINCT FROM OLD.deleted THEN RAISE EXCEPTION 'collection cannot be logically deleted' USING ERRCODE='23514'; END IF;
IF OLD.publication_status='ARCHIVED' AND NEW IS DISTINCT FROM OLD THEN RAISE EXCEPTION 'archived collection is immutable' USING ERRCODE='23514'; END IF;
IF NEW.authoring_version<>OLD.authoring_version+1 THEN RAISE EXCEPTION 'collection authoring version must advance exactly once' USING ERRCODE='23514'; END IF;
@@ -78,7 +79,7 @@ END $$;
CREATE FUNCTION education_enforce_collection_membership_mutation() RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
DECLARE c_status VARCHAR(16); c_version INTEGER; c_tenant BIGINT; c_scope VARCHAR(20); q_tenant BIGINT; q_scope VARCHAR(20); q_status VARCHAR(20); q_published BOOLEAN; token_count INTEGER;
DECLARE c_status VARCHAR(16); c_version INTEGER; c_tenant BIGINT; c_scope VARCHAR(20); q_tenant BIGINT; q_scope VARCHAR(20); q_status VARCHAR(20); q_published BOOLEAN; q_deleted BOOLEAN; token_count INTEGER;
BEGIN
IF TG_OP='UPDATE' THEN RAISE EXCEPTION 'collection membership replacement requires physical delete and insert' USING ERRCODE='23514'; END IF;
EXECUTE format('SELECT tenant_id,scope,publication_status,authoring_version FROM %I.education_question_collection WHERE id=$1 FOR UPDATE',TG_TABLE_SCHEMA)
@@ -89,9 +90,9 @@ BEGIN
IF token_count<>1 THEN RAISE EXCEPTION 'collection membership mutation requires transaction token' USING ERRCODE='23514'; END IF;
IF TG_OP='DELETE' THEN RETURN OLD; END IF;
IF NEW.deleted OR NEW.tenant_id IS DISTINCT FROM c_tenant OR NEW.scope IS DISTINCT FROM c_scope THEN RAISE EXCEPTION 'membership ownership must equal collection' USING ERRCODE='23514'; END IF;
EXECUTE format('SELECT tenant_id,scope,status,is_published FROM %I.education_question WHERE id=$1 FOR SHARE',TG_TABLE_SCHEMA)
INTO q_tenant,q_scope,q_status,q_published USING NEW.question_id;
IF q_tenant IS DISTINCT FROM c_tenant OR q_scope IS DISTINCT FROM 'TENANT_OWNED' OR q_status IS DISTINCT FROM 'PUBLISHED' OR NOT q_published THEN
EXECUTE format('SELECT tenant_id,scope,status,is_published,deleted FROM %I.education_question WHERE id=$1 FOR SHARE',TG_TABLE_SCHEMA)
INTO q_tenant,q_scope,q_status,q_published,q_deleted USING NEW.question_id;
IF q_tenant IS DISTINCT FROM c_tenant OR q_scope IS DISTINCT FROM 'TENANT_OWNED' OR q_status IS DISTINCT FROM 'PUBLISHED' OR NOT q_published OR q_deleted THEN
RAISE EXCEPTION 'membership question must be current-tenant published' USING ERRCODE='23514'; END IF;
RETURN NEW;
END $$;
@@ -111,6 +112,20 @@ BEGIN
RETURN NEW;
END $$;
CREATE FUNCTION education_require_question_collection_membership_count() RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
DECLARE actual_count INTEGER; consumed INTEGER;
BEGIN
IF OLD.publication_status IS DISTINCT FROM 'DRAFT' OR NEW.publication_status IS DISTINCT FROM 'DRAFT' THEN RETURN NULL; END IF;
EXECUTE format('SELECT count(*) FROM %I.education_question_collection_question WHERE tenant_id=$1 AND collection_id=$2 AND deleted=false',TG_TABLE_SCHEMA)
INTO actual_count USING NEW.tenant_id,NEW.id;
IF actual_count IS DISTINCT FROM NEW.question_count THEN RAISE EXCEPTION 'collection question_count must equal physical membership' USING ERRCODE='23514'; END IF;
EXECUTE format('DELETE FROM %I.education_question_collection_membership_token WHERE transaction_id=$1 AND tenant_id=$2 AND collection_id=$3 AND authoring_version=$4',TG_TABLE_SCHEMA)
USING pg_current_xact_id()::text::BIGINT,NEW.tenant_id,NEW.id,NEW.authoring_version;
GET DIAGNOSTICS consumed=ROW_COUNT; IF consumed<>1 THEN RAISE EXCEPTION 'collection membership transaction token required' USING ERRCODE='23514'; END IF;
RETURN NULL;
END $$;
CREATE FUNCTION education_require_question_collection_audit() RETURNS TRIGGER
LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$ DECLARE n INTEGER; BEGIN
IF NEW.publication_status IS NOT DISTINCT FROM OLD.publication_status THEN RETURN NULL; END IF;
@@ -122,12 +137,14 @@ CREATE FUNCTION education_prevent_question_collection_audit_mutation() RETURNS T
REVOKE ALL ON FUNCTION education_enforce_question_collection_authoring() FROM PUBLIC;
REVOKE ALL ON FUNCTION education_enforce_collection_membership_mutation() FROM PUBLIC;
REVOKE ALL ON FUNCTION education_validate_question_collection_audit() FROM PUBLIC;
REVOKE ALL ON FUNCTION education_require_question_collection_membership_count() FROM PUBLIC;
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_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_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