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