Files
codex-app-server/cloud/chv/payments.py
T
host b55b49b358 feat(chv): 对齐 Muse P10 支付安全——凭证隔离 + 结账检测 + 每次购买人工审批 + 一次性卡号
- 支付凭证隔离:PaymentCredentialVault(只活内存、白名单
  stripe-link/shop-pay、只存不透明 id + 掩码标签、原始卡号
  PAN 拒绝入库、按次释放绑定商家/金额/货币、单次 + TTL)
- 结账页检测:detect_checkout(URL + 无障碍树信号,确定性
  规则、不读原始 DOM、与 P4 detect_form_risk 同输入形状)
- 每次购买人工审批:PaymentGate 经 P5 对话外通道发审批,
  payload 含确切购买详情;完成时先验签、再验详情摘要
  (篡改即拒且审批保持待处理)、最后单次消费答复
- 一次性卡号:OneTimeCard 绑定商家/金额/货币/有效期,单次
  兑现;SimulatedWalletBackend 沙箱钱包(假 PAN 只在后端
  私有内存、charge 只记账不联网、取消即 void)
- 限额:SpendLimits 三档 minor units 比对,限额不可用时
  不编造上限(limits_checked=False)
- 审计:PaymentAudit 只留允许字段,秘密字段名丢弃 + redacted
- 审批通道最小扩展:ApprovalRequest 增加可选 details 字段,
  不传时 payload 与 P5 完全一致
- 真实支付与扣款不做:全部沙箱验证,真实钱包集成待用户
  另行批准;新测试 79 项,全量 cloud 套件 2037 OK

round-12 集成说明:本提交为 acf98f8 的 cherry-pick(只含 P10
自有 diff,基线 25bcd35 已含的 P3/P5 内容未重复合入;
approval_channel.py 仅加可选 details 字段)。
2026-11-07 00:09:55 +00:00

770 lines
34 KiB
Python
Raw 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.
"""支付安全门(feat-chv-muse-p10,叶子模块,纯标准库)。
对齐 Muse 官方安全文(security.muse.ai 2026-09-08,见
docs/chv-muse-p10-gap.md):支付时发行一次性卡号给商家而非
真实卡号,绑定特定商家、特定金额、有限有效期;已保存支付
凭证的网站每次购买都弹人工审批并展示确切购买详情;未购物
过的网站走内置合规钱包(Stripe Link 首发、Shop Pay 次之)。
本模块只做客内 agent 侧的支付安全门(检测、路由、限额、
审批、绑定、审计),不产生真实扣款:
- ``PaymentCredentialVault``:已保存支付方式的隔离存储,
走 P3 authd 模式(只活内存、白名单、按次释放绑定、审计
绝无凭证值),但另立库——只存不透明支付方式 id + 掩码
标签,原始卡号 PAN 拒绝入库;
- ``OneTimeCard``:一次性卡号的绑定与兑现校验(商家 +
金额 + 货币 + 有效期,单次有效);
- ``SimulatedWalletBackend``:沙箱钱包(假 PAN 只在后端
私有内存里,调用方只见掩码标签与 card_id;``charge``
只记账不联网),供测试与演示;真实钱包集成是待用户
另行批准的事项,见差距表 §3;
- ``PaymentGate``:编排——结账检测 → 路由 → 限额 →
P5 对话外审批(确切购买详情进 payload)→ 铸一次性卡
→ 兑现校验 → 记账 → 审计。无审批的支付一律拒绝。
真实一次性虚拟卡由 Link 在其侧发行(连接器文档原文),
本轨的卡是绑定与兑现校验的等价模型,不是发卡行。
"""
import hashlib
import json
import secrets
import time
from collections.abc import Callable
from dataclasses import dataclass
from cloud.chv.approval_channel import (
Answer,
request_approval,
resolve_via_channel,
verify_answer,
)
from cloud.chv.checkout_detect import CheckoutVerdict
from cloud.chv.sentinel import ApprovalQueue, EgressRequest
# 钱包提供方白名单(连接器文档现状:stripe-link 首发、
# shop-pay 已可用;未知提供方一律拒绝)。
ALLOWED_WALLET_PROVIDERS = frozenset({"stripe-link", "shop-pay"})
# 一次性卡默认有效期(秒)与按次释放默认有效期(秒)。
DEFAULT_CARD_TTL_SECONDS = 900
DEFAULT_RELEASE_TTL_SECONDS = 300
# 审计里绝不出现的字段名(命中即丢弃并标 redacted)。
_SECRET_FIELD_NAMES = ("card_number", "cardnumber", "pan", "cvv",
"cvc", "token", "secret", "password")
# 沙箱钱包的假卡号段(Stripe 测试卡号段,永不产生真实扣款)。
_TEST_PAN_PREFIX = "400000000000"
class PaymentRefusal(Exception):
"""支付拒绝:kind + 原因,不含任何卡号与凭证值。"""
def __init__(self, kind: str, message: str) -> None:
super().__init__("payment_refused:%s: %s" % (kind, message))
self.kind = kind
def luhn_valid(number: str) -> bool:
"""Luhn 校验(只用于识别“像卡号”的数字串,拒绝入库)。"""
digits = [int(c) for c in number if c.isdigit()]
if len(digits) < 13 or len(digits) > 19:
return False
total = 0
for i, d in enumerate(reversed(digits)):
if i % 2 == 1:
d *= 2
if d > 9:
d -= 9
total += d
return total % 10 == 0
def _looks_like_pan(value: str) -> bool:
"""值是否像原始卡号:去空格/横线后 13–19 位数字且过
Luhn。命中即视为 PAN,拒绝入库、拒绝进审计。"""
compact = value.replace(" ", "").replace("-", "")
return compact.isdigit() and luhn_valid(compact)
def _masked_label(pan: str, brand: str = "Visa") -> str:
"""假卡号的掩码标签(只露后 4 位,审计与审批里唯一
允许出现的卡号派生形式)。"""
return "%s ....%s" % (brand, pan[-4:])
@dataclass
class PaymentMethod:
"""一个已保存的支付方式(只含不透明 id + 掩码标签,
无真实卡号)。``merchant_host`` 是该凭证保存于的商家
主机(None = 未限定商家,释放时仍须绑定本次商家)。"""
provider: str
method_id: str
masked_label: str
merchant_host: str | None = None
def describe(self) -> dict:
"""审计/审批用描述(不含任何凭证值)。"""
return {"provider": self.provider, "method_id": self.method_id,
"masked_label": self.masked_label,
"merchant_host": self.merchant_host}
@dataclass
class PaymentRelease:
"""一次支付方式按次释放(替身):绑定到一次具体的购买。
兑现后交出的是不透明 method_id + 掩码标签,不是卡号。"""
release_id: str
provider: str
method_id: str
masked_label: str
merchant: str
amount_minor: int
currency: str
expires_at: float
uses_left: int = 1
def describe(self) -> dict:
"""审计用描述(只含绑定,不含值)。"""
return {"release_id": self.release_id,
"provider": self.provider, "method_id": self.method_id,
"masked_label": self.masked_label,
"merchant": self.merchant,
"amount_minor": self.amount_minor,
"currency": self.currency,
"expires_at": self.expires_at,
"uses_left": self.uses_left}
def __repr__(self) -> str: # 绝不含任何凭证值
return ("PaymentRelease(id=%s provider=%s merchant=%s "
"amount=%d %s)" % (self.release_id, self.provider,
self.merchant, self.amount_minor,
self.currency))
class PaymentCredentialVault:
"""只活内存的支付凭证保险库(一个 VM 一个实例)。
- 值只存在内存 dict 里,不落盘、不进日志;
- 只存不透明支付方式 id + 掩码标签:原始卡号 PAN、
未知提供方、空 id 一律拒绝入库;
- ``release()`` 按次发放:绑定 {商家, 金额, 货币},
单次有效、ttl 后过期;
- ``consume()`` 单次兑现:兑现一次即删除,过期也兑现
不了;兑现失败不留可重试的残值。
clock 可注入(测试用假时钟测过期),默认 time.time。"""
def __init__(self, clock: Callable[[], float] | None = None) -> None:
self._store: dict[tuple[str, str], PaymentMethod] = {}
self._releases: dict[str, PaymentRelease] = {}
self._clock = clock or time.time
def __repr__(self) -> str: # 绝不含任何凭证值
return "PaymentCredentialVault(providers=%s)" % sorted(
{p for (p, _m) in self._store})
def store(self, provider: str, method_id: str,
masked_label: str, merchant_host: str | None = None
) -> None:
"""入库:白名单校验 + PAN 拒绝。原始卡号、未知提供方、
空 id、空标签一律拒绝入库(PaymentRefusal),绝不静默
收下。"""
if provider not in ALLOWED_WALLET_PROVIDERS:
raise PaymentRefusal(
"unknown_provider",
"钱包提供方 %s 不在白名单内" % provider)
if not isinstance(method_id, str) or not method_id.strip():
raise PaymentRefusal("empty_method_id", "支付方式 id 为空")
if _looks_like_pan(method_id) or _looks_like_pan(masked_label):
raise PaymentRefusal(
"raw_pan_refused",
"原始卡号不得入库,只存不透明 id 与掩码标签")
if not isinstance(masked_label, str) or not masked_label.strip():
raise PaymentRefusal("empty_label", "掩码标签为空")
key = (provider, method_id)
self._store[key] = PaymentMethod(
provider, method_id, masked_label, merchant_host)
def has(self, provider: str, method_id: str) -> bool:
"""该(提供方,方式 id)是否已入库(不返回值)。"""
return (provider, method_id) in self._store
def methods_for_merchant(self, merchant_host: str) -> list:
"""该商家主机上已保存的支付方式(只返回描述对象,
不含任何凭证值)。"""
return [m for m in self._store.values()
if m.merchant_host is None
or m.merchant_host == merchant_host]
def all_methods(self) -> list:
"""全部已保存支付方式(描述对象,不含任何凭证值)。"""
return list(self._store.values())
def forget(self, provider: str, method_id: str) -> bool:
"""删一条入库支付方式;同时作废其全部未兑现释放。
未知键返回 False,不抛错。"""
gone = self._store.pop((provider, method_id), None) is not None
if gone:
doomed = [rid for rid, rel in self._releases.items()
if rel.provider == provider
and rel.method_id == method_id]
for rid in doomed:
self._releases.pop(rid, None)
return gone
def release(self, provider: str, method_id: str, merchant: str,
amount_minor: int, currency: str,
ttl_seconds: int = DEFAULT_RELEASE_TTL_SECONDS,
max_uses: int = 1) -> PaymentRelease:
"""按次发放:绑定须与入库作用域一致(merchant_host
有记录时须一致),金额须 > 0。发放的释放单次有效、
ttl 后过期;须经 consume() 兑现一次才交出方式引用。"""
method = self._store.get((provider, method_id))
if method is None:
raise PaymentRefusal(
"method_not_stored",
"支付方式 %s 未入库" % method_id)
if method.merchant_host is not None \
and method.merchant_host != merchant:
raise PaymentRefusal(
"scope_mismatch", "支付方式作用域商家不一致,拒绝释放")
if amount_minor <= 0:
raise PaymentRefusal("bad_amount", "金额须大于 0")
release = PaymentRelease(
release_id=secrets.token_hex(8),
provider=provider, method_id=method_id,
masked_label=method.masked_label, merchant=merchant,
amount_minor=amount_minor, currency=currency,
expires_at=self._clock() + ttl_seconds,
uses_left=max_uses)
self._releases[release.release_id] = release
return release
def consume(self, release_id: str) -> PaymentMethod:
"""兑现一次释放:过期/用完/未知 id 一律拒绝
(PaymentRefusal,不含值)。兑现成功返回支付方式
引用(不透明 id + 掩码标签),并把该释放删除。"""
release = self._releases.get(release_id)
if release is None:
raise PaymentRefusal(
"release_unknown", "释放记录不存在或已兑现")
if self._clock() >= release.expires_at:
self._releases.pop(release_id, None)
raise PaymentRefusal("release_expired", "释放已过期")
if release.uses_left <= 0:
self._releases.pop(release_id, None)
raise PaymentRefusal("release_exhausted", "释放已用完")
release.uses_left -= 1
method = self._store.get((release.provider, release.method_id))
self._releases.pop(release_id, None)
if method is None:
raise PaymentRefusal(
"method_gone", "支付方式已删除,释放作废")
return method
@dataclass
class PurchaseDetails:
"""一次购买的确切详情(审批单上展示给用户的全部内容,
也是防篡改摘要的输入)。金额以 minor units 计(分/日元
等无小数货币按其最小单位),货币为三位字母码,不换算。"""
merchant: str
merchant_host: str
items: tuple
amount_minor: int
currency: str
wallet_provider: str
def total_minor(self) -> int:
"""商品小计(数量 × 单价之和),须等于 amount_minor。"""
return sum(qty * unit for (_n, qty, unit) in self.items)
def validate(self) -> None:
"""校验详情自洽:金额 > 0、货币三位码、商品非空、
小计等于总额、提供方在白名单内。任一失败即
ValueError(调用方 bug,不走支付拒绝通道)。"""
if self.amount_minor <= 0:
raise ValueError("amount_minor must be > 0")
if len(self.currency) != 3 or not self.currency.isalpha():
raise ValueError("currency must be a 3-letter code")
if not self.items:
raise ValueError("items must not be empty")
if self.total_minor() != self.amount_minor:
raise ValueError("items total != amount_minor")
if self.wallet_provider not in ALLOWED_WALLET_PROVIDERS:
raise ValueError("unknown wallet_provider")
def describe(self) -> dict:
"""审批 payload 里的确切购买详情(商家、商品、数量、
金额、货币、提供方;无任何卡号与凭证值)。"""
return {"merchant": self.merchant,
"merchant_host": self.merchant_host,
"items": [{"name": n, "qty": q, "unit_minor": u}
for (n, q, u) in self.items],
"amount_minor": self.amount_minor,
"currency": self.currency,
"wallet_provider": self.wallet_provider}
def digest(self) -> str:
"""购买详情摘要(SHA-256,规范 JSON):审批时记下,
扣款时重算——详情被篡改(换商家/改金额/换商品)即
摘要对不上,支付拒绝。"""
canonical = json.dumps(self.describe(), sort_keys=True,
separators=(",", ":"),
ensure_ascii=False)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
@dataclass
class OneTimeCard:
"""一张一次性卡号(绑定与兑现校验的等价模型)。
绑定 {商家, 金额, 货币, 有效期},单次有效。真实一次
性虚拟卡由 Link 在其侧发行(连接器文档原文),本类
只做绑定校验——调用方只见 card_id 与掩码标签,卡号
本体只活在钱包后端私有内存里。"""
card_id: str
merchant: str
amount_minor: int
currency: str
expires_at: float
masked_label: str
uses_left: int = 1
def describe(self) -> dict:
"""审计用描述(只含绑定与掩码标签,不含卡号)。"""
return {"card_id": self.card_id, "merchant": self.merchant,
"amount_minor": self.amount_minor,
"currency": self.currency,
"expires_at": self.expires_at,
"masked_label": self.masked_label,
"uses_left": self.uses_left}
def __repr__(self) -> str: # 绝不含卡号
return ("OneTimeCard(id=%s merchant=%s amount=%d %s)"
% (self.card_id, self.merchant, self.amount_minor,
self.currency))
def authorize(self, merchant: str, amount_minor: int,
currency: str, now: float) -> tuple:
"""兑现校验:用完/过期/换商家/超额/换货币任一命中
即 (False, 原因);全过则消耗一次并返回 (True,
"ok")。单次有效,用过即废。"""
if self.uses_left <= 0:
return False, "card_exhausted"
if now >= self.expires_at:
return False, "card_expired"
if merchant != self.merchant:
return False, "wrong_merchant"
if currency != self.currency:
return False, "wrong_currency"
if amount_minor > self.amount_minor:
return False, "amount_exceeded"
self.uses_left -= 1
return True, "ok"
@dataclass
class SpendLimits:
"""钱包侧限额(minor units,无货币、不换算——按
stripe-link 连接器文档口径)。None = 该档未设置。
``daily_spent_minor`` / ``thirty_day_spent_minor`` 是
调用方传入的已用额度(本模块不记账)。"""
per_purchase_minor: int | None = None
daily_minor: int | None = None
thirty_day_minor: int | None = None
daily_spent_minor: int = 0
thirty_day_spent_minor: int = 0
def check(self, amount_minor: int) -> tuple:
"""逐项比 minor units:超任一档即 (False, 原因);
三档全为 None(限额不可用)→ (True,
"limits_unavailable"),绝不编造上限。"""
if self.per_purchase_minor is not None \
and amount_minor > self.per_purchase_minor:
return False, "over_per_purchase_limit"
if self.daily_minor is not None \
and self.daily_spent_minor + amount_minor \
> self.daily_minor:
return False, "over_daily_limit"
if self.thirty_day_minor is not None \
and self.thirty_day_spent_minor + amount_minor \
> self.thirty_day_minor:
return False, "over_thirty_day_limit"
if self.per_purchase_minor is None \
and self.daily_minor is None \
and self.thirty_day_minor is None:
return True, "limits_unavailable"
return True, "ok"
class PaymentAudit:
"""支付审计(内存态记录表)。
事件只含允许字段(商家、金额、货币、掩码标签、
pending_id、原因、路由、提供方);字段名命中
``_SECRET_FIELD_NAMES`` 一律丢弃并标 ``redacted``——
卡号与凭证值绝不进审计。"""
ALLOWED_FIELDS = frozenset({
"merchant", "merchant_host", "amount_minor", "currency",
"masked_label", "pending_id", "reason", "route",
"wallet_provider", "card_id", "limits_checked",
})
def __init__(self, clock: Callable[[], float] | None = None) -> None:
self._records: list = []
self._clock = clock or time.time
def record(self, event: str, fields: dict | None = None,
now: float | None = None) -> dict:
"""记一条审计(只留允许字段;秘密字段名丢弃并标
redacted)。返回记下的记录副本。"""
at = now if now is not None else self._clock()
kept: dict = {"event": event, "ts": at}
redacted = False
for key, value in (fields or {}).items():
if key in _SECRET_FIELD_NAMES:
redacted = True
continue
if key in self.ALLOWED_FIELDS:
kept[key] = value
if redacted:
kept["redacted"] = True
self._records.append(dict(kept))
return dict(kept)
def list_records(self) -> list:
"""全部审计记录(副本,按记录时间排序)。"""
return [dict(r) for r in self._records]
class SimulatedWalletBackend:
"""沙箱钱包(测试与演示用,绝不产生真实扣款)。
- 假 PAN 只存在 ``self._pans`` 私有内存里,调用方只见
card_id 与掩码标签;``__repr__`` 与账本里绝无卡号;
- ``mint_card`` 按购买详情铸一次性卡(绑定商家 + 金额
+ 货币 + 900 秒有效期);
- ``charge`` 只记账不联网:绑定校验通过即记一笔
``simulated_paid``,否则记拒绝原因;
- ``void_card`` 作废一张卡(取消待支付时调用)。
真实钱包集成是待用户另行批准的事项(见差距表 §3)。"""
def __init__(self, providers: tuple = ("stripe-link", "shop-pay"),
clock: Callable[[], float] | None = None) -> None:
self._providers = tuple(providers)
self._vault = PaymentCredentialVault(clock=clock)
self._pans: dict[str, str] = {}
self._cards: dict[str, OneTimeCard] = {}
self._voided: set = set()
self._ledger: list = []
self._clock = clock or time.time
self.fail_next_charge = False
def __repr__(self) -> str: # 绝不含任何卡号
return "SimulatedWalletBackend(providers=%s)" % list(
self._providers)
def supports(self, provider: str) -> bool:
"""该钱包提供方是否可用(沙箱里由构造参数决定,
真实侧由钱包连接状态决定)。"""
return provider in self._providers
def add_method(self, provider: str, method_id: str,
masked_label: str, merchant_host: str | None = None
) -> None:
"""往沙箱钱包里加一个已保存支付方式(只存不透明
id + 掩码标签,走同一入库校验)。"""
self._vault.store(provider, method_id, masked_label,
merchant_host)
def list_methods(self, merchant_host: str | None = None) -> list:
"""已保存支付方式(描述对象;merchant_host 给了
则只返回该商家的)。"""
if merchant_host is None:
return [m.describe()
for m in self._vault.all_methods()]
return [m.describe() for m in
self._vault.methods_for_merchant(merchant_host)]
def mint_card(self, details: PurchaseDetails,
now: float | None = None) -> OneTimeCard:
"""按购买详情铸一张一次性卡:假 PAN 只进私有内存,
返回的卡对象只有 card_id 与掩码标签。"""
at = now if now is not None else self._clock()
card_id = "otc_" + secrets.token_hex(8)
pan = _TEST_PAN_PREFIX + "%04d" % secrets.randbelow(10000)
self._pans[card_id] = pan
card = OneTimeCard(
card_id=card_id, merchant=details.merchant,
amount_minor=details.amount_minor,
currency=details.currency,
expires_at=at + DEFAULT_CARD_TTL_SECONDS,
masked_label=_masked_label(pan))
self._cards[card_id] = card
return card
def charge(self, card: OneTimeCard, details: PurchaseDetails,
now: float | None = None) -> dict:
"""模拟扣款:只记账不联网。绑定校验通过即记一笔
simulated_paid;``fail_next_charge`` 为真时模拟
钱包侧失败(扣款失败即 void 该卡)。"""
at = now if now is not None else self._clock()
entry: dict
if self.fail_next_charge:
self.fail_next_charge = False
self.void_card(card.card_id)
entry = {"status": "simulated_failed",
"reason": "wallet_declined",
"card_id": card.card_id}
self._ledger.append(dict(entry))
return dict(entry)
ok, reason = card.authorize(
details.merchant, details.amount_minor,
details.currency, at)
if not ok:
entry = {"status": "refused", "reason": reason,
"card_id": card.card_id}
self._ledger.append(dict(entry))
return dict(entry)
entry = {"status": "simulated_paid",
"card_id": card.card_id,
"merchant": details.merchant,
"amount_minor": details.amount_minor,
"currency": details.currency,
"masked_label": card.masked_label}
self._ledger.append(dict(entry))
return dict(entry)
def void_card(self, card_id: str) -> bool:
"""作废一张一次性卡(取消待支付时调用;已作废或
未知 id 返回 False,不抛错)。"""
if card_id in self._voided or card_id not in self._cards:
return False
self._voided.add(card_id)
card = self._cards[card_id]
card.uses_left = 0
return True
def ledger(self) -> list:
"""模拟账本(副本;只含记账条目,无任何卡号)。"""
return [dict(e) for e in self._ledger]
def _verdict_of(answer: Answer | dict) -> str:
"""答复对象或同款 dict 里的 verdict(verify_answer 已
校验过合法性,这里只取值;取不到即空串)。"""
if isinstance(answer, dict):
value = answer.get("verdict", "")
return value if isinstance(value, str) else ""
return getattr(answer, "verdict", "") or ""
class PaymentGate:
"""支付安全门:一次购买的完整编排。
``begin_purchase``:结账检测 → 路由(已保存凭证 /
合规钱包 stripe-link 优先、shop-pay 次之)→ 限额 →
P5 对话外审批(确切购买详情进 payload)。无审批不支付。
``complete_purchase``:验签(不消费)→ 验详情摘要
(篡改即拒,审批保持待处理)→ 单次消费答复 → 铸一次
性卡 → 兑现校验 → 记账 → 审计。审批被拒即停止,不扣款。
"""
def __init__(self, queue: ApprovalQueue,
wallet: SimulatedWalletBackend,
vault: PaymentCredentialVault,
channel_secret: str,
clock: Callable[[], float] | None = None,
audit: PaymentAudit | None = None) -> None:
self._queue = queue
self._wallet = wallet
self._vault = vault
self._secret = channel_secret
self._clock = clock or time.time
self._audit = audit or PaymentAudit(clock=clock)
self._pending: dict[str, dict] = {}
def _route(self, details: PurchaseDetails) -> tuple:
"""路由:商家有已保存凭证 → saved;无 → 合规钱包
(stripe-link 优先、shop-pay 次之);两家都不支持
→ (None, "no_wallet_route")。"""
saved = self._vault.methods_for_merchant(
details.merchant_host)
if saved:
return "saved", saved[0].provider
if self._wallet.supports("stripe-link"):
return "wallet", "stripe-link"
if self._wallet.supports("shop-pay"):
return "wallet", "shop-pay"
return "", "no_wallet_route"
def _refuse(self, details: PurchaseDetails, reason: str,
at: float, limits_checked: bool | None = None) -> dict:
"""记一条 payment_refused 审计并返回统一的拒绝结果。"""
fields: dict = {"merchant": details.merchant,
"merchant_host": details.merchant_host,
"amount_minor": details.amount_minor,
"currency": details.currency,
"wallet_provider": details.wallet_provider,
"reason": reason}
if limits_checked is not None:
fields["limits_checked"] = limits_checked
self._audit.record("payment_refused", fields, now=at)
out: dict = {"ok": False, "reason": reason}
if limits_checked is not None:
out["limits_checked"] = limits_checked
return out
def _request_payment_approval(self, details: PurchaseDetails,
route: str, provider: str,
limits_checked: bool,
at: float) -> dict:
"""记入 Sentinel 待审批队列并经 P5 通道发审批请求
(确切购买详情进 payload)。返回 begin 的成功结果。"""
request = EgressRequest(
connector="payment", method="purchase", scheme="https",
host=details.merchant_host, port=443, path="/checkout")
pending_id = self._queue.record(request, ["payment_approval"])
record = next(r for r in self._queue.list_pending()
if r["pending_id"] == pending_id)
approval = request_approval(
record, self._secret, now=at,
details=details.describe())
self._pending[pending_id] = {
"details": details, "digest": details.digest(),
"route": route, "provider": provider,
"card_id": None, "limits_checked": limits_checked}
self._audit.record(
"payment_approval_requested",
{"merchant": details.merchant,
"merchant_host": details.merchant_host,
"amount_minor": details.amount_minor,
"currency": details.currency,
"wallet_provider": details.wallet_provider,
"pending_id": pending_id, "route": route,
"limits_checked": limits_checked}, now=at)
return {"ok": True, "pending_id": pending_id,
"payload": approval.payload(), "route": route,
"provider": provider}
def begin_purchase(self, details: PurchaseDetails,
checkout: CheckoutVerdict,
limits: SpendLimits | None = None,
now: float | None = None) -> dict:
"""开始一次购买:返回 {"ok": True, "pending_id",
"payload", "route", "provider"},或 {"ok": False,
"reason"}。审批请求发给客户端 UI,payload 的 details
就是用户看到的确切购买详情。"""
details.validate()
at = now if now is not None else self._clock()
if not checkout.is_checkout:
return self._refuse(details, "not_checkout", at)
route, provider = self._route(details)
if not route:
return self._refuse(details, provider, at)
limits_checked = False
if limits is not None:
ok, reason = limits.check(details.amount_minor)
if not ok:
return self._refuse(details, reason, at, True)
limits_checked = reason != "limits_unavailable"
return self._request_payment_approval(
details, route, provider, limits_checked, at)
def _charge_after_approval(self, pending_id: str,
details: PurchaseDetails,
base: dict, at: float) -> dict:
"""审批通过后:铸一次性卡 → 兑现校验 → 记账。扣款
失败即 {"ok": False, "reason"},不扣款;成功即
{"ok": True, "charge", "card_id", "masked_label"}。"""
card = self._wallet.mint_card(details, now=at)
self._pending[pending_id]["card_id"] = card.card_id
result = self._wallet.charge(card, details, now=at)
if result.get("status") != "simulated_paid":
self._audit.record(
"payment_refused",
dict(base, reason=result.get("reason", "charge_failed"),
card_id=card.card_id), now=at)
self._pending.pop(pending_id, None)
return {"ok": False,
"reason": result.get("reason", "charge_failed")}
self._audit.record(
"payment_completed",
dict(base, card_id=card.card_id,
masked_label=card.masked_label), now=at)
self._pending.pop(pending_id, None)
return {"ok": True, "charge": result,
"card_id": card.card_id,
"masked_label": card.masked_label}
def complete_purchase(self, pending_id: str,
answer: Answer | dict,
actual_details: PurchaseDetails,
now: float | None = None) -> dict:
"""完成一次购买:验签 → 验详情摘要 → 单次消费答复
→ 铸一次性卡 → 兑现校验 → 记账。任一步失败即
{"ok": False, "reason"},不扣款。"""
pending = self._pending.get(pending_id)
if pending is None:
return {"ok": False, "reason": "unknown_pending"}
at = now if now is not None else self._clock()
details = pending["details"]
base = {"merchant": details.merchant,
"amount_minor": details.amount_minor,
"currency": details.currency,
"pending_id": pending_id}
if not verify_answer(answer, self._secret, now=at):
self._audit.record("payment_refused",
dict(base, reason="invalid_answer"),
now=at)
return {"ok": False, "reason": "invalid_answer"}
if actual_details.digest() != pending["digest"]:
self._audit.record("payment_refused",
dict(base, reason="details_tampered"),
now=at)
return {"ok": False, "reason": "details_tampered"}
if not resolve_via_channel(self._queue, answer, self._secret,
now=at):
return {"ok": False, "reason": "resolve_failed"}
if _verdict_of(answer) == "deny":
self._audit.record("payment_denied", base, now=at)
self._pending.pop(pending_id, None)
return {"ok": False, "reason": "denied"}
return self._charge_after_approval(pending_id, details,
base, at)
def cancel_purchase(self, pending_id: str,
now: float | None = None) -> dict:
"""取消一笔待支付:审批保持未消费(用户可重新发起),
已铸的卡即 void(取消待支付 void 虚拟卡,连接器文档
口径)。未知 pending 返回 False,不抛错。"""
pending = self._pending.pop(pending_id, None)
if pending is None:
return {"ok": False, "reason": "unknown_pending"}
at = now if now is not None else self._clock()
card_id = pending.get("card_id")
if card_id is not None:
self._wallet.void_card(card_id)
self._audit.record("payment_card_voided",
{"pending_id": pending_id,
"card_id": card_id}, now=at)
self._audit.record(
"payment_cancelled",
{"merchant": pending["details"].merchant,
"amount_minor": pending["details"].amount_minor,
"currency": pending["details"].currency,
"pending_id": pending_id}, now=at)
return {"ok": True, "reason": "cancelled"}
def pending_ids(self) -> list:
"""全部待处理购买的 pending_id(副本)。"""
return list(self._pending)