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

@@ -53,43 +53,41 @@ code_version: 2026-05-22
| [Consume 走 CollectionType=Bill 的设计](../concepts/prepaid/consume-via-bill-collection-type.md) | 独特设计:抵账单用 Bill 视角,资金来源标 meta.fund_source=prepaid |
| [月初批量自动抵扣设计](../concepts/prepaid/auto-deduction-design.md) | 待补 scheduled job,产品核心卖点 |
## 场景手册(16 篇,**待补充 ✋**)
> 🚧 概念骨架已就位,场景文档将在下一轮(轮 2)产出。预定结构如下。
## 场景手册(16 篇,**全部完成 ✅**)
### 📥 充值(Deposit)— 3 篇
- 🚧 [首次开户充值 5000](../scenarios/prepaid/deposit-first-time.md)
- 🚧 [已有账户追加充值](../scenarios/prepaid/deposit-additional-topup.md)
- 🚧 [小程序在线充值(待补设计意图)](../scenarios/prepaid/deposit-via-miniapp-pending.md)
- [首次开户充值 5000](../scenarios/prepaid/deposit-first-time.md)
- [已有账户追加充值](../scenarios/prepaid/deposit-additional-topup.md)
- [小程序在线充值(待补设计意图)](../scenarios/prepaid/deposit-via-miniapp-pending.md)
### 🧹 消费 Consume — 4 篇(最核心)
- 🚧 [手动抵扣月度物业费](../scenarios/prepaid/consume-monthly-property-bill.md)
- 🚧 [多个未付账单按 due_at 优先级抵扣](../scenarios/prepaid/consume-multiple-bills-priority.md)
- 🚧 [抵扣计量账单(水电费)](../scenarios/prepaid/consume-meter-bill.md)
- 🚧 [月初批量自动抵扣 job(设计意图 + 业务流程)](../scenarios/prepaid/consume-batch-auto-monthly.md)
- [手动抵扣月度物业费](../scenarios/prepaid/consume-monthly-property-bill.md)
- [多个未付账单按 due_at 优先级抵扣](../scenarios/prepaid/consume-multiple-bills-priority.md)
- [抵扣计量账单(水电费)](../scenarios/prepaid/consume-meter-bill.md)
- [月初批量自动抵扣 job(设计意图 + 业务流程)](../scenarios/prepaid/consume-batch-auto-monthly.md)
### 💰 退款(Refund)— 2 篇
- 🚧 [业户搬走全额退余](../scenarios/prepaid/refund-full-resident-moveout.md)
- 🚧 [部分消费后退余(不自动关账)](../scenarios/prepaid/refund-partial-after-consume.md)
- [业户搬走全额退余](../scenarios/prepaid/refund-full-resident-moveout.md)
- [部分消费后退余(不自动关账)](../scenarios/prepaid/refund-partial-after-consume.md)
### 🧊 冻结 / 解冻(Freeze / Unfreeze)— 2 篇
- 🚧 [疑似欺诈 / 风控冻结](../scenarios/prepaid/freeze-suspected-fraud.md)
- 🚧 [核实后解冻](../scenarios/prepaid/unfreeze-after-verification.md)
- [疑似欺诈 / 风控冻结](../scenarios/prepaid/freeze-suspected-fraud.md)
- [核实后解冻](../scenarios/prepaid/unfreeze-after-verification.md)
### 🔒 结清(Close)— 2 篇
- 🚧 [业户搬走主动关账](../scenarios/prepaid/close-resident-moveout.md)
- 🚧 [余额清零后不自动关,业户决定](../scenarios/prepaid/close-with-zero-balance-decision.md)
- [业户搬走主动关账](../scenarios/prepaid/close-resident-moveout.md)
- [余额清零后不自动关,业户决定](../scenarios/prepaid/close-with-zero-balance-decision.md)
### 🛡️ 异常 / 审计(3 篇)
- 🚧 [跨社区消费防御](../scenarios/prepaid/exception-cross-community-consume.md)
- 🚧 [冻结状态退款被三层守护拦截](../scenarios/prepaid/exception-refund-on-frozen.md)
- 🚧 [低余额业户预警 + 逾期账单排查](../scenarios/prepaid/audit-low-balance-and-overdue.md)
- [跨社区消费防御](../scenarios/prepaid/exception-cross-community-consume.md)
- [冻结状态退款被三层守护拦截](../scenarios/prepaid/exception-refund-on-frozen.md)
- [低余额业户预警 + 逾期账单排查](../scenarios/prepaid/audit-low-balance-and-overdue.md)
## 跨域引用
@@ -125,6 +123,9 @@ code_version: 2026-05-22
---
> [!info] 概念已完成,场景待补
> 本轮(轮 1)产出:6 个概念 + 本子模块地图 + 域总图更新。
> 下一轮(轮 2)产出:16 个场景文档,基于本知识地图骨架填充。
> [!success] prepaid 子模块:6 概念 + 16 场景 + 1 知识地图 = **23 篇完成**
>
> 写作日期:2026-05-25
> 对应代码版本:2026-05-22(详见 `packages/prop-acc/issue.md` Q4 段)
>
> 如果发现遗漏的场景或需要补充的细节,告诉我,可以单独补充新文档。

View File

@@ -0,0 +1,247 @@
---
title: prop-acc · prepaid · 场景 - 低余额业户预警 + 逾期账单排查
aliases:
- 低余额预警
- 预存款余额告警
- audit-low-balance-and-overdue
- 场景-预存款低余额预警
tags:
- 场景
- prop-acc
- 预存款
- 审计
audience:
- 业务人员
- 财务
- 产品
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:低余额业户预警 + 逾期账单排查
物业业务人员**每周** / 每月扫描:
1. **低余额预存款业户**(下月预计账单 > 当前余额) → 主动提醒充值
2. **预存款余额不够付未付账单的业户** → 跨核对
`LowBalancePrepaidListWidget` 是后台 Dashboard 上专门的 Widget,展示低余额账户清单。
## 典型情境
> [!example] 真实情境
> 物业财务王主管每周一上午看 `DepositPrepaidDashboard`:
>
> - 低余额业户清单 widget 显示 **45 户**业户的预存款余额 < 下月预计账单
> - 其中 **12 户**已有当月未付账单
> - 主管按清单逐户联系:
> - 12 户已欠 → 督促立即充值
> - 33 户预警(还没欠)→ 友好提醒"下月账单 ¥X,余额 ¥Y 不够,建议充值"
## 业务人员视角
### Dashboard 查看
后台 → 仪表盘 → `DepositPrepaidDashboard` 页面 → 看 `LowBalancePrepaidListWidget`
Widget 显示:
| 业户 | 当前余额 | 下月预计账单 | 差额 | 当前未付账单 |
|---|---|---|---|---|
| 张阿姨(12-3-501)| ¥200 | ¥800 | -¥600 | 0 |
| 陈先生(12-3-502)| ¥1,500 | ¥2,200 | -¥700 | 1(水电费 ¥220 已逾期 3 天)|
| 刘先生(12-3-503)| ¥0 | ¥800 | -¥800 | 1(物业费 ¥800 未付)|
| (省略)| | | | |
### 处置策略(分级)
| 紧急度 | 业户特征 | 处置 |
|---|---|---|
| **🔴 紧急** | 已有逾期账单 + 余额不够 | 立即联系 + 督促充值 + 必要时手动催收 |
| **🟡 警告** | 当前余额低于下月预计账单 | 提前 3-7 天友好提醒 |
| **🟢 关注** | 余额低于 2 个月账单合计 | 月度提醒一次,无需紧急动作 |
### 第 1 步:扫描清单
打开 `DepositPrepaidDashboard` → 看清单 widget。也可手动 SQL 查:
```sql
-- 低余额预存款业户(余额 < 下月预计账单)
SELECT
p.id AS account_id,
p.community_user_profile_id,
cup.name AS resident_name,
p.balance AS current_balance,
estimated_next_bill, -- 子查询算下月预计
(estimated_next_bill - p.balance) AS shortage,
COUNT(b.id) AS overdue_bills_count
FROM acc_prepaid_accounts p
JOIN community_user_profiles cup ON p.community_user_profile_id = cup.id
LEFT JOIN acc_bills b ON b.resident_id = cup.id
AND b.community_id = p.community_id
AND b.status = 'unpaid'
AND b.due_at < NOW()
WHERE p.status = 'active'
GROUP BY p.id, ...
HAVING current_balance < estimated_next_bill
ORDER BY shortage DESC;
```
### 第 2 步:分级处置
**🔴 紧急(已欠款)**:
- 短信 / 微信 / 电话联系业户
- 告知"您欠 X 月物业费 ¥800,请立即处理(充值预存款 / 现金 / 微信付)"
- 业户回应 → 处理(走 [[deposit-additional-topup]] 或其他渠道收款)
- 业户不回应 → 走逾期催收流程(本文不展开)
**🟡 警告(还没欠)**:
- 微信 / App 推送"友好提醒"
- "您的预存款余额 ¥X,下月账单约 ¥Y,建议提前充值"
- 不强制
**🟢 关注**:
- 月度汇总报告(给业户的"预存款健康度月报")
- 不打扰
### 第 3 步:出周报
```markdown
# 2026 年 5 月 第 4 周 预存款健康度周报
## 低余额业户清单(共 45 户)
- 🔴 紧急(已欠款):12 户,合计欠款 ¥9,860
- 🟡 警告:24 户
- 🟢 关注:9 户
## 已处置
- 紧急 12 户:已联系
- 5 户已充值 / 付清
- 4 户承诺本周内处理
- 3 户失联(进入催收)
- 警告 24 户:已推送提醒
## 趋势
- 比上周(38 户低余额)增加 7 户 → 趋势变差
- 可能原因:5 月账单出账,部分业户余额不够付
## 建议
- 加强自动抵扣 job 落地的紧迫性(目前业户充值后还得业务人员手动抵)
- 主动给余额接近 0 的业户 push 充值提醒(改 widget 配置)
```
## 业户视角
### 您可能收到的通知
#### 🟡 警告(友好提醒)
> 张阿姨您好,您的预存款余额 ¥200,下月物业费约 ¥800,**预计余额不够付**。建议提前充值,避免账单逾期产生提醒费用。
业户可选择:
- 立即充值
- 现金 / 微信付下月账单
- 不管(后果自负)
#### 🔴 紧急(欠款提醒)
> 张阿姨您好,您 5 月物业费 ¥800 **已逾期 3 天**未付。请尽快通过以下方式付清:
> 1. 微信小程序"我的预存款"充值后系统自动抵
> 2. 到前台现金 / POS 付
> 3. 微信扫码付
## 系统流程
```mermaid
flowchart TD
A[每周/月触发扫描] --> B[SQL 查低余额账户 + 未付账单]
B --> C{分级}
C -->|🔴 紧急已欠| D[强提醒 + 业务人员介入催收]
C -->|🟡 警告未欠| E[友好推送提醒]
C -->|🟢 关注| F[月度汇总报告]
D --> G{业户响应?}
G -->|充值| H[走 deposit-additional-topup]
G -->|其他渠道付| I[正常收款流程]
G -->|无响应| J[逾期催收流程<br/>本文外]
E --> K{业户行动?}
K -->|充值| H
K -->|不充| L[转入🔴紧急]
H --> M[业务人员手动 ConsumeAction 抵账单]
Note over M: 月初自动 job 落地后此步自动
```
## 关联工具
- **`DepositPrepaidDashboard`**:后台 Dashboard 页面,统一展示押金 + 预存款的健康指标
- **`LowBalancePrepaidListWidget`**:本场景核心 Widget,实时列出低余额账户
- **`MonthlyPrepaidFlowChart`**:展示预存款流入 / 流出趋势(充值 vs 消费 vs 退款),业务总监层面看
- **`DepositPrepaidStatsOverviewWidget`**:总览数字(总账户数、总余额、本月流水量)
## 常见问题
> [!question] "下月预计账单" 怎么算?
> 不在系统内的硬规则,看 Widget 实现:
>
> - 取业户近 3 个月平均月账单
> - 或取上月账单
> - 或固定基数(物业费固定 ¥800)
>
> 实际取哪种,看 `LowBalancePrepaidListWidget` 内置逻辑。可调整。
> [!question] 业户被提醒"低余额"但其实人家就喜欢月月手动付,不想预存,怎么办?
> 这种业户应**关账户**(走 [[close-resident-moveout|主动关账]]),避免后续骚扰。或者 Widget 上加"忽略"按钮,业户的"预存款关账"决策可以让业务人员跟进。
> [!question] 低余额预警有没有自动化(短信)?
> 看产品决策。**强烈推荐**:
>
> - 🟢 关注:不推送(避免骚扰)
> - 🟡 警告:每月 1 次推送(可控)
> - 🔴 紧急:立即推送(必要)
>
> 实施需要短信 / 微信模板消息接入。
> [!question] 月初批量自动抵扣 job 落地后,本场景作用还大吗?
> **仍重要**。job 跑完后,跳过的余额不足业户**仍需要业务人员介入**:
>
> - 通知充值
> - 跟踪是否充值
> - 充值后 / 业户其他渠道付后,手动 ConsumeAction 补抵
>
> 详见 [[consume-batch-auto-monthly]] "业务人员视角 - 异常介入" 段。
> [!question] 多社区的业户低余额清单怎么展示?
> Widget 按当前 panel 的 community 过滤(若是社区级 Filament Panel)。或者展示"按社区分组"。如果业务人员管多个社区,可在 Dashboard 选社区切换。
## 与 deposit 长期未关账户排查的对比
| 维度 | deposit audit-long-pending-accounts | prepaid 本场景 |
|---|---|---|
| 关注什么 | 长期 Active 未关账户(>2 年)| 低余额账户(下月可能欠)|
| 业务侧重 | 清理代管资金边界 | 预防业户欠费 |
| 频率 | 季度 / 半年 | 每周 / 每月 |
| 处置 | 关账 / retain / 联系业户 | 通知充值 / 催收 |
两者**都是审计扫描场景**,但关注点和频率不同。
## 异常分支
- 业户充值后忘了抵 → 业务人员手动 ConsumeAction
- 业户长期不响应低余额预警 → 进入逾期催收流程(本文外)
- 业户决定不再用预存款 → [[close-with-zero-balance-decision]] / [[close-resident-moveout]]
## 相关文档
- [[consume-batch-auto-monthly]]
- [[auto-deduction-design]]
- [[deposit-additional-topup]]
- [[close-resident-moveout]]
- [[../deposit/audit-long-pending-accounts]](deposit 对应审计场景对比)

View File

@@ -0,0 +1,197 @@
---
title: prop-acc · prepaid · 场景 - 跨社区消费防御
aliases:
- 跨社区消费拦截
- 跨小区抵账单防御
- exception-cross-community-consume
- 场景-预存款跨社区消费防御
tags:
- 场景
- prop-acc
- 预存款
- 异常
audience:
- 业务人员
- 架构师
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:跨社区消费防御
业务人员**误选**了"A 社区业户预存款账户"去抵"B 社区账单",系统**直接拦截**,不允许。`PrepaidAccount::consume()` 模型方法内置 community 校验,任何调用方都跑不掉。
## 典型情境
> [!example] 真实情境
> 业户陈先生在 A 社区(自住)和 B 社区(投资房出租)各有预存款账户:
>
> - A 社区账户余额 ¥5,000
> - B 社区账户余额 ¥200
>
> B 社区刚出账单"出租房物业费 ¥800",**B 社区余额 ¥200 不够付**。业务人员小李心想"陈先生 A 社区还有 ¥5,000,先抵 B 社区账单凑合下,以后再给陈先生说"。他打开陈先生 A 社区账户,选 B 社区账单,点抵扣 —— **系统直接拦截**,提示"预存款账户与账单不在同一社区,无法抵扣"。
## 业务人员视角
### 您看到什么
| 时刻 | 看到 |
|---|---|
| 进入 A 社区账户的 ViewPrepaidAccount | 状态 Active,balance 5000 |
| `ConsumeAction` Modal 表单 → 账单下拉 | **下拉只显示 A 社区账单**(B 社区账单不在下拉里,UI 层已过滤) |
| 如果硬调 API / tinker | 抛 InvalidArgumentException:"预存款账户与账单不在同一社区,无法抵扣" |
### 三道防御
防御层级与 [[exception-refund-on-frozen]] 类似:
1. **UI 层**:Modal 的账单下拉**只显示当前账户所在 community 的账单**
2. **Action 层**:`ConsumeFromPrepaidAccountAction` 入口校验 community 匹配
3. **模型层**(最严):`PrepaidAccount::consume()` 内置 community 校验,抛 InvalidArgumentException
```php
// PrepaidAccount.php
public function consume(Bill $bill, ...): PrepaidTransaction
{
if ($bill->community_id !== $this->community_id) {
throw new InvalidArgumentException(
'预存款账户与账单不在同一社区,无法抵扣'
);
}
// ... 其余逻辑
}
```
任何对 `PrepaidAccount::consume()` 的修改都会触发测试,确保守护不被无意中放宽。
### 正确路径
不同社区**独立处理**:
| 业户场景 | 正确处置 |
|---|---|
| A 社区想抵 A 社区账单 | 用 A 社区账户(本场景正常情况)|
| B 社区想抵 B 社区账单 | 用 B 社区账户 |
| **A 社区有钱 + B 社区缺钱** | **业户先在 B 社区充值**(走 [[deposit-additional-topup]]),再用 B 社区账户抵 B 社区账单 |
> [!info] 业务上能"A 社区退款 → 业户拿钱 → B 社区充值"吗?
> 完全可以,但**是业户自己的操作**:
>
> 1. 业务人员从 A 社区账户退 ¥800 给业户
> 2. 业户拿到 ¥800
> 3. 业户在 B 社区充 ¥800
> 4. B 社区账户抵账单
>
> 三步业务流程,**资金不直接跨社区流动** —— 各社区财务独立核算。
## 为什么这条守护这么严
> [!warning] 跨社区抵扣的灾难性后果
>
> 假设系统允许跨社区抵扣:
>
> | 反例 | 后果 |
> |---|---|
> | A 社区物业的钱**流出**到 B 社区物业 | 账面对不上,银行流水追溯困难 |
> | A 社区财务报表显示"代收 B 社区物业费" | 越权,A 社区无权管 B 社区收款 |
> | 业户提现:"我在 A 社区有 ¥1,000,在 B 社区抵 ¥1,000,然后从 A 社区退 ¥1,000" | A 社区净流出 ¥2,000(实际只该 ¥1,000)|
> | 各社区物业可能独立公司化,跨社区抵扣 = 关联交易 | 法务 / 税务问题 |
>
> 每个物业项目独立财务核算是行业基本要求。**跨社区抵扣 = 财务边界破坏**。
## 业户视角
业户在小程序"我的预存款"看到自己有 A、B 两个独立账户。**互不联通**,各自余额、各自流水、各自抵扣范围。
如果想跨社区"调资金",**只能业户自己做**:A 社区退款 → 自己拿钱 → B 社区充值。
## 系统流程
```mermaid
sequenceDiagram
participant 业务
participant Filament
participant ConsumeFromPrepaidAccountAction
participant PrepaidAccount[A 社区账户]
participant Bill[B 社区账单]
Note over 业务: 业务人员误想抵跨社区
业务->>Filament: 直接调 API 或 tinker:account_A.consume(bill_B, 800)
Filament->>ConsumeFromPrepaidAccountAction: handle(account_A, bill_B, 800)
ConsumeFromPrepaidAccountAction->>PrepaidAccount: consume(bill_B, 800)
PrepaidAccount->>PrepaidAccount: bill_B.community_id != self.community_id?
PrepaidAccount-->>ConsumeFromPrepaidAccountAction: throw InvalidArgumentException
ConsumeFromPrepaidAccountAction-->>Filament: 拦截 + 日志
Filament-->>业务: 报错:"预存款账户与账单不在同一社区,无法抵扣"
Note over PrepaidAccount,Bill: 无任何资金动作 / 流水产生
```
## 测试断言
代码层有专门测试覆盖此异常路径:
```php
test('cannot consume cross-community bill', function () {
$accountA = PrepaidAccount::factory()->for($communityA)->create(['balance' => 5000]);
$billB = Bill::factory()->for($communityB)->create(['amount' => 800]);
expect(fn () => $accountA->consume($billB, 800))
->toThrow(InvalidArgumentException::class, '预存款账户与账单不在同一社区');
expect($accountA->fresh()->balance)->toBe(5000.0); // 余额未变
expect(PrepaidTransaction::count())->toBe(0); // 流水未建
});
```
## 常见问题
> [!question] 同一物业公司管理多个社区,能不能允许跨社区抵?
> **业务层面**也不行。即使物业公司一家,每个社区**独立财务核算**(看营业执照、税务登记)。除非:
>
> - 多社区合并财务(罕见,需法务批准)
> - 业务方明确要求(走架构师评估)
>
> 当前设计假设"社区独立",未来若改变,需重新评估守护逻辑。
> [!question] 业户在小程序操作时能看到跨社区账户吗?
> 设计上**应该分开显示**。例如:
>
> ```
> 我的预存款
> ├── A 社区(自住):¥5,000
> └── B 社区(投资):¥200
> ```
>
> 不要混合显示总余额(避免业户误以为"跨社区可用")。
> [!question] 业户跨社区调资金的体验差,有什么改进?
> 长期可考虑:
>
> - **跨社区互转**:业户在小程序点"从 A 社区转 ¥1000 到 B 社区" → 系统两步操作(A 退 + B 充)+ 一张统一凭证
> - 但**资金仍走业户**(银行 / 微信回退再充值),系统不直接跨社区流动
> - 当前没有,需业务方推动
> [!question] 业户失联,A 社区有钱,B 社区欠费严重,能挪吗?
> **不能挪**。业户失联是业户的事,各社区独立催收。B 社区欠费走法务流程。
## 与 deposit 的对比
deposit 也有类似多账户(同业户可以有多种押金类型账户),但**没有跨社区消费场景**(押金不抵账单,不存在跨账户消费需求)。所以这条守护是 prepaid 独有。
## 异常分支
- 业务人员真的需要跨社区操作 → 业户自己走"A 退 + B 充"两步
- 多社区合并财务的特殊业务 → 架构师评估后改设计(目前无)
- 业务方提需求要跨社区抵扣 → 走架构评审,理由要充分
## 相关文档
- [[one-account-per-resident]]
- [[consume-monthly-property-bill]]
- [[consume-via-bill-collection-type]]
- [[exception-refund-on-frozen]]
- [[../cross/concepts/org-hierarchy|组织结构]]

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 状态机对比)