--- 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|业户]]