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 · 场景 - 冻结状态退款被三层守护拦截 |
|
|
|
已发布 | 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] 业户特别紧急要钱(如医疗),冻结状态能绕过吗? 绝对不能从系统层绕。业务流程上:
- 业务人员加急核实业户身份 + 紧急情况
- 走 unfreeze-after-verification 流程
- 解冻后立即 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 项修复:
- 删 DeleteAction / DeleteBulkAction
- Policy 从 1 个补到 9 个
PrepaidAccount.refund()加 canOperate 守护(最严重)- RefundAction UI 守护改为 canOperate && balance>0
- 6 个 Filament Action 加显式 authorize 调用
- EditAction 加 visible 守护
- PrepaidAccount.hasBalance() 辅助方法
当前未发现其他漏洞,但任何重要修改都应增量做安全审计。
异常分支
- 业务上需要退冻结账户 → 先 unfreeze-after-verification
- 业户失联无法核实 → 留 Frozen,等业户出现
- 冻结期间充值被拦 → 同样三层守护,处理一致