Saleor Order 支付历史:transactionSummaries投影设计解析(ADR-0010)
【免费下载链接】saleorSaleor Core: the high performance, composable, headless commerce API.项目地址: https://gitcode.com/gh_mirrors/sa/saleor
客户可见的支付历史是 Saleor 3.23 引入的Order.transactionSummaries字段:它以TransactionSummary投影(projection)的形式,从已有的TransactionItem行数据中安全地对外暴露支付方式与金额,而不是对受权限保护的Order.transactions字段做权限放宽。本文以 docs/adr/0010-order-payment-history-is-a-projection-not-an-opened-transaction.md 为骨架,结合 saleor/graphql/payment/types.py、saleor/graphql/order/types.py、saleor/payment/models.py 等源码与测试,深入讲解该 ADR 的决策背景、投影字段的完整设计、实现细节与安全边界,帮助读者理解为什么"投影"是比"开放权限"更优的方案,以及如何在 Storefront(账户页、订单摘要)中安全地展示资金流向。
一、ADR 背景:为什么需要一个"客户可见"的支付历史
Saleor 作为 headless commerce API,订单的支付数据一直通过Order.transactions(返回TransactionItem!列表)暴露给有权限的调用方。但 Storefront 场景下,需要在客户账户页 / 订单详情页向顾客本人展示"钱是如何进入和离开这笔订单的"。直接在Order.transactions上放宽权限是不可行的,原因有三:
- 开放整个类型会连带泄露内部信息:
TransactionItem携带externalUrl(PSP 后台深度链接)、events(内部账本、员工身份、幂等键)、createdBy、name、message等仅限内部/员工可见的数据。一旦字段开放,这些数据全部对公众可见。 id即交易 token,是可被无认证调用的能力凭证:TransactionItem.resolve_id直接返回root.token(见 saleor/graphql/payment/types.py)。而transactionInitialize/transactionProcess这两个 mutation 接受该 token 作为支付流程的凭据,且可被无认证(unauthenticated)调用方使用。将 token 暴露给公众等于把"继续处理支付"的能力拱手让人。- 对既有类型做字段白名单(allowlist)不可行:
PermissionsField会在无权限时直接抛错(见 saleor/graphql/core/fields.py,其get_resolver会在设置了permissions时用one_of_permissions_required装饰 resolver);若仅对部分字段放行,其他字段对公众仍会抛权限错误。[TransactionItem!]!中!意味着字段值非空(non-null):只要列表内任一条目有一个被拒绝的字段,整个订单查询就会因 GraphQL 非空冒泡(null propagation)而整体报错,导致订单页面完全不可用。
因此 ADR 的结论是:新建一个投影(projection)类型TransactionSummary,通过Order.transactionSummaries字段对外提供白名单化的只读视图。投影本质上是"白名单"——即使未来TransactionItem新增了字段,也不会自动泄漏到TransactionSummary中。
事实依据:上述设计意图全部记录在 docs/adr/0010-order-payment-history-is-a-projection-not-an-opened-transaction.md;
TransactionItem的完整字段集合(token、name、message、pspReference、events、createdBy、externalUrl等)定义于 saleor/graphql/payment/types.py。
二、Schema 设计:Order.transactionSummaries与TransactionSummary类型
2.1 Order 上的新字段
Order.transactionSummaries定义在 saleor/graphql/order/types.py:
transaction_summaries = NonNullList( TransactionSummary, description=( "Payment history of the order, with one entry per payment transaction " "that moved any money. Unlike `transactions`, it requires no permission " "and exposes only the payment method and the amounts, so it can be used " "to display payment details to the customer without exposing internal " "information." + ADDED_IN_323 ), required=True, )对应生成的 GraphQL Schema(saleor/graphql/schema.graphql):
""" Payment history of the order, with one entry per payment transaction that moved any money. Unlike `transactions`, it requires no permission and exposes only the payment method and the amounts, so it can be used to display payment details to the customer without exposing internal information. Added in Saleor 3.23. """ transactionSummaries: [TransactionSummary!]!关键点:
- 无权限要求:与
transactions(要求MANAGE_ORDERS或HANDLE_PAYMENTS之一)不同,transactionSummaries不需要任何权限。对比同文件中的 transactions 字段定义 及其带one_of_permissions_required装饰器的 resolve_transactions,可见两个字段的访问控制差异。 [TransactionSummary!]!非空列表:列表本身、列表元素均非空,与transactions的[TransactionItem!]!形态一致。- 命名规范:GraphQL 字段采用 camelCase(
transactionSummaries),Python 实现采用 snake_case(transaction_summaries)。
2.2TransactionSummary类型完整字段
TransactionSummary定义于 saleor/graphql/payment/types.py,是白名单投影的核心。其完整字段如下:
| 字段 | GraphQL 类型 | 必填 | 说明 | 对应 TransactionItem 数据 |
|---|---|---|---|---|
createdAt | DateTime | 是 | 支付交易创建时间 | created_at |
paymentMethodDetails | PaymentMethodDetails | 否 | 支付方式;公开场景下卡号数字与有效期被剥离 | payment_method_type等 |
authorizedAmount | Money | 是 | 已授权总额 | amount_authorized |
authorizePendingAmount | Money | 是 | 进行中的授权请求总额 | amount_authorize_pending |
chargedAmount | Money | 是 | 已收款总额 | amount_charged |
chargePendingAmount | Money | 是 | 进行中的收款请求总额 | amount_charge_pending |
refundedAmount | Money | 是 | 已退款总额 | amount_refunded |
canceledAmount | Money | 是 | 已取消总额 | amount_canceled |
生成的 Schema 位于 saleor/graphql/schema.graphql:
type TransactionSummary @doc(category: "Payments") { """Date and time at which payment transaction was created.""" createdAt: DateTime! """...card number digits and expiration date are stripped: `firstDigits`, `lastDigits`, `expMonth` and `expYear` of `CardPaymentMethodDetails` are always `null` here. Read them through `Order.transactions` instead, which requires MANAGE_ORDERS or HANDLE_PAYMENTS.""" paymentMethodDetails: PaymentMethodDetails """Total amount authorized for this payment.""" authorizedAmount: Money! authorizePendingAmount: Money! chargedAmount: Money! chargePendingAmount: Money! refundedAmount: Money! canceledAmount: Money! }注意TransactionSummary并没有实现Node接口(不是 relay 节点),因此没有id,也就从根本上杜绝了 token 暴露。同时它也没有events、actions、externalUrl、pspReference、createdBy、name、message等内部字段——这正是投影即白名单的体现。
2.3 金额字段的解析
TransactionSummary的金额解析直接映射TransactionItem模型上的对应字段(saleor/graphql/payment/types.py):
@staticmethod def resolve_authorized_amount(root: models.TransactionItem, _info): return root.amount_authorized @staticmethod def resolve_authorize_pending_amount(root: models.TransactionItem, _info): return root.amount_authorize_pending @staticmethod def resolve_charged_amount(root: models.TransactionItem, _info): return root.amount_charged @staticmethod def resolve_charge_pending_amount(root: models.TransactionItem, _info): return root.amount_charge_pending @staticmethod def resolve_refunded_amount(root: models.TransactionItem, _info): return root.amount_refunded @staticmethod def resolve_canceled_amount(root: models.TransactionItem, _info): return root.amount_canceledTransactionItem模型(saleor/payment/models.py)将这些金额统称为"value",并提供了判断资金是否发生流动的方法:
def has_money_movement(self) -> bool: """Return True if any money was moved by this transaction. A transaction with all amounts at zero is an abandoned payment attempt. """ return any( ( self.authorized_value, self.authorize_pending_value, self.charged_value, self.charge_pending_value, self.refunded_value, self.refund_pending_value, self.canceled_value, self.cancel_pending_value, ) )三、查询解析:零金额交易的过滤逻辑
resolve_transaction_summaries是投影的入口(saleor/graphql/order/types.py):
@staticmethod def resolve_transaction_summaries( root: SyncWebhookControlContext[models.Order], info ): return ( TransactionItemsByOrderIDLoader(info.context) .load(root.node.id) .then( lambda transactions: [ transaction for transaction in transactions if transaction.has_money_movement() ] ) )实现要点:
- 复用 DataLoader:通过
TransactionItemsByOrderIDLoader按订单 ID 批量加载TransactionItem,与resolve_transactions使用同一 Loader(saleor/graphql/order/types.py),因此同一请求中同时查询transactions与transactionSummaries不会产生重复数据库查询,符合 Saleor 的 DataLoader 批量加载惯例。 - 无权限装饰器:该方法没有
one_of_permissions_required装饰器,与resolve_transactions形成鲜明对比——投影字段对任何能解析该订单(能查询到order节点)的调用方开放。 - 零金额过滤:通过
has_money_movement()过滤掉"所有金额皆为零"的交易。这些交易是被放弃的支付尝试(abandoned payment attempts),对顾客没有任何信息价值。注意过滤发生在 Python 内存中(加载后.then(...)过滤),而非 SQL 查询层面,因为底层 Loader 是通用的。
边界情况:"已全额退款"的交易仍然会返回。判断依据是
has_money_movement()而非"当前净额是否非零"——只要交易在生命周期中移动过资金(例如先收款再全额退款,charged_value归零但refunded_value非零),它仍会出现在投影中,且chargedAmount显示为0、refundedAmount显示退款金额。这由测试 test_fully_refunded_transaction_is_returned 明确验证。
四、卡数据剥离:公开视图如何脱敏
paymentMethodDetails复用共享的PaymentMethodDetails接口及其具体实现(CardPaymentMethodDetails、GiftCardPaymentMethodDetails等),但投影的 resolver 会在交易的副本上把卡号数字与有效期置空(saleor/graphql/payment/types.py):
@staticmethod def resolve_payment_method_details(root: models.TransactionItem, _info): if not root.payment_method_type: return None # The shared `CardPaymentMethodDetails` resolvers read the card data # straight off the transaction, so strip it from a copy - this field is # public and the digits and expiration date must not leak. The copy is # never saved. public_transaction = copy(root) public_transaction.cc_first_digits = None public_transaction.cc_last_digits = None public_transaction.cc_exp_month = None public_transaction.cc_exp_year = None return public_transaction这里的设计精妙之处在于:
- 复用而不重写:
CardPaymentMethodDetails的 resolver(如 resolve_brand)直接从TransactionItem对象上读取字段。为了不修改共享类型,投影 resolver 用copy(root)复制一份内存副本,在副本上把cc_first_digits、cc_last_digits、cc_exp_month、cc_exp_year置为None,再交给共享类型解析。副本不会被保存,数据库中的原始卡数据不受影响。 - 保留品牌与方式名:公众调用方仍然能看到支付方式(
name)和品牌(brand),但看不到任何能识别具体卡片的信息(firstDigits、lastDigits、expMonth、expYear恒为null)。 - Staff 仍可读全量数据:员工继续通过
Order.transactions(TransactionItem.paymentMethodDetails,resolver 直接返回root,见 saleor/graphql/payment/types.py)读取完整卡数据,需要MANAGE_ORDERS或HANDLE_PAYMENTS权限。 - Gift card 场景:礼品卡详情(
brand、lastChars、isSaleorGiftcard)照常返回,因为礼品卡尾号不是敏感卡数据(见测试 test_gift_card_details_are_returned)。
五、测试验证:白名单与脱敏的保障
测试文件 saleor/graphql/order/tests/queries/test_order_transaction_summaries.py 完整覆盖了 ADR 的各项设计承诺,是理解该特性的最佳入口:
| 测试 | 验证点 |
|---|---|
test_available_to_any_requester_that_can_resolve_the_order(L65) | 匿名客户端(api_client)与用户客户端(user_api_client)都能查询,且有无MANAGE_ORDERS权限均不影响结果——证明该字段无权限门槛 |
test_card_digits_and_expiration_date_are_stripped(L98) | 卡号为4111...1111、有效期12/2035的交易,投影中firstDigits/lastDigits/expMonth/expYear均为null,而name、brand保留;且refresh_from_db()后原始cc_last_digits == "1111"未变——证明剥离发生在副本上 |
test_gift_card_details_are_returned(L137) | 礼品卡支付方式返回brand、lastChars、isSaleorGiftcard |
test_transaction_without_any_money_movement_is_filtered_out(L169) | 只有产生资金流动的交易进入投影;全零交易被过滤(assert abandoned_transaction.has_money_movement() is False) |
test_fully_refunded_transaction_is_returned(L193) | 全额退款后charged_value == 0,但交易仍在投影中,refundedAmount正确显示退款金额 |
test_internal_fields_are_not_exposed(L220) | 对id、token、pspReference、events { id }、actions、externalUrl查询均返回错误Cannot query field ... on type "TransactionSummary"——证明投影是严格的字段白名单 |
其中test_internal_fields_are_not_exposed是 ADR"投影即白名单"论点的最直接证据:TransactionSummary类型上根本不存在id/token/pspReference/events/actions/externalUrl这些字段,因此即便未来TransactionItem新增字段,只要不显式加入TransactionSummary,就永远不会泄漏。
测试中还提供了可直接复用的完整查询示例(L12-L36):
query Order($id: ID!) { order(id: $id) { transactionSummaries { createdAt authorizedAmount { amount currency } authorizePendingAmount { amount currency } chargedAmount { amount currency } chargePendingAmount { amount currency } refundedAmount { amount currency } canceledAmount { amount currency } paymentMethodDetails { name ... on CardPaymentMethodDetails { brand firstDigits lastDigits expMonth expYear } } } } }六、对比与最佳实践:为什么投影优于开放权限
6.1 方案对比
| 方案 | 问题 |
|---|---|
开放Order.transactions(无权限) | 泄露externalUrl、events(内部账本、员工身份、幂等键)、createdBy、name、message;id即 token,可被无认证的transactionInitialize/transactionProcess用作支付凭据 |
在既有TransactionItem上做字段级权限 | PermissionsField对无权限字段直接抛错;且[TransactionItem!]!中任一非空字段被拒都会使整个订单查询因非空冒泡而失败 |
新建TransactionSummary投影(本 ADR 采用) | 类型层面只定义白名单字段;无id(无 token);金额字段全部非空、数据取自同一TransactionItem;卡数据在副本上剥离 |
6.2 从本 ADR 可以提炼的通用原则
- 对外 API 优先用投影类型而非权限放宽:当"客户可见视图"与"内部完整视图"差异较大时,新建只读投影类型比在同一类型上做字段级权限更安全、更易维护。
- 白名单优于黑名单:投影类型只声明需要公开的字段,天然免疫"未来
TransactionItem新增内部字段而忘记加白名单导致泄漏"的风险。 - 非空类型会放大字段级权限的故障面:
[Type!]!中只要一个元素的一个必填字段解析失败,整个字段值为null并向上传播。如果必须在现有类型上做权限控制,需评估非空冒泡对上层查询的影响。 - 敏感数据脱敏应在 resolver 层基于副本完成:Saleor 的做法(
copy(root)后置空敏感字段)既复用了共享类型,又不污染数据库原始数据,测试也验证了"剥离不影响存储"。
6.3 适用前提与限制
- 该特性Added in Saleor 3.23(
ADDED_IN_323),仅适用于使用新版 Transactions API(TransactionItem)的订单;旧的 legacyPaymentAPI 不在投影范围内(Order.payments已被标记 deprecated,见 saleor/graphql/schema.graphql)。 - 投影仍受
order节点本身的解析能力约束:能解析到该订单(如订单归属校验、公开结账流程等既有机制)的调用方才能查询其transactionSummaries,它并不绕过订单自身的可见性规则。 paymentMethodDetails为可空字段(无!):当交易没有payment_method_type时返回null,测试中test_available_to_any_requester_that_can_resolve_the_order即验证了paymentMethodDetails is None的场景(L95)。
七、总结
ADR-0010 记录了一个典型的 API 安全设计决策:Saleor 没有选择在Order.transactions上放宽权限,而是通过Order.transactionSummaries提供TransactionSummary投影。这个投影在类型层面就是白名单——没有id/token,没有events、externalUrl等内部字段,金额字段全部非空;在数据层面通过has_money_movement()过滤掉无信息量的被放弃支付尝试,并在交易副本上剥离卡号数字与有效期。源码中的 resolver(saleor/graphql/order/types.py)、类型定义(saleor/graphql/payment/types.py)、模型方法(saleor/payment/models.py)与全套测试(test_order_transaction_summaries.py)互相印证,完整落地了 ADR 的设计意图。对于需要在 Storefront 账户页展示订单支付历史、又不想触碰权限边界的开发者,transactionSummaries就是现成、安全且经过测试验证的标准答案。
【免费下载链接】saleorSaleor Core: the high performance, composable, headless commerce API.项目地址: https://gitcode.com/gh_mirrors/sa/saleor
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考