--- title: prop-acc · prepaid · 场景 - 冻结状态退款被三层守护拦截 aliases: - 冻结状态退款被拒 - exception-refund-on-frozen - 场景-冻结状态退款拦截 tags: - 场景 - prop-acc - 预存款 - 异常 audience: - 业务人员 - 架构师 status: 已发布 sub_feature: prepaid last_review: 2026-05-25 code_version: 2026-05-22 --- # 场景:冻结状态退款被三层守护拦截 业务人员对 Frozen 账户**误**点退款 / 充值 / 消费 等按钮,系统**三层**(UI / Policy / 模型)守护拦截。**最严**在模型层 —— 即使绕过 UI 和 Policy,模型方法的 `canOperate()` 检查兜底,任何调用方都跑不掉。 > [!info] 历史教训 > 这是 issue.md Q4 第二轮明确修复的**严重漏洞**(项目第 9 项):原 `PrepaidAccount::refund()` 方法**完全不查状态**(只查金额),Frozen / Closed 账户都能被退款。模型层是最底层防御,Action 类绕过 = 没人挡住。修复后所有写入模型方法(`deposit / consume / refund`)统一调 `canOperate()`,严格只允许 Active。 ## 典型情境 > [!example] 真实情境 > 王女士预存款账户因风控异常被冻结(详见 [[freeze-suspected-fraud]]),余额 ¥50,000。王女士的"亲戚"找到物业说:"她身体不好,委托我领回余额。" 出示了模糊的身份证复印件(无授权书)。 > > 物业职员小李没核实关系真实性,**直接打开账户点 RefundAction**。系统拦截: > > | 拦截层 | 触发 | > |---|---| > | UI 层(Filament Action visible)| `canOperate()` 返 false → 按钮**灰化**(理论上点不到)| > | Policy 层(`RefundAction->authorize('refund')`)| 即使绕 UI 直接调,Policy 拦 → 抛 AuthorizationException | > | 模型层(`PrepaidAccount::refund()`)| 即使绕 Policy 调模型方法,`canOperate()` 内置检查 → 抛 RuntimeException | > > 任何一层挡住即拦截。**模型层是最后兜底**,即使 tinker / artisan / 第三方包都跑不掉。 ## 业务人员视角 ### 您看到什么 | 时刻 | 看到 | |---|---| | 进入 Frozen 账户的 ViewPrepaidAccount | 状态显示 🧊 Frozen | | 状态管理组按钮 | "充值 / 消费 / 退款 / 冻结 / 关账" 全部**灰化**,只剩 "解冻" 可点 | | 如果硬调 API | 抛错(看不同层抛哪个)| ### 三道防御详解 ```mermaid flowchart TD A[业务人员点 RefundAction] --> B[UI 层:button.visible
canOperate] B -->|false| C[按钮灰化,点不到] B -->|绕过 UI
直接调表单| D[Policy 层:->authorize 'refund'
PrepaidAccountPolicy::refund] D -->|拒绝| E[抛 AuthorizationException] D -->|绕过 Policy
直接调模型| F[模型层:account->refund
canOperate 检查] F -->|拒绝| G[抛 RuntimeException
账户处于非可操作状态] ``` **三道独立** —— 任意一道挡住即拦截。 ### 业务上正确做法 不要硬绕过。**沟通业户 + 走调解 / 法务流程**: | 业户/请求方反应 | 处理 | |---|---| | "我急用钱" | 解释:账户冻结调查中,需先完成风控核实 → [[unfreeze-after-verification]] | | "我证明身份了你为什么不退" | 核实材料是否充分(身份证复印件不够,需原件 + 当面确认 + 业户授权书)| | "我亲戚委托我" | 委托关系需公证书,否则**绝不退**(常见欺诈套路)| | 真的本人核实通过 | 走 [[unfreeze-after-verification|解冻]] → 解冻后可正常退款 | ## 三层守护的代码层面 ### UI 层(Filament Action) ```php RefundAction::make() ->visible(fn (PrepaidAccount $record) => $record->canOperate() && $record->balance > 0 ) ``` Frozen → `canOperate()=false` → 按钮 hidden。 ### Policy 层 ```php // PrepaidAccountPolicy.php public function refund(AuthUser $user, PrepaidAccount $record): bool { return $user->can('update prepaid accounts') && $record->canOperate() && $record->hasBalance(); } ``` `RefundAction::make()->authorize('refund')` 触发此方法,Frozen → 返 false → 抛 AuthorizationException。 ### 模型层(最严) ```php // PrepaidAccount.php public function refund(float $amount, ...): PrepaidTransaction { if (! $this->canOperate()) { throw new RuntimeException( "账户处于 {$this->status->value} 状态,无法操作" ); } // ... 其余逻辑 } ``` 任何调用方(Filament Action / Action 类 / tinker / artisan / 测试)调 `$account->refund()`,模型层 `canOperate()` 检查兜底。 ## 系统流程(API 被绕过的极端情况) ```mermaid sequenceDiagram participant 调用方[非 Filament 调用方] participant Action[RefundFromPrepaidAccountAction] participant Model[PrepaidAccount] participant DB Note over 调用方: 例如 tinker:RefundFromPrepaidAccountAction::handle(frozen_account, 5000) 调用方->>Action: handle(frozen_account, 5000, channel) Action->>Model: refund(5000) Model->>Model: canOperate()? Frozen → false Model-->>Action: throw RuntimeException Action-->>调用方: 抛出 + 日志记录 Note over DB: 无任何写入,事务自动回滚 ``` ## 测试断言 ```php test('cannot refund on frozen account', function () { $account = PrepaidAccount::factory()->frozen()->create(['balance' => 5000]); // 模型层 expect(fn () => $account->refund(2000)) ->toThrow(RuntimeException::class, '无法操作'); // Action 层 expect(fn () => app(RefundFromPrepaidAccountAction::class) ->handle($account, 2000, $channel)) ->toThrow(RuntimeException::class); expect($account->fresh()->balance)->toBe(5000.0); // 余额未变 expect(PrepaidTransaction::count())->toBe(0); // 流水未建 }); test('cannot consume on frozen account', function () { // 同样的三层守护 }); test('cannot deposit on frozen account', function () { // 同样的三层守护 }); ``` **3 个测试覆盖三种写入操作的 Frozen 状态拒绝**,确保守护不被无意中放宽。 ## 与 deposit 的对比 deposit 模块同样三层守护,但**守护方法粒度不同**: | 模块 | 模型层守护方法 | |---|---| | deposit | `canDeposit()` / `canWithdraw()`(分二种)| | prepaid | `canOperate()`(统一)| 理由:deposit 业务上有"只能加不能减"的中间状态(理论上 Frozen 时**加**没问题?现已收紧为都不允许)。prepaid 设计简单,统一拒绝所有写入。 详见 [[account-state-machine]] "canOperate 是模型层的最严防御" 段。 ## 常见问题 > [!question] 业户特别紧急要钱(如医疗),冻结状态能绕过吗? > **绝对不能从系统层绕**。业务流程上: > > 1. 业务人员加急核实业户身份 + 紧急情况 > 2. 走 [[unfreeze-after-verification|解冻]] 流程 > 3. 解冻后立即 RefundAction > > 整个流程**1-2 小时内可完成**,比"擅自绕守护退款"的合规风险小得多。 > [!question] 三层守护是不是过度设计了?一层就够吧? > **不是过度**。每一层都有可能被绕: > > - UI 灰化 → 用户可能开浏览器开发者工具篡改 DOM > - Policy → API 调用者可能直接调 Action 类(绕过 Filament Action) > - **模型层** → tinker / artisan / 测试 / 第三方包都直接操作模型 > > 多层独立 = 任意一层挡住即安全。代码层面成本极低(每层一两行)。 > [!question] 为什么 prepaid 的修复是"第二轮"才做? > issue.md Q4 提到的"第一轮"是 prepaid 模块刚做时,只有 `voidReverse` 一个 Policy 方法(其他都没)。第二轮全面审查发现这个**严重漏洞**(模型层不查状态),补齐了 9 个 Policy 方法 + 模型方法守护 + 测试。 > [!question] 已发现的所有漏洞都补齐了吗? > 详见 issue.md Q4 "第二轮已落地 (2026-05-22)" 段,8 项修复: > > 7. 删 DeleteAction / DeleteBulkAction > 8. Policy 从 1 个补到 9 个 > 9. **`PrepaidAccount.refund()` 加 canOperate 守护(最严重)** > 10. RefundAction UI 守护改为 canOperate && balance>0 > 11. 6 个 Filament Action 加显式 authorize 调用 > 12. EditAction 加 visible 守护 > 13. PrepaidAccount.hasBalance() 辅助方法 > > 当前未发现其他漏洞,但任何重要修改都应增量做安全审计。 ## 异常分支 - 业务上需要退冻结账户 → 先 [[unfreeze-after-verification]] - 业户失联无法核实 → 留 Frozen,等业户出现 - 冻结期间充值被拦 → 同样三层守护,处理一致 ## 相关文档 - [[freeze-suspected-fraud]] - [[unfreeze-after-verification]] - [[account-state-machine]] - [[exception-cross-community-consume]] - [[../deposit/account-state-machine]](deposit 状态机对比)