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 字段)。
This commit is contained in:
1 parent
90c42f6e1d
commit
b55b49b358
7 files changed
+1939
-8
No files matched your search
@@ -0,0 +1,769 @@
|
||||
"""支付安全门(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)
|
||||
Reference in new issue
Block a user