docs(billing): 固化计费闭环决策与迁移基础
新增 ADR-002、计费流程说明和 0069 增量迁移,建立独立计费状态、结算 Outbox、显式免费与钱包约束。历史成功未扣费任务仅进入人工复核,不执行追扣。\n\n验证:迁移安全验证与 tests/ci/migrations-test.sh 通过。
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
ALTER TABLE gateway_tasks
|
||||
ADD COLUMN IF NOT EXISTS billing_version text NOT NULL DEFAULT 'effective-pricing-v2',
|
||||
ADD COLUMN IF NOT EXISTS billing_status text NOT NULL DEFAULT 'not_started',
|
||||
ADD COLUMN IF NOT EXISTS billing_currency text NOT NULL DEFAULT 'resource',
|
||||
ADD COLUMN IF NOT EXISTS pricing_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS request_fingerprint text,
|
||||
ADD COLUMN IF NOT EXISTS idempotency_key_hash text,
|
||||
ADD COLUMN IF NOT EXISTS reservation_amount numeric(38, 9) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS execution_token uuid,
|
||||
ADD COLUMN IF NOT EXISTS execution_lease_expires_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS billing_updated_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS billing_settled_at timestamptz;
|
||||
|
||||
ALTER TABLE gateway_tasks
|
||||
ADD CONSTRAINT gateway_tasks_billing_status_v2_check CHECK (
|
||||
billing_status IN (
|
||||
'not_started', 'pending', 'processing', 'settled', 'released',
|
||||
'retryable_failed', 'manual_review', 'not_required'
|
||||
)
|
||||
) NOT VALID,
|
||||
ADD CONSTRAINT gateway_tasks_reservation_amount_v2_check CHECK (reservation_amount >= 0) NOT VALID;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_tasks_idempotency_hash_v2
|
||||
ON gateway_tasks(user_id, idempotency_key_hash)
|
||||
WHERE idempotency_key_hash IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_tasks_execution_lease_v2
|
||||
ON gateway_tasks(status, execution_lease_expires_at)
|
||||
WHERE status = 'running';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_tasks_billing_status_v2
|
||||
ON gateway_tasks(billing_status, billing_updated_at, created_at);
|
||||
|
||||
ALTER TABLE gateway_task_attempts
|
||||
ADD COLUMN IF NOT EXISTS pricing_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS request_fingerprint text,
|
||||
ADD COLUMN IF NOT EXISTS upstream_submission_status text NOT NULL DEFAULT 'not_submitted',
|
||||
ADD COLUMN IF NOT EXISTS upstream_submission_updated_at timestamptz;
|
||||
|
||||
ALTER TABLE gateway_task_attempts
|
||||
ADD CONSTRAINT gateway_task_attempts_submission_v2_check CHECK (
|
||||
upstream_submission_status IN ('not_submitted', 'submitting', 'response_received')
|
||||
) NOT VALID;
|
||||
|
||||
ALTER TABLE settlement_outbox
|
||||
ADD COLUMN IF NOT EXISTS action text NOT NULL DEFAULT 'settle',
|
||||
ADD COLUMN IF NOT EXISTS amount numeric(38, 9) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS currency text NOT NULL DEFAULT 'resource',
|
||||
ADD COLUMN IF NOT EXISTS pricing_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS locked_by text,
|
||||
ADD COLUMN IF NOT EXISTS lock_token uuid,
|
||||
ADD COLUMN IF NOT EXISTS locked_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS last_error_code text,
|
||||
ADD COLUMN IF NOT EXISTS last_error_message text,
|
||||
ADD COLUMN IF NOT EXISTS completed_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS manual_review_reason text;
|
||||
|
||||
ALTER TABLE settlement_outbox
|
||||
ADD CONSTRAINT settlement_outbox_action_v2_check CHECK (action IN ('settle', 'release')) NOT VALID,
|
||||
ADD CONSTRAINT settlement_outbox_status_v2_check CHECK (
|
||||
status IN ('pending', 'processing', 'retryable_failed', 'completed', 'manual_review')
|
||||
) NOT VALID,
|
||||
ADD CONSTRAINT settlement_outbox_amount_v2_check CHECK (amount >= 0) NOT VALID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_settlement_outbox_claim_v2
|
||||
ON settlement_outbox(status, next_attempt_at, created_at)
|
||||
WHERE status IN ('pending', 'retryable_failed');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_settlement_outbox_stale_v2
|
||||
ON settlement_outbox(status, locked_at)
|
||||
WHERE status = 'processing';
|
||||
|
||||
ALTER TABLE model_pricing_rules
|
||||
ADD COLUMN IF NOT EXISTS is_free boolean NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE model_pricing_rules
|
||||
ADD CONSTRAINT model_pricing_rules_explicit_free_v2_check CHECK (
|
||||
base_price > 0 OR (base_price = 0 AND is_free)
|
||||
) NOT VALID;
|
||||
|
||||
ALTER TABLE gateway_wallet_accounts
|
||||
ADD CONSTRAINT gateway_wallet_nonnegative_frozen_v2_check CHECK (frozen_balance >= 0) NOT VALID,
|
||||
ADD CONSTRAINT gateway_wallet_balance_covers_frozen_v2_check CHECK (balance >= frozen_balance) NOT VALID;
|
||||
|
||||
ALTER TABLE gateway_wallet_transactions
|
||||
ADD CONSTRAINT gateway_wallet_transactions_account_restrict_v2
|
||||
FOREIGN KEY (account_id) REFERENCES gateway_wallet_accounts(id) ON DELETE RESTRICT NOT VALID;
|
||||
|
||||
UPDATE gateway_tasks task
|
||||
SET billing_status = 'not_required',
|
||||
billing_updated_at = now()
|
||||
WHERE task.run_mode <> 'production'
|
||||
OR task.gateway_user_id IS NULL;
|
||||
|
||||
UPDATE gateway_tasks task
|
||||
SET billing_status = 'settled',
|
||||
billing_currency = COALESCE(NULLIF(task.billing_summary->>'currency', ''), 'resource'),
|
||||
billing_updated_at = now(),
|
||||
billing_settled_at = COALESCE(task.finished_at, task.updated_at)
|
||||
WHERE task.status = 'succeeded'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM gateway_wallet_transactions transaction
|
||||
WHERE transaction.reference_type = 'gateway_task'
|
||||
AND transaction.reference_id = task.id::text
|
||||
AND transaction.transaction_type = 'task_billing'
|
||||
);
|
||||
|
||||
UPDATE gateway_tasks task
|
||||
SET billing_status = 'manual_review',
|
||||
billing_currency = COALESCE(NULLIF(task.billing_summary->>'currency', ''), 'resource'),
|
||||
billing_updated_at = now()
|
||||
WHERE task.status = 'succeeded'
|
||||
AND task.run_mode = 'production'
|
||||
AND task.gateway_user_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM gateway_wallet_transactions transaction
|
||||
WHERE transaction.reference_type = 'gateway_task'
|
||||
AND transaction.reference_id = task.id::text
|
||||
AND transaction.transaction_type = 'task_billing'
|
||||
);
|
||||
|
||||
INSERT INTO settlement_outbox (
|
||||
task_id, event_type, action, amount, currency, payload, status, next_attempt_at
|
||||
)
|
||||
SELECT task.id,
|
||||
'task.billing.release',
|
||||
'release',
|
||||
transaction.amount,
|
||||
account.currency,
|
||||
jsonb_build_object(
|
||||
'taskId', task.id,
|
||||
'reservationIdempotencyKey', transaction.idempotency_key,
|
||||
'classification', 'historical_terminal_reservation'
|
||||
),
|
||||
'pending',
|
||||
now()
|
||||
FROM gateway_tasks task
|
||||
JOIN gateway_wallet_transactions transaction
|
||||
ON transaction.reference_type = 'gateway_task'
|
||||
AND transaction.reference_id = task.id::text
|
||||
AND transaction.transaction_type = 'reserve'
|
||||
JOIN gateway_wallet_accounts account ON account.id = transaction.account_id
|
||||
WHERE task.status IN ('failed', 'cancelled')
|
||||
AND COALESCE(transaction.idempotency_key, '') <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM gateway_wallet_transactions release
|
||||
WHERE release.account_id = transaction.account_id
|
||||
AND release.transaction_type = 'release'
|
||||
AND release.idempotency_key = transaction.idempotency_key || ':release'
|
||||
)
|
||||
ON CONFLICT (task_id, event_type) DO NOTHING;
|
||||
|
||||
UPDATE gateway_tasks task
|
||||
SET billing_status = 'pending',
|
||||
billing_updated_at = now()
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM settlement_outbox outbox
|
||||
WHERE outbox.task_id = task.id
|
||||
AND outbox.action = 'release'
|
||||
AND outbox.status IN ('pending', 'processing', 'retryable_failed')
|
||||
);
|
||||
Reference in New Issue
Block a user