ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Saleor 礼品卡受限状态在客户删除后的持久化设计:ADR 0002 安全决策与源码实现解析

Saleor 礼品卡受限状态在客户删除后的持久化设计:ADR 0002 安全决策与源码实现解析 Saleor 礼品卡受限状态在客户删除后的持久化设计ADR 0002 安全决策与源码实现解析【免费下载链接】saleorSaleor Core: the high performance, composable, headless commerce API.项目地址: https://gitcode.com/gh_mirrors/sa/saleor本文基于 Saleor 仓库中的架构决策记录 docs/adr/0002-assignment-survives-user-deletion.md 展开围绕被限制assigned的礼品卡在客户被删除后必须保持受限这一核心安全决策梳理其设计动机、数据模型约束、删除流程的底层实现与测试验证。读者读完本文后将理解为什么删除客户不能顺带解除礼品卡限制Saleor 如何通过on_deletePROTECTdeactivate_assigned_gift_cards()的组合保证删除客户永远是安全的操作以及如何在代码中复现与验证这一行为。一、决策背景为什么要让受限状态在删除后继续存活Saleor 将礼品卡定位为不记名凭证bearer instrument——谁持有卡码谁就能消费包括在访客结账guest checkout中使用。这是刻意保留的默认行为详见 docs/adr/0001-gift-cards-remain-bearer-instruments.md。在此之上客户绑定assignment是一个可选叠加的限制只有工作人员显式将卡限制给某位客户后限制才生效且只能通过工作人员显式操作完成详见 docs/adr/0003-manual-assignment-only-no-auto-assign.md。ADR 0002 要回答的问题是当被绑定的客户被删除时这张卡该怎么办决策结论很明确卡绝不能悄悄变回任何人都能用的自由状态。系统必须保留绑定的痕迹让卡片保持受限——实际上是锁死直到工作人员显式地将其重新绑定给另一位客户。如果选择相反的方案删除客户时直接解除限制就会把一个刻意受限的卡悄悄变回不记名卡构成安全回归一张原本只给某个账户使用的卡会变成任何人都能消费。保留绑定痕迹则让删除客户成为一项永不扩大卡消费主体范围的安全操作。与相邻 ADR 的约束关系这条决策与相邻的两条 ADR 共同构成礼品卡限制机制的完整图景ADR核心决策与 0002 的关系0001-gift-cards-remain-bearer-instruments.md礼品卡默认是不记名凭证客户绑定是可选限制绑定只是叠加层删除客户不能破坏这一层0002-assignment-survives-user-deletion.md删除客户后限制不消失卡被锁定本文主题0003-manual-assignment-only-no-auto-assign.md绑定只能由工作人员显式执行解除锁定同样只能由工作人员显式重绑二、实现基座assigned_to字段与PROTECT约束决策落地在 saleor/giftcard/models.py 的GiftCard模型中。与谁最后用过这张卡的审计字段used_by不同该字段在删除时使用on_deletemodels.SET_NULL仅作历史记录绑定使用独立字段assigned_to models.ForeignKey( settings.AUTH_USER_MODEL, blankTrue, nullTrue, # PROTECT (not SET_NULL): deleting an assignee must go through # deactivate_assigned_gift_cards() so the card is detached AND # deactivated together. A raw delete is refused rather than silently # leaving an active card restricted to a ghost user. on_deletemodels.PROTECT, related_nameassigned_gift_cards, ) assigned_to_email models.EmailField(nullTrue, blankTrue)关键设计点on_deletePROTECT而非SET_NULL这是限制必须存活决策在数据库层的第一道强制。若被绑定用户仍被卡片引用直接删除用户会被数据库拒绝ProtectedError而不是静默置空后让卡片处于限制指向幽灵用户的危险状态。删除必须走受控流程见第三节。assigned_to_email独立保存邮箱快照即使assigned_to外键被置空邮箱仍保留从而卡原本属于谁这一痕迹不丢失可供审计与后续人工处理。配套索引模型Meta中定义了BTreeIndex(fields[assigned_to], namegiftcard_assigned_to_idx)models.py保证按被绑定用户查找卡片删除流程的核心查询走索引。与used_by语义分离绑定是允许谁消费的前瞻性限制used_by是最后谁消费过的历史审计记录二者绝不混用ADR 0001 的明确要求。模型层还有一个关键辅助方法usage_restriction_reasonmodels.py专门处理被绑定用户已不存在的卡片判定def usage_restriction_reason(self, user_id: int | None) - str | None: if self.assigned_to_email and not self.assigned_to_id: return restricted to a deleted customer if self.assigned_to_id and self.assigned_to_id ! user_id: return restricted to another customer return None即卡片被绑定用户已删除assigned_to已置空但邮箱仍在时卡片对任何人都不可用——包括邮箱恰好与assigned_to_email一致的访客因为系统无法再验证所有者身份。这从模型层直接实现了锁定直到工作人员重绑的语义。三、删除流程deactivate_assigned_gift_cards的受控解绑由于assigned_to是PROTECT删除用户前必须先解除引用。Saleor 在 saleor/giftcard/utils.py 提供专用函数deactivate_assigned_gift_cards(users)其 docstring 明确了职责被删除用户的受限卡必须在其被删除前解绑并停用。assigned_to使用PROTECT因此必须先清空引用否则删除会被拒绝。应在删除用户的事务内、删除动作之前从所有删除用户的 mutation 中调用。核心实现逻辑def deactivate_assigned_gift_cards(users) - None: # 锁定并快照所有被绑定卡片含非激活的防止并发激活造成已解绑但激活的泄漏 assigned_cards list( gift_card_qs_select_for_update() .filter(assigned_to__inusers) .values_list(pk, is_active) ) assigned_ids [] deactivated_ids [] for gift_card_id, is_active in assigned_cards: assigned_ids.append(gift_card_id) if is_active: deactivated_ids.append(gift_card_id) # 一次性清空受保护外键并无条件停用锁定集合 GiftCard.objects.filter(pk__inassigned_ids).update( assigned_toNone, is_activeFalse ) if deactivated_ids: events.gift_cards_deactivated_event(deactivated_ids, userNone, appNone)行为要点解绑assigned_to None与停用is_active False原子地一起发生卡片无法再被验证归属因此必须停用不能只解绑不停用那等于变回自由卡正是 ADR 要禁止的。保留assigned_to_email解绑只清外键邮箱快照不动。事件记录只有真正从激活变为停用的卡才产生DEACTIVATED事件本就停用的卡不重复记录避免噪音事件。并发安全通过gift_card_qs_select_for_update()见 saleor/giftcard/lock_objects.py行锁快照包括非激活卡的集合随后用单条UPDATE无条件停用整个锁定集合。这保证即使并发写者在解绑前一刻激活某张卡最终结果依然是解绑且停用不存在竞态泄漏窗口。批量删除友好users可以是任意可迭代对象或 queryset天然支持单个与批量删除两种场景。调用链所有删除用户的入口都经过它从源码检索可以看到所有删除用户的 GraphQL mutation 都在删除事务内、真正删除前调用该函数删除场景调用位置客户删除customerDeletesaleor/graphql/account/mutations/staff/customer_delete.pyclean_instance内事务包裹见perform_mutation的traced_atomic_transaction员工删除staffDeletesaleor/graphql/account/mutations/staff/staff_delete.py账户自助删除accountDeletesaleor/graphql/account/mutations/account/account_delete.py客户批量删除customerBulkDeletesaleor/graphql/account/bulk_mutations/customer_bulk_delete.py员工批量删除staffBulkDeletesaleor/graphql/account/bulk_mutations/staff_bulk_delete.py以customerDelete为例customer_delete.pyclassmethod def perform_mutation(cls, root, info: ResolveInfo, /, **data): with traced_atomic_transaction(): results super().perform_mutation(root, info, **data) cls.post_process(info) return results classmethod def clean_instance(cls, info: ResolveInfo, instance) - None: super().clean_instance(info, instance) # Runs inside the deletion transaction, right before the user is # removed. Required because GiftCard.assigned_to is on_deletePROTECT. deactivate_assigned_gift_cards([instance]) delete_user_unique_attribute_values([instance.pk])即删除操作处于同一事务内解绑停用先于用户删除执行若中途出错整个事务回滚卡片状态与用户删除保持一致。任何绕过该函数直接删除用户的尝试都会被数据库层的PROTECT拒绝——应用层受控流程 数据库层兜底约束形成纵深防御。四、绑定限制如何建立assign_gift_card_to_user为完整理解删除时锁定的语义需要知道限制是如何建立的。在 saleor/giftcard/utils.py 中assign_gift_card_to_user(gift_card, user)在行锁事务内完成绑定with traced_atomic_transaction(): locked gift_card_qs_select_for_update().get(pkgift_card.pk) if locked.last_used_on is not None: raise GiftCardCannotAssign(Cannot assign a gift card that was already used in an order.) # 已挂接结账单且存在有效支付/交易记录的卡不可绑定 # 防止通过支付交易记录绕过限制见代码注释对 gift card gateway 路径的说明 ... locked.assigned_to user locked.assigned_to_email user.email locked.save(update_fields[assigned_to, assigned_to_email])绑定前的校验GiftCardCannotAssign异常包括卡已被订单使用过last_used_on非空、卡挂接的结账单存在有效支付或交易记录含通过礼品卡支付网关、由TransactionItem.gift_card关联但从未进入checkouts多对多关系的场景。绑定成功即写入assigned_to与assigned_to_email快照——正是删除流程随后要处理的两个字段。五、测试验证行为被单元测试明确锁定仓库用一组针对性测试固化该行为见 saleor/giftcard/tests/test_utils.py测试验证点test_deactivate_assigned_gift_cards_detaches_and_deactivates解绑后assigned_to_id为None、卡被停用、邮箱快照保留且产生DEACTIVATED事件test_deactivate_assigned_gift_cards_ignores_other_users只处理被删除用户名下的卡其他用户绑定的卡保持激活与绑定不变test_deactivate_assigned_gift_cards_no_event_for_already_inactive已停用的卡不重复产生事件但外键仍被清空保证用户可删除test_deactivate_assigned_gift_cards_deactivates_card_activated_before_detach模拟并发写者在解绑前一刻激活卡片最终结果仍是解绑且停用验证竞态防护此外 saleor/giftcard/tests/test_models.py 的注释明确说明删除必须先经过deactivate_assigned_gift_cards()该函数被删除 mutation 使用直接删除被PROTECT拒绝——印证了数据库层兜底这一设计。结账侧的拒绝逻辑也有对应测试受限卡在结账时被拒且报错为通用的InvalidPromoCode见 saleor/giftcard/utils.py绝不泄露卡片被绑定的事实或绑定对象防止被当作邮箱枚举enumeration oracleADR 0001 的明确要求。六、安全模型小结将 ADR 0002 与源码对应可以得到完整的防护链条建立限制仅工作人员显式调用assign_gift_card_to_userADR 0003写入assigned_toassigned_to_email。使用限制结账时usage_restriction_reason校验归属被绑定用户已删除的卡对任何人含邮箱匹配的访客都不可用失败信息统一为InvalidPromoCode不泄露归属。删除客户所有删除 mutation 在同一事务内先调用deactivate_assigned_gift_cards将卡解绑并停用、保留邮箱痕迹任何遗漏路径都被on_deletePROTECT在数据库层拦截。恢复使用卡片保持停用锁定直到工作人员显式将其重新绑定重绑后卡片重新激活对应assign_gift_card_to_user的写入路径。这一设计把删除客户固化为永远安全、绝不扩大消费主体范围的操作被限制的卡要么跟着原主人一起停用要么被显式重绑给新主人绝不会静默变回不记名卡。这也是 Saleor 在 docs/adr/0002-assignment-survives-user-deletion.md 中记录并长期坚持该决策的根本原因。【免费下载链接】saleorSaleor Core: the high performance, composable, headless commerce API.项目地址: https://gitcode.com/gh_mirrors/sa/saleor创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进