0
0
0

2025多功能商城系统支持门店自提可做社区团购三级分销多人拼团限时秒杀独立部署开源版

职场区 3253582893 1月前
1183

源码获取:ym7k.com/11779/

2025 多功能商城系统:门店自提、社区团购与三级分销的工程实现

引子:功能清单的长度不等于工程的成熟度

"支持门店自提、可做社区团购、三级分销、多人拼团、限时秒杀、独立部署、开源"——这七个关键词,几乎覆盖了 2025 年中小型商城系统的全部功能诉求。但把它们放在一起,并不自动等于一个成熟的系统。真正的工程问题在于:这些功能如何在同一套订单、商品和用户体系上共存,而不互相冲突。

一个具体的例子:用户通过社区团购下单,选择门店自提,使用了拼团优惠价,同时他又是一个分销商的下级。这笔订单需要正确处理四件事:交付方式(自提)、成团状态(拼团)、价格计算(拼团价 + 分销佣金)、佣金归属(哪个分销商获得佣金)。任何一个环节出错,都会导致退款纠纷、佣金错算或库存错乱。

这正是多功能商城系统的工程本质:它不是一个功能集合,而是一套能够正确处理功能组合的状态机系统。

本文从五种功能的共存架构、门店自提的 LBS 与核销、社区团购的团长模型、三级分销的路径计算、秒杀的并发控制,到独立部署开源版的工程实践,展开这类系统的完整技术分析。

一、功能共存的核心:统一的订单状态机

1.1 订单是功能的交汇点

在多功能商城系统中,所有功能最终都汇聚到订单上。订单是功能的交汇点,也是工程复杂度的集中体现。

一笔订单可能同时携带以下属性:交付方式(快递 / 自提 / 社区团购自提)、营销玩法(拼团 / 秒杀 / 砍价 / 普通购买)、分销归属(哪个分销商引导的)、支付方式(微信 / 支付宝 / 余额)。这些属性的组合决定了订单的状态流转路径。

1.2 订单表的字段设计

sql
CREATE TABLE `order` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `order_no` varchar(32) NOT NULL,
  `user_id` int(11) NOT NULL,
  `merchant_id` int(11) NOT NULL DEFAULT '1',
  
  -- 交付相关
  `delivery_type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1=快递 2=门店自提 3=社区团购自提',
  `pickup_point_id` int(11) DEFAULT NULL COMMENT '自提点ID',
  `pickup_code` varchar(10) DEFAULT NULL COMMENT '自提核销码',
  `address_id` int(11) DEFAULT NULL COMMENT '收货地址ID',
  
  -- 营销相关
  `promotion_type` varchar(20) DEFAULT NULL COMMENT 'seckill/groupbuy/bargain/normal',
  `promotion_id` int(11) DEFAULT NULL COMMENT '活动ID',
  `group_id` int(11) DEFAULT NULL COMMENT '拼团ID',
  
  -- 分销相关
  `distributor_id` int(11) DEFAULT NULL COMMENT '归属分销商',
  `distribution_level` tinyint(1) DEFAULT NULL COMMENT '分销层级',
  
  -- 金额相关
  `total_amount` decimal(12,2) NOT NULL COMMENT '商品总额',
  `discount_amount` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '优惠金额',
  `pay_amount` decimal(12,2) NOT NULL COMMENT '实付金额',
  `commission_amount` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '佣金总额',
  
  -- 状态
  `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '订单状态',
  `pay_status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '支付状态',
  `settle_status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '结算状态',
  
  `paid_at` datetime DEFAULT NULL,
  `completed_at` datetime DEFAULT NULL,
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_order_no` (`order_no`),
  KEY `idx_user_status` (`user_id`, `status`),
  KEY `idx_pickup` (`pickup_point_id`, `status`),
  KEY `idx_promotion` (`promotion_type`, `promotion_id`),
  KEY `idx_distributor` (`distributor_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

关键设计有几点。delivery_type 区分三种交付方式,pickup_point_id 和 pickup_code 只在自提订单中有效。promotion_type 和 promotion_id 记录营销来源,一个订单只能属于一种营销活动(拼团或秒杀,不能同时)。distributor_id 记录分销归属,在订单创建时确定,后续不随分销关系变化。

1.3 状态机的统一设计

订单状态机需要覆盖所有功能组合的流转路径。

php
<?php
// app/common/service/OrderStateMachine.php

namespace app\common\service;

use think\Db;

class OrderStateMachine
{
    // 通用订单状态
    const PENDING = 0;      // 待付款
    const PAID = 1;         // 已付款,待处理
    const READY = 2;        // 待自提(自提订单)
    const SHIPPED = 3;      // 已发货(快递订单)
    const COMPLETED = 4;    // 已完成
    const CANCELLED = 5;    // 已取消
    const REFUNDING = 6;    // 退款中
    const REFUNDED = 7;     // 已退款

    // 允许的状态流转
    protected $transitions = [
        self::PENDING => [self::PAID, self::CANCELLED],
        self::PAID => [self::READY, self::SHIPPED, self::REFUNDING],
        self::READY => [self::COMPLETED, self::REFUNDING],
        self::SHIPPED => [self::COMPLETED, self::REFUNDING],
        self::REFUNDING => [self::REFUNDED, self::PAID],
    ];

    /**
     * 状态流转
     */
    public function transition(int $orderId, int $toStatus, array $context = []): bool
    {
        Db::startTrans();
        try {
            $order = Db::name('order')
                ->where('id', $orderId)
                ->lock(true)
                ->find();

            if (!$order) {
                throw new \Exception('订单不存在');
            }

            $fromStatus = $order['status'];

            if (!isset($this->transitions[$fromStatus])
                || !in_array($toStatus, $this->transitions[$fromStatus])) {
                throw new \Exception("订单状态不允许从 {$fromStatus} 流转到 {$toStatus}");
            }

            // 更新状态
            Db::name('order')->where('id', $orderId)->update(array_merge([
                'status' => $toStatus,
                'updated_at' => date('Y-m-d H:i:s'),
            ], $context));

            // 触发副作用
            $this->afterTransition($order, $toStatus);

            Db::commit();
            return true;

        } catch (\Exception $e) {
            Db::rollback();
            throw $e;
        }
    }

    /**
     * 状态变更后的副作用
     */
    protected function afterTransition(array $order, int $toStatus): void
    {
        switch ($toStatus) {
            case self::PAID:
                $this->onPaid($order);
                break;
            case self::COMPLETED:
                $this->onCompleted($order);
                break;
            case self::REFUNDED:
                $this->onRefunded($order);
                break;
        }
    }

    /**
     * 支付成功后的处理
     */
    protected function onPaid(array $order): void
    {
        // 1. 自提订单生成核销码
        if (in_array($order['delivery_type'], [2, 3])) {
            $code = $this->generatePickupCode($order['id']);
            Db::name('order')->where('id', $order['id'])->update([
                'pickup_code' => $code,
            ]);
        }

        // 2. 拼团订单加入拼团
        if ($order['promotion_type'] === 'groupbuy' && $order['group_id']) {
            (new GroupBuyService())->joinGroup(
                $order['group_id'],
                $order['user_id'],
                1
            );
        }

        // 3. 更新商品库存(已在创建订单时预扣,此处确认扣减)
        // 4. 发送支付成功通知
    }

    /**
     * 订单完成后的处理
     */
    protected function onCompleted(array $order): void
    {
        // 1. 结算分销佣金
        if ($order['distributor_id']) {
            (new CommissionService())->settle($order['id']);
        }

        // 2. 结算团长佣金(社区团购订单)
        if ($order['delivery_type'] == 3 && $order['pickup_point_id']) {
            (new CommunityService())->settleLeaderCommission($order['id']);
        }
    }

    /**
     * 退款完成后的处理
     */
    protected function onRefunded(array $order): void
    {
        // 1. 恢复库存
        // 2. 取消佣金记录
        Db::name('commission')
            ->where('order_id', $order['id'])
            ->update(['status' => 3]); // 3=已失效
    }

    protected function generatePickupCode(int $orderId): string
    {
        // 生成6位数字核销码,确保唯一
        do {
            $code = str_pad(mt_rand(0, 999999), 6, '0', STR_PAD_LEFT);
            $exists = Db::name('order')
                ->where('pickup_code', $code)
                ->where('status', 'in', [self::PAID, self::READY])
                ->count();
        } while ($exists);
        return $code;
    }
}

这个状态机的核心设计是统一的流转规则 + 分场景的副作用处理。无论订单是拼团、秒杀还是普通购买,状态流转的规则是统一的。但支付成功后的副作用(生成核销码、加入拼团、结算佣金)根据订单类型差异化处理。

二、门店自提:LBS 与核销流程

2.1 自提点的数据模型

sql
CREATE TABLE `pickup_point` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(100) NOT NULL,
  `address` varchar(255) NOT NULL,
  `province` varchar(50) DEFAULT NULL,
  `city` varchar(50) DEFAULT NULL,
  `district` varchar(50) DEFAULT NULL,
  `latitude` decimal(10,7) NOT NULL,
  `longitude` decimal(10,7) NOT NULL,
  `phone` varchar(20) DEFAULT NULL,
  `business_hours` varchar(100) DEFAULT NULL COMMENT '营业时间,如 09:00-21:00',
  `is_community` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否社区团购自提点',
  `leader_id` int(11) DEFAULT NULL COMMENT '团长ID(社区团购)',
  `status` tinyint(1) NOT NULL DEFAULT '1',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_location` (`latitude`, `longitude`),
  KEY `idx_city` (`city`),
  KEY `idx_community` (`is_community`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2.2 附近自提点查询

附近自提点的查询采用两阶段策略:先用经纬度范围做粗略筛选,再用 Haversine 公式精算距离。

php
<?php
// app/common/service/PickupService.php

namespace app\common\service;

use think\Db;

class PickupService
{
    /**
     * 查询附近自提点
     */
    public function nearby(float $lat, float $lng, float $radius = 5, int $limit = 20): array
    {
        $latDelta = $radius / 111;
        $lngDelta = $radius / (111 * cos(deg2rad($lat)));

        $points = Db::name('pickup_point')
            ->where('status', 1)
            ->where('latitude', 'between', [$lat - $latDelta, $lat + $latDelta])
            ->where('longitude', 'between', [$lng - $lngDelta, $lng + $lngDelta])
            ->select()
            ->toArray();

        foreach ($points as &$point) {
            $point['distance'] = $this->haversine(
                $lat, $lng,
                (float)$point['latitude'],
                (float)$point['longitude']
            );
        }

        usort($points, function ($a, $b) {
            return $a['distance'] <=> $b['distance'];
        });

        return array_slice($points, 0, $limit);
    }

    protected function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float
    {
        $R = 6371000;
        $dLat = deg2rad($lat2 - $lat1);
        $dLng = deg2rad($lng2 - $lng1);
        $a = sin($dLat / 2) ** 2
            + cos(deg2rad($lat1)) * cos(deg2rad($lat2))
            * sin($dLng / 2) ** 2;
        return $R * 2 * atan2(sqrt($a), sqrt(1 - $a));
    }
}

2.3 核销流程

用户到店后,出示核销码,商户扫码或手动输入完成核销。

php
<?php
// app/common/service/PickupVerifyService.php

namespace app\common\service;

use think\Db;

class PickupVerifyService
{
    /**
     * 核销自提
     */
    public function verify(string $pickupCode, int $operatorId): array
    {
        Db::startTrans();
        try {
            $order = Db::name('order')
                ->where('pickup_code', $pickupCode)
                ->where('status', OrderStateMachine::READY)
                ->lock(true)
                ->find();

            if (!$order) {
                throw new \Exception('核销码无效或订单未备货');
            }

            // 检查核销时间是否在营业时间内
            $point = Db::name('pickup_point')
                ->where('id', $order['pickup_point_id'])
                ->find();
            if (!$this->isBusinessHours($point['business_hours'])) {
                throw new \Exception('当前不在营业时间内');
            }

            // 执行状态流转
            $stateMachine = new OrderStateMachine();
            $stateMachine->transition($order['id'], OrderStateMachine::COMPLETED, [
                'completed_at' => date('Y-m-d H:i:s'),
                'verify_operator' => $operatorId,
            ]);

            // 记录核销日志
            Db::name('pickup_log')->insert([
                'order_id' => $order['id'],
                'pickup_point_id' => $order['pickup_point_id'],
                'operator_id' => $operatorId,
                'created_at' => date('Y-m-d H:i:s'),
            ]);

            Db::commit();

            return [
                'order_no' => $order['order_no'],
                'goods_count' => $order['goods_count'],
            ];

        } catch (\Exception $e) {
            Db::rollback();
            throw $e;
        }
    }

    protected function isBusinessHours(?string $hours): bool
    {
        if (!$hours) return true;
        [$start, $end] = explode('-', $hours);
        $now = date('H:i');
        return $now >= trim($start) && $now <= trim($end);
    }
}

核销的关键工程问题是幂等性。 如果商户误操作点击了两次核销,系统必须保证只有第一次生效。lock(true) 和状态检查共同保证了这一点。

三、社区团购:团长模型与佣金

3.1 团长的数据模型

社区团购的团长既是自提点的负责人,也是社区的分销节点。

sql
CREATE TABLE `group_leader` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) NOT NULL,
  `pickup_point_id` int(11) NOT NULL,
  `community_name` varchar(100) DEFAULT NULL,
  `commission_rate` decimal(5,2) NOT NULL DEFAULT '10.00' COMMENT '佣金比例',
  `total_commission` decimal(12,2) NOT NULL DEFAULT '0.00',
  `status` tinyint(1) NOT NULL DEFAULT '1',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user` (`user_id`),
  KEY `idx_pickup_point` (`pickup_point_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

3.2 团长佣金结算

php
<?php
// app/common/service/CommunityService.php

namespace app\common\service;

use think\Db;

class CommunityService
{
    /**
     * 结算团长佣金
     */
    public function settleLeaderCommission(int $orderId): void
    {
        $order = Db::name('order')->where('id', $orderId)->find();
        if (!$order || $order['delivery_type'] != 3) {
            return;
        }

        $leader = Db::name('group_leader')
            ->where('pickup_point_id', $order['pickup_point_id'])
            ->where('status', 1)
            ->find();

        if (!$leader) return;

        // 幂等检查
        $exists = Db::name('leader_commission')
            ->where('order_id', $orderId)
            ->count();
        if ($exists) return;

        $commission = round(
            $order['pay_amount'] * $leader['commission_rate'] / 100,
            2
        );

        Db::startTrans();
        try {
            Db::name('leader_commission')->insert([
                'leader_id' => $leader['id'],
                'order_id' => $orderId,
                'order_amount' => $order['pay_amount'],
                'rate' => $leader['commission_rate'],
                'amount' => $commission,
                'status' => 0,
                'created_at' => date('Y-m-d H:i:s'),
            ]);

            Db::name('group_leader')
                ->where('id', $leader['id'])
                ->inc('total_commission', $commission)
                ->update();

            Db::commit();
        } catch (\Exception $e) {
            Db::rollback();
            throw $e;
        }
    }
}

四、三级分销:路径计算与佣金分配

4.1 分销关系表

sql
CREATE TABLE `distribution` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) NOT NULL,
  `parent_id` int(11) DEFAULT NULL,
  `path` varchar(500) NOT NULL COMMENT '关系路径,如 1-5-12',
  `level` tinyint(1) NOT NULL DEFAULT '1',
  `total_commission` decimal(12,2) NOT NULL DEFAULT '0.00',
  `status` tinyint(1) NOT NULL DEFAULT '1',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user` (`user_id`),
  KEY `idx_parent` (`parent_id`),
  KEY `idx_path` (`path`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

path 字段存储从根节点到当前节点的完整路径,格式为 1-5-12。查询某用户的所有下级时,只需 WHERE path LIKE '1-5-%',比递归查询高效得多。

4.2 佣金计算

php
<?php
// app/common/service/CommissionService.php

namespace app\common\service;

use think\Db;

class CommissionService
{
    protected $rates = [1 => 10, 2 => 5, 3 => 2]; // 三级佣金比例

    /**
     * 结算分销佣金
     */
    public function settle(int $orderId): void
    {
        $order = Db::name('order')->where('id', $orderId)->find();
        if (!$order || $order['status'] != OrderStateMachine::COMPLETED) {
            return;
        }

        // 幂等检查
        $exists = Db::name('commission')
            ->where('order_id', $orderId)
            ->count();
        if ($exists) return;

        // 查找买家的分销关系
        $relation = Db::name('distribution')
            ->where('user_id', $order['user_id'])
            ->find();
        if (!$relation) return;

        // 从 path 中解析上级链
        $pathParts = explode('-', $relation['path']);
        array_pop($pathParts); // 移除自己
        $ancestors = array_reverse($pathParts); // 最近的上级在前

        Db::startTrans();
        try {
            foreach ($ancestors as $index => $ancestorId) {
                $level = $index + 1;
                if ($level > 3) break;

                $rate = $this->rates[$level] ?? 0;
                if ($rate <= 0) continue;

                $amount = round($order['pay_amount'] * $rate / 100, 2);

                Db::name('commission')->insert([
                    'order_id' => $orderId,
                    'user_id' => $ancestorId,
                    'from_user_id' => $order['user_id'],
                    'level' => $level,
                    'order_amount' => $order['pay_amount'],
                    'rate' => $rate,
                    'amount' => $amount,
                    'status' => 0,
                    'created_at' => date('Y-m-d H:i:s'),
                ]);
            }
            Db::commit();
        } catch (\Exception $e) {
            Db::rollback();
            throw $e;
        }
    }
}

五、秒杀:三层并发保护

秒杀是所有功能中并发最高的场景。系统采用Redis 预扣库存 + 用户防重 + 队列削峰的三层保护。

php
<?php
// app/common/service/SeckillService.php

namespace app\common\service;

use think\facade\Redis;
use think\facade\Queue;

class SeckillService
{
    /**
     * 秒杀下单
     */
    public function seckill(int $seckillId, int $userId): array
    {
        $stockKey = "seckill:stock:{$seckillId}";
        $userKey = "seckill:user:{$seckillId}:{$userId}";

        // 第一层:用户防重
        if (Redis::exists($userKey)) {
            return ['code' => 429, 'msg' => '您已参与过本次秒杀'];
        }

        // 第二层:原子扣减库存
        $script = <<<LUA
            local stock = redis.call('GET', KEYS[1])
            if not stock then return -1 end
            if tonumber(stock) <= 0 then return 0 end
            redis.call('DECR', KEYS[1])
            return 1
LUA;

        $result = Redis::eval($script, [$stockKey], 1);

        if ($result === -1) {
            return ['code' => 400, 'msg' => '秒杀未开始'];
        }
        if ($result === 0) {
            return ['code' => 400, 'msg' => '商品已售罄'];
        }

        Redis::setex($userKey, 30, 1);

        // 第三层:异步入队
        try {
            Queue::push('app\job\SeckillOrder', [
                'seckill_id' => $seckillId,
                'user_id' => $userId,
            ], 'seckill');

            return ['code' => 200, 'msg' => '排队中,请稍候'];

        } catch (\Exception $e) {
            Redis::incr($stockKey);
            Redis::del($userKey);
            throw $e;
        }
    }
}

Lua 脚本的使用是关键。 库存的"检查-扣减"必须原子化,否则在高并发下会出现超卖。Lua 脚本在 Redis 中执行时是原子的,这是解决超卖问题的标准方案。

六、独立部署开源版:从源码到运行

6.1 环境要求

text
操作系统:CentOS 7+ / Ubuntu 20.04+
Web 服务器:Nginx 1.18+
PHP:7.4 / 8.0(需 redis、fileinfo、curl、gd、pdo_mysql、bcmath 扩展)
数据库:MySQL 5.7+
缓存:Redis 6.0+

6.2 部署流程

bash
# 1. 拉取源码
cd /var/www
git clone https://github.com/your-repo/multi-mall.git
cd multi-mall

# 2. 安装依赖
composer install --no-dev --optimize-autoloader

# 3. 配置环境
cp .env.example .env
vim .env

# 4. 导入数据库
mysql -u root -p multi_mall < install/multi_mall.sql

# 5. 设置权限
chmod -R 755 runtime public/uploads
chown -R www:www runtime public/uploads

# 6. 访问安装向导
# 浏览器打开 http://your-domain/install

6.3 定时任务

bash
crontab -e

# 拼团超时检查(每分钟)
* * * * * cd /var/www/multi-mall && php think groupbuy:check_expire

# 秒杀活动状态更新(每分钟)
* * * * * cd /var/www/multi-mall && php think seckill:update_status

# 订单自动确认收货(每小时)
0 * * * * cd /var/www/multi-mall && php think order:auto_confirm

# 佣金自动结算(每天凌晨)
0 3 * * * cd /var/www/multi-mall && php think commission:auto_settle

# 数据清理(每天凌晨)
0 4 * * * cd /var/www/multi-mall && php think system:cleanup

# 队列消费者(supervisor 守护)
php think queue:work --queue seckill,order,notification --daemon

七、边界与张力

这套系统在工程实践中,有几个需要清醒认识的边界。

第一,功能组合的测试复杂度。 当一笔订单同时涉及自提、拼团和分销时,测试用例的组合会急剧增加。系统需要建立完善的自动化测试,覆盖各种组合场景。

第二,门店自提的库存管理。 自提订单的库存在下单时预扣,但用户可能不来提货。系统需要设置自提有效期(如 7 天),超期未提货自动取消并恢复库存。

第三,社区团购的团长管理。 团长的招募、培训和激励是运营层面的工作。系统只能提供工具(佣金结算、业绩统计),不能替代运营。

第四,三级分销的合规风险。 三级分销在某些司法管辖区可能触及传销的法律边界。系统需要提供分销层级的配置(1-3 级可调),并确保佣金来源于真实的商品销售。

第五,独立部署的升级难题。 独立部署的系统无法像 SaaS 一样热更新。每次升级都需要提供迁移脚本,且需要处理客户自定义修改的代码冲突。

结语:多功能商城的工程本质是"状态机的正确性"

2025 多功能商城系统的核心工程挑战,可以归结为一句话:当五种功能叠加在一笔订单上时,系统能否正确地计算价格、流转状态、结算佣金。

门店自提的工程难点不在 LBS 查询,而在核销的幂等性和营业时间的校验。社区团购的难点不在团长管理,而在订单完成后团长佣金的正确结算。三级分销的难点不在路径存储,而在佣金计算的幂等性和层级追溯的准确性。秒杀的难点不在库存扣减,而在高并发下的超卖防护和请求削峰。

最新回复 (0)

    暂无评论

请先登录后发表评论!

返回
请先登录后发表评论!