Files
uniprop-manual/prop-acc/scenarios/prepaid/exception-refund-on-frozen.md
2026-05-25 23:37:58 +08:00

8.4 KiB

title, aliases, tags, audience, status, sub_feature, last_review, code_version
title aliases tags audience status sub_feature last_review code_version
prop-acc · prepaid · 场景 - 冻结状态退款被三层守护拦截
冻结状态退款被拒
exception-refund-on-frozen
场景-冻结状态退款拦截
场景
prop-acc
预存款
异常
业务人员
架构师
已发布 prepaid 2026-05-25 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 抛错(看不同层抛哪个)

三道防御详解

flowchart TD
  A[业务人员点 RefundAction] --> B[UI 层:button.visible<br/>canOperate]
  B -->|false| C[按钮灰化,点不到]
  B -->|绕过 UI<br/>直接调表单| D[Policy 层:->authorize 'refund'<br/>PrepaidAccountPolicy::refund]
  D -->|拒绝| E[抛 AuthorizationException]
  D -->|绕过 Policy<br/>直接调模型| F[模型层:account->refund<br/>canOperate 检查]
  F -->|拒绝| G[抛 RuntimeException<br/>账户处于非可操作状态]

三道独立 —— 任意一道挡住即拦截。

业务上正确做法

不要硬绕过。沟通业户 + 走调解 / 法务流程:

业户/请求方反应 处理
"我急用钱" 解释:账户冻结调查中,需先完成风控核实 → unfreeze-after-verification
"我证明身份了你为什么不退" 核实材料是否充分(身份证复印件不够,需原件 + 当面确认 + 业户授权书)
"我亲戚委托我" 委托关系需公证书,否则绝不退(常见欺诈套路)
真的本人核实通过 走 [[unfreeze-after-verification

三层守护的代码层面

UI 层(Filament Action)

RefundAction::make()
    ->visible(fn (PrepaidAccount $record) =>
        $record->canOperate() && $record->balance > 0
    )

Frozen → canOperate()=false → 按钮 hidden。

Policy 层

// 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。

模型层(最严)

// 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 被绕过的极端情况)

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: 无任何写入,事务自动回滚

测试断言

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 项修复:

  1. 删 DeleteAction / DeleteBulkAction
  2. Policy 从 1 个补到 9 个
  3. PrepaidAccount.refund() 加 canOperate 守护(最严重)
  4. RefundAction UI 守护改为 canOperate && balance>0
  5. 6 个 Filament Action 加显式 authorize 调用
  6. EditAction 加 visible 守护
  7. PrepaidAccount.hasBalance() 辅助方法

当前未发现其他漏洞,但任何重要修改都应增量做安全审计。

异常分支

  • 业务上需要退冻结账户 → 先 unfreeze-after-verification
  • 业户失联无法核实 → 留 Frozen,等业户出现
  • 冻结期间充值被拦 → 同样三层守护,处理一致

相关文档