forked from wangziqi/ruoyi-vue-pro
feat(education): add manual question collections
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
-- EDU-010: current-tenant manual Question Collection authoring lifecycle.
|
||||
-- JAVA_READ application authority is enforced in Java; database guards protect direct mutation.
|
||||
|
||||
ALTER TABLE education_question_collection
|
||||
ADD COLUMN publication_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
ADD COLUMN authoring_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,
|
||||
is_active = is_active AND NOT is_hidden,
|
||||
is_hidden = NOT (is_active AND NOT is_hidden),
|
||||
question_count = (SELECT count(*) FROM education_question_collection_question membership
|
||||
WHERE membership.collection_id = education_question_collection.id AND membership.deleted = false);
|
||||
|
||||
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_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));
|
||||
|
||||
CREATE TABLE education_question_collection_lifecycle_audit (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL, collection_id BIGINT NOT NULL, authoring_version INTEGER NOT NULL,
|
||||
actor_id BIGINT NOT NULL, from_status VARCHAR(16) NOT NULL, to_status VARCHAR(16) NOT NULL,
|
||||
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
creator VARCHAR(64) DEFAULT '', create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updater VARCHAR(64) DEFAULT '', update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||
FOREIGN KEY (collection_id) REFERENCES education_question_collection(id),
|
||||
CHECK ((from_status='DRAFT' AND to_status='ACTIVE') OR (from_status='ACTIVE' AND to_status='ARCHIVED')),
|
||||
UNIQUE (tenant_id, collection_id, authoring_version));
|
||||
|
||||
CREATE TABLE education_question_collection_lifecycle_transition_token (
|
||||
transaction_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, collection_id BIGINT NOT NULL,
|
||||
authoring_version INTEGER NOT NULL, from_status VARCHAR(16) NOT NULL, to_status VARCHAR(16) NOT NULL,
|
||||
PRIMARY KEY(transaction_id,tenant_id,collection_id,authoring_version,from_status,to_status));
|
||||
REVOKE ALL ON TABLE education_question_collection_lifecycle_transition_token FROM PUBLIC;
|
||||
|
||||
CREATE TABLE education_question_collection_membership_token (
|
||||
transaction_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, collection_id BIGINT NOT NULL, authoring_version INTEGER NOT NULL,
|
||||
PRIMARY KEY(transaction_id,tenant_id,collection_id,authoring_version));
|
||||
REVOKE ALL ON TABLE education_question_collection_membership_token FROM PUBLIC;
|
||||
|
||||
CREATE FUNCTION education_enforce_question_collection_authoring() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
|
||||
BEGIN
|
||||
IF TG_OP='INSERT' THEN
|
||||
IF NEW.deleted THEN RAISE EXCEPTION 'collection cannot start deleted' USING ERRCODE='23514'; END IF;
|
||||
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
|
||||
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.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;
|
||||
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.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' THEN
|
||||
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;
|
||||
END IF;
|
||||
IF NEW.publication_status IS DISTINCT FROM OLD.publication_status THEN
|
||||
IF NOT ((OLD.publication_status='DRAFT' AND NEW.publication_status='ACTIVE') OR (OLD.publication_status='ACTIVE' AND NEW.publication_status='ARCHIVED')) THEN
|
||||
RAISE EXCEPTION 'invalid collection lifecycle transition' USING ERRCODE='23514'; END IF;
|
||||
EXECUTE format('INSERT INTO %I.education_question_collection_lifecycle_transition_token VALUES ($1,$2,$3,$4,$5,$6)',TG_TABLE_SCHEMA)
|
||||
USING pg_current_xact_id()::text::BIGINT,NEW.tenant_id,NEW.id,NEW.authoring_version,OLD.publication_status,NEW.publication_status;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
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;
|
||||
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)
|
||||
INTO c_tenant,c_scope,c_status,c_version USING COALESCE(NEW.collection_id,OLD.collection_id);
|
||||
IF c_status IS DISTINCT FROM 'DRAFT' OR c_scope IS DISTINCT FROM 'TENANT_OWNED' THEN RAISE EXCEPTION 'collection membership is DRAFT-only' USING ERRCODE='23514'; END IF;
|
||||
EXECUTE format('SELECT count(*) 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)
|
||||
INTO token_count USING pg_current_xact_id()::text::BIGINT,c_tenant,COALESCE(NEW.collection_id,OLD.collection_id),c_version;
|
||||
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
|
||||
RAISE EXCEPTION 'membership question must be current-tenant published' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION education_validate_question_collection_audit() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
|
||||
DECLARE c_tenant BIGINT; c_version INTEGER; c_status VARCHAR(16); consumed INTEGER;
|
||||
BEGIN
|
||||
IF NEW.deleted THEN RAISE EXCEPTION 'collection audit cannot be deleted' USING ERRCODE='23514'; END IF;
|
||||
EXECUTE format('SELECT tenant_id,authoring_version,publication_status FROM %I.education_question_collection WHERE id=$1 FOR SHARE',TG_TABLE_SCHEMA)
|
||||
INTO c_tenant,c_version,c_status USING NEW.collection_id;
|
||||
IF c_tenant IS DISTINCT FROM NEW.tenant_id OR c_version IS DISTINCT FROM NEW.authoring_version OR c_status IS DISTINCT FROM NEW.to_status THEN
|
||||
RAISE EXCEPTION 'collection audit must accompany transition' USING ERRCODE='23514'; END IF;
|
||||
EXECUTE format('DELETE FROM %I.education_question_collection_lifecycle_transition_token WHERE transaction_id=$1 AND tenant_id=$2 AND collection_id=$3 AND authoring_version=$4 AND from_status=$5 AND to_status=$6',TG_TABLE_SCHEMA)
|
||||
USING pg_current_xact_id()::text::BIGINT,NEW.tenant_id,NEW.collection_id,NEW.authoring_version,NEW.from_status,NEW.to_status;
|
||||
GET DIAGNOSTICS consumed=ROW_COUNT; IF consumed<>1 THEN RAISE EXCEPTION 'collection audit must accompany transition' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
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;
|
||||
EXECUTE format('SELECT count(*) FROM %I.education_question_collection_lifecycle_audit WHERE tenant_id=$1 AND collection_id=$2 AND authoring_version=$3 AND from_status=$4 AND to_status=$5 AND deleted=false',TG_TABLE_SCHEMA)
|
||||
INTO n USING NEW.tenant_id,NEW.id,NEW.authoring_version,OLD.publication_status,NEW.publication_status;
|
||||
IF n<>1 THEN RAISE EXCEPTION 'collection lifecycle audit required' USING ERRCODE='23514'; END IF; RETURN NULL; END $$;
|
||||
CREATE FUNCTION education_prevent_question_collection_audit_mutation() RETURNS TRIGGER LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$ BEGIN RAISE EXCEPTION 'collection audit is append-only' USING ERRCODE='23514'; END $$;
|
||||
|
||||
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_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_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
|
||||
IF to_regclass('system_menu') IS NULL THEN RETURN; END IF;
|
||||
INSERT INTO system_menu(id,name,permission,type,sort,parent_id,path,icon,component,component_name,status,visible,keep_alive,always_show,creator,updater) VALUES
|
||||
(6806,'题集创作','education:collection:author',3,6,6800,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'),
|
||||
(6807,'题集发布','education:collection:publish',3,7,6800,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'),
|
||||
(6808,'题集归档','education:collection:archive',3,8,6800,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') ON CONFLICT(id) DO NOTHING;
|
||||
SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ((id=6806 AND permission='education:collection:author' AND type=3 AND parent_id=6800) OR (id=6807 AND permission='education:collection:publish' AND type=3 AND parent_id=6800) OR (id=6808 AND permission='education:collection:archive' AND type=3 AND parent_id=6800));
|
||||
IF installed<>3 THEN RAISE EXCEPTION 'Education collection RBAC seed IDs conflict' USING ERRCODE='23505'; END IF;
|
||||
END $$;
|
||||
Reference in New Issue
Block a user