vault backup: 2026-05-25 23:37:58

This commit is contained in:
Willie
2026-05-25 23:37:58 +08:00
parent 344bd552d1
commit e759ec39ae
5 changed files with 703 additions and 25 deletions

View File

@@ -0,0 +1,233 @@
---
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<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)
```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 状态机对比)