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,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|组织结构]]