0
0
0

商城系统源码支持多端(H5小程序APP支持)Thinkphp+Uniapp技术

职场区 3253582893 1月前
2223

源码获取:ym7k.com/10650/

一、多角色权限模型:RBAC 与数据可见性

1.1 四类角色的权限边界

物业系统的权限模型比普通商城复杂,因为它不仅有"功能权限"(能不能操作某个功能),还有"数据权限"(能看到哪些数据)。

业主 只能看到自己的房产、自己的缴费记录、自己的报修工单。数据权限是"自己"。

物业人员 能看到自己负责区域的报修工单、通知和活动。数据权限是"区域"。

商户 只能看到自己的商品、订单和结算记录。数据权限是"自己"。

管理员 能看到全部数据。数据权限是"全部"。

这种多层次的权限模型,用标准的 RBAC(基于角色的访问控制)只能解决功能权限,数据权限需要额外的设计。

sql
-- 角色表
CREATE TABLE `role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(50) NOT NULL COMMENT '角色名称',
  `code` varchar(50) NOT NULL COMMENT '角色标识',
  `data_scope` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0=全部 1=本区域 2=本部门 3=仅本人',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 用户角色关联表
CREATE TABLE `user_role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) NOT NULL,
  `role_id` int(11) NOT NULL,
  `community_id` int(11) DEFAULT NULL COMMENT '所属小区',
  `building_id` int(11) DEFAULT NULL COMMENT '所属楼栋',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_role` (`user_id`, `role_id`),
  KEY `idx_community` (`community_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

data_scope 字段是数据权限的核心。它定义了角色的数据可见范围:0 表示全部数据,1 表示本区域数据,2 表示本部门数据,3 表示仅本人的数据。

1.2 数据权限的自动注入

数据权限的实现方式是在查询时自动注入过滤条件。这与多商户系统的租户隔离类似,但更加灵活——不同角色的过滤维度不同。

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

namespace app\common\service;

use think\Db;

class DataScopeService
{
    /**
     * 为查询注入数据权限过滤条件
     */
    public function apply($query, $userId, $table = '')
    {
        $user = $this->getUserWithRoles($userId);
        if (!$user) {
            return $query->where('1=0'); // 无权限,返回空集
        }

        // 超级管理员不做限制
        if ($user['is_admin']) {
            return $query;
        }

        // 取权限范围最宽的角色
        $scopes = array_column($user['roles'], 'data_scope');
        $minScope = min($scopes);

        switch ($minScope) {
            case 0: // 全部数据
                return $query;

            case 1: // 本区域
                $communityIds = $this->getUserCommunityIds($user);
                return $query->whereIn('community_id', $communityIds);

            case 2: // 本部门
                $deptIds = $this->getUserDeptIds($user);
                return $query->whereIn('dept_id', $deptIds);

            case 3: // 仅本人
                return $query->where('user_id', $userId);

            default:
                return $query->where('1=0');
        }
    }

    protected function getUserWithRoles(int $userId): ?array
    {
        $user = Db::name('user')->where('id', $userId)->find();
        if (!$user) {
            return null;
        }

        $roles = Db::name('user_role')
            ->alias('ur')
            ->join('role r', 'ur.role_id = r.id')
            ->where('ur.user_id', $userId)
            ->field('r.*, ur.community_id, ur.building_id')
            ->select();

        $user['roles'] = $roles;
        $user['is_admin'] = in_array('admin', array_column($roles, 'code'));

        return $user;
    }

    protected function getUserCommunityIds(array $user): array
    {
        return array_unique(array_filter(
            array_column($user['roles'], 'community_id')
        ));
    }

    protected function getUserDeptIds(array $user): array
    {
        return array_unique(array_filter(
            array_column($user['roles'], 'building_id')
        ));
    }
}

这个服务的核心设计是:在数据访问层统一注入过滤条件,而不是在每个业务方法中手动编写。 这避免了"某个接口忘记加权限过滤"导致的数据泄露。

1.3 房产与业主的绑定关系

物业系统的一个特殊之处是"房产"是核心实体。业主不是抽象的用户,而是"某个房产的业主"。一个业主可能拥有多套房产,一个房产可能有多个业主(如夫妻共有)。

sql
-- 房产表
CREATE TABLE `property` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `community_id` int(11) NOT NULL COMMENT '小区ID',
  `building_id` int(11) NOT NULL COMMENT '楼栋ID',
  `unit` varchar(20) NOT NULL COMMENT '单元',
  `floor` int(11) NOT NULL COMMENT '楼层',
  `room_no` varchar(20) NOT NULL COMMENT '房号',
  `area` decimal(10,2) NOT NULL COMMENT '建筑面积',
  `property_fee_rate` decimal(10,2) NOT NULL COMMENT '物业费单价(元/㎡/月)',
  `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1=正常 2=空置 3=装修中',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_community_building` (`community_id`, `building_id`),
  UNIQUE KEY `uk_room` (`community_id`, `building_id`, `unit`, `floor`, `room_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 业主房产关联表
CREATE TABLE `owner_property` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) NOT NULL,
  `property_id` int(11) NOT NULL,
  `relation` varchar(20) NOT NULL COMMENT 'owner/family/tenant',
  `is_primary` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否主要联系人',
  `verified_at` datetime DEFAULT NULL COMMENT '认证时间',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_property` (`user_id`, `property_id`),
  KEY `idx_property` (`property_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

property_fee_rate 字段是物业费计算的基础。每个房产可能有不同的物业费单价(如住宅和商铺不同),因此单价存储在房产层面而非小区层面。

二、物业费的计算、缴纳与催缴

2.1 物业费的计算模型

物业费的计算涉及几个变量:房产面积、单价、缴费周期、滞纳金。

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

namespace app\common\service;

use think\Db;

class PropertyFeeService
{
    /**
     * 计算物业费
     */
    public function calculate(int $propertyId, string $startDate, string $endDate): array
    {
        $property = Db::name('property')->where('id', $propertyId)->find();
        if (!$property) {
            throw new \Exception('房产不存在');
        }

        // 计算月数
        $start = new \DateTime($startDate);
        $end = new \DateTime($endDate);
        $interval = $start->diff($end);
        $months = $interval->y * 12 + $interval->m;
        if ($interval->d > 0) {
            $months += 1; // 不足一月按一月计
        }

        // 基础物业费
        $baseFee = $property['area'] * $property['property_fee_rate'] * $months;

        // 检查是否有历史欠费
        $arrears = $this->getArrears($propertyId);

        // 计算滞纳金(欠费金额的 0.05%/天)
        $lateFee = 0;
        foreach ($arrears as $item) {
            $overdueDays = $this->getOverdueDays($item['due_date']);
            if ($overdueDays > 0) {
                $lateFee += $item['amount'] * 0.0005 * $overdueDays;
            }
        }

        return [
            'property_id' => $propertyId,
            'area' => $property['area'],
            'rate' => $property['property_fee_rate'],
            'months' => $months,
            'start_date' => $startDate,
            'end_date' => $endDate,
            'base_fee' => round($baseFee, 2),
            'arrears' => round(array_sum(array_column($arrears, 'amount')), 2),
            'late_fee' => round($lateFee, 2),
            'total' => round($baseFee + $lateFee, 2),
        ];
    }

    /**
     * 获取历史欠费
     */
    protected function getArrears(int $propertyId): array
    {
        return Db::name('property_fee_bill')
            ->where('property_id', $propertyId)
            ->where('status', 'unpaid')
            ->where('due_date', '<', date('Y-m-d'))
            ->select()
            ->toArray();
    }

    protected function getOverdueDays(string $dueDate): int
    {
        $due = strtotime($dueDate);
        $now = time();
        return $now > $due ? (int)(($now - $due) / 86400) : 0;
    }
}

滞纳金的计算是一个需要注意的工程细节。滞纳金不能无限累积,通常有上限(如不超过本金)。同时,滞纳金的计算需要幂等——同一个账单重复计算应该得到相同的结果。

2.2 账单生成与推送

物业费账单通常是按月或按季度批量生成的。系统需要提供一个批量生成的功能,并推送给业主。

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

namespace app\common\service;

use think\Db;
use think\facade\Queue;

class BillGenerateService
{
    /**
     * 批量生成账单
     */
    public function batchGenerate(int $communityId, string $period): array
    {
        // period 格式:2025-01(月度)或 2025-Q1(季度)

        $properties = Db::name('property')
            ->where('community_id', $communityId)
            ->where('status', 1)
            ->select();

        $created = 0;
        $skipped = 0;

        foreach ($properties as $property) {
            // 检查是否已生成
            $exists = Db::name('property_fee_bill')
                ->where('property_id', $property['id'])
                ->where('period', $period)
                ->count();

            if ($exists) {
                $skipped++;
                continue;
            }

            // 计算账单金额
            $months = $this->periodToMonths($period);
            $amount = $property['area'] * $property['property_fee_rate'] * $months;

            $billId = Db::name('property_fee_bill')->insertGetId([
                'property_id' => $property['id'],
                'community_id' => $communityId,
                'period' => $period,
                'amount' => round($amount, 2),
                'due_date' => $this->getDueDate($period),
                'status' => 'unpaid',
                'created_at' => date('Y-m-d H:i:s'),
            ]);

            // 异步入队,推送通知
            Queue::push('app\job\SendBillNotification', [
                'bill_id' => $billId,
            ], 'notification');

            $created++;
        }

        return ['created' => $created, 'skipped' => $skipped];
    }

    protected function periodToMonths(string $period): int
    {
        if (strpos($period, 'Q') !== false) {
            return 3;
        }
        return 1;
    }

    protected function getDueDate(string $period): string
    {
        // 账单生成后 30 天到期
        return date('Y-m-d', strtotime('+30 days'));
    }
}

推送通知使用队列异步处理,避免批量生成时阻塞主流程。这是处理批量操作的常见模式。

2.3 缴费流程与对账

缴费涉及支付回调和对账。支付成功后,账单状态更新为"已支付",同时记录支付流水。

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

namespace app\common\service;

use think\Db;

class PaymentCallbackService
{
    /**
     * 处理支付回调
     */
    public function handle(array $callbackData): bool
    {
        $orderNo = $callbackData['out_trade_no'];
        $transactionId = $callbackData['transaction_id'];

        // 幂等检查
        $exists = Db::name('payment_log')
            ->where('transaction_id', $transactionId)
            ->count();
        if ($exists) {
            return true; // 已处理,直接返回成功
        }

        Db::startTrans();
        try {
            // 1. 查找账单
            $bill = Db::name('property_fee_bill')
                ->where('bill_no', $orderNo)
                ->lock(true)
                ->find();

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

            if ($bill['status'] === 'paid') {
                Db::commit();
                return true; // 已支付,幂等返回
            }

            // 2. 更新账单状态
            Db::name('property_fee_bill')
                ->where('id', $bill['id'])
                ->update([
                    'status' => 'paid',
                    'paid_at' => date('Y-m-d H:i:s'),
                    'transaction_id' => $transactionId,
                ]);

            // 3. 记录支付流水
            Db::name('payment_log')->insert([
                'order_no' => $orderNo,
                'transaction_id' => $transactionId,
                'amount' => $callbackData['total_fee'] / 100,
                'type' => 'property_fee',
                'created_at' => date('Y-m-d H:i:s'),
            ]);

            Db::commit();
            return true;

        } catch (\Exception $e) {
            Db::rollback();
            \think\facade\Log::error('支付回调处理失败', [
                'order_no' => $orderNo,
                'error' => $e->getMessage(),
            ]);
            return false;
        }
    }
}

幂等性是支付回调的核心要求。 微信支付可能重复发送回调通知,系统必须能够识别并正确处理重复通知。使用 transaction_id 做幂等键是标准做法。

三、报修工单:状态机与派单逻辑

3.1 报修工单的数据模型

sql
CREATE TABLE `repair_order` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `order_no` varchar(32) NOT NULL COMMENT '工单号',
  `user_id` int(11) NOT NULL COMMENT '报修人',
  `property_id` int(11) NOT NULL COMMENT '报修房产',
  `community_id` int(11) NOT NULL,
  `category` varchar(50) NOT NULL COMMENT '报修类别:水电/门窗/电梯等',
  `title` varchar(200) NOT NULL,
  `description` text COMMENT '问题描述',
  `images` text COMMENT '图片列表JSON',
  `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0=待接单 1=处理中 2=已完成 3=待评价 4=已评价 5=已取消',
  `priority` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0=普通 1=紧急 2=特急',
  `handler_id` int(11) DEFAULT NULL COMMENT '处理人',
  `assigned_at` datetime DEFAULT NULL COMMENT '派单时间',
  `completed_at` datetime DEFAULT NULL,
  `rating` tinyint(1) DEFAULT NULL COMMENT '评分 1-5',
  `comment` varchar(500) DEFAULT NULL COMMENT '评价内容',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_order_no` (`order_no`),
  KEY `idx_user` (`user_id`),
  KEY `idx_community_status` (`community_id`, `status`),
  KEY `idx_handler` (`handler_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

3.2 报修工单的状态机

报修工单的状态流转比订单简单,但派单逻辑更复杂。

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

namespace app\common\service;

use think\Db;

class RepairService
{
    // 工单状态
    const STATUS_PENDING = 0;    // 待接单
    const STATUS_PROCESSING = 1; // 处理中
    const STATUS_COMPLETED = 2;  // 已完成
    const STATUS_RATED = 3;      // 已评价
    const STATUS_CANCELLED = 4;  // 已取消

    /**
     * 创建报修工单
     */
    public function create(array $data): array
    {
        $orderNo = date('YmdHis') . mt_rand(1000, 9999);

        $id = Db::name('repair_order')->insertGetId([
            'order_no' => $orderNo,
            'user_id' => $data['user_id'],
            'property_id' => $data['property_id'],
            'community_id' => $data['community_id'],
            'category' => $data['category'],
            'title' => $data['title'],
            'description' => $data['description'] ?? '',
            'images' => json_encode($data['images'] ?? []),
            'priority' => $data['priority'] ?? 0,
            'status' => self::STATUS_PENDING,
            'created_at' => date('Y-m-d H:i:s'),
        ]);

        // 自动派单
        $this->autoAssign($id);

        return ['id' => $id, 'order_no' => $orderNo];
    }

    /**
     * 自动派单
     * 根据报修类别和区域,匹配有空的维修人员
     */
    protected function autoAssign(int $orderId): void
    {
        $order = Db::name('repair_order')->where('id', $orderId)->find();

        // 查找该小区、该类别下的可用维修人员
        $handler = Db::name('staff')
            ->alias('s')
            ->join('staff_skill ss', 's.id = ss.staff_id')
            ->where('s.community_id', $order['community_id'])
            ->where('ss.category', $order['category'])
            ->where('s.status', 1)
            ->where('s.current_load', '<', 5) // 当前工单数少于5
            ->order('s.current_load', 'asc')
            ->find();

        if ($handler) {
            $this->assign($orderId, $handler['id']);
        }
        // 如果没有可用人员,工单留在待接单池
    }

    /**
     * 派单
     */
    public function assign(int $orderId, int $handlerId): bool
    {
        Db::startTrans();
        try {
            $order = Db::name('repair_order')
                ->where('id', $orderId)
                ->lock(true)
                ->find();

            if ($order['status'] != self::STATUS_PENDING) {
                throw new \Exception('工单状态不允许派单');
            }

            Db::name('repair_order')
                ->where('id', $orderId)
                ->update([
                    'handler_id' => $handlerId,
                    'status' => self::STATUS_PROCESSING,
                    'assigned_at' => date('Y-m-d H:i:s'),
                ]);

            // 更新维修人员负载
            Db::name('staff')
                ->where('id', $handlerId)
                ->inc('current_load', 1)
                ->update();

            Db::commit();
            return true;

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

    /**
     * 完成工单
     */
    public function complete(int $orderId, int $handlerId, string $result = ''): bool
    {
        Db::startTrans();
        try {
            $order = Db::name('repair_order')
                ->where('id', $orderId)
                ->lock(true)
                ->find();

            if ($order['handler_id'] != $handlerId) {
                throw new \Exception('无权操作此工单');
            }

            if ($order['status'] != self::STATUS_PROCESSING) {
                throw new \Exception('工单状态不允许完成');
            }

            Db::name('repair_order')
                ->where('id', $orderId)
                ->update([
                    'status' => self::STATUS_COMPLETED,
                    'completed_at' => date('Y-m-d H:i:s'),
                ]);

            // 减少维修人员负载
            Db::name('staff')
                ->where('id', $handlerId)
                ->dec('current_load', 1)
                ->update();

            // 发送通知给业主,邀请评价
            $this->notifyOwner($order['user_id'], $orderId);

            Db::commit();
            return true;

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

自动派单的负载均衡是报修模块的一个关键设计。系统不是随机分配,而是根据维修人员的当前工单数(current_load)选择负载最低的人员。这避免了"某个维修人员忙死,其他人员闲着"的情况。

四、活动报名与商城模块

4.1 活动报名

活动报名是物业系统的社区服务功能。它的核心是"报名-审核-参与-签到"的链路。

sql
CREATE TABLE `activity` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `community_id` int(11) NOT NULL,
  `title` varchar(200) NOT NULL,
  `cover` varchar(255) DEFAULT NULL,
  `description` text,
  `location` varchar(255) DEFAULT NULL,
  `start_at` datetime NOT NULL,
  `end_at` datetime NOT NULL,
  `signup_deadline` datetime NOT NULL,
  `max_participants` int(11) DEFAULT NULL COMMENT '人数上限,NULL不限',
  `need_review` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否需要审核',
  `status` tinyint(1) NOT NULL DEFAULT '1',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_community_status` (`community_id`, `status`),
  KEY `idx_time` (`start_at`, `end_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `activity_signup` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `activity_id` int(11) NOT NULL,
  `user_id` int(11) NOT NULL,
  `name` varchar(50) NOT NULL COMMENT '参与人姓名',
  `phone` varchar(20) NOT NULL,
  `remark` varchar(200) DEFAULT NULL,
  `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0=待审核 1=已通过 2=已拒绝 3=已取消',
  `checked_in` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已签到',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_activity_user` (`activity_id`, `user_id`),
  KEY `idx_activity_status` (`activity_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

活动报名的并发控制与拼团类似:多人同时报名时,需要保证人数不超过上限。

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

namespace app\common\service;

use think\Db;
use think\facade\Redis;

class ActivityService
{
    /**
     * 报名活动
     */
    public function signup(int $activityId, int $userId, array $data): array
    {
        $activity = Db::name('activity')->where('id', $activityId)->find();
        if (!$activity || $activity['status'] != 1) {
            throw new \Exception('活动不存在或已结束');
        }

        if (strtotime($activity['signup_deadline']) < time()) {
            throw new \Exception('报名已截止');
        }

        // 检查是否已报名
        $exists = Db::name('activity_signup')
            ->where('activity_id', $activityId)
            ->where('user_id', $userId)
            ->count();
        if ($exists) {
            throw new \Exception('您已报名该活动');
        }

        // 人数限制的原子扣减
        if ($activity['max_participants'] > 0) {
            $key = "activity:quota:{$activityId}";
            if (Redis::exists($key)) {
                $remain = Redis::decr($key);
                if ($remain < 0) {
                    Redis::incr($key);
                    throw new \Exception('报名人数已满');
                }
            }
        }

        Db::startTrans();
        try {
            Db::name('activity_signup')->insert([
                'activity_id' => $activityId,
                'user_id' => $userId,
                'name' => $data['name'],
                'phone' => $data['phone'],
                'remark' => $data['remark'] ?? '',
                'status' => $activity['need_review'] ? 0 : 1,
                'created_at' => date('Y-m-d H:i:s'),
            ]);

            Db::commit();

            return [
                'need_review' => (bool)$activity['need_review'],
                'message' => $activity['need_review']
                    ? '报名成功,等待审核'
                    : '报名成功',
            ];

        } catch (\Exception $e) {
            Db::rollback();
            if ($activity['max_participants'] > 0) {
                Redis::incr("activity:quota:{$activityId}");
            }
            throw $e;
        }
    }

    /**
     * 签到(扫码或输入签到码)
     */
    public function checkIn(int $activityId, int $userId): bool
    {
        $signup = Db::name('activity_signup')
            ->where('activity_id', $activityId)
            ->where('user_id', $userId)
            ->where('status', 1)
            ->find();

        if (!$signup) {
            throw new \Exception('未找到有效的报名记录');
        }

        if ($signup['checked_in']) {
            throw new \Exception('您已签到');
        }

        Db::name('activity_signup')
            ->where('id', $signup['id'])
            ->update([
                'checked_in' => 1,
                'checked_in_at' => date('Y-m-d H:i:s'),
            ]);

        return true;
    }
}

4.2 商城模块的集成

物业系统的商城模块与独立商城系统类似,但有两个特殊点:商品主要面向本小区业主,配送通常是到物业中心自提或送货上门。

sql
CREATE TABLE `mall_order` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `order_no` varchar(32) NOT NULL,
  `user_id` int(11) NOT NULL,
  `community_id` int(11) NOT NULL,
  `property_id` int(11) DEFAULT NULL COMMENT '关联房产,用于送货',
  `total_amount` decimal(10,2) NOT NULL,
  `pay_amount` decimal(10,2) NOT NULL,
  `delivery_type` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0=自提 1=送货上门',
  `delivery_address` varchar(255) DEFAULT NULL,
  `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0=待付款 1=已付款 2=配送中 3=已完成 4=已取消',
  `paid_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_community` (`community_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

商城模块的工程实现与标准商城系统类似,此处不再展开。关键差异在于:配送范围限定在本小区,订单创建时校验用户的 property_id 是否属于该 community_id。

五、CentOS 部署实践

5.1 环境配置

物业管理系统运行在 CentOS 环境中,需要配置以下组件。

bash
# 1. 安装基础软件
yum install -y epel-release
yum install -y nginx mysql-server redis php php-fpm php-mysql \
    php-redis php-gd php-curl php-mbstring php-xml

# 2. 启动服务
systemctl start nginx
systemctl start mysqld
systemctl start redis
systemctl start php-fpm

# 3. 设置开机自启
systemctl enable nginx mysqld redis php-fpm

5.2 Nginx 配置

nginx
server {
    listen 80;
    server_name property.example.com;
    root /var/www/property/public;
    index index.php index.html;

    # ThinkPHP 伪静态
    location / {
        if (!-e $request_filename) {
            rewrite ^(.*)$ /index.php?s=$1 last;
            break;
        }
    }

    # PHP 处理
    location ~ \.php$ {
        fastcgi_pass unix:/run/php-fpm/www.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    # 禁止访问敏感目录
    location ~ ^/(runtime|vendor|\.env) {
        deny all;
    }

    # 静态资源缓存
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # 上传文件大小限制
    client_max_body_size 20m;
}

5.3 定时任务

物业系统需要多个定时任务来处理账单生成、逾期提醒、工单超时等。

bash
# 编辑 crontab
crontab -e

# 每月1日生成物业费账单
0 2 1 * * cd /var/www/property && php think bill:generate --period=$(date +\%Y-\%m)

# 每天上午9点发送缴费提醒
0 9 * * * cd /var/www/property && php think bill:remind

# 每小时检查超时工单
0 * * * * cd /var/www/property && php think repair:check_timeout

# 每天凌晨清理过期数据
0 3 * * * cd /var/www/property && php think system:cleanup

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

5.4 数据备份

物业系统的数据涉及业主信息、缴费记录、工单记录,备份至关重要。

bash
#!/bin/bash
# /opt/backup/property_backup.sh

BACKUP_DIR=/opt/backup/property
DATE=$(date +%Y%m%d)
DB_NAME=property

# 备份数据库
mysqldump -u root -p"$DB_PASSWORD" $DB_NAME | gzip > \
    "$BACKUP_DIR/db_${DATE}.sql.gz"

# 备份上传文件
tar -czf "$BACKUP_DIR/uploads_${DATE}.tar.gz" \
    /var/www/property/public/uploads

# 删除7天前的备份
find $BACKUP_DIR -name "*.gz" -mtime +7 -delete

echo "Backup completed: $DATE"

六、边界与张力

物业管理系统在工程实践中,有几个需要清醒认识的边界。

第一,数据隐私的敏感性。 物业系统存储业主的姓名、电话、房号、缴费记录,这些是高度敏感的个人信息。系统需要做数据脱敏(如日志中不记录完整手机号)、访问审计(记录谁查看了哪些业主数据)、以及符合《个人信息保护法》的要求。

第二,线下流程与线上流程的耦合。 物业管理的很多流程是线下的:维修人员实际去业主家维修、业主实际到物业中心缴费、活动实际在小区广场举办。系统需要支持这些线下环节的记录和确认(如维修完成需要业主确认、线下缴费需要人工录入)。

第三,多小区 SaaS 化的挑战。 如果系统要服务多个小区(SaaS 模式),数据隔离的要求比单小区更严格。不同小区的业主数据不能互相可见,不同小区的物业人员不能跨小区操作。这需要在数据模型层面引入 community_id 作为隔离维度。

第四,老旧小区的数字化适配。 很多老旧小区的业主以老年人为主,他们可能不熟悉小程序操作。系统需要提供简化版的界面、语音提示、以及线下人工协助的通道。这是产品层面的设计问题,但会影响技术方案的选择。

第五,与现有物业系统的集成。 很多小区已经使用了门禁系统、监控系统、电梯控制系统。新系统需要与这些系统集成,而不是替代它们。这要求系统提供开放的 API 和 webhook 机制。

结语:物业系统的工程本质是"多角色 + 多流程 + 强隐私"

UniApp 小区物业管理系统的核心工程挑战,可以归结为三个关键词。

多角色 意味着权限模型必须同时处理功能权限和数据权限。RBAC 解决功能权限,data_scope 解决数据权限,两者结合才能保证"业主只能看到自己的数据,物业人员只能看到本区域的数据"。

多流程 意味着系统要同时管理物业费缴纳、报修工单、活动报名、商城订单四条独立的业务链路。每条

本回答由 AI 生成,内容仅供参考,请仔细甄别
 
 
 
 
likeshop多商户saas无限开独立版带uniapp前段thinkphp框架后端(含种草社区等新插件) 生成一篇文章
 

LikeShop 多商户 SaaS 无限开独立版:从种草社区到多租户的工程架构

引子:一张截图暴露的架构复杂度

打开 LikeShop 多商户 SaaS 无限开独立版的小程序,首页看到的是:轮播图、十宫格营销入口(积分商城、砍价、超级拼团、签到、秒杀、领券、榜单、推荐、收藏、资讯)、积分商城横幅、商城头条、活动专区(精选商品、新人福利)。底部导航是:首页、分类、种草社区、购物车、我的。

切换到"种草社区"标签页,看到的是:关注/发现双 tab、分类筛选(全部、数码、宠物、美食、家居、美妆、时尚)、双列瀑布流内容卡片(图文/视频混排)、每张卡片有关注按钮、点赞数、作者信息。

这两张截图背后,是一个需要同时处理多租户隔离、多端适配、内容社区、营销引擎的复合系统。LikeShop 的"无限开独立版"意味着它可以部署一次,服务无限个商户,每个商户拥有独立的小程序、独立的 H5、独立的 APP。这比普通的多商户商城复杂一个量级。

本文从 LikeShop 的 SaaS 多租户架构、UniApp 多端编译策略、种草社区的内容模型、营销插件的可插拔设计,到独立版部署的工程实践,展开这类系统的完整技术分析。

一、SaaS 无限开:多租户架构的核心设计

1.1 "无限开"的工程含义

"无限开独立版"这个描述包含两个关键约束:无限开 意味着商户数量没有硬性上限,系统需要水平扩展;独立版 意味着每个商户可以拥有独立的域名、独立的小程序、独立的品牌配置,但底层共享同一套代码和数据库。

这与"多商户商城"有本质区别。多商户商城是"一个商城里有多个店铺",所有店铺共享同一个前端入口。而"无限开独立版"是"一个系统生成多个独立的商城",每个商城有自己的前端、自己的用户体系、自己的商品。

这要求系统在数据模型层面引入租户(Tenant) 作为顶层隔离维度。

sql
-- 租户表:系统的顶层实体
CREATE TABLE `tenant` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `code` varchar(32) NOT NULL COMMENT '租户唯一标识',
  `name` varchar(100) NOT NULL COMMENT '商城名称',
  `domain` varchar(100) DEFAULT NULL COMMENT '独立域名',
  `mini_appid` varchar(64) DEFAULT NULL COMMENT '小程序 AppID',
  `mini_secret` varchar(128) DEFAULT NULL COMMENT '小程序 Secret',
  `logo` varchar(255) DEFAULT NULL,
  `theme` text COMMENT '主题配置JSON',
  `config` text COMMENT '业务配置JSON',
  `expire_at` datetime DEFAULT NULL COMMENT '到期时间',
  `status` tinyint(1) NOT NULL DEFAULT '1',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_code` (`code`),
  UNIQUE KEY `uk_domain` (`domain`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

每个业务表都带 tenant_id 字段,所有查询都通过框架层自动注入租户条件。

1.2 租户识别:五种入口

用户访问时,系统需要知道"这是哪个商户的商城"。LikeShop 支持五种识别方式。

第一,独立域名。 商户可以绑定自己的域名(如 shop-a.com),系统通过域名匹配租户。

第二,小程序 AppID。 每个商户有独立的小程序 AppID,小程序端请求携带 AppID,系统据此识别租户。

第三,路径前缀。 对于没有独立域名的商户,使用路径前缀(如 mall.example.com/shop-a/)。

第四,请求参数。 小程序通过 scene 参数传递租户标识。

第五,HTTP 头。 客户端在请求头中显式声明 X-Tenant-Code。

php
<?php
// app/common/middleware/TenantMiddleware.php

namespace app\common\middleware;

use think\facade\Db;
use think\Request;

class TenantMiddleware
{
    protected $except = [
        'api/tenant/*',
        'api/platform/*',
        'install/*',
    ];

    public function handle(Request $request, \Closure $next)
    {
        $path = $request->pathinfo();
        foreach ($this->except as $pattern) {
            if (fnmatch($pattern, $path)) {
                return $next($request);
            }
        }

        $tenant = $this->resolveTenant($request);
        if (!$tenant) {
            return json(['code' => 404, 'msg' => '商城不存在']);
        }

        if ($tenant['status'] != 1) {
            return json(['code' => 403, 'msg' => '商城已停用']);
        }

        if ($tenant['expire_at'] && strtotime($tenant['expire_at']) < time()) {
            return json(['code' => 403, 'msg' => '商城已到期']);
        }

        // 注入租户上下文
        app()->bind('tenant_id', $tenant['id']);
        app()->bind('tenant', $tenant);
        $request->tenantId = $tenant['id'];

        return $next($request);
    }

    protected function resolveTenant(Request $request): ?array
    {
        // 1. 域名
        $host = $request->host(true);
        $tenant = Db::name('tenant')->where('domain', $host)->find();
        if ($tenant) return $tenant;

        // 2. 小程序 AppID
        $appid = $request->header('X-Mini-Appid');
        if ($appid) {
            $tenant = Db::name('tenant')->where('mini_appid', $appid)->find();
            if ($tenant) return $tenant;
        }

        // 3. 请求头租户标识
        $code = $request->header('X-Tenant-Code');
        if ($code) {
            $tenant = Db::name('tenant')->where('code', $code)->find();
            if ($tenant) return $tenant;
        }

        // 4. 请求参数
        $code = $request->param('tenant_code');
        if ($code) {
            return Db::name('tenant')->where('code', $code)->find();
        }

        // 5. 路径前缀
        if (preg_match('#^shop/([a-z0-9]+)/#i', $request->pathinfo(), $m)) {
            return Db::name('tenant')->where('code', $m[1])->find();
        }

        return null;
    }
}

1.3 数据隔离的自动注入

租户识别之后,所有业务查询都需要自动带上 tenant_id 条件。ThinkPHP 可以通过模型基类和查询作用域实现这一层自动保护。

php
<?php
// app/common/model/BaseModel.php

namespace app\common\model;

use think\Model;

class BaseModel extends Model
{
    protected $tenantScoped = true;

    protected static function init()
    {
        // 查询时自动注入 tenant_id
        static::beforeQuery(function ($query) {
            if (static::$tenantScoped ?? true) {
                $tenantId = app('tenant_id');
                if ($tenantId) {
                    $query->where('tenant_id', $tenantId);
                }
            }
        });

        // 写入时自动填充 tenant_id
        static::beforeInsert(function ($model) {
            if (($model::$tenantScoped ?? true) && !$model->tenant_id) {
                $model->tenant_id = app('tenant_id');
            }
        });
    }
}

业务模型继承 BaseModel 后,读写操作自动带上租户条件,开发者不需要在每个查询中手动添加。

但需要注意:这种自动保护是"默认安全",不是"绝对安全" 。原生 SQL(Db::query())会绕过这层保护,需要代码审查和自动化测试来持续验证。

二、UniApp 多端编译:一套代码,无限个商城

2.1 多端编译的核心挑战

LikeShop 的 UniApp 前端需要编译为小程序、H5 和 APP。但"无限开"意味着同一个前端代码需要为不同的商户编译出不同的小程序。这不是简单的"一套代码多端运行",而是"一套代码 × N 个商户 × 3 个终端"。

工程上的解决方案是编译时注入租户配置。不同商户的小程序使用不同的 manifest.json 配置(AppID、名称、图标),但共享同一套页面代码。

javascript
// build/config.js — 编译配置

const tenants = {
  'shop-a': {
    name: 'A商城',
    appid: 'wx1111111111111111',
    apiBase: 'https://api.example.com/shop-a/',
    themeColor: '#fe2c55',
  },
  'shop-b': {
    name: 'B商城',
    appid: 'wx2222222222222222',
    apiBase: 'https://api.example.com/shop-b/',
    themeColor: '#1890ff',
  },
};

module.exports = tenants;

编译脚本根据 TENANT 环境变量选择对应的配置,动态生成 manifest.json 并注入到代码中。

bash
# 编译 A 商城的小程序
TENANT=shop-a npm run build:mp-weixin

# 编译 B 商城的小程序
TENANT=shop-b npm run build:mp-weixin

# 编译 A 商城的 H5
TENANT=shop-a npm run build:h5

2.2 运行时租户识别

对于 H5 端(一个域名服务多个商户),租户标识在运行时动态获取,而非编译时注入。

javascript
// utils/tenant.js

class TenantManager {
  constructor() {
    this.tenantCode = null;
    this.config = null;
  }

  async init() {
    // 1. 从小程序 AppID 或域名推断
    let code = this.detectFromEnvironment();

    // 2. 从 URL 参数获取
    if (!code) {
      code = this.getUrlParam('tenant');
    }

    // 3. 从本地缓存获取
    if (!code) {
      code = uni.getStorageSync('tenant_code');
    }

    if (!code) {
      throw new Error('无法识别租户');
    }

    this.tenantCode = code;

    // 4. 加载租户配置
    const res = await uni.request({
      url: `https://api.example.com/api/tenant/config?code=${code}`,
    });
    this.config = res.data.data;
    uni.setStorageSync('tenant_code', code);
    uni.setStorageSync('tenant_config', this.config);

    return this.config;
  }

  detectFromEnvironment() {
    // #ifdef MP-WEIXIN
    // 小程序端通过 AppID 识别
    return null; // 由编译时注入
    // #endif

    // #ifdef H5
    // H5 端通过域名识别
    const host = window.location.hostname;
    // 域名映射表,或调用后端 API 解析
    return null; // 由后端解析
    // #endif

    return null;
  }

  getUrlParam(name) {
    // #ifdef H5
    const params = new URLSearchParams(window.location.search);
    return params.get(name);
    // #endif
    return null;
  }
}

export const tenantManager = new TenantManager();

2.3 主题的动态切换

不同商户有不同的品牌色和视觉风格。LikeShop 通过 CSS 变量实现主题的动态切换。

scss
/* styles/theme.scss */

:root {
  --primary-color: #fe2c55;
  --primary-light: #ff6b8a;
  --text-primary: #333;
  --text-secondary: #666;
  --bg-page: #f5f5f5;
}

/* 主题类:运行时动态挂载到根元素 */
.theme-blue {
  --primary-color: #1890ff;
  --primary-light: #40a9ff;
}

.theme-green {
  --primary-color: #52c41a;
  --primary-light: #73d13d;
}
javascript
// 应用启动时设置主题
const config = uni.getStorageSync('tenant_config');
if (config && config.theme_color) {
  // #ifdef H5
  document.documentElement.style.setProperty('--primary-color', config.theme_color);
  // #endif

  // #ifdef MP-WEIXIN
  // 小程序端通过 setData 或全局样式类实现
  // #endif
}

三、种草社区:内容模型与推荐流

3.1 种草社区的内容模型

种草社区是 LikeShop 的核心差异化功能。它的数据模型与普通商城的内容模块不同,需要支持图文和视频混排、话题标签、用户关注关系。

sql
-- 种草内容表
CREATE TABLE `community_post` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `tenant_id` int(11) NOT NULL,
  `user_id` int(11) NOT NULL COMMENT '发布者',
  `type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1=图文 2=视频',
  `title` varchar(200) DEFAULT NULL,
  `content` text COMMENT '正文',
  `images` text COMMENT '图片列表JSON',
  `video_url` varchar(500) DEFAULT NULL,
  `video_cover` varchar(255) DEFAULT NULL,
  `topics` varchar(500) DEFAULT NULL COMMENT '话题标签,逗号分隔',
  `product_ids` text COMMENT '关联商品ID列表JSON',
  `view_count` int(11) NOT NULL DEFAULT '0',
  `like_count` int(11) NOT NULL DEFAULT '0',
  `collect_count` int(11) NOT NULL DEFAULT '0',
  `comment_count` int(11) NOT NULL DEFAULT '0',
  `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0=待审核 1=已发布 2=拒绝',
  `published_at` datetime DEFAULT NULL,
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_tenant_status` (`tenant_id`, `status`),
  KEY `idx_user` (`user_id`),
  KEY `idx_published` (`published_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 用户关注关系表
CREATE TABLE `user_follow` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `tenant_id` int(11) NOT NULL,
  `user_id` int(11) NOT NULL COMMENT '关注者',
  `target_id` int(11) NOT NULL COMMENT '被关注者',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_follow` (`tenant_id`, `user_id`, `target_id`),
  KEY `idx_target` (`target_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

3.2 关注流与发现流的推荐逻辑

种草社区的两个 tab——"关注"和"发现"——对应两套不同的推荐逻辑。

关注流 只展示用户关注的创作者的内容,按发布时间倒序。逻辑简单,但需要高效地查询"我关注的人发布的内容"。

发现流 是全平台的推荐流,需要混合多个来源:热门内容、新内容、基于兴趣的推荐。

php
<?php
// app/api/service/CommunityFeedService.php

namespace app\api\service;

use think\facade\Db;
use think\facade\Cache;

class CommunityFeedService
{
    /**
     * 关注流
     */
    public function followingFeed(int $tenantId, int $userId, int $page, int $pageSize): array
    {
        $followedIds = Db::name('user_follow')
            ->where('tenant_id', $tenantId)
            ->where('user_id', $userId)
            ->column('target_id');

        if (empty($followedIds)) {
            return ['list' => [], 'total' => 0];
        }

        $query = Db::name('community_post')
            ->where('tenant_id', $tenantId)
            ->where('status', 1)
            ->whereIn('user_id', $followedIds)
            ->order('published_at', 'desc');

        $total = $query->count();
        $list = $query->page($page, $pageSize)->select()->toArray();

        return $this->enrich($list, $userId), $total;
    }

    /**
     * 发现流:混合推荐
     */
    public function discoverFeed(int $tenantId, int $userId, int $page, int $pageSize): array
    {
        $cacheKey = "feed:discover:{$tenantId}:{$userId}:{$page}";
        $cached = Cache::get($cacheKey);
        if ($cached) {
            return $cached;
        }

        // 1. 热门内容(近7天,按点赞+收藏+评论加权)
        $hot = Db::name('community_post')
            ->where('tenant_id', $tenantId)
            ->where('status', 1)
            ->where('published_at', '>', date('Y-m-d H:i:s', strtotime('-7 days')))
            ->orderRaw('(like_count * 3 + collect_count * 5 + comment_count * 4) DESC')
            ->limit(20)
            ->select()
            ->toArray();

        // 2. 兴趣内容(基于用户历史互动的话题)
        $interestTopics = $this->getUserInterestTopics($tenantId, $userId);
        $interest = [];
        if (!empty($interestTopics)) {
            $conditions = array_map(function ($t) {
                return "FIND_IN_SET('{$t}', topics)";
            }, $interestTopics);
            $interest = Db::name('community_post')
                ->where('tenant_id', $tenantId)
                ->where('status', 1)
                ->whereRaw('(' . implode(' OR ', $conditions) . ')')
                ->order('published_at', 'desc')
                ->limit(15)
                ->select()
                ->toArray();
        }

        // 3. 新鲜内容(24小时内,扶持新创作者)
        $fresh = Db::name('community_post')
            ->where('tenant_id', $tenantId)
            ->where('status', 1)
            ->where('published_at', '>', date('Y-m-d H:i:s', strtotime('-24 hours')))
            ->where('view_count', '<', 500)
            ->order('published_at', 'desc')
            ->limit(10)
            ->select()
            ->toArray();

        // 4. 混合去重
        $merged = $this->mergeAndShuffle([$hot, $interest, $fresh]);

        // 5. 分页
        $offset = ($page - 1) * $pageSize;
        $result = array_slice($merged, $offset, $pageSize);

        $enriched = $this->enrich($result, $userId);
        Cache::set($cacheKey, $enriched, 300);

        return $enriched;
    }

    /**
     * 补充作者信息、关联商品、用户互动状态
     */
    protected function enrich(array $posts, int $userId): array
    {
        if (empty($posts)) {
            return [];
        }

        $postIds = array_column($posts, 'id');
        $authorIds = array_unique(array_column($posts, 'user_id'));

        // 批量加载作者
        $authors = Db::name('user')
            ->whereIn('id', $authorIds)
            ->field('id, nickname, avatar, is_verified')
            ->select()
            ->toArray();
        $authorMap = array_column($authors, null, 'id');

        // 批量加载当前用户的点赞状态
        $likedIds = Db::name('community_like')
            ->where('user_id', $userId)
            ->whereIn('post_id', $postIds)
            ->column('post_id');
        $likedMap = array_flip($likedIds);

        // 批量加载关联商品
        $postProducts = Db::name('community_post_product')
            ->alias('cpp')
            ->join('product p', 'cpp.product_id = p.id')
            ->whereIn('cpp.post_id', $postIds)
            ->field('cpp.post_id, p.id, p.name, p.cover, p.price')
            ->select()
            ->toArray();
        $productMap = [];
        foreach ($postProducts as $pp) {
            $productMap[$pp['post_id']][] = $pp;
        }

        foreach ($posts as &$post) {
            $post['author'] = $authorMap[$post['user_id']] ?? null;
            $post['is_liked'] = isset($likedMap[$post['id']]);
            $post['products'] = $productMap[$post['id']] ?? [];
        }

        return $posts;
    }

    protected function getUserInterestTopics(int $tenantId, int $userId): array
    {
        $cacheKey = "interest:topics:{$tenantId}:{$userId}";
        $cached = Cache::get($cacheKey);
        if ($cached) return $cached;

        $actions = Db::name('community_action')
            ->alias('ca')
            ->join('community_post cp', 'ca.post_id = cp.id')
            ->where('ca.user_id', $userId)
            ->where('ca.created_at', '>', date('Y-m-d H:i:s', strtotime('-30 days')))
            ->whereIn('ca.action', ['like', 'collect'])
            ->column('cp.topics');

        $topicCount = [];
        foreach ($actions as $topics) {
            foreach (explode(',', $topics ?? '') as $t) {
                $t = trim($t);
                if ($t) $topicCount[$t] = ($topicCount[$t] ?? 0) + 1;
            }
        }
        arsort($topicCount);
        $top = array_slice(array_keys($topicCount), 0, 8);
        Cache::set($cacheKey, $top, 3600);
        return $top;
    }

    protected function mergeAndShuffle(array $sources): array
    {
        $seen = [];
        $result = [];
        // 交错合并,避免某一类内容连续出现
        $maxLen = max(array_map('count', $sources));
        for ($i = 0; $i < $maxLen; $i++) {
            foreach ($sources as $source) {
                if (isset($source[$i]) && !isset($seen[$source[$i]['id']])) {
                    $seen[$source[$i]['id']] = true;
                    $result[] = $source[$i];
                }
            }
        }
        return $result;
    }
}

这个推荐服务的核心设计是多源混合 + 交错排序。热门、兴趣、新鲜三类内容按"轮询"方式交错排列,而不是简单拼接。这避免了"前20条全是热门内容,用户看不到新鲜内容"的问题。

四、营销插件的可插拔设计

4.1 插件化架构

LikeShop 的营销功能(拼团、砍价、秒杀、签到、积分商城等)以"插件"形式组织。每个插件有独立的目录、独立的配置、独立的路由和模板。这种设计允许商户按需启用插件,也允许开发者独立开发和分发插件。

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

namespace app\common\service;

use think\facade\Db;

class PluginService
{
    protected $pluginPath;

    public function __construct()
    {
        $this->pluginPath = app()->getRootPath() . 'plugins/';
    }

    /**
     * 获取所有已安装的插件
     */
    public function installed(): array
    {
        $dirs = glob($this->pluginPath . '*', GLOB_ONLYDIR);
        $plugins = [];
        foreach ($dirs as $dir) {
            $configFile = $dir . '/config.json';
            if (file_exists($configFile)) {
                $plugins[] = json_decode(file_get_contents($configFile), true);
            }
        }
        return $plugins;
    }

    /**
     * 获取租户启用的插件
     */
    public function enabledForTenant(int $tenantId): array
    {
        $rows = Db::name('tenant_plugin')
            ->where('tenant_id', $tenantId)
            ->where('status', 1)
            ->select()
            ->toArray();

        $codes = array_column($rows, 'plugin_code');

        return array_filter($this->installed(), function ($p) use ($codes) {
            return in_array($p['code'], $codes);
        });
    }

    /**
     * 启用插件
     */
    public function enable(int $tenantId, string $code): bool
    {
        $exists = Db::name('tenant_plugin')
            ->where('tenant_id', $tenantId)
            ->where('plugin_code', $code)
            ->find();

        if ($exists) {
            Db::name('tenant_plugin')
                ->where('id', $exists['id'])
                ->update(['status' => 1, 'updated_at' => date('Y-m-d H:i:s')]);
        } else {
            Db::name('tenant_plugin')->insert([
                'tenant_id' => $tenantId,
                'plugin_code' => $code,
                'status' => 1,
                'config' => '{}',
                'created_at' => date('Y-m-d H:i:s'),
            ]);
        }

        // 执行插件的安装钩子
        $this->runHook($code, 'onEnable', $tenantId);
        return true;
    }

    protected function runHook(string $code, string $hook, int $tenantId): void
    {
        $hookFile = $this->pluginPath . $code . '/hooks.php';
        if (file_exists($hookFile)) {
            $hooks = include $hookFile;
            if (isset($hooks[$hook]) && is_callable($hooks[$hook])) {
                call_user_func($hooks[$hook], $tenantId);
            }
        }
    }
}

4.2 插件的动态路由注册

插件的路由不是硬编码在主路由文件中,而是在插件启用时动态注册。这使得插件的启用/禁用不需要修改主代码。

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

namespace app\common\service;

use think\facade\Route;

class PluginRouteService
{
    /**
     * 为租户注册启用的插件路由
     */
    public function registerForTenant(int $tenantId): void
    {
        $plugins = (new PluginService())->enabledForTenant($tenantId);

        foreach ($plugins as $plugin) {
            $routeFile = app()->getRootPath() . "plugins/{$plugin['code']}/route.php";
            if (file_exists($routeFile)) {
                // 插件路由带租户前缀,避免冲突
                Route::group("plugin/{$plugin['code']}", function () use ($routeFile) {
                    include $routeFile;
                });
            }
        }
    }
}

插件化的工程意义在于:营销功能的增加不需要修改核心代码。商户启用"超级拼团"插件时,系统只加载该插件的路由、模型和模板,不启用时完全不加载。这既降低了代码耦合,也减少了不必要的性能开销。

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

5.1 环境要求

LikeShop 独立版的后端基于 ThinkPHP 框架,前端基于 UniApp。推荐环境如下。

text
操作系统:CentOS 7+ / Ubuntu 20.04+
Web 服务器:Nginx 1.20+
PHP:7.4 / 8.0(需 redis、fileinfo、curl、gd、pdo_mysql、bcmath 扩展)
数据库:MySQL 5.7+ / 8.0
缓存:Redis 6.0+
Node.js:16+(用于前端编译)

5.2 后端部署

bash
# 1. 拉取代码
cd /var/www
git clone https://github.com/likeshop/likeshop.git
cd likeshop

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

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

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

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

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

5.3 前端编译

bash
cd /var/www/likeshop/uni-app

# 安装依赖
npm install

# 配置 API 地址和租户标识
vim config/index.js

# 编译 H5
npm run build:h5

# 编译微信小程序
npm run build:mp-weixin

# 编译 APP
npm run build:app

# 编译后的 H5 文件复制到后端 public 目录
cp -r dist/build/h5/* ../public/h5/

5.4 Nginx 配置

nginx
server {
    listen 80;
    server_name your-domain.com;
    root /var/www/likeshop/public;
    index index.php index.html;

    # 主应用
    location / {
        if (!-e $request_filename) {
            rewrite ^(.*)$ /index.php?s=$1 last;
        }
    }

    # PHP 处理
    location ~ \.php$ {
        fastcgi_pass unix:/run/php-fpm/www.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    # H5 端
    location /h5/ {
        alias /var/www/likeshop/public/h5/;
        try_files $uri $uri/ /h5/index.html;
    }

    # 禁止访问敏感目录
    location ~ ^/(runtime|vendor|\.env|plugins/.*/\.git) {
        deny all;
    }

    client_max_body_size 50m;
}

5.5 定时任务与队列

bash
# 编辑 crontab
crontab -e

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

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

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

# 种草内容定时发布(每分钟)
* * * * * cd /var/www/likeshop && php think community:publish_scheduled

# 每日数据统计(凌晨2点)
0 2 * * * cd /var/www/likeshop && php think stats:daily

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

六、边界与张力

LikeShop 多商户 SaaS 无限开独立版在工程实践中,有几个需要清醒认识的边界。

第一,无限开的资源天花板。 "无限开"是商业描述,不是技术现实。每个租户都占用数据库连接、Redis 缓存、文件存储和 CPU 时间。当租户数量达到数百个时,单台服务器的资源会成为瓶颈。系统需要支持水平扩展(数据库读写分离、Redis 集群、对象存储),但这会增加部署复杂度。

第二,小程序 AppID 的管理复杂度。 每个商户需要自己的小程序 AppID,这意味着每个商户需要单独注册小程序、单独提交审核。系统可以提供"一键生成小程序"的辅助工具,但无法绕过微信的审核流程。

第三,种草社区的审核成本。 UGC 内容需要审核,包括机器审核(鉴黄、暴恐、涉政)和人工审核。对于多租户系统,审核策略需要按租户配置——某些商户可能允许更宽松的内容,某些商户需要更严格的审核。

第四,插件生态的质量控制。 插件化架构允许第三方开发者开发和分发插件,但插件的质量参差不齐。系统需要提供插件审核机制、版本管理、依赖检查和安全扫描。

第五,数据迁移与租户隔离的冲突。 当商户从"共享版"升级到"独立版"时,需要将其数据从共享数据库中迁移到独立部署中。这个迁移过程需要保证数据完整性,且不能影响其他租户的正常运行。

结语:LikeShop 的工程本质是"多租户 + 多端 + 可插拔"

LikeShop 多商户 SaaS 无限开独立版的核心工程挑战,可以归结为三个关键词。

多租户 意味着系统需要在数据层、配置层、编译层都支持租户隔离。域名识别、AppID 识别、路径识别、参数识别——五种租户识别方式的组合,覆盖了从小程序到 H5 到 APP 的所有入口。

多端 意味着同一套业务逻辑需要在 UniApp 的编译目标上正确运行。小程序有微信沙箱限制,H5 有浏览器兼容性问题,APP 有原生能力差异。条件编译和运行时适配层是解决这些差异的核心工具。

可插拔 意味着营销功能以插件形式组织,按租户启用。拼团、砍价、秒杀、签到、积分商城——每个插件有独立的路由、模型和模板,启用时动态加载,禁用时完全不加载。这既降低了代码耦合,也给了商户灵活的功能组合能力。

这套系统的价值,最终体现在一个朴素的判断上:一个商户从注册到拥有自己的小程序商城,需要多长时间? 如果答案是"几分钟",那它就是一个合格的 SaaS 系统;如果答案是"几天",那它只是一个多商户商城。LikeShop 的"无限开独立版"定位,正是朝着"几分钟"这个目标设计的。

 
 
Shopro商城高级版,uniapp多平台移动商城(微信公众号、微信小程序、H5网页、Android-App、IOS-App购物商城) 主要功能:登录、注册、签到、富文本、分类、购物车、筛选、地址、商品评论、积分商城、拼团、秒杀、多规格、全端分享、路由拦截、分享海报、多支付方式、订单管理、用户信息等等。 生成一篇文章
 

Shopro 商城高级版:UniApp 多平台移动商城的工程架构与功能实现

引子:功能清单背后的工程组织问题

登录、注册、签到、富文本、分类、购物车、筛选、地址、商品评论、积分商城、拼团、秒杀、多规格、全端分享、路由拦截、分享海报、多支付方式、订单管理、用户信息——当这些功能被列在同一个产品描述中时,它代表的不是一个简单的功能堆叠,而是一个需要精心组织的工程系统。

每一个功能都有其技术复杂度。多规格涉及 SKU 的组合与库存管理,拼团涉及成团状态与超时退款,秒杀涉及高并发下的库存扣减,路由拦截涉及权限与登录态的全局管理,分享海报涉及 Canvas 绘制与跨端差异。这些功能不是孤立的,它们共享用户体系、订单体系、支付体系和消息体系。

Shopro 商城高级版基于 UniApp,支持微信公众号、微信小程序、H5 网页、Android App、iOS App 五个平台。这五个平台的差异进一步放大了工程复杂度:公众号和小程序运行在微信生态中,可以调用微信登录和微信支付;H5 运行在浏览器中,支付需要跳转;App 运行在原生容器中,需要集成原生支付 SDK。

本文从多平台架构的适配策略、多规格商品的数据模型、拼团与秒杀的并发控制、路由拦截与登录态管理、分享海报的跨端实现,到多支付方式的统一抽象,展开这类系统的完整技术分析。

一、多平台架构:五个终端的统一与分化

1.1 五端的本质差异

Shopro 支持的五个平台,在技术能力上的差异可以归纳为三个维度。

运行环境。 微信公众号和微信小程序运行在微信的 JavaScript 引擎中,受微信沙箱限制。H5 运行在浏览器中,有完整的 DOM 和 BOM。App 运行在原生容器中(UniApp 使用 weex 或原生渲染),可以调用设备原生能力。

支付能力。 微信公众号使用 JSAPI 支付,微信小程序使用 wx.requestPayment,H5 使用微信 H5 支付或支付宝网页支付,App 使用微信/支付宝原生 SDK。四种支付方式的参数构造和回调处理完全不同。

登录能力。 微信公众号使用网页授权获取 openid,微信小程序使用 wx.login 获取 code,H5 使用手机号验证码或账号密码,App 使用手机号验证码或第三方登录(微信、Apple)。

这些差异决定了:业务逻辑层可以完全共享,但能力调用层必须分平台适配。

1.2 统一的能力抽象层

Shopro 的工程实践是:在业务代码和平台 API 之间建立一个能力抽象层。业务代码调用统一的接口,抽象层根据平台分发到具体的实现。

javascript
// utils/platform/index.js — 平台能力抽象层

class PlatformAdapter {
  /**
   * 登录:获取登录凭证
   */
  async getLoginCredential() {
    // #ifdef MP-WEIXIN
    return this.getWechatMiniCredential();
    // #endif

    // #ifdef H5
    if (this.isWechatBrowser()) {
      return this.getWechatOAuthCredential();
    }
    return this.getH5Credential();
    // #endif

    // #ifdef APP-PLUS
    return this.getAppCredential();
    // #endif
  }

  // #ifdef MP-WEIXIN
  getWechatMiniCredential() {
    return new Promise((resolve, reject) => {
      uni.login({
        provider: 'weixin',
        success: (res) => resolve({ type: 'wx_code', code: res.code }),
        fail: reject,
      });
    });
  }
  // #endif

  // #ifdef H5
  isWechatBrowser() {
    return /MicroMessenger/i.test(navigator.userAgent);
  }

  getWechatOAuthCredential() {
    // 跳转微信授权页,回调后从 URL 获取 code
    const redirectUri = encodeURIComponent(window.location.href);
    const appid = this.getConfig('wechat_oa_appid');
    window.location.href = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appid}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_userinfo&state=STATE#wechat_redirect`;
    return new Promise(() => {}); // 页面跳转,不返回
  }

  getH5Credential() {
    // H5 使用手机号验证码登录
    return Promise.resolve({ type: 'h5', needSms: true });
  }
  // #endif

  // #ifdef APP-PLUS
  getAppCredential() {
    return Promise.resolve({ type: 'app', needSms: true });
  }
  // #endif

  /**
   * 支付:统一支付接口
   */
  async pay(orderNo, payType) {
    // 1. 向后端请求支付参数
    const res = await uni.request({
      url: '/api/pay/create',
      method: 'POST',
      data: { order_no: orderNo, pay_type: payType },
    });

    const params = res.data.data;

    // 2. 根据平台调用对应的支付
    // #ifdef MP-WEIXIN
    if (payType === 'wechat') {
      return this.payWechatMini(params);
    }
    // #endif

    // #ifdef H5
    if (payType === 'wechat') {
      return this.payWechatH5(params);
    }
    if (payType === 'alipay') {
      return this.payAlipayH5(params);
    }
    // #endif

    // #ifdef APP-PLUS
    if (payType === 'wechat') {
      return this.payWechatApp(params);
    }
    if (payType === 'alipay') {
      return this.payAlipayApp(params);
    }
    // #endif

    throw new Error('当前平台不支持该支付方式');
  }

  // #ifdef MP-WEIXIN
  payWechatMini(params) {
    return new Promise((resolve, reject) => {
      uni.requestPayment({
        provider: 'wxpay',
        timeStamp: params.timeStamp,
        nonceStr: params.nonceStr,
        package: params.package,
        signType: params.signType,
        paySign: params.paySign,
        success: resolve,
        fail: reject,
      });
    });
  }
  // #endif

  // #ifdef H5
  payWechatH5(params) {
    // H5 微信支付:跳转到 mweb_url
    window.location.href = params.mweb_url;
    return new Promise(() => {});
  }

  payAlipayH5(params) {
    // H5 支付宝支付:跳转到支付宝收银台
    window.location.href = params.pay_url;
    return new Promise(() => {});
  }
  // #endif

  // #ifdef APP-PLUS
  payWechatApp(params) {
    return new Promise((resolve, reject) => {
      uni.requestPayment({
        provider: 'wxpay',
        orderInfo: params,
        success: resolve,
        fail: reject,
      });
    });
  }

  payAlipayApp(params) {
    return new Promise((resolve, reject) => {
      uni.requestPayment({
        provider: 'alipay',
        orderInfo: params.orderInfo,
        success: resolve,
        fail: reject,
      });
    });
  }
  // #endif
}

export const platform = new PlatformAdapter();

这个抽象层的核心价值是:业务代码只需要调用 platform.pay(orderNo, 'wechat'),不需要关心当前是哪个平台。 平台差异被封装在抽象层内部,通过条件编译分发到具体实现。

二、多规格商品:SKU 的数据模型与选择逻辑

2.1 SKU 的数据模型

多规格商品是商城系统中最复杂的数据模型之一。一个商品可能有多个规格维度(颜色、尺寸、版本),每个维度的组合对应一个独立的 SKU,每个 SKU 有自己的价格、库存和图片。

sql
-- 商品表
CREATE TABLE `product` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(200) NOT NULL,
  `cover` varchar(255) NOT NULL,
  `price` decimal(10,2) NOT NULL COMMENT '最低价格(展示用)',
  `market_price` decimal(10,2) DEFAULT NULL,
  `stock` int(11) NOT NULL DEFAULT '0' COMMENT '总库存',
  `sales` int(11) NOT NULL DEFAULT '0',
  `spec_type` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0=单规格 1=多规格',
  `specs` text COMMENT '规格定义JSON',
  `status` tinyint(1) NOT NULL DEFAULT '1',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- SKU 表
CREATE TABLE `product_sku` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `product_id` int(11) NOT NULL,
  `sku_code` varchar(64) DEFAULT NULL COMMENT 'SKU 编码',
  `spec_values` varchar(255) NOT NULL COMMENT '规格值组合,如 "红色, XL, 标准版"',
  `spec_ids` varchar(100) NOT NULL COMMENT '规格值ID组合,如 "1_3_5"',
  `price` decimal(10,2) NOT NULL,
  `market_price` decimal(10,2) DEFAULT NULL,
  `stock` int(11) NOT NULL DEFAULT '0',
  `sales` int(11) NOT NULL DEFAULT '0',
  `image` varchar(255) DEFAULT NULL COMMENT 'SKU 专属图片',
  `status` tinyint(1) NOT NULL DEFAULT '1',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_product` (`product_id`),
  KEY `idx_spec_ids` (`spec_ids`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

specs 字段的 JSON 结构定义了规格维度和规格值:

json
{
  "dimensions": [
    {
      "name": "颜色",
      "values": [
        {"id": 1, "name": "红色"},
        {"id": 2, "name": "蓝色"}
      ]
    },
    {
      "name": "尺寸",
      "values": [
        {"id": 3, "name": "S"},
        {"id": 4, "name": "M"},
        {"id": 5, "name": "L"}
      ]
    }
  ]
}

spec_ids 字段用下划线连接规格值 ID,如 1_3 表示"红色 + S"。这个设计使得 SKU 的查询可以通过精确匹配 spec_ids 完成。

2.2 规格选择的可用性判断

多规格选择的核心交互是:用户选择某个规格值时,系统需要判断"这个组合是否有库存",并动态禁用不可选的规格值。

javascript
// utils/sku.js

/**
 * 根据已选规格值,判断某个规格值是否可选
 */
export function isSpecValueAvailable(skus, dimensions, selected, dimIndex, valueId) {
  // 构造一个"假设选中该值"的选择状态
  const testSelection = { ...selected, [dimIndex]: valueId };

  // 检查是否存在匹配该组合且有库存的 SKU
  return skus.some(sku => {
    if (sku.stock <= 0) return false;

    const skuSpecIds = sku.spec_ids.split('_').map(Number);

    // 检查已选的每个维度是否与 SKU 匹配
    for (let i = 0; i < dimensions.length; i++) {
      if (testSelection[i] !== undefined && testSelection[i] !== null) {
        if (skuSpecIds[i] !== testSelection[i]) {
          return false;
        }
      }
    }

    return true;
  });
}

/**
 * 根据已选规格,查找对应的 SKU
 */
export function findSku(skus, dimensions, selected) {
  // 所有维度都已选择
  const allSelected = Object.keys(selected).length === dimensions.length;
  if (!allSelected) return null;

  const specIds = dimensions.map((_, i) => selected[i]).join('_');
  return skus.find(sku => sku.spec_ids === specIds) || null;
}

/**
 * 计算规格选择的状态(用于渲染禁用态)
 */
export function computeSpecState(skus, dimensions, selected) {
  const state = {};

  dimensions.forEach((dim, dimIndex) => {
    state[dimIndex] = {};
    dim.values.forEach(value => {
      state[dimIndex][value.id] = isSpecValueAvailable(
        skus, dimensions, selected, dimIndex, value.id
      );
    });
  });

  return state;
}

这个 SKU 选择逻辑的核心是"假设选中 + 匹配检查"。对于每个规格值,系统假设它被选中,然后检查是否存在匹配该组合且有库存的 SKU。如果存在,该规格值可选;否则禁用。

这个算法的时间复杂度是 O(维度数 × 规格值数 × SKU数)。对于典型的商品(3 个维度、每个维度 5 个值、125 个 SKU),计算量是可以接受的。但如果 SKU 数量很大(如 1000+),需要预计算可用性矩阵。

三、拼团与秒杀:并发控制与状态管理

3.1 拼团的成团逻辑

拼团的核心是"成团条件"的实时判断。当参团人数达到要求时,所有成员的订单状态从"待成团"变为"已成团"。

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

namespace app\common\service;

use think\Db;
use think\facade\Redis;

class GroupBuyService
{
    const STATUS_PENDING = 1;   // 待成团
    const STATUS_SUCCESS = 2;   // 已成团
    const STATUS_FAILED = 3;    // 已失败

    /**
     * 参团
     */
    public function join(int $groupId, int $userId, int $quantity = 1): array
    {
        $group = Db::name('group')->where('id', $groupId)->find();
        if (!$group || $group['status'] != self::STATUS_PENDING) {
            throw new \Exception('拼团不存在或已结束');
        }

        if (strtotime($group['expire_at']) < time()) {
            throw new \Exception('拼团已过期');
        }

        // 检查是否已参与
        $exists = Db::name('group_member')
            ->where('group_id', $groupId)
            ->where('user_id', $userId)
            ->count();
        if ($exists) {
            throw new \Exception('您已参与该拼团');
        }

        // Redis 原子扣减库存
        $stockKey = "group:stock:{$groupId}";
        $remain = Redis::decrBy($stockKey, $quantity);
        if ($remain < 0) {
            Redis::incrBy($stockKey, $quantity);
            throw new \Exception('库存不足');
        }

        Db::startTrans();
        try {
            // 记录参团
            Db::name('group_member')->insert([
                'group_id' => $groupId,
                'user_id' => $userId,
                'quantity' => $quantity,
                'created_at' => date('Y-m-d H:i:s'),
            ]);

            // 更新参团人数(悲观锁防止并发成团判断冲突)
            $group = Db::name('group')
                ->where('id', $groupId)
                ->lock(true)
                ->find();

            $newCount = $group['joined_count'] + $quantity;
            Db::name('group')
                ->where('id', $groupId)
                ->update(['joined_count' => $newCount]);

            // 检查是否成团
            $isSuccess = $newCount >= $group['group_size'];
            if ($isSuccess && $group['status'] == self::STATUS_PENDING) {
                Db::name('group')
                    ->where('id', $groupId)
                    ->update([
                        'status' => self::STATUS_SUCCESS,
                        'success_at' => date('Y-m-d H:i:s'),
                    ]);
                $this->onSuccess($groupId);
            }

            Db::commit();

            return [
                'joined_count' => $newCount,
                'group_size' => $group['group_size'],
                'status' => $isSuccess ? self::STATUS_SUCCESS : self::STATUS_PENDING,
            ];

        } catch (\Exception $e) {
            Db::rollback();
            Redis::incrBy($stockKey, $quantity);
            throw $e;
        }
    }

    /**
     * 成团后的处理
     */
    protected function onSuccess(int $groupId): void
    {
        // 1. 更新所有成员的订单状态
        Db::name('order')
            ->where('group_id', $groupId)
            ->where('status', 'pending_group')
            ->update(['status' => 'paid']);

        // 2. 发送成团通知
        $members = Db::name('group_member')
            ->where('group_id', $groupId)
            ->select();
        foreach ($members as $member) {
            $this->notify($member['user_id'], '恭喜,您的拼团已成功!');
        }
    }

    /**
     * 超时检查(定时任务)
     */
    public function checkExpire(): void
    {
        $expired = Db::name('group')
            ->where('status', self::STATUS_PENDING)
            ->where('expire_at', '<', date('Y-m-d H:i:s'))
            ->limit(100)
            ->select();

        foreach ($expired as $group) {
            Db::startTrans();
            try {
                Db::name('group')
                    ->where('id', $group['id'])
                    ->update(['status' => self::STATUS_FAILED]);

                // 触发自动退款
                Db::name('order')
                    ->where('group_id', $group['id'])
                    ->where('status', 'pending_group')
                    ->update(['status' => 'refunding']);

                Db::commit();
            } catch (\Exception $e) {
                Db::rollback();
                \think\facade\Log::error('拼团超时处理失败', [
                    'group_id' => $group['id'],
                    'error' => $e->getMessage(),
                ]);
            }
        }
    }

    protected function notify(int $userId, string $message): void
    {
        // 发送通知
    }
}

3.2 秒杀的三层保护

秒杀是并发最高的场景。Shopro 的秒杀实现采用三层保护: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' => '您已参与过本次秒杀'];
        }

        // 第二层:原子扣减库存
        $stock = Redis::get($stockKey);
        if ($stock === false || $stock <= 0) {
            return ['code' => 400, 'msg' => '商品已售罄'];
        }

        $remain = Redis::decr($stockKey);
        if ($remain < 0) {
            Redis::incr($stockKey);
            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;
        }
    }
}

前端在提交秒杀请求后,不直接返回订单结果,而是返回"排队中"。 用户在前端看到一个排队页面,通过轮询或 WebSocket 获取排队结果。这避免了高并发请求直接打到数据库。

四、路由拦截与登录态管理

4.1 路由拦截的实现

Shopro 的路由拦截需要处理三种场景:需要登录才能访问的页面、需要特定权限才能访问的页面、需要重定向的页面(如已登录用户不应再看到登录页)。

UniApp 没有原生的路由守卫,需要通过 uni.addInterceptor 实现。

javascript
// utils/router/interceptor.js

const whiteList = [
  '/pages/index/index',
  '/pages/goods/detail',
  '/pages/goods/list',
  '/pages/login/login',
  '/pages/login/register',
];

const needAuth = [
  '/pages/order/create',
  '/pages/order/list',
  '/pages/user/profile',
  '/pages/user/address',
  '/pages/cart/index',
];

export function setupRouterInterceptor() {
  // 拦截 navigateTo
  uni.addInterceptor('navigateTo', {
    invoke(args) {
      return checkRoute(args.url);
    },
  });

  // 拦截 redirectTo
  uni.addInterceptor('redirectTo', {
    invoke(args) {
      return checkRoute(args.url);
    },
  });

  // 拦截 switchTab
  uni.addInterceptor('switchTab', {
    invoke(args) {
      return checkRoute(args.url);
    },
  });
}

function checkRoute(url) {
  const path = url.split('?')[0];

  // 白名单直接放行
  if (whiteList.some(w => path.startsWith(w))) {
    return true;
  }

  // 需要登录的页面
  if (needAuth.some(a => path.startsWith(a))) {
    if (!isLoggedIn()) {
      // 重定向到登录页,并记录来源
      uni.navigateTo({
        url: `/pages/login/login?redirect=${encodeURIComponent(url)}`,
      });
      return false;
    }
  }

  return true;
}

function isLoggedIn() {
  const token = uni.getStorageSync('token');
  if (!token) return false;

  // 检查 token 是否过期(本地检查,不请求后端)
  const expireAt = uni.getStorageSync('token_expire_at');
  if (expireAt && expireAt < Date.now()) {
    uni.removeStorageSync('token');
    return false;
  }

  return true;
}

4.2 Token 的自动刷新

登录态管理的另一个关键问题是 Token 过期后的自动刷新。当请求返回 401 时,系统需要自动刷新 Token 并重试原请求,而不是直接跳转到登录页。

javascript
// utils/request.js

let isRefreshing = false;
let pendingRequests = [];

async function request(options) {
  const token = uni.getStorageSync('token');
  if (token) {
    options.header = options.header || {};
    options.header['Authorization'] = `Bearer ${token}`;
  }

  return new Promise((resolve, reject) => {
    uni.request({
      ...options,
      success: async (res) => {
        // Token 过期,尝试刷新
        if (res.statusCode === 401) {
          if (!isRefreshing) {
            isRefreshing = true;
            try {
              await refreshToken();
              // 重试所有等待的请求
              pendingRequests.forEach(cb => cb());
              pendingRequests = [];
            } catch (e) {
              // 刷新失败,跳转登录
              uni.navigateTo({ url: '/pages/login/login' });
            } finally {
              isRefreshing = false;
            }
          }

          // 将当前请求加入等待队列
          return new Promise((resolveRetry) => {
            pendingRequests.push(async () => {
              const retryRes = await request(options);
              resolveRetry(retryRes);
            });
          }).then(resolve).catch(reject);
        }

        if (res.statusCode === 200) {
          resolve(res.data);
        } else {
          reject(res);
        }
      },
      fail: reject,
    });
  });
}

async function refreshToken() {
  const refreshToken = uni.getStorageSync('refresh_token');
  if (!refreshToken) {
    throw new Error('无 refresh token');
  }

  const res = await new Promise((resolve, reject) => {
    uni.request({
      url: '/api/auth/refresh',
      method: 'POST',
      data: { refresh_token: refreshToken },
      success: resolve,
      fail: reject,
    });
  });

  if (res.data.code !== 200) {
    throw new Error('刷新失败');
  }

  uni.setStorageSync('token', res.data.data.token);
  uni.setStorageSync('refresh_token', res.data.data.refresh_token);
  uni.setStorageSync('token_expire_at', res.data.data.expire_at);
}

这个 Token 刷新机制的核心是请求队列 + 单次刷新。当多个请求同时返回 401 时,只触发一次刷新,其他请求等待刷新完成后重试。这避免了"多个请求同时刷新 Token"导致的竞态条件。

五、分享海报:Canvas 绘制的跨端实现

5.1 分享海报的工程挑战

分享海报是商城系统中最容易被低估的功能。它的技术挑战在于:Canvas 绘制在不同平台上的 API 差异极大。

小程序使用 wx.createCanvasContext(旧版)或 Canvas 2D(新版),H5 使用标准的 CanvasRenderingContext2D,App 使用 uni.createCanvasContext。图片的加载、绘制、导出也各有不同。

5.2 跨端海报绘制

javascript
// utils/poster.js

export async function generatePoster(options) {
  const {
    canvasId,
    width = 750,
    height = 1334,
    background,
    qrcode,
    avatar,
    nickname,
    productImage,
    productName,
    productPrice,
  } = options;

  // 获取 Canvas 上下文(分平台)
  const ctx = await getCanvasContext(canvasId, width, height);

  // 1. 绘制背景
  if (background) {
    const bg = await loadImage(background);
    ctx.drawImage(bg, 0, 0, width, height);
  } else {
    ctx.fillStyle = '#ffffff';
    ctx.fillRect(0, 0, width, height);
  }

  // 2. 绘制商品图片(圆角)
  const prodImg = await loadImage(productImage);
  drawRoundedImage(ctx, prodImg, 40, 40, 670, 670, 20);

  // 3. 绘制商品名称
  ctx.fillStyle = '#333333';
  ctx.font = 'bold 32px sans-serif';
  wrapText(ctx, productName, 40, 760, 670, 44, 2);

  // 4. 绘制价格
  ctx.fillStyle = '#fe2c55';
  ctx.font = 'bold 48px sans-serif';
  ctx.fillText(`¥${productPrice}`, 40, 900);

  // 5. 绘制头像(圆形)
  if (avatar) {
    const avatarImg = await loadImage(avatar);
    drawCircleImage(ctx, avatarImg, 40, 1000, 80);
  }

  // 6. 绘制昵称
  ctx.fillStyle = '#666666';
  ctx.font = '28px sans-serif';
  ctx.fillText(nickname || '匿名用户', 140, 1050);

  // 7. 绘制二维码
  const qrImg = await loadImage(qrcode);
  ctx.drawImage(qrImg, 550, 980, 160, 160);

  // 8. 导出图片(分平台)
  return await exportCanvas(canvasId, width, height);
}

async function getCanvasContext(canvasId, width, height) {
  // #ifdef MP-WEIXIN
  return new Promise((resolve) => {
    const query = uni.createSelectorQuery();
    query.select(`#${canvasId}`)
      .fields({ node: true, size: true })
      .exec((res) => {
        const canvas = res[0].node;
        canvas.width = width;
        canvas.height = height;
        const ctx = canvas.getContext('2d');
        resolve(ctx);
      });
  });
  // #endif

  // #ifdef H5
  return new Promise((resolve) => {
    const canvas = document.getElementById(canvasId);
    canvas.width = width;
    canvas.height = height;
    resolve(canvas.getContext('2d'));
  });
  // #endif

  // #ifdef APP-PLUS
  return uni.createCanvasContext(canvasId);
  // #endif
}

function loadImage(src) {
  return new Promise((resolve, reject) => {
    // #ifdef MP-WEIXIN
    const img = uni.createOffscreenCanvas().createImage();
    img.onload = () => resolve(img);
    img.onerror = reject;
    img.src = src;
    // #endif

    // #ifdef H5
    const img = new Image();
    img.crossOrigin = 'anonymous';
    img.onload = () => resolve(img);
    img.onerror = reject;
    img.src = src;
    // #endif

    // #ifdef APP-PLUS
    // App 端使用 uni.getImageInfo 获取本地路径
    uni.getImageInfo({
      src,
      success: (res) => resolve(res.path),
      fail: reject,
    });
    // #endif
  });
}

function drawRoundedImage(ctx, img, x, y, w, h, r) {
  ctx.save();
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + w, y, x + w, y + h, r);
  ctx.arcTo(x + w, y + h, x, y + h, r);
  ctx.arcTo(x, y + h, x, y, r);
  ctx.arcTo(x, y, x + w, y, r);
  ctx.closePath();
  ctx.clip();
  ctx.drawImage(img, x, y, w, h);
  ctx.restore();
}

function drawCircleImage(ctx, img, x, y, size) {
  ctx.save();
  ctx.beginPath();
  ctx.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2);
  ctx.closePath();
  ctx.clip();
  ctx.drawImage(img, x, y, size, size);
  ctx.restore();
}

function wrapText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
  const chars = text.split('');
  let line = '';
  let lineCount = 0;

  for (let i = 0; i < chars.length; i++) {
    const testLine = line + chars[i];
    const metrics = ctx.measureText(testLine);
    if (metrics.width > maxWidth && line !== '') {
      ctx.fillText(line, x, y + lineCount * lineHeight);
      line = chars[i];
      lineCount++;
      if (lineCount >= maxLines) {
        ctx.fillText('...', x, y + lineCount * lineHeight);
        return;
      }
    } else {
      line = testLine;
    }
  }
  ctx.fillText(line, x, y + lineCount * lineHeight);
}

async function exportCanvas(canvasId, width, height) {
  // #ifdef MP-WEIXIN
  return new Promise((resolve) => {
    uni.createSelectorQuery()
      .select(`#${canvasId}`)
      .fields({ node: true })
      .exec((res) => {
        const canvas = res[0].node;
        uni.canvasToTempFilePath({
          canvas,
          success: (r) => resolve(r.tempFilePath),
        });
      });
  });
  // #endif

  // #ifdef H5
  const canvas = document.getElementById(canvasId);
  return canvas.toDataURL('image/png');
  // #endif

  // #ifdef APP-PLUS
  return new Promise((resolve) => {
    uni.canvasToTempFilePath({
      canvasId,
      success: (r) => resolve(r.tempFilePath),
    });
  });
  // #endif
}

这段代码的核心工程决策是:用条件编译隔离平台差异,但保持绘制逻辑的统一。 drawRoundedImage、drawCircleImage、wrapText 这些绘制函数在所有平台上共享,只有 Canvas 上下文的获取和图片的导出是分平台的。

六、边界与张力

Shopro 商城高级版在工程实践中,有几个需要清醒认识的边界。

第一,五端一致性的测试成本。 五个平台的组合意味着测试矩阵是单端的五倍。每次功能迭代都需要在五个平台上验证。虽然 UniApp 的条件编译降低了代码维护成本,但没有降低测试成本。自动化测试(尤其是 E2E 测试)在多端场景下尤为重要。

第二,小程序包体积限制。 微信小程序主包限制 2MB,总包限制 20MB。商城系统的图片、字体、第三方库都会占用包体积。工程实践包括:图片使用 CDN、分包加载、按需引入组件、避免引入过大的第三方库。

第三,分享海报的图片跨域问题。 H5 端绘制海报时,如果商品图片来自不同的域名,Canvas 会因为跨域污染而无法导出。解决方案是:图片使用同源域名、或配置 CORS 头、或通过后端代理转发图片。

第四,多支付方式的回调一致性。 微信支付、支付宝支付、H5 支付的回调格式完全不同。系统需要为每种支付方式实现独立的回调解析逻辑,但最终都归一到统一的"订单支付成功"事件。

第五,积分商城的库存与积分的一致性问题。 用户用积分兑换商品时,需要同时扣减积分和库存。这两个操作必须在一个事务中完成,否则会出现"积分扣了但库存没扣"或"库存扣了但积分没扣"的不一致。

结语:商城系统的工程本质是"统一抽象 + 差异隔离"

Shopro 商城高级版的核心工程挑战,可以归结为一句话:在统一业务逻辑的同时,正确隔离平台差异和功能差异。

平台差异通过能力抽象层处理。登录、支付、分享、Canvas 绘制这些平台相关的操作,都被封装在抽象层内部,业务代码只调用统一接口。条件编译是实现这层隔离的工具,但应该谨慎使用——能用运行时检测解决的,不要用编译时分支。

功能差异通过模块化处理。多规格、拼团、秒杀、积分商城这些功能,各自有独立的数据模型和业务逻辑,但它们共享用户体系、订单体系和支付体系。模块之间的边界应该清晰,耦合应该最小。

这套系统的价值,最终体现在一个朴素的判断上:用户从微信公众号、微信小程序、H5 网页、Android App 还是 iOS App 进入商城,看到的商品、购物车、订单和积分,应该是完全一致的。 这个"一致"就是多平台商城系统的工程底线。

最新回复 (0)

    暂无评论

请先登录后发表评论!

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