PAYATHON 2026

Laravel 4 应用中的业务逻辑应放在哪里?

支付老李

结论

账务汇总计算属于业务逻辑,不适合堆在 controller 或 view 里。可以按下面的方式划分职责:

  • Eloquent model 表达数据关系,以及少量与单个实体密切相关的规则。
  • 独立的 service 处理多个付款和参与者之间的汇总计算。
  • controller 接收请求、调用 service,再把结果传给 view。
  • view 只展示数据,不查询数据库,也不重新计算金额。

从数据库读取数据不等于处理全部业务逻辑。查询可以放在 Eloquent model、repository 或查询对象中。至于每个人该承担多少、最终净余额是多少、应该怎样互相结算,更适合交给专门的账务服务。

先明确账务模型

现有的 payers_payments 同时记录了实际支付金额和是否参与分摊。这样的设计可以工作,但混合了两个不同的概念:

  1. 谁实际垫付了多少钱。
  2. 谁应该承担这笔费用,以及承担多少。

假设 Alice 垫付了 300 元,Alice、Bob、Carol 三人平摊。Alice 的支付额是 300 元,应承担额则只有 100 元。这两项数据应分别表示。

如果每笔付款始终只有一个垫付人,可以采用较简单的结构:

payers
- id
- name

payments
- id
- description
- amount
- paid_by
- paid_at

payment_participants
- payment_id
- payer_id
- share

这里的字段含义是:

  • payments.paid_by 指向实际垫付人。
  • payments.amount 是付款总额。
  • payment_participants 记录参与分摊的人。
  • share 可以表示权重,例如三人均摊时都为 1,也可以改为明确的应承担金额。

如果同一笔费用允许多人共同支付,还需要进一步拆分:

payment_contributions
- payment_id
- payer_id
- amount

payment_allocations
- payment_id
- payer_id
- amount

payment_contributions 记录实际支付金额,payment_allocations 记录应承担金额。两者分开后,账务含义更明确,也可以直接验证:

付款总额 = contributions.amount 之和
付款总额 = allocations.amount 之和

如果只是学习项目,而且当前结构已经完成,没有必要马上重写。不过在计算过程中,至少要在代码里明确区分 paid 和 owed,不要用一个布尔字段隐含复杂的分摊规则。

Model 负责关系和实体规则

以一人垫付、多人按权重分摊为例,Laravel 4 的 Eloquent model 可以这样定义:

<?php

class Payment extends Eloquent
{
    protected $fillable = array(
        'description',
        'amount',
        'paid_by',
        'paid_at'
    );

    public function payer()
    {
        return $this->belongsTo('Payer', 'paid_by');
    }

    public function participants()
    {
        return $this->belongsToMany(
            'Payer',
            'payment_participants'
        )->withPivot('share');
    }
}
<?php

class Payer extends Eloquent
{
    public function paymentsMade()
    {
        return $this->hasMany('Payment', 'paid_by');
    }

    public function participatedPayments()
    {
        return $this->belongsToMany(
            'Payment',
            'payment_participants'
        )->withPivot('share');
    }
}

Model 适合处理与单笔付款密切相关的计算。例如,可以根据权重计算某个参与者应承担的金额:

<?php

class Payment extends Eloquent
{
    // 省略关系定义

    public function amountOwedBy(Payer $payer)
    {
        $participant = $this->participants->first(function ($key, $item) use ($payer) {
            return $item->id === $payer->id;
        });

        if (!$participant) {
            return 0;
        }

        $totalShare = $this->participants->sum(function ($participant) {
            return (int) $participant->pivot->share;
        });

        if ($totalShare <= 0) {
            throw new LogicException('A payment must have a positive total share.');
        }

        return $this->amount * $participant->pivot->share / $totalShare;
    }
}

实际处理金额时,不建议直接使用浮点数。上面的代码主要用于说明职责划分,生产代码应使用整数分或可靠的 decimal 运算。

用 Service 生成汇总

汇总页面需要处理多笔付款和多个人员,属于跨实体的业务流程。可以创建一个 PaymentSummaryService:

<?php

class PaymentSummaryService
{
    public function build()
    {
        $payers = Payer::orderBy('name')->get();

        $payments = Payment::with(array(
            'payer',
            'participants'
        ))->orderBy('paid_at')->get();

        $balances = array();

        foreach ($payers as $payer) {
            $balances[$payer->id] = array(
                'payer' => $payer,
                'paid' => 0,
                'owed' => 0,
                'balance' => 0
            );
        }

        foreach ($payments as $payment) {
            $amount = (int) $payment->amount;

            $balances[$payment->paid_by]['paid'] += $amount;

            $totalShare = $payment->participants->sum(function ($participant) {
                return (int) $participant->pivot->share;
            });

            if ($totalShare <= 0) {
                throw new LogicException(
                    'Payment '.$payment->id.' has no valid participants.'
                );
            }

            $allocations = $this->allocate(
                $amount,
                $payment->participants,
                $totalShare
            );

            foreach ($allocations as $payerId => $owedAmount) {
                $balances[$payerId]['owed'] += $owedAmount;
            }
        }

        foreach ($balances as &$balance) {
            $balance['balance'] = $balance['paid'] - $balance['owed'];
        }
        unset($balance);

        return array(
            'payments' => $payments,
            'balances' => $balances,
            'settlements' => $this->calculateSettlements($balances)
        );
    }

    private function allocate($amount, $participants, $totalShare)
    {
        $allocations = array();
        $allocated = 0;
        $lastIndex = count($participants) - 1;

        foreach ($participants as $index => $participant) {
            if ($index === $lastIndex) {
                $shareAmount = $amount - $allocated;
            } else {
                $shareAmount = (int) floor(
                    $amount * $participant->pivot->share / $totalShare
                );
            }

            $allocations[$participant->id] = $shareAmount;
            $allocated += $shareAmount;
        }

        return $allocations;
    }

    private function calculateSettlements(array $balances)
    {
        $creditors = array();
        $debtors = array();

        foreach ($balances as $payerId => $row) {
            if ($row['balance'] > 0) {
                $creditors[] = array(
                    'payer' => $row['payer'],
                    'amount' => $row['balance']
                );
            } elseif ($row['balance'] < 0) {
                $debtors[] = array(
                    'payer' => $row['payer'],
                    'amount' => -$row['balance']
                );
            }
        }

        $settlements = array();
        $creditorIndex = 0;
        $debtorIndex = 0;

        while (
            isset($creditors[$creditorIndex]) &&
            isset($debtors[$debtorIndex])
        ) {
            $amount = min(
                $creditors[$creditorIndex]['amount'],
                $debtors[$debtorIndex]['amount']
            );

            $settlements[] = array(
                'from' => $debtors[$debtorIndex]['payer'],
                'to' => $creditors[$creditorIndex]['payer'],
                'amount' => $amount
            );

            $creditors[$creditorIndex]['amount'] -= $amount;
            $debtors[$debtorIndex]['amount'] -= $amount;

            if ($creditors[$creditorIndex]['amount'] === 0) {
                $creditorIndex++;
            }

            if ($debtors[$debtorIndex]['amount'] === 0) {
                $debtorIndex++;
            }
        }

        return $settlements;
    }
}

这段代码统一假设金额以分为单位保存。例如,12345 表示 123.45 元,可以避免 0.1 + 0.2 一类浮点精度问题。

每个人的净余额按下面的公式计算:

balance = paid - owed

其中:

  • balance > 0:此人应收款。
  • balance < 0:此人应付款。
  • balance = 0:此人的账务已经平衡。

calculateSettlements() 使用贪心方式生成一组可行的转账方案。它可以结清所有余额,但不能保证在任意约束下都得到数学意义上的最少转账次数。如果业务要求转账次数必须最少,就需要单独定义优化目标,并使用更复杂的算法。

Controller 保持简单

Controller 不需要了解分摊公式,也不应遍历付款记录计算余额。它只负责调用 service:

<?php

class SummaryController extends BaseController
{
    protected $summary;

    public function __construct(PaymentSummaryService $summary)
    {
        $this->summary = $summary;
    }

    public function index()
    {
        $summary = $this->summary->build();

        return View::make('summary.index', $summary);
    }
}

Laravel 4 的 IoC container 可以解析没有额外依赖的具体类。如果 service 还依赖 repository,可以显式绑定接口:

App::bind(
    'PaymentRepositoryInterface',
    'EloquentPaymentRepository'
);

然后通过构造函数注入:

public function __construct(PaymentRepositoryInterface $payments)
{
    $this->payments = $payments;
}

Repository 并不是必需的。如果应用规模很小,让 service 直接使用 Eloquent 完全可以。等查询变得复杂、需要重复使用,或者需要隔离数据来源时,再引入 repository 更合适。

View 只展示整理好的数据

Service 返回的数据结构已经适合展示,Blade 模板只需负责格式化输出:

<h2>成员余额</h2>

<table>
    <thead>
        <tr>
            <th>成员</th>
            <th>已支付</th>
            <th>应承担</th>
            <th>净余额</th>
        </tr>
    </thead>
    <tbody>
        @foreach ($balances as $row)
            <tr>
                <td>{{{ $row['payer']->name }}}</td>
                <td>{{ number_format($row['paid'] / 100, 2) }}</td>
                <td>{{ number_format($row['owed'] / 100, 2) }}</td>
                <td>{{ number_format($row['balance'] / 100, 2) }}</td>
            </tr>
        @endforeach
    </tbody>
</table>

<h2>建议结算</h2>

<ul>
    @foreach ($settlements as $settlement)
        <li>
            {{{ $settlement['from']->name }}}
            支付给
            {{{ $settlement['to']->name }}}
            {{ number_format($settlement['amount'] / 100, 2) }}
        </li>
    @endforeach
</ul>

不要在 Blade 中执行 Eloquent 查询,也不要在模板中决定分摊方式。否则,同一套业务规则很难复用于 API、命令行任务或测试。

需要特别注意的问题

金额不要随意使用浮点数

建议将金额保存为整数分:

10.25 元 → 1025

如果必须使用数据库的 DECIMAL,PHP 端也要谨慎处理,必要时可以使用 BCMath。不要用普通浮点运算判断两个金额是否相等。

舍入差额必须有明确归属

100 分由 3 人均摊时无法整除,因此要明确规定多出的 1 分如何分配,例如:

  • 按稳定顺序分给前几个参与者;
  • 分给垫付人;
  • 使用最大余数法。

无论选择哪种方式,最终都要满足:

所有人的应承担金额之和 = 付款总额

保存必要的历史事实

如果付款记录不能随着规则变化而改变,最好保存每个人最终分配到的明确金额,而不是只保存 pays 或权重。否则,参与者、分摊规则或默认权重一旦修改,旧账重新计算后可能得到不同结果。

避免 N+1 查询

汇总数据时,应通过 with() 预加载关系:

Payment::with(array('payer', 'participants'))->get();

不要在循环中逐条查询付款人或参与者。

为计算服务编写独立测试

至少应覆盖以下情况:

  • 一人付款、多人均摊;
  • 多笔付款由不同人垫付;
  • 某人付款但不参与分摊;
  • 金额不能整除;
  • 没有参与者或总权重为零;
  • 所有净余额之和为零;
  • 结算完成后每个人的余额都归零。

最终可以保留这样的结构:

Eloquent models
    表达关系和单个实体规则

PaymentSummaryService
    加载账务、计算分摊、汇总余额、生成结算建议

SummaryController
    调用 service 并传递结果

Blade view
    格式化和展示结果

对于小型 Laravel 4 项目,这样的职责划分已经足够清楚,也方便以后增加 repository、API 或其他分摊策略。

备注:内容仅供参考。