vault backup: 2026-05-25 23:37:58
This commit is contained in:
233
prop-acc/scenarios/prepaid/exception-refund-on-frozen.md
Normal file
233
prop-acc/scenarios/prepaid/exception-refund-on-frozen.md
Normal 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 状态机对比)
|
||||
Reference in New Issue
Block a user