feat(education): add native question publication lifecycle

This commit is contained in:
2026-07-30 21:31:08 +08:00
parent 4db4a7d371
commit 34bc1fe41e
22 changed files with 2044 additions and 20 deletions

View File

@@ -0,0 +1,487 @@
-- EDU-010: make native question authoring draft-first and keep lifecycle audit
-- in the same PostgreSQL transaction as publication state changes.
CREATE FUNCTION education_question_answer_key_is_valid(
question_type VARCHAR,
answer_key VARCHAR,
question_options JSONB
)
RETURNS BOOLEAN
LANGUAGE plpgsql
IMMUTABLE
SET search_path = pg_catalog, pg_temp
AS $$
DECLARE
normalized_type VARCHAR;
answer_labels JSONB;
option_count INTEGER;
distinct_label_count INTEGER;
ordered_option_count INTEGER;
distinct_order_count INTEGER;
BEGIN
normalized_type := lower(trim(COALESCE(question_type, '')));
IF answer_key IS NULL OR btrim(answer_key) = ''
OR jsonb_typeof(question_options) <> 'array' THEN
RETURN false;
END IF;
IF normalized_type IN (
'fill', 'text', 'terms', 'short_answer', 'composition', 'discuss',
'translation', 'case_analysis', 'brief_analysis', 'calculation',
'analysis_design', 'combination', 'solution'
) THEN
RETURN jsonb_array_length(question_options) = 0;
END IF;
IF normalized_type NOT IN ('choice', 'multi', 'multi_choice', 'judge', 'image')
OR jsonb_array_length(question_options) < 2 THEN
RETURN false;
END IF;
SELECT count(*),
count(DISTINCT btrim(item ->> 'label')),
count(*) FILTER (WHERE jsonb_typeof(item -> 'order') = 'number'),
count(DISTINCT (item ->> 'order')::NUMERIC)
FILTER (WHERE jsonb_typeof(item -> 'order') = 'number')
INTO option_count, distinct_label_count, ordered_option_count, distinct_order_count
FROM jsonb_array_elements(question_options) item
WHERE jsonb_typeof(item) = 'object'
AND (SELECT count(*) FROM jsonb_object_keys(item)) = 3
AND item ?& ARRAY['label', 'content', 'order']
AND jsonb_typeof(item -> 'label') = 'string'
AND btrim(item ->> 'label') <> ''
AND jsonb_typeof(item -> 'content') = 'string'
AND btrim(item ->> 'content') <> ''
AND jsonb_typeof(item -> 'order') IN ('number', 'null');
IF option_count <> jsonb_array_length(question_options)
OR distinct_label_count <> option_count
OR distinct_order_count <> ordered_option_count THEN
RETURN false;
END IF;
IF normalized_type IN ('multi', 'multi_choice') THEN
BEGIN
answer_labels := answer_key::JSONB;
EXCEPTION WHEN others THEN
RETURN false;
END;
IF jsonb_typeof(answer_labels) <> 'array'
OR jsonb_array_length(answer_labels) = 0
OR EXISTS (
SELECT 1 FROM jsonb_array_elements(answer_labels) answer
WHERE jsonb_typeof(answer) <> 'string'
OR btrim(answer #>> '{}') = ''
OR NOT EXISTS (
SELECT 1 FROM jsonb_array_elements(question_options) item
WHERE btrim(item ->> 'label') = btrim(answer #>> '{}')
)
)
OR (SELECT count(*) FROM jsonb_array_elements_text(answer_labels)) <>
(SELECT count(DISTINCT btrim(value))
FROM jsonb_array_elements_text(answer_labels) value) THEN
RETURN false;
END IF;
RETURN true;
END IF;
RETURN EXISTS (
SELECT 1 FROM jsonb_array_elements(question_options) item
WHERE btrim(item ->> 'label') = btrim(answer_key)
);
END;
$$;
REVOKE ALL ON FUNCTION education_question_answer_key_is_valid(VARCHAR, VARCHAR, JSONB) FROM PUBLIC;
-- Hold back concurrent inserts and updates until the historical safety check,
-- normalization, and lifecycle write guards all commit together.
LOCK TABLE education_question IN SHARE ROW EXCLUSIVE MODE;
DO $$
DECLARE
invalid_question_id BIGINT;
BEGIN
SELECT id
INTO invalid_question_id
FROM education_question
WHERE status = 'PUBLISHED'
AND is_published = true
AND (
stem IS NULL OR btrim(stem) = '' OR
NOT education_question_answer_key_is_valid(type, correct_answer, options)
)
ORDER BY id
LIMIT 1;
IF invalid_question_id IS NOT NULL THEN
RAISE EXCEPTION 'unsafe historical published question % blocks V4080', invalid_question_id
USING ERRCODE = '23514';
END IF;
END;
$$;
ALTER TABLE education_question DROP CONSTRAINT ck_education_question_status;
UPDATE education_question
SET status = CASE
WHEN status = 'DRAFT' THEN 'DRAFT'
WHEN status = 'PUBLISHED' AND is_published = true THEN 'PUBLISHED'
ELSE 'ARCHIVED'
END,
is_published = CASE
WHEN status = 'PUBLISHED' AND is_published = true THEN true
ELSE false
END;
ALTER TABLE education_question ALTER COLUMN status SET DEFAULT 'DRAFT';
ALTER TABLE education_question ALTER COLUMN is_published SET DEFAULT false;
ALTER TABLE education_question
ADD CONSTRAINT ck_education_question_status
CHECK (status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')),
ADD CONSTRAINT ck_education_question_publication_consistent
CHECK (is_published = (status = 'PUBLISHED'));
CREATE TABLE education_question_version (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
scope VARCHAR(20) NOT NULL,
question_id BIGINT NOT NULL,
version_number INTEGER NOT NULL,
stem TEXT NOT NULL,
type VARCHAR(32) NOT NULL,
type_label VARCHAR(50),
difficulty VARCHAR(32),
question_content JSONB,
options JSONB NOT NULL,
correct_answer VARCHAR(500),
explanation TEXT,
analysis TEXT,
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,
CONSTRAINT ck_education_question_version_scope CHECK (
(scope = 'PUBLIC' AND tenant_id = 0) OR
(scope = 'TENANT_OWNED' AND tenant_id > 0)
),
CONSTRAINT ck_education_question_version_number CHECK (version_number > 0),
CONSTRAINT fk_education_question_version_question
FOREIGN KEY (question_id) REFERENCES education_question (id),
CONSTRAINT uk_education_question_version_question_number
UNIQUE (question_id, version_number),
CONSTRAINT uk_education_question_version_tenant_question_number
UNIQUE (tenant_id, question_id, version_number)
);
COMMENT ON TABLE education_question_version IS
'教育-题目不可变内容版本;当前版本号由 education_question.content_version 指向';
COMMENT ON COLUMN education_question_version.correct_answer IS
'服务端答案;不得进入学生端安全投影';
INSERT INTO education_question_version (
tenant_id, scope, question_id, version_number, stem, type, type_label,
difficulty, question_content, options, correct_answer, explanation, analysis,
creator, create_time, updater, update_time, deleted
)
SELECT tenant_id, scope, id, content_version, stem, type, type_label,
difficulty, question_content, options, correct_answer, explanation, analysis,
creator, create_time, updater, update_time, false
FROM education_question;
ALTER TABLE education_question
ADD CONSTRAINT fk_education_question_current_version
FOREIGN KEY (id, content_version)
REFERENCES education_question_version (question_id, version_number)
DEFERRABLE INITIALLY DEFERRED;
CREATE TABLE education_question_lifecycle_audit (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
question_id BIGINT NOT NULL,
content_version INTEGER NOT NULL,
actor_id BIGINT NOT NULL,
from_status VARCHAR(20) NOT NULL,
to_status VARCHAR(20) 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,
CONSTRAINT ck_education_question_lifecycle_audit_transition CHECK (
(from_status = 'DRAFT' AND to_status = 'PUBLISHED') OR
(from_status = 'PUBLISHED' AND to_status = 'ARCHIVED')
),
CONSTRAINT fk_education_question_lifecycle_audit_version
FOREIGN KEY (tenant_id, question_id, content_version)
REFERENCES education_question_version (tenant_id, question_id, version_number),
CONSTRAINT uk_education_question_lifecycle_audit_transition
UNIQUE (tenant_id, question_id, from_status, to_status)
);
COMMENT ON TABLE education_question_lifecycle_audit IS
'教育-题目发布生命周期追加式领域审计,与状态转换同事务提交';
CREATE INDEX idx_education_question_lifecycle_audit_tenant_question
ON education_question_lifecycle_audit (tenant_id, question_id, occurred_at, id);
CREATE TABLE education_question_lifecycle_transition_token (
transaction_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
question_id BIGINT NOT NULL,
content_version INTEGER NOT NULL,
from_status VARCHAR(20) NOT NULL,
to_status VARCHAR(20) NOT NULL,
PRIMARY KEY (transaction_id, tenant_id, question_id, from_status, to_status)
);
COMMENT ON TABLE education_question_lifecycle_transition_token IS
'题目状态触发器与审计触发器之间的事务内临时凭据;成功审计后即消费';
REVOKE ALL ON TABLE education_question_lifecycle_transition_token FROM PUBLIC;
CREATE FUNCTION education_prevent_question_version_mutation()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path = pg_catalog, pg_temp
AS $$
BEGIN
RAISE EXCEPTION 'question versions are immutable'
USING ERRCODE = '23514';
END;
$$;
CREATE FUNCTION education_enforce_question_lifecycle_transition()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, pg_temp
AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
IF NEW.status <> 'DRAFT' OR NEW.is_published THEN
RAISE EXCEPTION 'new questions must start in DRAFT'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END IF;
IF NEW.status IS DISTINCT FROM OLD.status
AND (NEW.scope <> 'TENANT_OWNED' OR NEW.tenant_id <= 0) THEN
RAISE EXCEPTION 'PUBLIC question lifecycle is not managed by tenant authoring'
USING ERRCODE = '23514';
END IF;
IF NEW.status IS DISTINCT FROM OLD.status AND NOT (
(OLD.status = 'DRAFT' AND NEW.status = 'PUBLISHED') OR
(OLD.status = 'PUBLISHED' AND NEW.status = 'ARCHIVED')
) THEN
RAISE EXCEPTION 'invalid question lifecycle transition from % to %', OLD.status, NEW.status
USING ERRCODE = '23514';
END IF;
IF NEW.status IS DISTINCT FROM OLD.status THEN
EXECUTE format(
'INSERT INTO %I.education_question_lifecycle_transition_token' ||
' (transaction_id, tenant_id, question_id, content_version, from_status, to_status)' ||
' VALUES ($1, $2, $3, $4, $5, $6)',
TG_TABLE_SCHEMA)
USING pg_current_xact_id()::text::BIGINT, NEW.tenant_id, NEW.id,
NEW.content_version, OLD.status, NEW.status;
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION education_prevent_question_lifecycle_audit_mutation()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path = pg_catalog, pg_temp
AS $$
BEGIN
RAISE EXCEPTION 'question lifecycle audit is append-only'
USING ERRCODE = '23514';
END;
$$;
CREATE FUNCTION education_prevent_question_content_projection_mutation()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path = pg_catalog, pg_temp
AS $$
BEGIN
RAISE EXCEPTION 'question content must change through immutable versions'
USING ERRCODE = '23514';
END;
$$;
CREATE FUNCTION education_require_question_lifecycle_audit()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path = pg_catalog, pg_temp
AS $$
DECLARE
audit_exists BOOLEAN;
BEGIN
IF NEW.status IS NOT DISTINCT FROM OLD.status THEN
RETURN NEW;
END IF;
EXECUTE format(
'SELECT EXISTS (SELECT 1 FROM %I.education_question_lifecycle_audit' ||
' WHERE tenant_id = $1 AND question_id = $2 AND content_version = $3' ||
' AND from_status = $4 AND to_status = $5 AND deleted = false)',
TG_TABLE_SCHEMA)
INTO audit_exists
USING NEW.tenant_id, NEW.id, NEW.content_version, OLD.status, NEW.status;
IF NOT audit_exists THEN
RAISE EXCEPTION 'question lifecycle transition requires transactional audit'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION education_check_question_version_ownership()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, pg_temp
AS $$
DECLARE
question_tenant_id BIGINT;
question_scope VARCHAR(20);
BEGIN
EXECUTE format(
'SELECT tenant_id, scope FROM %I.education_question WHERE id = $1 FOR SHARE',
TG_TABLE_SCHEMA)
INTO question_tenant_id, question_scope
USING NEW.question_id;
IF question_tenant_id IS NULL OR question_scope IS NULL THEN
RAISE EXCEPTION 'question version points to missing question %', NEW.question_id
USING ERRCODE = '23503';
END IF;
IF NEW.tenant_id IS DISTINCT FROM question_tenant_id
OR NEW.scope IS DISTINCT FROM question_scope THEN
RAISE EXCEPTION 'question version ownership must equal question ownership'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION education_validate_question_lifecycle_audit_insert()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, pg_temp
AS $$
DECLARE
question_status VARCHAR(20);
question_version INTEGER;
transition_token_count INTEGER;
BEGIN
EXECUTE format(
'SELECT status, content_version FROM %I.education_question' ||
' WHERE tenant_id = $1 AND id = $2 FOR SHARE',
TG_TABLE_SCHEMA)
INTO question_status, question_version
USING NEW.tenant_id, NEW.question_id;
IF question_status IS NULL OR question_version IS NULL
OR question_status IS DISTINCT FROM NEW.to_status
OR question_version IS DISTINCT FROM NEW.content_version THEN
RAISE EXCEPTION 'question lifecycle audit must accompany its state transition'
USING ERRCODE = '23514';
END IF;
EXECUTE format(
'DELETE FROM %I.education_question_lifecycle_transition_token' ||
' WHERE transaction_id = $1 AND tenant_id = $2 AND question_id = $3' ||
' AND content_version = $4 AND from_status = $5 AND to_status = $6',
TG_TABLE_SCHEMA)
USING pg_current_xact_id()::text::BIGINT, NEW.tenant_id, NEW.question_id,
NEW.content_version, NEW.from_status, NEW.to_status;
GET DIAGNOSTICS transition_token_count = ROW_COUNT;
IF transition_token_count <> 1 THEN
RAISE EXCEPTION 'question lifecycle audit must accompany its state transition'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
REVOKE ALL ON FUNCTION education_prevent_question_version_mutation() FROM PUBLIC;
REVOKE ALL ON FUNCTION education_enforce_question_lifecycle_transition() FROM PUBLIC;
REVOKE ALL ON FUNCTION education_prevent_question_lifecycle_audit_mutation() FROM PUBLIC;
REVOKE ALL ON FUNCTION education_prevent_question_content_projection_mutation() FROM PUBLIC;
REVOKE ALL ON FUNCTION education_require_question_lifecycle_audit() FROM PUBLIC;
REVOKE ALL ON FUNCTION education_check_question_version_ownership() FROM PUBLIC;
REVOKE ALL ON FUNCTION education_validate_question_lifecycle_audit_insert() FROM PUBLIC;
CREATE TRIGGER trg_education_question_version_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, question_id ON education_question_version
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('question_id', 'education_question');
CREATE TRIGGER trg_education_question_version_ownership_equal
BEFORE INSERT OR UPDATE OF tenant_id, scope, question_id ON education_question_version
FOR EACH ROW EXECUTE FUNCTION education_check_question_version_ownership();
CREATE TRIGGER trg_education_question_version_scope_immutable
BEFORE UPDATE OF tenant_id, scope ON education_question_version
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
CREATE TRIGGER trg_education_question_version_immutable
BEFORE UPDATE OR DELETE ON education_question_version
FOR EACH ROW EXECUTE FUNCTION education_prevent_question_version_mutation();
CREATE TRIGGER trg_education_question_lifecycle_transition
BEFORE INSERT OR UPDATE OF status ON education_question
FOR EACH ROW EXECUTE FUNCTION education_enforce_question_lifecycle_transition();
CREATE TRIGGER trg_education_question_lifecycle_audit_immutable
BEFORE UPDATE OR DELETE ON education_question_lifecycle_audit
FOR EACH ROW EXECUTE FUNCTION education_prevent_question_lifecycle_audit_mutation();
CREATE TRIGGER trg_education_question_lifecycle_audit_insert_valid
BEFORE INSERT ON education_question_lifecycle_audit
FOR EACH ROW EXECUTE FUNCTION education_validate_question_lifecycle_audit_insert();
CREATE TRIGGER trg_education_question_content_projection_immutable
BEFORE UPDATE OF content_version, stem, type, type_label, difficulty,
question_content, options, correct_answer, explanation, analysis
ON education_question
FOR EACH ROW EXECUTE FUNCTION education_prevent_question_content_projection_mutation();
CREATE CONSTRAINT TRIGGER trg_education_question_lifecycle_audit_required
AFTER UPDATE ON education_question
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION education_require_question_lifecycle_audit();
-- The platform schema is adopted before Education Flyway in deployed environments.
-- Isolated Education migration tests intentionally have no System tables.
DO $$
DECLARE
installed_permission_count 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
(6800, '教育管理', '', 1, 50, 0, '/education', 'ep:school', NULL,
NULL, 0, false, true, true, 'education-flyway', 'education-flyway'),
(6801, '能力查询', 'education:capability', 3, 1, 6800, '', '', NULL,
NULL, 0, false, true, true, 'education-flyway', 'education-flyway'),
(6802, '题目创作', 'education:question:author', 3, 2, 6800, '', '', NULL,
NULL, 0, false, true, true, 'education-flyway', 'education-flyway'),
(6803, '题目发布', 'education:question:publish', 3, 3, 6800, '', '', NULL,
NULL, 0, false, true, true, 'education-flyway', 'education-flyway'),
(6804, '题目归档', 'education:question:archive', 3, 4, 6800, '', '', NULL,
NULL, 0, false, true, true, 'education-flyway', 'education-flyway')
ON CONFLICT (id) DO NOTHING;
SELECT count(*)
INTO installed_permission_count
FROM system_menu
WHERE status = 0
AND deleted = 0
AND (
(id = 6800 AND permission = '' AND type = 1 AND parent_id = 0 AND path = '/education')
OR (id = 6801 AND permission = 'education:capability' AND type = 3 AND parent_id = 6800)
OR (id = 6802 AND permission = 'education:question:author' AND type = 3 AND parent_id = 6800)
OR (id = 6803 AND permission = 'education:question:publish' AND type = 3 AND parent_id = 6800)
OR (id = 6804 AND permission = 'education:question:archive' AND type = 3 AND parent_id = 6800)
);
IF installed_permission_count <> 5 THEN
RAISE EXCEPTION 'Education System RBAC seed IDs conflict with existing system_menu rows'
USING ERRCODE = '23505';
END IF;
END;
$$;