Files
uniprop-manual/prop-acc/scenarios/prepaid/exception-cross-community-consume.md
2026-05-25 23:37:58 +08:00

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 · 场景 - 跨社区消费防御
跨社区消费拦截
跨小区抵账单防御
exception-cross-community-consume
场景-预存款跨社区消费防御
场景
prop-acc
预存款
异常
业务人员
架构师
已发布 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 类似:

  1. UI 层:Modal 的账单下拉只显示当前账户所在 community 的账单
  2. Action 层:ConsumeFromPrepaidAccountAction 入口校验 community 匹配
  3. 模型层(最严):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 社区充值"吗? 完全可以,但是业户自己的操作:

  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 社区充值。

系统流程

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 充"两步
  • 多社区合并财务的特殊业务 → 架构师评估后改设计(目前无)
  • 业务方提需求要跨社区抵扣 → 走架构评审,理由要充分

相关文档