一、前言
这一篇聚焦单商户SaaS商城的分销模块,重点讲佣金计算 和结算逻辑这两个二开时最容易出问题的环节。
而单商户SaaS版的佣金是平台级别的成本------平台从自己的收入中拿出一部分作为佣金奖励给分销员。这意味着SaaS版的分销佣金直接影响平台的利润率,佣金计算的准确性和结算的时机控制比多商户版更关键。
另一个区别是SaaS版支持多开(多租户) ,一套系统可以给多个客户开SAAS账号使用。分销配置需要按租户隔离,每个租户可以独立设置自己的分销层级、佣金比例和结算规则。
这篇文章就从佣金计算引擎、结算条件判断、多租户隔离和售后退款处理四个层面,把SaaS版分销模块的二开要点拆开讲清楚。
二、分销体系的核心设计
2.1 分销层级与佣金分配
LikeShop的分销支持一级分销、二级分销和三级分销。佣金根据商品所设置的分销比例核算。举一个具体的例子:如果A是B的上级,B是C的上级,C是D的上级,那么A购买商品时自己是没有分销佣金的(佣金由下一级产生),如果B购买商品,那么A是以一级分销比例获得佣金;如果C购买商品,那么B是以一级分销比例获得佣金,A是以二级分销比例获得佣金。
这里有一个容易被忽略的约束:A购买商品时自己是没有分销佣金的。佣金只由"下级购买"产生,上级才能拿到。这个规则在佣金计算逻辑中必须严格实现。
分销自购返佣是另一个可配置的选项。当〖分销用户〗自己购买也能获得佣金时,自购佣金比例才会生效;如果关闭,则自购佣金比例无效。
2.2 分销员模式的三种类型
LikeShop支持三种分销员开通模式:人人分销 (会员注册即成为分销会员)、申请分销 (需要申请并审核通过)、指定分销(通过后台开通指定会员分销权)。
指定分销状态是按会员等级指定的,需要在平台后台设置用户设置中自定义可参与分销的会员等级,未指定的分销等级的会员不允许参与分销。
2.3 分销功能开启的两级开关
分销功能需要在两个层面开启。平台后台的分销设置中要开启分销功能 ,否则分销推广按钮不会显示、也不会建立新的分销关系。同时,商品必须是分销商品且设置了所属等级的佣金比例,佣金比例不能设置为0,设置为0时分销订单不产生;佣金小于1分钱时,分销订单也不产生。
三、佣金计算引擎的实现
3.1 佣金计算的基本规则
佣金计算金额为商品被成功购买发生的净交易额,即:佣金计算金额 = 商品总金额 - 优惠券金额 - 用户折扣金额 -/+ 商品改价金额 - 运费金额。
这个公式有几个关键约束:
运费不计算佣金。 购买发生的交易金额中非商品本身金额的部分不计佣,如快递费。
使用优惠券/积分等非现金支付的订单,非现金支付的部分不计算佣金。这意味着如果用户用积分抵扣了50元,佣金只能按实际现金支付的部分计算。
营销订单(秒杀、拼团、砍价)不计算佣金,商品实际佣金小于0.01的订单也不计算。
3.2 佣金计算的代码实现
以下是一个佣金计算引擎的代码示例,封装了上述所有规则:
php
// server/app/common/logic/DistributionLogic.php
class DistributionLogic
{
/**
* 计算订单的分销佣金
* @param int $orderId 订单ID
* @return array 各分销层级的佣金明细
*/
public static function calculateCommission(int $orderId): array
{
// 1. 查询订单及商品
$order = OrderModel::find($orderId);
if (!$order) {
return [];
}
// 2. 检查订单类型:营销订单不计算佣金
if (in_array($order['order_type'], [1, 2, 3])) {
// 1-秒杀;2-拼团;3-砍价
return [];
}
// 3. 检查分销功能是否开启
$distribEnabled = ConfigModel::where('key', 'distribution_enabled')
->where('tenant_id', $order['tenant_id'])
->value('value');
if (!$distribEnabled) {
return [];
}
// 4. 查询订单商品,计算可计佣金额
$orderGoodsList = OrderGoodsModel::where('order_id', $orderId)->select();
$commissionBase = 0;
foreach ($orderGoodsList as $goods) {
// 检查商品是否为分销商品
$isDistribGoods = GoodsModel::where('id', $goods['goods_id'])
->where('is_distribution', 1)
->find();
if (!$isDistribGoods) {
continue;
}
// 计算该商品的净交易额
// 净交易额 = 商品总价 - 优惠券分摊 - 积分抵扣 - 运费分摊
$goodsNetAmount = self::calculateGoodsNetAmount($goods, $order);
$commissionBase += $goodsNetAmount;
}
if ($commissionBase <= 0) {
return [];
}
// 5. 查找分销关系链
$buyerId = $order['user_id'];
$relationChain = self::getDistributionChain($buyerId, $order['tenant_id']);
if (empty($relationChain)) {
return [];
}
// 6. 按层级计算佣金
$commissions = [];
$levels = [1 => '一级', 2 => '二级', 3 => '三级'];
foreach ($levels as $level => $levelName) {
if (!isset($relationChain[$level - 1])) {
break; // 没有该层级的上级
}
$distributor = $relationChain[$level - 1];
// 检查分销商状态是否正常
if ($distributor['status'] != 1) {
continue;
}
// 获取该层级的佣金比例
$rate = self::getCommissionRate(
$distributor['level'],
$level,
$order['tenant_id']
);
if ($rate <= 0) {
continue;
}
$commissionAmount = round($commissionBase * $rate / 100, 2);
// 佣金小于0.01不产生
if ($commissionAmount < 0.01) {
continue;
}
$commissions[] = [
'distributor_id' => $distributor['user_id'],
'level' => $level,
'level_name' => $levelName,
'rate' => $rate,
'amount' => $commissionAmount,
'base_amount' => $commissionBase,
];
}
return $commissions;
}
/**
* 计算商品的净交易额
*/
private static function calculateGoodsNetAmount(array $goods, array $order): float
{
$goodsTotal = $goods['total_price'];
// 扣除优惠券分摊
$couponDeduct = self::allocateCoupon($goods, $order);
// 扣除积分抵扣分摊
$integralDeduct = self::allocateIntegral($goods, $order);
// 扣除运费分摊(运费不计佣)
$freightDeduct = self::allocateFreight($goods, $order);
$netAmount = $goodsTotal - $couponDeduct - $integralDeduct - $freightDeduct;
return max($netAmount, 0);
}
/**
* 获取分销关系链
* @return array 按层级排列的分销商列表
*/
private static function getDistributionChain(int $buyerId, int $tenantId): array
{
$chain = [];
$currentId = $buyerId;
$maxLevel = ConfigModel::where('key', 'distribution_level')
->where('tenant_id', $tenantId)
->value('value') ?? 2;
for ($i = 0; $i < $maxLevel; $i++) {
$user = UserModel::where('id', $currentId)
->where('tenant_id', $tenantId)
->find();
if (!$user || !$user['invite_user_id']) {
break;
}
$parent = UserModel::where('id', $user['invite_user_id'])
->where('tenant_id', $tenantId)
->find();
if (!$parent) {
break;
}
// 检查上级的分销状态
$distributor = DistributionUserModel::where('user_id', $parent['id'])
->where('tenant_id', $tenantId)
->find();
if (!$distributor || $distributor['status'] != 1) {
break;
}
$chain[] = [
'user_id' => $parent['id'],
'level' => $distributor['level'],
'status' => $distributor['status'],
];
$currentId = $parent['id'];
}
return $chain;
}
/**
* 获取指定层级的佣金比例
*/
private static function getCommissionRate(int $distributorLevel, int $level, int $tenantId): float
{
$rateConfig = DistributionLevelModel::where('level', $distributorLevel)
->where('tenant_id', $tenantId)
->find();
if (!$rateConfig) {
return 0;
}
$rateField = 'level_' . $level . '_rate';
return (float)($rateConfig[$rateField] ?? 0);
}
}
3.3 佣金记录的数据结构
佣金记录用于查看每笔订单的分销佣金明细,佣金状态分为待结算、已返佣、已失效三种。订单结算后才会进行返佣,结算前如果有售后退款,则佣金会失效。
佣金记录的核心字段包括:订单编号、实际付款金额、规格ID、商品数量、创建时间、佣金金额、佣金状态、佣金状态描述。
四、结算逻辑的实现
4.1 结算的三个必要条件
佣金结算需要同时满足三个条件:商品已确认收货 + 已过商品售后期 + 已过结算时间,并且还需要定时任务执行正常,如未配置定时任务需要参考通用部署文档进行配置。
这三个条件的含义是:
商品已确认收货------订单状态必须已经完成,用户没有申请退货。
已过商品售后期------售后期在平台后台的交易设置中配置,对账结算只能结算已过售后期的订单,还在售后期内的订单暂时不能结算。
已过结算时间------结算时间在平台后台的分销结算设置中配置,设置了订单完成后多少天可以尝试结算。
4.2 结算时机与售后期的独立配置
这里有一个二开时极其容易踩坑的设计:商城的佣金"结算时机"与"订单售后退款时长"是独立设置的,两边的设置时间可以不一样。
官方文档举了一个例子:后台分销的"结算时机"设置为5分钟,交易设置的"订单售后退款时长"设置为7天。小C购买分销商品,收到货并点击确认收货的5分钟后,当定时任务执行完的时候,该佣金就已经发放出去了,然后在这7天里小C依旧可以对此商品发起售后申请,因为他的订单还处于订单售后退款时长内。
设置不合理的话,很容易出现"既能结算佣金给到该用户上级,事后该用户又能对这笔订单发起售后流程"的情况 。二开时建议在结算逻辑中增加一个校验:结算时间不能短于售后期,或者在结算前强制检查订单是否仍在售后期内。
4.3 结算的代码实现
php
// server/app/common/logic/DistributionSettleLogic.php
class DistributionSettleLogic
{
/**
* 定时任务:扫描并结算到期的分销佣金
*/
public static function settleDueCommissions(): array
{
$now = time();
$result = ['settled' => 0, 'skipped' => 0, 'errors' => 0];
// 1. 查询所有待结算的佣金记录
$pendingRecords = DistributionCommissionModel::where('status', 0) // 0-待结算
->where('create_time', '<', $now - 60) // 至少1分钟前的记录
->limit(200)
->select();
foreach ($pendingRecords as $record) {
try {
// 2. 查询订单
$order = OrderModel::find($record['order_id']);
if (!$order) {
self::invalidateCommission($record['id'], '订单不存在');
$result['skipped']++;
continue;
}
// 3. 检查订单状态:必须已完成或待收货
if (!in_array($order['order_status'], [2, 3])) {
$result['skipped']++;
continue;
}
// 4. 检查是否在售后期内
$afterSaleDays = ConfigModel::where('key', 'after_sale_days')
->where('tenant_id', $record['tenant_id'])
->value('value') ?? 7;
$afterSaleDeadline = $order['finish_time'] + $afterSaleDays * 86400;
if ($now < $afterSaleDeadline) {
$result['skipped']++;
continue; // 还在售后期内,跳过
}
// 5. 检查是否在售后中
$inAfterSale = AfterSaleModel::where('order_id', $order['id'])
->whereIn('status', [1, 2]) // 1-售后中;2-售后成功
->find();
if ($inAfterSale) {
// 有售后记录,佣金失效
self::invalidateCommission($record['id'], '订单存在售后记录');
$result['skipped']++;
continue;
}
// 6. 检查结算时机
$settleDelay = ConfigModel::where('key', 'distribution_settle_delay')
->where('tenant_id', $record['tenant_id'])
->value('value') ?? 0;
$settleTime = $order['finish_time'] + $settleDelay * 86400;
if ($now < $settleTime) {
$result['skipped']++;
continue; // 未到结算时机
}
// 7. 执行结算
self::settleCommission($record);
$result['settled']++;
} catch (Exception $e) {
Log::error("佣金结算失败:{$record['id']},{$e->getMessage()}");
$result['errors']++;
}
}
return $result;
}
/**
* 执行佣金结算
*/
private static function settleCommission(array $record): void
{
Db::startTrans();
try {
// 更新佣金状态为已返佣
DistributionCommissionModel::where('id', $record['id'])
->where('status', 0)
->update([
'status' => 1, // 1-已返佣
'settle_time' => time(),
'update_time' => time(),
]);
// 增加分销商的佣金收益
UserModel::where('id', $record['distributor_id'])
->inc('earnings', $record['amount'])
->update(['update_time' => time()]);
// 记录账户流水
AccountLogModel::create([
'user_id' => $record['distributor_id'],
'source_type' => 3, // 分销佣金
'source_id' => $record['id'],
'source_sn' => $record['order_sn'],
'change_amount' => $record['amount'],
'change_type' => 1, // 增加
'remark' => '分销佣金结算',
'tenant_id' => $record['tenant_id'],
'create_time' => time(),
]);
Db::commit();
} catch (Exception $e) {
Db::rollback();
throw $e;
}
}
/**
* 佣金失效
*/
private static function invalidateCommission(int $commissionId, string $reason): void
{
DistributionCommissionModel::where('id', $commissionId)
->where('status', 0)
->update([
'status' => 2, // 2-已失效
'invalid_reason' => $reason,
'update_time' => time(),
]);
}
}
4.4 售后对佣金的影响
售后处理对佣金的影响是分销模块二开中最复杂的环节。根据官方的售后处理规则:
售后成功的订单,对应的佣金将失效 。如果一个订单有多个商品,除售后的商品外,其他商品依照佣金规则正常进行结算。售后不成功的订单,佣金正常结算给用户。
如果消费者在分销订单结算时间后发起售后申请的,结算的分销佣金平台不予追回,请谨慎设置分销订单结算时间。
这意味着二开时需要在售后退款成功回调中增加佣金失效处理逻辑:
php
// server/app/common/logic/AfterSaleRefundLogic.php 中的退款成功处理
class AfterSaleRefundLogic
{
public static function refundSuccess(int $afterSaleId): void
{
// ... 原有的退款逻辑
// 新增:佣金失效处理
$afterSale = AfterSaleModel::find($afterSaleId);
$orderGoods = OrderGoodsModel::find($afterSale['order_goods_id']);
// 查询该订单商品对应的待结算佣金
$pendingCommissions = DistributionCommissionModel::where('order_id', $afterSale['order_id'])
->where('order_goods_id', $afterSale['order_goods_id'])
->where('status', 0) // 待结算
->select();
foreach ($pendingCommissions as $commission) {
DistributionSettleLogic::invalidateCommission(
$commission['id'],
'售后退款成功,佣金失效'
);
}
}
}
五、SaaS多租户的隔离设计
5.1 租户隔离的核心字段
单商户SaaS版支持多开,一套系统给多个客户开SAAS账号使用。分销配置需要按租户隔离,所有涉及分销的数据表和配置表都需要增加 tenant_id 字段。
需要按租户隔离的数据包括:
| 数据 | 隔离方式 |
|---|---|
| 分销配置(层级、自购返佣等) | ConfigModel 的 tenant_id |
| 分销等级佣金比例 | DistributionLevelModel 的 tenant_id |
| 分销关系链 | UserModel 的 invite_user_id + tenant_id |
| 佣金记录 | DistributionCommissionModel 的 tenant_id |
| 结算时间配置 | ConfigModel 的 tenant_id |
5.2 租户隔离的代码约束
在佣金计算和结算逻辑中,所有查询必须带上 tenant_id 过滤条件 。这是SaaS版二开最核心的安全约束------如果漏了 tenant_id,A租户的佣金数据会被B租户看到,这是最严重的业务风险。
php
// ❌ 错误:没有租户隔离
$rate = DistributionLevelModel::where('level', $level)->value('rate');
// ✅ 正确:带上租户隔离
$rate = DistributionLevelModel::where('level', $level)
->where('tenant_id', $tenantId)
->value('rate');
六、二开实战:几个常见的改造场景
场景一:新增"团队分红"佣金类型
需求:除了常规的一级、二级分销佣金外,增加"团队分红"------当分销员的直推团队达到一定人数时,额外获得团队订单的分红。
改造步骤:
第一步 :在分销等级表中新增 team_bonus_rate 字段,存储团队分红比例。
第二步:在佣金计算引擎中增加团队分红的计算分支:
php
// 在 calculateCommission 中增加
if ($distributor['team_size'] >= $teamThreshold) {
$teamBonus = round($commissionBase * $teamBonusRate / 100, 2);
if ($teamBonus >= 0.01) {
$commissions[] = [
'distributor_id' => $distributor['user_id'],
'level' => 0, // 0表示团队分红
'level_name' => '团队分红',
'rate' => $teamBonusRate,
'amount' => $teamBonus,
];
}
}
第三步 :在佣金记录表中增加 commission_type 字段,区分常规佣金和团队分红。
场景二:修改结算条件为"按周期结算"
需求:当前结算条件是"订单完成后X天",业务要求改为"按自然月结算"------每月1号结算上月所有已过售后期的订单佣金。
改造步骤:
第一步:在结算设置中增加"结算方式"配置项,支持"按订单完成时间"和"按自然月"两种方式。
第二步:在定时任务中增加按自然月结算的判断逻辑:
php
// 按自然月结算
$lastMonthStart = strtotime(date('Y-m-01', strtotime('-1 month')));
$lastMonthEnd = strtotime(date('Y-m-01')) - 1;
$pendingRecords = DistributionCommissionModel::where('status', 0)
->where('create_time', '>=', $lastMonthStart)
->where('create_time', '<=', $lastMonthEnd)
->select();
场景三:增加佣金提现手续费的自定义配置
LikeShop支持5种佣金支付方式:钱包余额、微信零钱、银行卡、微信收款码、支付宝收款码。佣金提现至钱包余额时不需要扣除手续费,其他提现方式均需要手续费。
如果业务要求"提现到微信零钱的手续费按提现金额的2%收取",需要在提现逻辑中增加手续费计算:
php
// 提现手续费计算
$withdrawAmount = $params['amount'];
$feeRate = 0;
if ($params['type'] == 2) { // 微信零钱
$feeRate = ConfigModel::where('key', 'withdraw_fee_wechat')->value('value') ?? 0;
}
$fee = round($withdrawAmount * $feeRate / 100, 2);
$actualAmount = $withdrawAmount - $fee;
七、二开避坑清单
坑一:结算时间短于售后期。 官方明确提醒,结算时机和售后时长独立设置,设置不合理会导致"佣金已发放但用户仍可发起售后"。建议在配置层面增加校验:结算时间不能短于售后期。
坑二:佣金计算没有扣除运费和优惠券分摊。 运费不计佣,优惠券和积分抵扣的非现金部分也不计佣。佣金计算引擎中必须正确处理这些分摊逻辑。
坑三:SaaS版查询没有加 tenant_id。 这是SaaS版最严重的业务风险。所有分销相关的查询必须带上 tenant_id 过滤条件。
坑四:售后退款没有触发佣金失效。 售后成功的订单,对应的待结算佣金必须失效。需要在退款成功回调中增加佣金失效处理逻辑。
坑五:分销关系链查询没有检查上级状态。 如果上级的分销资格被冻结或过期,佣金不应该继续计算。getDistributionChain 方法中必须检查每一层上级的 status 字段。
坑六:营销订单错误地计算了佣金。 秒杀、拼团、砍价订单不计算佣金,佣金计算引擎中必须在入口处过滤掉这些订单类型。
八、总结
LikeShop单商户SaaS版分销模块二开的核心可以概括为三条线:
佣金计算引擎:核心公式为"商品总金额 - 优惠券 - 积分抵扣 - 运费",扣除所有非商品金额部分。营销订单不计算佣金,佣金比例不能为0,佣金小于0.01不产生。
结算逻辑:结算需要同时满足"已确认收货 + 已过售后期 + 已过结算时间"三个条件,通过定时任务扫描执行。结算时机和售后时长独立配置,设置不当会导致佣金已发但售后仍可发起。
多租户隔离 :SaaS版所有分销数据必须按 tenant_id 隔离,佣金计算和结算逻辑中的每一处查询都要带上租户过滤条件。
二开时守住三条底线:佣金计算扣除所有非商品金额、结算前强制校验售后状态、所有查询加 tenant_id 过滤。把这三点处理好,SaaS版的分销模块就能稳定支撑多租户运营。