'待结算', self::COMMISSION_STATUS_SETTLED => '已结算', self::COMMISSION_STATUS_SKIPPED => '不分佣', ]; //小程序码 scene 中推广人参数名(scene 最长 32 字符) const QRCODE_SCENE_KEY = 'pid'; //小程序码落地页 const QRCODE_PAGE = 'pages/home/home'; const QRCODE_CACHE_PREFIX = 'promotion_qrcode:'; const QRCODE_CACHE_TTL = 604800; //7天 const ENV_VERSIONS = ['release', 'trial', 'develop']; //推广订单列表展示的订单状态(已支付及之后) const VISIBLE_ORDER_STATUS = [ OrderService::ORDER_STATUS_WAIT_SHIP, OrderService::ORDER_STATUS_SHIPPED, OrderService::ORDER_STATUS_FINISHED, ]; /** * 是否开启推广分佣 * @return bool */ public static function isEnabled(): bool { //后台配置为 switch 类型,开启存 '1',关闭存 '0' return (string)config('site.enable_promotion_commission') === '1'; } /** * 按订单类型获取佣金比例(0~1),为 0 表示该类订单不分佣 * - 软件订单:site.soft_promotion_commission_rate * - 需求订单(首付款/尾款):site.promotion_commission_ratio * @param int $orderType 订单类型 OrderService::ORDER_TYPE_* * @return string */ public static function getRatio(int $orderType): string { $key = $orderType === OrderService::ORDER_TYPE_SOFTWARE ? 'site.soft_promotion_commission_rate' : 'site.promotion_commission_ratio'; return self::normalizeRatio(config($key)); } /** * 规范化比例配置,后台填写大于 1 时按百分比处理,如 10 表示 10% * @param mixed $ratio * @return string */ protected static function normalizeRatio($ratio): string { if (!is_numeric($ratio) || $ratio <= 0) { return '0'; } $ratio = (string)$ratio; if (bccomp($ratio, '1', 4) > 0) { $ratio = bcdiv($ratio, '100', 4); } return bccomp($ratio, '1', 4) > 0 ? '1' : bcadd($ratio, '0', 4); } /** * 绑定推广关系(永久绑定,已绑定的用户不再变更),失败只记日志不抛异常,避免影响登录 * @param int $userId 当前用户ID * @param int $promoterId 推广人用户ID * @return bool 是否本次绑定成功 */ public static function bindPromoter(int $userId, int $promoterId): bool { if ($userId <= 0 || $promoterId <= 0 || $userId === $promoterId) { return false; } try { $user = User::where('id', $userId)->field('id,promoter_id')->find(); if (!$user || $user->promoter_id > 0) { return false; } $promoter = User::where('id', $promoterId)->field('id,promoter_id,status')->find(); if (!$promoter || ($promoter->status && $promoter->status !== 'normal')) { return false; } //禁止互相绑定 if (intval($promoter->promoter_id) === $userId) { return false; } //条件更新保证并发下只绑定一次 $res = User::where(['id' => $userId, 'promoter_id' => 0])->update([ 'promoter_id' => $promoterId, 'promoter_bind_time' => time(), ]); if ($res) { Log::info(__CLASS__ . ' 绑定推广关系:' . json_encode(['user_id' => $userId, 'promoter_id' => $promoterId], JSON_UNESCAPED_UNICODE)); } return (bool)$res; } catch (\Exception $e) { Log::error(__CLASS__ . ' 绑定推广关系失败:' . json_encode(['user_id' => $userId, 'promoter_id' => $promoterId, 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE)); return false; } } /** * 获取用户的推广人ID(用于下单快照) * @param int $userId * @return int */ public static function getPromoterId(int $userId): int { if ($userId <= 0) { return 0; } return intval(User::where('id', $userId)->value('promoter_id')); } /** * 结算满足条件的已完成订单佣金(可在外层事务中调用,内部使用保存点,单笔失败不影响外层业务) * @param array $where 订单范围条件,如 ['parent_order_no' => 'xxx'] * @return void */ public static function settleOrders(array $where) { $orderIds = self::where($where) ->where('order_status', OrderService::ORDER_STATUS_FINISHED) ->where('promoter_id', '>', 0) ->where('commission_status', self::COMMISSION_STATUS_PENDING) ->column('id'); foreach ($orderIds as $orderId) { self::settleOrder(intval($orderId)); } } /** * 兜底:结算所有待结算的已完成推广订单(定时任务调用) * @return void */ public static function settlePendingOrders() { self::where('order_status', OrderService::ORDER_STATUS_FINISHED) ->where('promoter_id', '>', 0) ->where('commission_status', self::COMMISSION_STATUS_PENDING) ->field('id') ->chunk(100, function ($orders) { foreach ($orders as $order) { self::settleOrder(intval($order->id)); } }); } /** * 结算单笔订单佣金 * @param int $orderId * @return bool 是否发放了佣金 */ public static function settleOrder(int $orderId): bool { Db::startTrans(); try { $order = self::where('id', $orderId)->lock(true)->find(); if (!$order || intval($order->commission_status) !== self::COMMISSION_STATUS_PENDING || intval($order->order_status) !== OrderService::ORDER_STATUS_FINISHED || intval($order->promoter_id) <= 0) { Db::commit(); return false; } $promoterId = intval($order->promoter_id); $ratio = self::isEnabled() ? self::getRatio(intval($order->order_type)) : '0'; $amount = bcmul((string)$order->payment_amount, $ratio, 2); $isPaid = OrderPaymentService::where(['order_no' => $order->order_no, 'pay_status' => 'SUCCESS'])->count() > 0; $skip = bccomp($amount, '0', 2) <= 0 || !$isPaid || $promoterId === intval($order->user_id); $saveData = [ 'commission_ratio' => $ratio, 'commission_amount' => $skip ? 0 : $amount, 'commission_status' => $skip ? self::COMMISSION_STATUS_SKIPPED : self::COMMISSION_STATUS_SETTLED, 'commission_time' => time(), ]; if (!$skip) { self::ensureAccount($promoterId); $title = OrderService::ORDER_TYPE_MAP[$order->order_type] ?? '订单'; $log = AccountLogService::addLog( AccountLogService::ACCOUNT_TYPE_AMOUNT, $promoterId, $amount, '推广佣金-' . $title . $order->order_no, AccountLogService::TYPE_COMMISSION, 0, AccountLogService::INC_EXP_INCREASE, $order->id, $order->payment_amount, $ratio ); AccountService::updateData($promoterId, $amount, 0, 0, 0, $log->id); try { MessageService::createData( $promoterId, '推广佣金到账', '您推广的订单' . $order->order_no . '已完成,佣金' . $amount . '元已存入余额', ['page' => 'pages/promoter/index/index'] ); } catch (\Exception $e) { Log::error(__CLASS__ . ' 佣金到账站内消息失败:' . json_encode(['order_id' => $orderId, 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE)); } } $upRes = self::where(['id' => $orderId, 'commission_status' => self::COMMISSION_STATUS_PENDING])->update($saveData); if (!$upRes) { exception('更新订单佣金状态失败'); } Db::commit(); Log::info(__CLASS__ . ' 推广佣金结算:' . json_encode(['order_id' => $orderId, 'promoter_id' => $promoterId] + $saveData, JSON_UNESCAPED_UNICODE)); return !$skip; } catch (\Exception $e) { Db::rollback(); Log::error(__CLASS__ . ' 推广佣金结算失败:' . json_encode(['order_id' => $orderId, 'error' => $e->getMessage(), 'file' => $e->getFile() . ':' . $e->getLine()], JSON_UNESCAPED_UNICODE)); return false; } } /** * 推广人账户不存在时补建(手机号注册的用户可能没有账户) * @param int $userId * @return void */ protected static function ensureAccount(int $userId) { if (!Accounts::where('user_id', $userId)->count()) { Accounts::create(['user_id' => $userId]); } } /** * 推广中心概览 * @param int $userId * @return array */ public static function getOverview(int $userId): array { $base = self::where('promoter_id', $userId)->where('order_status', 'in', self::VISIBLE_ORDER_STATUS); $settledAmount = (clone $base)->where('commission_status', self::COMMISSION_STATUS_SETTLED)->sum('commission_amount'); $pendingCount = (clone $base)->where('commission_status', self::COMMISSION_STATUS_PENDING)->count(); return [ 'enabled' => self::isEnabled(), 'soft_ratio_percent' => floatval(bcmul(self::getRatio(OrderService::ORDER_TYPE_SOFTWARE), '100', 2)), 'demand_ratio_percent' => floatval(bcmul(self::getRatio(OrderService::ORDER_TYPE_FIRST_PAYMENT), '100', 2)), 'invite_count' => User::where('promoter_id', $userId)->count(), 'order_count' => (clone $base)->count(), 'settled_amount' => bcadd((string)$settledAmount, '0', 2), 'pending_count' => $pendingCount, ]; } /** * 推广订单列表 * @param int $userId * @param int $pageSize * @return array */ public static function getOrderList(int $userId, int $pageSize = 10): array { $list = self::where('promoter_id', $userId) ->where('order_status', 'in', self::VISIBLE_ORDER_STATUS) ->field('id,user_id,order_no,order_type,order_status,payment_amount,commission_amount,commission_status,commission_time,createtime') ->order('id desc') ->paginate($pageSize) ->toArray(); $userIds = array_unique(array_column($list['data'], 'user_id')); $users = $userIds ? User::where('id', 'in', $userIds)->column('nickname,avatar', 'id') : []; foreach ($list['data'] as &$item) { $buyer = $users[$item['user_id']] ?? []; $item['buyer_nickname'] = $buyer['nickname'] ?? ''; $item['buyer_avatar'] = $buyer['avatar'] ?? ''; $item['order_type_text'] = OrderService::ORDER_TYPE_MAP[$item['order_type']] ?? ''; $item['commission_status'] = intval($item['commission_status']); $item['commission_status_text'] = self::COMMISSION_STATUS_MAP[$item['commission_status']] ?? ''; $item['createtime_text'] = date('Y-m-d H:i', $item['createtime']); unset($item['user_id']); } unset($item); return $list; } /** * 获取推广小程序码(scene=pid=用户ID),返回 data URL,结果缓存 7 天 * @param int $userId * @param string $envVersion release|trial|develop * @return string * @throws \Exception */ public static function getQrcode(int $userId, string $envVersion = 'release'): string { if (!in_array($envVersion, self::ENV_VERSIONS, true)) { $envVersion = 'release'; } $cacheKey = self::QRCODE_CACHE_PREFIX . $userId . ':' . $envVersion; if ($cached = Cache::get($cacheKey)) { return $cached; } $response = (new EasyWeChatService())->app->getClient()->postJson('/wxa/getwxacodeunlimit', [ 'scene' => self::QRCODE_SCENE_KEY . '=' . $userId, 'page' => self::QRCODE_PAGE, 'check_path' => $envVersion === 'release', 'env_version' => $envVersion, 'width' => 430, ]); //成功返回图片二进制,失败返回 JSON if (strpos($response->getHeaderLine('content-type', false), 'image') === false) { $error = $response->toArray(false); Log::error(__CLASS__ . ' 生成推广小程序码失败:' . json_encode(['user_id' => $userId, 'res' => $error], JSON_UNESCAPED_UNICODE)); throw new \Exception('生成推广码失败:' . ($error['errmsg'] ?? '未知错误')); } $dataUrl = $response->toDataUrl(); Cache::set($cacheKey, $dataUrl, self::QRCODE_CACHE_TTL); return $dataUrl; } }