7.4 KiB
title, aliases, tags, audience, status, sub_feature, last_review, code_version
| title | aliases | tags | audience | status | sub_feature | last_review | code_version | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| prop-acc · prepaid · 场景 - 跨社区消费防御 |
|
|
|
已发布 | prepaid | 2026-05-25 | 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 类似:
- UI 层:Modal 的账单下拉只显示当前账户所在 community 的账单
- Action 层:
ConsumeFromPrepaidAccountAction入口校验 community 匹配 - 模型层(最严):
PrepaidAccount::consume()内置 community 校验,抛 InvalidArgumentException
// 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 社区充值"吗? 完全可以,但是业户自己的操作:
- 业务人员从 A 社区账户退 ¥800 给业户
- 业户拿到 ¥800
- 业户在 B 社区充 ¥800
- 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 社区充值。
系统流程
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: 无任何资金动作 / 流水产生
测试断言
代码层有专门测试覆盖此异常路径:
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 充"两步
- 多社区合并财务的特殊业务 → 架构师评估后改设计(目前无)
- 业务方提需求要跨社区抵扣 → 走架构评审,理由要充分