如何为 bills 与 payments 设计 Object Oriented Design?
结论
建议建立独立的账务分配记录,明确保存 Payment 与 Bill 的对应关系。Bill 的剩余金额、付款状态和账户总余额,则根据这些账务记录动态汇总。
保存记录和实时计算并不冲突。比较稳妥的设计是:
Bill记录应付金额。Payment记录实际收到或支付的资金。PaymentAllocation记录某笔Payment分配给某张Bill的金额。- 余额属于派生值,可以在查询时计算,遇到性能问题时也可以缓存。
- 原始账务记录尽量不要直接修改或删除,出现错误时通过冲销记录纠正。
需要注意的是,只有将付款应用到具体账单时,才创建 PaymentAllocation。创建 Bill 或 Payment 本身不需要同时创建映射记录。
为什么需要独立的分配对象
Bill 和 Payment 往往不是简单的一对一关系:
- 一张账单可以分多次支付。
- 一笔付款可以同时支付多张账单。
- 付款可能暂时没有指定账单,成为账户余额或预付款。
- 付款金额可能超过账单的剩余金额。
- 已完成的分配可能需要撤销或重新分配。
所以,两者实际构成多对多关系,而且关系中还包含一个必要属性,也就是本次分配的金额。
Bill 1 ─── n PaymentAllocation n ─── 1 Payment
PaymentAllocation 并不是为了迎合数据库结构而增加的冗余表。它记录的是实际存在的业务事实。例如:
Payment P1001 中的 300 元被用于支付 Bill B2001。
这条事实需要长期保存,也需要支持审计和追踪。
推荐的数据模型
下面是一个简化的关系数据库设计:
CREATE TABLE bills (
id BIGINT PRIMARY KEY,
account_id BIGINT NOT NULL,
amount_due DECIMAL(19, 4) NOT NULL,
currency CHAR(3) NOT NULL,
issued_at TIMESTAMP NOT NULL,
due_at TIMESTAMP,
status VARCHAR(20) NOT NULL
);
CREATE TABLE payments (
id BIGINT PRIMARY KEY,
account_id BIGINT NOT NULL,
amount DECIMAL(19, 4) NOT NULL,
currency CHAR(3) NOT NULL,
received_at TIMESTAMP NOT NULL,
status VARCHAR(20) NOT NULL
);
CREATE TABLE payment_allocations (
id BIGINT PRIMARY KEY,
bill_id BIGINT NOT NULL REFERENCES bills(id),
payment_id BIGINT NOT NULL REFERENCES payments(id),
amount DECIMAL(19, 4) NOT NULL,
created_at TIMESTAMP NOT NULL,
CHECK (amount > 0)
);
CREATE INDEX idx_allocations_bill
ON payment_allocations(bill_id);
CREATE INDEX idx_allocations_payment
ON payment_allocations(payment_id);
如果同一笔付款对同一张账单只能有一条分配记录,还可以添加:
ALTER TABLE payment_allocations
ADD CONSTRAINT uq_payment_bill UNIQUE (payment_id, bill_id);
如果系统允许多次分配、冲销或重新分配,则不应添加这个唯一约束。此时可以通过分配状态、冲销记录或账务分录区分不同操作。
余额如何计算
一张账单的剩余金额可以表示为:
remaining = bill.amount_due - SUM(valid allocations)
对应的查询如下:
SELECT
b.id,
b.amount_due,
b.amount_due - COALESCE(SUM(pa.amount), 0) AS remaining_amount
FROM bills b
LEFT JOIN payment_allocations pa
ON pa.bill_id = b.id
WHERE b.id = :bill_id
GROUP BY b.id, b.amount_due;
账单是否付清也应根据余额判断:
remaining_amount = 0 -> 已付清
remaining_amount > 0 -> 未付清或部分支付
remaining_amount < 0 -> 超额支付,通常意味着分配规则存在问题
如果业务不允许超额分配,应在写入 PaymentAllocation 时直接阻止,而不是等到查询余额时再处理。
账户的未付总额可以这样计算:
SELECT
COALESCE(SUM(b.amount_due), 0)
- COALESCE((
SELECT SUM(pa.amount)
FROM payment_allocations pa
JOIN bills allocated_bill ON allocated_bill.id = pa.bill_id
WHERE allocated_bill.account_id = :account_id
), 0) AS outstanding_balance
FROM bills b
WHERE b.account_id = :account_id;
实际项目还需要根据状态排除已取消的账单、失败的付款、无效分配和已冲销记录。
面向对象建模
不要让 Bill 遍历整个系统的付款历史。它只需要处理与自身有关的分配记录,或者由专门的 repository 返回汇总结果。
下面是一个简化的 Java 示例:
import java.math.BigDecimal;
import java.util.List;
public final class Bill {
private final long id;
private final BigDecimal amountDue;
public Bill(long id, BigDecimal amountDue) {
if (amountDue.signum() < 0) {
throw new IllegalArgumentException("amountDue must not be negative");
}
this.id = id;
this.amountDue = amountDue;
}
public long getId() {
return id;
}
public BigDecimal getAmountDue() {
return amountDue;
}
public BigDecimal remainingAmount(List<PaymentAllocation> allocations) {
BigDecimal allocated = allocations.stream()
.map(PaymentAllocation::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return amountDue.subtract(allocated);
}
public boolean isPaid(List<PaymentAllocation> allocations) {
return remainingAmount(allocations).signum() == 0;
}
}
import java.math.BigDecimal;
public final class PaymentAllocation {
private final long billId;
private final long paymentId;
private final BigDecimal amount;
public PaymentAllocation(
long billId,
long paymentId,
BigDecimal amount
) {
if (amount.signum() <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
this.billId = billId;
this.paymentId = paymentId;
this.amount = amount;
}
public long getBillId() {
return billId;
}
public long getPaymentId() {
return paymentId;
}
public BigDecimal getAmount() {
return amount;
}
}
创建分配记录的规则适合放在 application service 或 domain service 中:
public final class PaymentAllocationService {
private final BillRepository billRepository;
private final PaymentRepository paymentRepository;
private final PaymentAllocationRepository allocationRepository;
public PaymentAllocationService(
BillRepository billRepository,
PaymentRepository paymentRepository,
PaymentAllocationRepository allocationRepository
) {
this.billRepository = billRepository;
this.paymentRepository = paymentRepository;
this.allocationRepository = allocationRepository;
}
public void allocate(
long paymentId,
long billId,
BigDecimal amount
) {
Bill bill = billRepository.getByIdForUpdate(billId);
Payment payment = paymentRepository.getByIdForUpdate(paymentId);
BigDecimal billRemaining =
allocationRepository.getRemainingAmount(billId);
BigDecimal paymentAvailable =
allocationRepository.getUnallocatedAmount(paymentId);
if (amount.signum() <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
if (amount.compareTo(billRemaining) > 0) {
throw new IllegalStateException(
"allocation exceeds bill remaining amount"
);
}
if (amount.compareTo(paymentAvailable) > 0) {
throw new IllegalStateException(
"allocation exceeds payment available amount"
);
}
if (!bill.getCurrency().equals(payment.getCurrency())) {
throw new IllegalStateException("currency mismatch");
}
allocationRepository.insert(
new PaymentAllocation(billId, paymentId, amount)
);
}
}
这里的 getByIdForUpdate 表示分配过程需要适当的并发控制。具体做法取决于数据库和事务隔离级别,不一定要使用悲观锁。
是否应该保存 remainingAmount
通常不应把 remainingAmount 作为唯一事实来源,因为它可以根据账单金额和有效分配记录计算出来。
如果同时保存以下三个值:
Bill.amountDue
PaymentAllocation.amount
Bill.remainingAmount
那么每次分配都必须同时修改后两个值。只要其中一次更新失败,就可能出现:
amountDue - SUM(allocations) != remainingAmount
一旦发生这种情况,系统很难确定哪个值才是正确的。
因此,权威数据应当是原始业务事实:
- 账单产生了多少应付金额;
- 实际收到了多少付款;
- 付款如何分配;
- 哪些操作后来被撤销。
remainingAmount、isPaid 和账户总余额都是这些事实的投影。
派生值也不需要每次都由应用程序加载完整历史并逐条计算。数据库可以使用 SUM 直接聚合,而且只会扫描与当前账单有关的分配记录。为 bill_id 建立索引后,这和遍历整个 transaction history 并不是一回事。
性能不足时再增加投影或缓存
如果数据量很大,而且余额查询十分频繁,可以维护单独的余额投影:
CREATE TABLE bill_balances (
bill_id BIGINT PRIMARY KEY REFERENCES bills(id),
allocated_amount DECIMAL(19, 4) NOT NULL,
remaining_amount DECIMAL(19, 4) NOT NULL,
updated_at TIMESTAMP NOT NULL
);
写入分配记录时,在同一个数据库事务中更新投影:
BEGIN;
INSERT INTO payment_allocations (
id,
bill_id,
payment_id,
amount,
created_at
) VALUES (
:id,
:bill_id,
:payment_id,
:amount,
CURRENT_TIMESTAMP
);
UPDATE bill_balances
SET allocated_amount = allocated_amount + :amount,
remaining_amount = remaining_amount - :amount,
updated_at = CURRENT_TIMESTAMP
WHERE bill_id = :bill_id
AND remaining_amount >= :amount;
COMMIT;
必须检查 UPDATE 实际影响了多少行。如果影响零行,可能是余额不足、记录不存在,也可能发生了并发竞争。此时应回滚事务。
在这种设计中:
payment_allocations是权威账务记录。bill_balances是用于快速读取的投影。- 投影损坏后,可以根据权威记录重新构建。
不能反过来只保存余额而丢弃分配记录,否则系统无法解释余额是如何形成的。
事务历史与账务分录的区别
如果 transaction history 只包含 Bill created、Payment received 等业务事件,它无法完整说明一笔付款被用于哪张账单。系统仍然需要记录付款分配事件。
可以采用以下事件形式:
BillIssued
PaymentReceived
PaymentAllocated
PaymentAllocationReversed
BillCancelled
系统再通过重放事件计算状态。这属于 event sourcing,适用于审计、时间点还原和事件追踪要求很高的系统,但实现复杂度也会明显增加。
对大多数账单系统来说,关系表加事务已经足够:
bills
payments
payment_allocations
没有必要只为计算余额而引入完整的 event sourcing。
不要随意修改已经入账的记录
账务系统通常不仅要保存最终状态,还要说明过去发生了什么。已经确认的付款分配如果需要纠正,最好增加冲销记录,不要直接覆盖原记录。
例如,原分配为 100 元:
Allocation: +100
撤销时追加:
Reversal: -100
有效分配金额仍然通过求和得到:
SUM(+100, -100) = 0
数据库既可以采用带正负号的分录模型,也可以使用 reversal_of_id 指向被冲销的记录。具体选择取决于审计要求,但原则相同:保留历史。
必须处理的业务约束
实现时至少要明确以下规则:
-
金额类型
不要使用
float或double表示货币。数据库使用DECIMAL,代码使用BigDecimal、整数最小货币单位,或者专门的Moneyvalue object。 -
币种
金额必须带有币种。不同币种不能直接相加或分配。如果允许换汇,需要单独记录汇率、换汇时间和换汇结果。
-
并发分配
两个请求可能同时读取同一份剩余余额,随后都成功分配。余额检查和记录写入必须处于同一个事务中,并配合行锁、原子条件更新或合适的事务隔离级别。
-
幂等性
支付平台可能重复发送通知。
Payment应保存外部交易 ID 并建立唯一约束,防止同一笔付款被重复登记。 -
失败与待确认付款
只有已经确认的付款才能参与有效余额计算。
pending、failed、reversed等状态都需要有明确的语义。 -
舍入规则
税费、汇率或按比例分摊可能产生小数差异。舍入精度和舍入模式必须由业务规则确定,不能依赖编程语言或数据库的默认行为。
-
退款和贷项
退款、credit note 和普通付款不是同一个业务概念。它们可以共用账务分录基础设施,但领域模型仍应保留各自的含义。
一个实用的分层方式
系统职责可以这样划分:
Bill / Payment
保存各自的业务属性和局部规则
PaymentAllocation
表达一笔付款如何应用到一张账单
PaymentAllocationService
校验并执行分配、撤销和重新分配
Repository
持久化对象并执行余额聚合查询
BillBalanceProjection
在确有性能需求时提供快速读取
这样既不会把所有逻辑都塞进 Bill,也不会让数据库表成为缺少业务含义的数据容器。
最终建议
可以先建立 Bill、Payment 和 PaymentAllocation 三个核心模型,并把分配记录作为权威账务数据。余额由数据库聚合计算,配合正确的索引,通常不需要遍历完整历史。
只有监控结果证明聚合查询已经成为性能瓶颈时,再增加余额投影、缓存或定期快照。即使采用这些优化,也要保留原始分配记录,以便审计、核对和重新计算。
通用原则并不是余额必须保存,或者必须实时计算,而是:
保存不能丢失的业务事实,根据事实派生当前状态。需要优化性能时,可以缓存派生结果,但不能让缓存取代账务事实。
备注:内容仅供参考。
