docs(billing): 固化计费闭环决策与迁移基础

新增 ADR-002、计费流程说明和 0069 增量迁移,建立独立计费状态、结算 Outbox、显式免费与钱包约束。历史成功未扣费任务仅进入人工复核,不执行追扣。\n\n验证:迁移安全验证与 tests/ci/migrations-test.sh 通过。
This commit is contained in:
2026-07-20 23:09:18 +08:00
parent dcf5c4f340
commit 01a013c809
3 changed files with 314 additions and 0 deletions
@@ -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')
);