feat(education): add tenant question placement

This commit is contained in:
2026-07-30 22:28:08 +08:00
parent 34bc1fe41e
commit 2a40cbd69e
21 changed files with 813 additions and 44 deletions

View File

@@ -0,0 +1,138 @@
-- EDU-010: make tenant Question Placement an explicit optimistic command and
-- require a stable, available Content Node before native publication.
ALTER TABLE education_question
ADD COLUMN placement_version INTEGER NOT NULL DEFAULT 0,
ADD CONSTRAINT ck_education_question_placement_version
CHECK (placement_version >= 0);
COMMENT ON COLUMN education_question.placement_version IS
'题目目录放置的乐观锁版本;不属于不可变题目内容版本';
CREATE FUNCTION education_enforce_question_placement_mutation()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path = pg_catalog, pg_temp
AS $$
BEGIN
IF NEW.node_id IS NOT DISTINCT FROM OLD.node_id
AND NEW.placement_version IS NOT DISTINCT FROM OLD.placement_version THEN
RETURN NEW;
END IF;
IF NEW.scope <> 'TENANT_OWNED' OR NEW.tenant_id <= 0 THEN
RAISE EXCEPTION 'PUBLIC question placement is not managed by tenant authoring'
USING ERRCODE = '23514';
END IF;
IF OLD.status <> 'DRAFT' OR NEW.status <> 'DRAFT' THEN
RAISE EXCEPTION 'published or archived question placement is immutable'
USING ERRCODE = '23514';
END IF;
IF NEW.node_id IS NOT DISTINCT FROM OLD.node_id THEN
RAISE EXCEPTION 'question placement version cannot change without placement'
USING ERRCODE = '23514';
END IF;
IF NEW.placement_version IS NULL
OR NEW.placement_version <> OLD.placement_version + 1 THEN
RAISE EXCEPTION 'question placement version must advance exactly once'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION education_enforce_question_lifecycle_transition()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, pg_temp
AS $$
DECLARE
placement_node_id BIGINT;
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 OLD.status = 'DRAFT' AND NEW.status = 'PUBLISHED' THEN
IF NEW.node_id IS NULL THEN
RAISE EXCEPTION 'question must be placed before publication'
USING ERRCODE = '23514';
END IF;
EXECUTE format(
'SELECT id FROM %I.education_content_node' ||
' WHERE id = $1 AND deleted = false AND is_active = true' ||
' AND is_hidden = false AND is_selectable = true FOR SHARE',
TG_TABLE_SCHEMA)
INTO placement_node_id
USING NEW.node_id;
IF placement_node_id IS NULL THEN
RAISE EXCEPTION 'question placement target is unavailable'
USING ERRCODE = '23514';
END IF;
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;
$$;
REVOKE ALL ON FUNCTION education_enforce_question_placement_mutation() FROM PUBLIC;
REVOKE ALL ON FUNCTION education_enforce_question_lifecycle_transition() FROM PUBLIC;
CREATE TRIGGER trg_education_question_placement_mutation
BEFORE UPDATE OF node_id, placement_version ON education_question
FOR EACH ROW EXECUTE FUNCTION education_enforce_question_placement_mutation();
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
(6805, '题目归类', 'education:question:classify', 3, 5, 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 id = 6805
AND permission = 'education:question:classify'
AND type = 3
AND parent_id = 6800
AND status = 0
AND deleted = 0;
IF installed_permission_count <> 1 THEN
RAISE EXCEPTION 'Education Question Placement RBAC seed ID conflicts with existing system_menu row'
USING ERRCODE = '23505';
END IF;
END;
$$;