198 lines
7.4 KiB
Markdown
198 lines
7.4 KiB
Markdown
|
|
---
|
||
|
|
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|组织结构]]
|