feat(education): add bounded commercialization entitlements

This commit is contained in:
2026-07-31 12:58:48 +08:00
parent 8312859b39
commit 5bc1e9e634
27 changed files with 804 additions and 19 deletions

View File

@@ -0,0 +1,143 @@
-- EDU-013: bounded education commercialization.
-- Financial products, orders, payments, and refunds remain owned by Mall/Pay.
ALTER TABLE education_question_collection
ADD COLUMN access_mode VARCHAR(16) NOT NULL DEFAULT 'FREE',
ADD CONSTRAINT ck_education_question_collection_access_mode
CHECK (access_mode IN ('FREE', 'PRIVATE', 'PAID'));
CREATE OR REPLACE FUNCTION education_enforce_question_collection_authoring() RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
DECLARE member RECORD; target_node_tenant BIGINT; target_node_scope VARCHAR(20); target_node_entry BIGINT;
target_node_deleted BOOLEAN; target_node_active BOOLEAN; target_node_hidden BOOLEAN;
target_entry_tenant BIGINT; target_entry_scope VARCHAR(20); target_entry_deleted BOOLEAN;
target_entry_active BOOLEAN; target_entry_hidden BOOLEAN;
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 RAISE EXCEPTION 'PUBLIC collection writes are not tenant authoring' USING ERRCODE='23514';
ELSIF NEW.scope='TENANT_OWNED' THEN
IF 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;
EXECUTE format('SELECT node.tenant_id,node.scope,node.entry_id,node.deleted,node.is_active,node.is_hidden,entry.tenant_id,entry.scope,entry.deleted,entry.is_active,entry.is_hidden FROM %I.education_content_node node JOIN %I.education_content_entry entry ON entry.id=node.entry_id WHERE node.id=$1 FOR SHARE OF node,entry',TG_TABLE_SCHEMA,TG_TABLE_SCHEMA)
INTO target_node_tenant,target_node_scope,target_node_entry,target_node_deleted,target_node_active,target_node_hidden,target_entry_tenant,target_entry_scope,target_entry_deleted,target_entry_active,target_entry_hidden USING NEW.node_id;
IF target_node_tenant IS DISTINCT FROM NEW.tenant_id OR target_node_scope IS DISTINCT FROM 'TENANT_OWNED' OR target_node_deleted OR NOT target_node_active OR target_node_hidden OR NEW.entry_id IS DISTINCT FROM target_node_entry OR target_entry_scope NOT IN ('PUBLIC','TENANT_OWNED') OR (target_entry_scope='TENANT_OWNED' AND target_entry_tenant IS DISTINCT FROM NEW.tenant_id) OR target_entry_deleted OR NOT target_entry_active OR target_entry_hidden THEN RAISE EXCEPTION 'tenant collection target node or entry is unavailable' USING ERRCODE='23514'; END IF;
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;
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.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.access_mode IS DISTINCT FROM OLD.access_mode 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 OLD.publication_status='DRAFT' AND (NEW.node_id IS DISTINCT FROM OLD.node_id OR NEW.entry_id IS DISTINCT FROM OLD.entry_id OR NEW.publication_status IS DISTINCT FROM OLD.publication_status) THEN
EXECUTE format('SELECT node.tenant_id,node.scope,node.entry_id,node.deleted,node.is_active,node.is_hidden,entry.tenant_id,entry.scope,entry.deleted,entry.is_active,entry.is_hidden FROM %I.education_content_node node JOIN %I.education_content_entry entry ON entry.id=node.entry_id WHERE node.id=$1 FOR SHARE OF node,entry',TG_TABLE_SCHEMA,TG_TABLE_SCHEMA)
INTO target_node_tenant,target_node_scope,target_node_entry,target_node_deleted,target_node_active,target_node_hidden,target_entry_tenant,target_entry_scope,target_entry_deleted,target_entry_active,target_entry_hidden USING NEW.node_id;
IF target_node_tenant IS DISTINCT FROM NEW.tenant_id OR target_node_scope IS DISTINCT FROM 'TENANT_OWNED' OR target_node_deleted OR NOT target_node_active OR target_node_hidden OR NEW.entry_id IS DISTINCT FROM target_node_entry OR target_entry_scope NOT IN ('PUBLIC','TENANT_OWNED') OR (target_entry_scope='TENANT_OWNED' AND target_entry_tenant IS DISTINCT FROM NEW.tenant_id) OR target_entry_deleted OR NOT target_entry_active OR target_entry_hidden THEN RAISE EXCEPTION 'tenant collection target node or entry is unavailable' USING ERRCODE='23514'; END IF;
END IF;
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);
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;
IF NEW.publication_status='ACTIVE' THEN FOR member IN EXECUTE format('SELECT question.tenant_id,question.scope,question.status,question.is_published,question.deleted FROM %I.education_question_collection_question membership JOIN %I.education_question question ON question.id=membership.question_id WHERE membership.collection_id=$1 AND NOT membership.deleted FOR SHARE OF question',TG_TABLE_SCHEMA,TG_TABLE_SCHEMA) USING NEW.id LOOP IF member.tenant_id IS DISTINCT FROM NEW.tenant_id OR member.scope<>'TENANT_OWNED' OR member.status<>'PUBLISHED' OR NOT member.is_published OR member.deleted THEN RAISE EXCEPTION 'active collection requires published tenant questions' USING ERRCODE='23514'; END IF; END LOOP; 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 $$;
COMMENT ON COLUMN education_question_collection.access_mode IS
'Authoritative access classification. access_rules remains descriptive and is never authorization input.';
CREATE TABLE education_resource_product_binding (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
resource_type VARCHAR(32) NOT NULL,
resource_id BIGINT NOT NULL,
product_spu_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
version INTEGER NOT NULL DEFAULT 0,
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_product_binding_resource_type CHECK (resource_type IN ('QUESTION_COLLECTION')),
CONSTRAINT ck_education_product_binding_status CHECK (status IN ('ACTIVE', 'INACTIVE')),
CONSTRAINT ck_education_product_binding_version CHECK (version >= 0),
CONSTRAINT uk_education_product_binding_resource UNIQUE (tenant_id, resource_type, resource_id),
CONSTRAINT uk_education_product_binding_product UNIQUE (tenant_id, product_spu_id)
);
COMMENT ON TABLE education_resource_product_binding IS 'Education resource to Mall SPU binding; no product or financial ledger data';
CREATE TABLE education_entitlement (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
resource_type VARCHAR(32) NOT NULL,
resource_id BIGINT NOT NULL,
product_spu_id BIGINT,
status VARCHAR(16) NOT NULL,
valid_from TIMESTAMP NOT NULL,
expires_at TIMESTAMP,
granted_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP,
revoke_reason VARCHAR(32),
version INTEGER NOT NULL DEFAULT 0,
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_entitlement_resource_type CHECK (resource_type IN ('QUESTION_COLLECTION')),
CONSTRAINT ck_education_entitlement_status CHECK (status IN ('ACTIVE', 'REVOKED')),
CONSTRAINT ck_education_entitlement_window CHECK (expires_at IS NULL OR expires_at > valid_from),
CONSTRAINT ck_education_entitlement_revoke CHECK (
(status = 'ACTIVE' AND revoked_at IS NULL AND revoke_reason IS NULL) OR
(status = 'REVOKED' AND revoked_at IS NOT NULL AND revoke_reason IN ('REVOKE', 'REFUND', 'EXPIRE'))),
CONSTRAINT ck_education_entitlement_version CHECK (version >= 0),
CONSTRAINT uk_education_entitlement_subject UNIQUE (tenant_id, user_id, resource_type, resource_id)
);
CREATE INDEX idx_education_entitlement_access
ON education_entitlement (tenant_id, user_id, resource_type, resource_id, status, expires_at)
WHERE deleted = false;
COMMENT ON TABLE education_entitlement IS 'Tenant/user education access aggregate; not a payment, order, refund, or financial ledger';
CREATE TABLE education_entitlement_event (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
source_system VARCHAR(32) NOT NULL,
source_event_id VARCHAR(128) NOT NULL,
event_type VARCHAR(16) NOT NULL,
request_hash VARCHAR(64) NOT NULL,
entitlement_id BIGINT,
outcome VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
occurred_at TIMESTAMP NOT NULL,
processed_at 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 fk_education_entitlement_event_entitlement FOREIGN KEY (entitlement_id) REFERENCES education_entitlement(id),
CONSTRAINT ck_education_entitlement_event_type CHECK (event_type IN ('GRANT', 'REVOKE', 'REFUND', 'EXPIRE')),
CONSTRAINT ck_education_entitlement_event_outcome CHECK (outcome IN ('PROCESSING', 'APPLIED', 'NOOP')),
CONSTRAINT uk_education_entitlement_event UNIQUE (tenant_id, source_system, source_event_id)
);
COMMENT ON TABLE education_entitlement_event IS 'Duplicate-safe fulfillment event state; contains no financial ledger';
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
(6809,'教育商品绑定','education:commercialization:binding',3,9,6800,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'),
(6810,'教育权益管理','education:commercialization:entitlement',3,10,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=6809 AND permission='education:commercialization:binding' AND type=3 AND parent_id=6800) OR
(id=6810 AND permission='education:commercialization:entitlement' AND type=3 AND parent_id=6800));
IF installed<>2 THEN RAISE EXCEPTION 'Education commercialization RBAC seed IDs conflict' USING ERRCODE='23505'; END IF;
END $$;