vault backup: 2026-05-25 23:02:51

This commit is contained in:
Willie
2026-05-25 23:02:51 +08:00
parent 754fcadaf4
commit b3a8c502e7
6 changed files with 917 additions and 16 deletions

View File

@@ -0,0 +1,168 @@
---
title: prop-acc · prepaid · 一户一账约束
aliases:
- 一户一账
- 预存款一户一账
- one-account-per-resident
- 跨社区防御
tags:
- 概念
- prop-acc
- 预存款
- 数据约束
audience:
- 业户
- 业务人员
- 架构师
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 一户一账约束
预存款账户**每个社区每业户最多一个**。数据库唯一约束 + 业务层跨社区防御**双层保证**。这是 prepaid 子模块**最重要的独有约束**,影响开户、消费、跨社区调度的所有流程。
## 约束的形式化
```sql
UNIQUE INDEX (community_id, community_user_profile_id)
ON acc_prepaid_accounts;
```
- 同业户在同社区 → 只能有 1 个预存款账户
- 同业户在不同社区 → 可以各自有 1 个预存款账户(多社区独立)
## 为什么要一户一账
> [!info] 类比
> 业户在同一家超市办储值卡,通常**只有一张**。两张卡难管理(余额分散、要充两次、消费时选哪张),业务上无价值。
具体好处:
| 好处 | 解释 |
|---|---|
| **抵扣账单清晰** | 业户某月物业费 ¥800,系统找该业户的预存款账户(唯一),余额够就扣,不够就提示充值 |
| **业户体验简单** | 业户在小程序只看到 1 个账户,1 个余额,不用选 |
| **报表对账简单** | "本社区所有预存款余额" = sum(prepaid_accounts.balance where community_id=X),无重复计算 |
| **避免资金分散** | 业户分散在两个账户各 ¥500,某月账单 ¥800,系统抵不动(单账户余额不够);合并 ¥1000 就能抵 |
## 与 deposit 的对比
| 维度 | DepositAccount | PrepaidAccount |
|---|---|---|
| 每业户账户数 | **多个**(按 fee_type、按 asset) | **1 个**(每社区) |
| 唯一约束 | 无(可重复)| `(community_id, community_user_profile_id)` |
| 业务理由 | 不同押金种类性质不同(装修押金 vs 入驻押金 vs 设备押金) | 预存款只是"钱包",细分无意义 |
deposit 的多账户合理:同业户可以有"装修保证金 ¥5000" + "入驻押金 ¥2000" + "设备押金 ¥1000",各自独立结算。prepaid 的单账户合理:同业户只有一个"预存款钱包",钱都在一起。
## 跨社区防御
业户可能**同时入住多个社区**(例如自住 + 投资房在另一社区)。两个社区各有自己的预存款账户(`community_id` 不同,各自唯一)。这是**正常情况**,系统允许。
但**禁止跨社区消费** —— A 社区的预存款不能抵 B 社区的账单。这条由 `PrepaidAccount::consume()` 方法**内置守护**:
```php
public function consume(Bill $bill, ...): PrepaidTransaction
{
// 跨社区防御
if ($bill->community_id !== $this->community_id) {
throw new InvalidArgumentException(
'预存款账户与账单不在同一社区,无法抵扣'
);
}
// ...
}
```
> [!warning] 即使账户表面可"逻辑抵扣"也禁止
> 假设业户在 A 社区有 ¥5000 预存,B 社区有 ¥800 物业费账单。逻辑上"业户的钱够付"。但**仍禁止跨社区**抵扣,理由:
>
> - 每个社区财务独立核算,跨社区抵扣等于在两个社区之间转账(账面对不上)
> - 业户与 A 社区物业的合同关系,与 B 社区无关
> - 物业管理通常按社区独立公司化运营
>
> 业户要付 B 社区账单 → 在 B 社区充值新预存款,或直接现金 / 微信付。
详见 [[exception-cross-community-consume]]。
## 开户时的约束行为
业务人员尝试为某社区某业户**再次**开预存款账户时,系统会:
1. UI 层:`PrepaidAccountForm` 在提交前 SQL 查询是否已存在(可前置友好提示)
2. 数据库层:即使 UI 绕过,unique 约束抛 SQLSTATE 23000(duplicate entry)
**业务上**:同业户同社区只允许一次开户。如果业户旧账户已 Closed(搬走过又回来),通常做法是:
| 旧账户状态 | 业务处理 |
|---|---|
| Active | 不开新账户,继续用 |
| Frozen | 先解冻,继续用 |
| Closed + balance=0 | **目前阻塞**:开新会撞 unique。需要业务层加 `where status != 'closed'` 软约束,或允许"重启" Closed 账户(目前不支持)|
| Closed + balance>0 | 罕见情况(类似 retain),业务层应避免 |
> [!warning] 已知设计 gap
> 当前 unique 约束**没有过滤 Closed 状态**,等于"账户一旦关了就不能再开"。如果有"业户搬走又回来"的真实场景,需要后续讨论:
> - 改约束为 `WHERE status != 'closed'`(部分数据库支持)
> - 或允许 reopen Closed 账户(目前 deposit/prepaid 都禁)
> - 或运维 / 业务流程上把 closed 账户改名(改业户 id?)
>
> 暂时按"业户搬走绝大多数不会回来"的假设运行。issue.md Q4 未列入待补,业务方未提出。
## 业户视角
业户基本感受不到这条约束 —— 您本来就只期望"一个钱包":
- 同社区:看到一个余额、一份流水
- 跨社区(若有):分别看,不混淆
唯一可见的影响:跨社区购物 / 缴费时,不能用 A 社区的钱付 B 社区的账。
## 业务人员视角
开户时:
- 输入业户档案 → 系统检查该业户在本社区是否已有账户
- 已有 Active / Frozen → 提示"已有账户,请直接充值",跳到 `ViewPrepaidAccount`
- 已有 Closed → 当前阻塞,需特殊处理(联系运维)
- 无 → 走正常开户流程([[deposit-first-time]])
消费时:
- 系统找业户在本社区的预存款账户(唯一)
- 余额够 → 抵扣
- 余额不够 → 提示业户先充值
## 系统视角
抵扣账单的查找逻辑:
```php
// 伪代码 — 找业户的预存款账户
$account = PrepaidAccount::query()
->where('community_id', $bill->community_id)
->where('community_user_profile_id', $bill->resident_id)
->where('status', PrepaidAccountStatus::Active)
->first(); // ← 因为 unique,first() 直接拿到唯一一个(或 null)
if (! $account || $account->balance < $bill->amount) {
// 余额不够 / 没账户
return BillPaymentResult::insufficient();
}
$account->consume($bill, $bill->amount);
```
唯一约束让 `first()` 100% 准确(理论上不需要 `firstOrFail()` 之类容错)。
## 相关文档
- [[prepaid-account-vs-transaction]]
- [[account-state-machine]]
- [[consume-via-bill-collection-type]]
- [[exception-cross-community-consume]]
- [[deposit-first-time]]
- [[../cross/concepts/resident|业户]]