Files
lindyson-ai/apps/api/migrations/047_state_machine_invariants.sql
Juno ChowandClaude Fable 5.1 f665082dad chore: 建立产品仓库,完成开源前的敏感信息清理
前端(Vite + React)、API/worker(Fastify + BullMQ)、api-contracts 契约包、
迁移脚本、测试与自托管 docker-compose 示例。

开源准备:运维脚本与机器信息已拆到私有仓库;公司名、备案号、隐私邮箱改读
publicCompliance.local.json(仓库只保留占位模板);站点地址与客服二维码改为
VITE_ 环境变量;fixtures 中的真实用户标识已匿名化;三个 package.json 标注
Apache-2.0。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 02:10:46 +08:00

723 lines
28 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 状态机强约束与账本后置校验。
-- 旧兼容列仍需双读观察 7 天,本迁移只禁止新旧路径继续制造不一致;物理删列须在观察期后单独发布。
-- 历史 credit_ledger 允许没有业务事件键;迁移后新增流水必须提供稳定事件键。
CREATE OR REPLACE FUNCTION require_credit_ledger_business_event_key()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.business_event_key IS NULL OR btrim(NEW.business_event_key) = '' THEN
RAISE EXCEPTION '新增积分流水必须提供 business_event_key';
END IF;
IF TG_OP = 'UPDATE'
AND OLD.business_event_key IS NOT NULL
AND NEW.business_event_key IS DISTINCT FROM OLD.business_event_key THEN
RAISE EXCEPTION 'credit_ledger.business_event_key 一旦写入不可修改:ledger=%', OLD.id;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS credit_ledger_require_business_event_key ON credit_ledger;
CREATE TRIGGER credit_ledger_require_business_event_key
BEFORE INSERT OR UPDATE OF business_event_key ON credit_ledger
FOR EACH ROW EXECUTE FUNCTION require_credit_ledger_business_event_key();
-- users 的两个可用桶必须等于 lot 可用余额汇总。使用延迟约束触发器,允许同一事务内先改 lot 再改缓存。
CREATE OR REPLACE FUNCTION assert_user_credit_lot_balance(target_user_id uuid)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
cached_main integer;
cached_addon integer;
lot_main bigint;
lot_addon bigint;
BEGIN
SELECT main_credits, addon_credits
INTO cached_main, cached_addon
FROM users
WHERE id = target_user_id;
IF NOT FOUND THEN
RETURN;
END IF;
SELECT
COALESCE(sum(available_amount) FILTER (WHERE bucket = 'main'), 0),
COALESCE(sum(available_amount) FILTER (WHERE bucket = 'addon'), 0)
INTO lot_main, lot_addon
FROM credit_lots
WHERE user_id = target_user_id;
IF cached_main::bigint <> lot_main OR cached_addon::bigint <> lot_addon THEN
RAISE EXCEPTION
'用户积分缓存与 lot 汇总不一致:user=%, cache=(%, %), lots=(%, %)',
target_user_id, cached_main, cached_addon, lot_main, lot_addon;
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION check_user_credit_lot_balance_from_user()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM assert_user_credit_lot_balance(NEW.id);
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION check_user_credit_lot_balance_from_lot()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP <> 'INSERT' THEN
PERFORM assert_user_credit_lot_balance(OLD.user_id);
END IF;
IF TG_OP <> 'DELETE' AND (TG_OP = 'INSERT' OR NEW.user_id IS DISTINCT FROM OLD.user_id) THEN
PERFORM assert_user_credit_lot_balance(NEW.user_id);
END IF;
RETURN NULL;
END;
$$;
DROP TRIGGER IF EXISTS users_credit_lot_balance_check ON users;
CREATE CONSTRAINT TRIGGER users_credit_lot_balance_check
AFTER INSERT OR UPDATE OF main_credits, addon_credits ON users
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION check_user_credit_lot_balance_from_user();
DROP TRIGGER IF EXISTS credit_lots_user_balance_check ON credit_lots;
CREATE CONSTRAINT TRIGGER credit_lots_user_balance_check
AFTER INSERT OR UPDATE OR DELETE ON credit_lots
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION check_user_credit_lot_balance_from_lot();
-- lot 的 held/consumed/reversed 必须能由 allocation 重放;周期过期可以没有 allocation,故只校验其下界。
CREATE OR REPLACE FUNCTION assert_credit_lot_allocations(target_lot_id uuid)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
lot_user_id uuid;
lot_held integer;
lot_consumed integer;
lot_expired integer;
lot_reversed integer;
allocation_held bigint;
allocation_consumed bigint;
allocation_expired bigint;
allocation_reversed bigint;
BEGIN
SELECT user_id, held_amount, consumed_amount, expired_amount, reversed_amount
INTO lot_user_id, lot_held, lot_consumed, lot_expired, lot_reversed
FROM credit_lots
WHERE id = target_lot_id;
IF NOT FOUND THEN
RETURN;
END IF;
IF EXISTS (
SELECT 1 FROM credit_allocations
WHERE lot_id = target_lot_id AND user_id <> lot_user_id
) THEN
RAISE EXCEPTION 'allocation 用户与 lot 用户不一致:lot=%', target_lot_id;
END IF;
SELECT
COALESCE(sum(amount) FILTER (WHERE status = 'held'), 0),
COALESCE(sum(amount) FILTER (WHERE status = 'settled'), 0),
COALESCE(sum(amount) FILTER (WHERE status = 'expired'), 0),
COALESCE(sum(amount) FILTER (WHERE status = 'reversed'), 0)
INTO allocation_held, allocation_consumed, allocation_expired, allocation_reversed
FROM credit_allocations
WHERE lot_id = target_lot_id;
IF lot_held::bigint <> allocation_held
OR lot_consumed::bigint <> allocation_consumed
OR lot_reversed::bigint <> allocation_reversed
OR lot_expired::bigint < allocation_expired THEN
RAISE EXCEPTION
'lot 与 allocation 不一致:lot=%, held=%/%, consumed=%/%, expired=%/%, reversed=%/%',
target_lot_id,
lot_held, allocation_held,
lot_consumed, allocation_consumed,
lot_expired, allocation_expired,
lot_reversed, allocation_reversed;
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION check_credit_lot_allocations_from_lot()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP <> 'INSERT' THEN
PERFORM assert_credit_lot_allocations(OLD.id);
END IF;
IF TG_OP <> 'DELETE' AND (TG_OP = 'INSERT' OR NEW.id IS DISTINCT FROM OLD.id) THEN
PERFORM assert_credit_lot_allocations(NEW.id);
END IF;
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION check_credit_lot_allocations_from_allocation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP <> 'INSERT' THEN
PERFORM assert_credit_lot_allocations(OLD.lot_id);
END IF;
IF TG_OP <> 'DELETE' AND (TG_OP = 'INSERT' OR NEW.lot_id IS DISTINCT FROM OLD.lot_id) THEN
PERFORM assert_credit_lot_allocations(NEW.lot_id);
END IF;
RETURN NULL;
END;
$$;
DROP TRIGGER IF EXISTS credit_lots_allocation_check ON credit_lots;
CREATE CONSTRAINT TRIGGER credit_lots_allocation_check
AFTER INSERT OR UPDATE OR DELETE ON credit_lots
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION check_credit_lot_allocations_from_lot();
DROP TRIGGER IF EXISTS credit_allocations_lot_check ON credit_allocations;
CREATE CONSTRAINT TRIGGER credit_allocations_lot_check
AFTER INSERT OR UPDATE OR DELETE ON credit_allocations
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION check_credit_lot_allocations_from_allocation();
ALTER TABLE payment_attempts
DROP CONSTRAINT IF EXISTS payment_attempts_lease_state_check,
DROP CONSTRAINT IF EXISTS payment_attempts_launch_state_check,
DROP CONSTRAINT IF EXISTS payment_attempts_terminal_state_check,
DROP CONSTRAINT IF EXISTS payment_attempts_channel_revision_check;
ALTER TABLE payment_attempts
ADD CONSTRAINT payment_attempts_lease_state_check CHECK (
(status = 'processing' AND lease_token IS NOT NULL AND lease_until IS NOT NULL)
OR (status <> 'processing' AND lease_token IS NULL AND lease_until IS NULL)
) NOT VALID,
ADD CONSTRAINT payment_attempts_launch_state_check CHECK (
status <> 'ready' OR
(launch_ciphertext IS NOT NULL AND launch_iv IS NOT NULL AND launch_tag IS NOT NULL AND ready_at IS NOT NULL)
) NOT VALID,
ADD CONSTRAINT payment_attempts_terminal_state_check CHECK (
status NOT IN ('paid', 'closed', 'failed', 'manual-review') OR terminal_at IS NOT NULL
) NOT VALID,
ADD CONSTRAINT payment_attempts_channel_revision_check CHECK (
status = 'manual-review' OR channel_revision_id IS NOT NULL
) NOT VALID;
ALTER TABLE payment_attempts VALIDATE CONSTRAINT payment_attempts_lease_state_check;
ALTER TABLE payment_attempts VALIDATE CONSTRAINT payment_attempts_launch_state_check;
ALTER TABLE payment_attempts VALIDATE CONSTRAINT payment_attempts_terminal_state_check;
ALTER TABLE payment_attempts VALIDATE CONSTRAINT payment_attempts_channel_revision_check;
ALTER TABLE order_refund_attempts
DROP CONSTRAINT IF EXISTS order_refund_attempts_lease_state_check,
DROP CONSTRAINT IF EXISTS order_refund_attempts_retry_state_check,
DROP CONSTRAINT IF EXISTS order_refund_attempts_terminal_state_check,
DROP CONSTRAINT IF EXISTS order_refund_attempts_channel_revision_check;
ALTER TABLE order_refund_attempts
ADD CONSTRAINT order_refund_attempts_lease_state_check CHECK (
(status = 'processing' AND lease_token IS NOT NULL AND lease_until IS NOT NULL)
OR (status <> 'processing' AND lease_token IS NULL AND lease_until IS NULL)
) NOT VALID,
ADD CONSTRAINT order_refund_attempts_retry_state_check CHECK (
status NOT IN ('queued', 'submitted', 'uncertain') OR next_at IS NOT NULL
) NOT VALID,
ADD CONSTRAINT order_refund_attempts_terminal_state_check CHECK (
status NOT IN ('succeeded', 'failed', 'manual-review') OR terminal_at IS NOT NULL
) NOT VALID,
ADD CONSTRAINT order_refund_attempts_channel_revision_check CHECK (
status = 'manual-review' OR channel_revision_id IS NOT NULL
) NOT VALID;
ALTER TABLE order_refund_attempts VALIDATE CONSTRAINT order_refund_attempts_lease_state_check;
ALTER TABLE order_refund_attempts VALIDATE CONSTRAINT order_refund_attempts_retry_state_check;
ALTER TABLE order_refund_attempts VALIDATE CONSTRAINT order_refund_attempts_terminal_state_check;
ALTER TABLE order_refund_attempts VALIDATE CONSTRAINT order_refund_attempts_channel_revision_check;
ALTER TABLE generation_provider_runs
DROP CONSTRAINT IF EXISTS generation_provider_runs_lease_pair_check,
DROP CONSTRAINT IF EXISTS generation_provider_runs_terminal_check;
ALTER TABLE generation_provider_runs
ADD CONSTRAINT generation_provider_runs_lease_pair_check CHECK (
(lease_token IS NULL) = (lease_expires_at IS NULL)
) NOT VALID,
ADD CONSTRAINT generation_provider_runs_terminal_check CHECK (
status NOT IN ('settled', 'refunded') OR completed_at IS NOT NULL
) NOT VALID;
ALTER TABLE generation_provider_runs VALIDATE CONSTRAINT generation_provider_runs_lease_pair_check;
ALTER TABLE generation_provider_runs VALIDATE CONSTRAINT generation_provider_runs_terminal_check;
ALTER TABLE upload_sessions
DROP CONSTRAINT IF EXISTS upload_sessions_lease_state_check,
DROP CONSTRAINT IF EXISTS upload_sessions_completed_state_check,
DROP CONSTRAINT IF EXISTS upload_sessions_retention_state_check,
DROP CONSTRAINT IF EXISTS upload_sessions_status_stage_check,
DROP CONSTRAINT IF EXISTS upload_sessions_retryable_state_check,
DROP CONSTRAINT IF EXISTS upload_sessions_sealed_state_check;
ALTER TABLE upload_sessions
ADD CONSTRAINT upload_sessions_lease_state_check CHECK (
(status = 'processing' AND lease_token IS NOT NULL AND lease_expires_at IS NOT NULL)
OR (status <> 'processing' AND lease_token IS NULL AND lease_expires_at IS NULL)
) NOT VALID,
ADD CONSTRAINT upload_sessions_completed_state_check CHECK (
status <> 'completed' OR
(asset_id IS NOT NULL AND final_object_key IS NOT NULL AND sealed_at IS NOT NULL
AND actual_size_bytes IS NOT NULL AND completed_at IS NOT NULL)
) NOT VALID,
ADD CONSTRAINT upload_sessions_retention_state_check CHECK (
status NOT IN ('failed', 'canceled', 'expired') OR retention_until IS NOT NULL
) NOT VALID,
ADD CONSTRAINT upload_sessions_status_stage_check CHECK (
(status = 'pending' AND processing_stage IN ('waiting-upload', 'queued'))
OR (status = 'processing' AND processing_stage IN (
'sealing', 'validating', 'probing', 'thumbnailing', 'persisting'
))
OR (status = 'completed' AND processing_stage = 'completed')
OR (status = 'failed' AND processing_stage = 'failed')
OR (status = 'canceled' AND processing_stage = 'canceled')
OR (status = 'expired' AND processing_stage = 'expired')
) NOT VALID,
ADD CONSTRAINT upload_sessions_retryable_state_check CHECK (
status = 'failed' OR retryable = false
) NOT VALID,
ADD CONSTRAINT upload_sessions_sealed_state_check CHECK (
(sealed_at IS NULL AND sealed_size_bytes IS NULL)
OR (sealed_at IS NOT NULL AND sealed_size_bytes IS NOT NULL AND final_object_key IS NOT NULL)
) NOT VALID;
ALTER TABLE upload_sessions VALIDATE CONSTRAINT upload_sessions_lease_state_check;
ALTER TABLE upload_sessions VALIDATE CONSTRAINT upload_sessions_completed_state_check;
ALTER TABLE upload_sessions VALIDATE CONSTRAINT upload_sessions_retention_state_check;
ALTER TABLE upload_sessions VALIDATE CONSTRAINT upload_sessions_status_stage_check;
ALTER TABLE upload_sessions VALIDATE CONSTRAINT upload_sessions_retryable_state_check;
ALTER TABLE upload_sessions VALIDATE CONSTRAINT upload_sessions_sealed_state_check;
ALTER TABLE object_deletion_outbox
DROP CONSTRAINT IF EXISTS object_deletion_outbox_lease_state_check,
DROP CONSTRAINT IF EXISTS object_deletion_outbox_terminal_state_check;
ALTER TABLE object_deletion_outbox
ADD CONSTRAINT object_deletion_outbox_lease_state_check CHECK (
(status = 'processing' AND claim_token IS NOT NULL AND lease_expires_at IS NOT NULL)
OR (status <> 'processing' AND claim_token IS NULL AND lease_expires_at IS NULL)
) NOT VALID,
ADD CONSTRAINT object_deletion_outbox_terminal_state_check CHECK (
status NOT IN ('completed', 'skipped') OR completed_at IS NOT NULL
) NOT VALID;
ALTER TABLE object_deletion_outbox VALIDATE CONSTRAINT object_deletion_outbox_lease_state_check;
ALTER TABLE object_deletion_outbox VALIDATE CONSTRAINT object_deletion_outbox_terminal_state_check;
ALTER TABLE payment_notification_outbox
DROP CONSTRAINT IF EXISTS payment_notification_outbox_lease_state_check,
DROP CONSTRAINT IF EXISTS payment_notification_outbox_completed_state_check;
ALTER TABLE payment_notification_outbox
ADD CONSTRAINT payment_notification_outbox_lease_state_check CHECK (
(status = 'processing' AND lease_token IS NOT NULL AND lease_until IS NOT NULL)
OR (status <> 'processing' AND lease_token IS NULL AND lease_until IS NULL)
) NOT VALID,
ADD CONSTRAINT payment_notification_outbox_completed_state_check CHECK (
status <> 'completed' OR completed_at IS NOT NULL
) NOT VALID;
ALTER TABLE payment_notification_outbox VALIDATE CONSTRAINT payment_notification_outbox_lease_state_check;
ALTER TABLE payment_notification_outbox VALIDATE CONSTRAINT payment_notification_outbox_completed_state_check;
ALTER TABLE provider_events
DROP CONSTRAINT IF EXISTS provider_events_processed_state_check;
ALTER TABLE provider_events
ADD CONSTRAINT provider_events_processed_state_check CHECK (
status <> 'processed' OR processed_at IS NOT NULL
) NOT VALID;
ALTER TABLE provider_events VALIDATE CONSTRAINT provider_events_processed_state_check;
-- lot/allocation 的来源身份不可变;只有金额状态和 allocation 状态可以按领域服务推进。
CREATE OR REPLACE FUNCTION preserve_credit_lot_identity()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF ROW(
NEW.user_id, NEW.bucket, NEW.source_type, NEW.source_order_id,
NEW.source_event_key, NEW.original_amount, NEW.expires_at, NEW.created_at
) IS DISTINCT FROM ROW(
OLD.user_id, OLD.bucket, OLD.source_type, OLD.source_order_id,
OLD.source_event_key, OLD.original_amount, OLD.expires_at, OLD.created_at
) THEN
RAISE EXCEPTION 'credit_lots 来源身份不可修改:lot=%', OLD.id;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS credit_lots_preserve_identity ON credit_lots;
CREATE TRIGGER credit_lots_preserve_identity
BEFORE UPDATE ON credit_lots
FOR EACH ROW EXECUTE FUNCTION preserve_credit_lot_identity();
CREATE OR REPLACE FUNCTION preserve_credit_allocation_identity()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF ROW(
NEW.user_id, NEW.lot_id, NEW.business_event_key, NEW.purpose,
NEW.amount, NEW.related_order_id, NEW.related_job_id, NEW.created_at
) IS DISTINCT FROM ROW(
OLD.user_id, OLD.lot_id, OLD.business_event_key, OLD.purpose,
OLD.amount, OLD.related_order_id, OLD.related_job_id, OLD.created_at
) THEN
RAISE EXCEPTION 'credit_allocations 业务身份不可修改:allocation=%', OLD.id;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS credit_allocations_preserve_identity ON credit_allocations;
CREATE TRIGGER credit_allocations_preserve_identity
BEFORE UPDATE ON credit_allocations
FOR EACH ROW EXECUTE FUNCTION preserve_credit_allocation_identity();
-- attempt、通道 revision、供应商事件和 provider run 的业务身份不可变;worker 只能推进状态和补齐供应商结果。
CREATE OR REPLACE FUNCTION preserve_payment_attempt_identity()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF ROW(
NEW.order_id, NEW.attempt, NEW.idempotency_key, NEW.request_hash,
NEW.merchant_order_no, NEW.channel_revision_id, NEW.amount, NEW.currency,
NEW.surface, NEW.way_code
) IS DISTINCT FROM ROW(
OLD.order_id, OLD.attempt, OLD.idempotency_key, OLD.request_hash,
OLD.merchant_order_no, OLD.channel_revision_id, OLD.amount, OLD.currency,
OLD.surface, OLD.way_code
) THEN
RAISE EXCEPTION 'payment_attempts 业务身份不可修改:attempt=%', OLD.id;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS payment_attempts_preserve_identity ON payment_attempts;
CREATE TRIGGER payment_attempts_preserve_identity
BEFORE UPDATE ON payment_attempts
FOR EACH ROW EXECUTE FUNCTION preserve_payment_attempt_identity();
CREATE OR REPLACE FUNCTION preserve_refund_attempt_identity()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF ROW(
NEW.order_id, NEW.attempt, NEW.request_hash, NEW.merchant_refund_no,
NEW.merchant_order_no, NEW.channel_revision_id, NEW.amount, NEW.currency,
NEW.client_ip
) IS DISTINCT FROM ROW(
OLD.order_id, OLD.attempt, OLD.request_hash, OLD.merchant_refund_no,
OLD.merchant_order_no, OLD.channel_revision_id, OLD.amount, OLD.currency,
OLD.client_ip
) THEN
RAISE EXCEPTION 'order_refund_attempts 业务身份不可修改:attempt=%', OLD.id;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS order_refund_attempts_preserve_identity ON order_refund_attempts;
CREATE TRIGGER order_refund_attempts_preserve_identity
BEFORE UPDATE ON order_refund_attempts
FOR EACH ROW EXECUTE FUNCTION preserve_refund_attempt_identity();
CREATE OR REPLACE FUNCTION reject_payment_channel_revision_mutation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'payment_channel_revisions 是不可变快照,只能新增 revision';
END;
$$;
DROP TRIGGER IF EXISTS payment_channel_revisions_immutable ON payment_channel_revisions;
CREATE TRIGGER payment_channel_revisions_immutable
BEFORE UPDATE OR DELETE ON payment_channel_revisions
FOR EACH ROW EXECUTE FUNCTION reject_payment_channel_revision_mutation();
CREATE OR REPLACE FUNCTION preserve_provider_event_identity()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF ROW(
NEW.provider, NEW.event_type, NEW.event_key, NEW.payload_hash, NEW.payload,
NEW.order_id, NEW.payment_attempt_id, NEW.refund_attempt_id, NEW.received_at
) IS DISTINCT FROM ROW(
OLD.provider, OLD.event_type, OLD.event_key, OLD.payload_hash, OLD.payload,
OLD.order_id, OLD.payment_attempt_id, OLD.refund_attempt_id, OLD.received_at
) THEN
RAISE EXCEPTION 'provider_events 原始事件不可修改:event=%', OLD.id;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS provider_events_preserve_identity ON provider_events;
CREATE TRIGGER provider_events_preserve_identity
BEFORE UPDATE ON provider_events
FOR EACH ROW EXECUTE FUNCTION preserve_provider_event_identity();
CREATE OR REPLACE FUNCTION preserve_payment_notification_identity()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF ROW(NEW.event_key, NEW.order_id, NEW.payload, NEW.created_at)
IS DISTINCT FROM ROW(OLD.event_key, OLD.order_id, OLD.payload, OLD.created_at) THEN
RAISE EXCEPTION 'payment_notification_outbox 事件身份不可修改:outbox=%', OLD.id;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS payment_notification_outbox_preserve_identity ON payment_notification_outbox;
CREATE TRIGGER payment_notification_outbox_preserve_identity
BEFORE UPDATE ON payment_notification_outbox
FOR EACH ROW EXECUTE FUNCTION preserve_payment_notification_identity();
CREATE OR REPLACE FUNCTION preserve_generation_provider_run_identity()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF ROW(
NEW.task_id, NEW.task_attempt, NEW.provider, NEW.request_hash,
NEW.submission_key, NEW.callback_token, NEW.output_object_key,
NEW.thumbnail_object_key, NEW.staging_object_key
) IS DISTINCT FROM ROW(
OLD.task_id, OLD.task_attempt, OLD.provider, OLD.request_hash,
OLD.submission_key, OLD.callback_token, OLD.output_object_key,
OLD.thumbnail_object_key, OLD.staging_object_key
) THEN
RAISE EXCEPTION 'generation_provider_runs 业务身份或对象路径不可修改:run=%', OLD.id;
END IF;
IF OLD.provider_task_id IS NOT NULL
AND NEW.provider_task_id IS DISTINCT FROM OLD.provider_task_id THEN
RAISE EXCEPTION 'generation_provider_runs.provider_task_id 不可替换:run=%', OLD.id;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS generation_provider_runs_preserve_identity ON generation_provider_runs;
CREATE TRIGGER generation_provider_runs_preserve_identity
BEFORE UPDATE ON generation_provider_runs
FOR EACH ROW EXECUTE FUNCTION preserve_generation_provider_run_identity();
CREATE OR REPLACE FUNCTION preserve_upload_session_identity()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF ROW(
NEW.user_id, NEW.object_key, NEW.filename, NEW.content_type,
NEW.max_size_bytes, NEW.idempotency_key, NEW.request_hash, NEW.source,
NEW.asset_type, NEW.asset_name, NEW.folder_id, NEW.final_object_key
) IS DISTINCT FROM ROW(
OLD.user_id, OLD.object_key, OLD.filename, OLD.content_type,
OLD.max_size_bytes, OLD.idempotency_key, OLD.request_hash, OLD.source,
OLD.asset_type, OLD.asset_name, OLD.folder_id, OLD.final_object_key
) THEN
RAISE EXCEPTION 'upload_sessions 请求身份或对象路径不可修改:session=%', OLD.id;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS upload_sessions_preserve_identity ON upload_sessions;
CREATE TRIGGER upload_sessions_preserve_identity
BEFORE UPDATE ON upload_sessions
FOR EACH ROW EXECUTE FUNCTION preserve_upload_session_identity();
CREATE OR REPLACE FUNCTION reject_state_machine_history_delete()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION '% 是不可变历史记录,禁止 DELETE:id=%', TG_TABLE_NAME, OLD.id;
END;
$$;
DROP TRIGGER IF EXISTS payment_attempts_reject_delete ON payment_attempts;
CREATE TRIGGER payment_attempts_reject_delete
BEFORE DELETE ON payment_attempts
FOR EACH ROW EXECUTE FUNCTION reject_state_machine_history_delete();
DROP TRIGGER IF EXISTS credit_lots_reject_delete ON credit_lots;
CREATE TRIGGER credit_lots_reject_delete
BEFORE DELETE ON credit_lots
FOR EACH ROW EXECUTE FUNCTION reject_state_machine_history_delete();
DROP TRIGGER IF EXISTS credit_allocations_reject_delete ON credit_allocations;
CREATE TRIGGER credit_allocations_reject_delete
BEFORE DELETE ON credit_allocations
FOR EACH ROW EXECUTE FUNCTION reject_state_machine_history_delete();
DROP TRIGGER IF EXISTS order_refund_attempts_reject_delete ON order_refund_attempts;
CREATE TRIGGER order_refund_attempts_reject_delete
BEFORE DELETE ON order_refund_attempts
FOR EACH ROW EXECUTE FUNCTION reject_state_machine_history_delete();
DROP TRIGGER IF EXISTS provider_events_reject_delete ON provider_events;
CREATE TRIGGER provider_events_reject_delete
BEFORE DELETE ON provider_events
FOR EACH ROW EXECUTE FUNCTION reject_state_machine_history_delete();
DROP TRIGGER IF EXISTS generation_provider_runs_reject_delete ON generation_provider_runs;
CREATE TRIGGER generation_provider_runs_reject_delete
BEFORE DELETE ON generation_provider_runs
FOR EACH ROW EXECUTE FUNCTION reject_state_machine_history_delete();
-- 当前指针必须始终指向同一业务实体;这些跨表规则无法用普通 CHECK 表达。
CREATE OR REPLACE FUNCTION check_order_attempt_pointers()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.current_payment_attempt_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM payment_attempts
WHERE id = NEW.current_payment_attempt_id AND order_id = NEW.id
) THEN
RAISE EXCEPTION 'orders.current_payment_attempt_id 存在跨订单引用:order=%', NEW.id;
END IF;
IF NEW.current_refund_attempt_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM order_refund_attempts
WHERE id = NEW.current_refund_attempt_id AND order_id = NEW.id
) THEN
RAISE EXCEPTION 'orders.current_refund_attempt_id 存在跨订单引用:order=%', NEW.id;
END IF;
RETURN NULL;
END;
$$;
DROP TRIGGER IF EXISTS orders_attempt_pointer_check ON orders;
CREATE CONSTRAINT TRIGGER orders_attempt_pointer_check
AFTER INSERT OR UPDATE OF current_payment_attempt_id, current_refund_attempt_id ON orders
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION check_order_attempt_pointers();
CREATE OR REPLACE FUNCTION check_generation_run_pointer()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.active_provider_run_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM generation_provider_runs
WHERE id = NEW.active_provider_run_id
AND task_id = NEW.id
AND task_attempt = NEW.attempt
) THEN
RAISE EXCEPTION 'generation_tasks.active_provider_run_id 与当前 attempt 不一致:task=%', NEW.id;
END IF;
RETURN NULL;
END;
$$;
DROP TRIGGER IF EXISTS generation_tasks_run_pointer_check ON generation_tasks;
CREATE CONSTRAINT TRIGGER generation_tasks_run_pointer_check
AFTER INSERT OR UPDATE OF active_provider_run_id, attempt ON generation_tasks
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION check_generation_run_pointer();
CREATE OR REPLACE FUNCTION check_completed_upload_asset()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.status = 'completed' AND NOT EXISTS (
SELECT 1 FROM assets
WHERE id = NEW.asset_id
AND user_id = NEW.user_id
AND object_key = NEW.final_object_key
) THEN
RAISE EXCEPTION 'completed upload session 与最终资产不一致:session=%', NEW.id;
END IF;
RETURN NULL;
END;
$$;
DROP TRIGGER IF EXISTS upload_sessions_asset_pointer_check ON upload_sessions;
CREATE CONSTRAINT TRIGGER upload_sessions_asset_pointer_check
AFTER INSERT OR UPDATE OF status, asset_id, final_object_key ON upload_sessions
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION check_completed_upload_asset();
-- 迁移时再次扫描已有数据,避免只对未来写入生效。
DO $$
DECLARE
target_user_id uuid;
target_lot_id uuid;
BEGIN
-- 创建约束触发器不会扫描旧行;发布前必须主动验证所有缓存、lot 与 allocation。
FOR target_user_id IN SELECT id FROM users LOOP
PERFORM assert_user_credit_lot_balance(target_user_id);
END LOOP;
FOR target_lot_id IN SELECT id FROM credit_lots LOOP
PERFORM assert_credit_lot_allocations(target_lot_id);
END LOOP;
IF EXISTS (
SELECT 1
FROM orders item
JOIN payment_attempts attempt ON attempt.id = item.current_payment_attempt_id
WHERE attempt.order_id <> item.id
) THEN
RAISE EXCEPTION 'orders.current_payment_attempt_id 存在跨订单引用';
END IF;
IF EXISTS (
SELECT 1
FROM orders item
JOIN order_refund_attempts attempt ON attempt.id = item.current_refund_attempt_id
WHERE attempt.order_id <> item.id
) THEN
RAISE EXCEPTION 'orders.current_refund_attempt_id 存在跨订单引用';
END IF;
IF EXISTS (
SELECT 1
FROM generation_tasks task
JOIN generation_provider_runs run ON run.id = task.active_provider_run_id
WHERE run.task_id <> task.id OR run.task_attempt <> task.attempt
) THEN
RAISE EXCEPTION 'generation_tasks.active_provider_run_id 与当前 attempt 不一致';
END IF;
IF EXISTS (
SELECT 1
FROM upload_sessions session
JOIN assets asset ON asset.id = session.asset_id
WHERE session.status = 'completed'
AND (asset.user_id <> session.user_id OR asset.object_key <> session.final_object_key)
) THEN
RAISE EXCEPTION 'completed upload session 与最终资产不一致';
END IF;
END
$$;
COMMENT ON COLUMN orders.payment_create_claimed_at IS
'已弃用:支付创建认领改由 payment_attempts.lease_token 围栏;观察期后物理删除。';
COMMENT ON COLUMN orders.refund_dispatch_status IS
'兼容双读字段:权威退款状态位于 order_refund_attempts;观察期后物理删除。';
COMMENT ON COLUMN orders.refund_dispatch_lease_until IS
'兼容双读字段:权威租约位于 order_refund_attempts;观察期后物理删除。';
COMMENT ON COLUMN generation_tasks.provider_result IS
'兼容双读字段:权威供应商结果位于 generation_provider_runs;观察期后物理删除。';
COMMENT ON COLUMN generation_tasks.lease_expires_at IS
'兼容双读字段:权威 worker 租约使用 claim_token 与 generation_provider_runs.lease_token。';