false, 'webhookUrl' => '', 'contactPerson' => '运营', ]; $row = Db::name('system_config') ->where('key', self::CONFIG_KEY) ->where('enterprise_id', 0) ->find(); if (!$row || empty($row['value'])) { return $def; } $v = is_string($row['value']) ? json_decode($row['value'], true) : $row['value']; if (!is_array($v)) { return $def; } return array_merge($def, $v); } /** * 支付成功(含测试付费、充值等):每订单仅推一次 */ public static function onOrderPaid(int $orderDbId, int $userId): void { if ($orderDbId <= 0 || $userId <= 0) { return; } $dedupKey = 'order_paid:' . $orderDbId; $order = Db::name('orders')->where('id', $orderDbId)->find(); if (!$order) { return; } $productType = (string) ($order['productType'] ?? ''); $amountFen = (int) ($order['amount'] ?? 0); $title = (string) ($order['productTitle'] ?? ''); $source = self::sourceLabelForOrder($productType, $title, $amountFen); self::pushLead([ 'dedupKey' => $dedupKey, 'userId' => $userId, 'source' => $source, 'extraLine' => '订单号: ' . ($order['orderNo'] ?? '') . ' · 金额: ¥' . number_format($amountFen / 100, 2), ]); } /** * 首次绑定手机号(测试完成留资) */ public static function onPhoneBound(int $userId, string $phone): void { if ($userId <= 0 || trim($phone) === '') { return; } self::pushLead([ 'dedupKey' => 'phone_bind:' . $userId, 'userId' => $userId, 'source' => '测试完成·授权手机号', 'phone' => $phone, ]); } /** * 小程序「了解自己」页提交咨询申请(与 CrmReport 存客宝上报并行;飞书机器人需 feishu_lead_webhook.enabled) */ public static function onDeepServiceConsultApply(int $userId, string $source, string $categoryTag): void { if ($userId <= 0) { return; } $src = trim($source) !== '' ? trim($source) : '深度服务·申请咨询'; $tag = trim($categoryTag); $dedup = 'deep_consult_apply:' . $userId . ':' . md5($src . '|' . $tag) . ':' . date('YmdH'); $extra = $tag !== '' ? ('套餐: ' . self::oneLine($tag, 60)) : ''; self::pushLead([ 'dedupKey' => $dedup, 'userId' => $userId, 'source' => $src, 'extraLine' => $extra, ]); } /** * @param array{dedupKey:string,userId:int,source:string,phone?:string,extraLine?:string} $p */ public static function pushLead(array $p): void { $cfg = self::getConfig(); if (empty($cfg['enabled'])) { return; } $url = trim((string) ($cfg['webhookUrl'] ?? '')); if ($url === '' || stripos($url, 'http') !== 0) { return; } $dedupKey = $p['dedupKey'] ?? ''; if ($dedupKey === '') { return; } if (!self::beginDedup($dedupKey)) { return; } $userId = (int) ($p['userId'] ?? 0); $wu = $userId > 0 ? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone')->find() : null; $nickname = trim((string) ($wu['nickname'] ?? '')); if ($nickname === '') { $nickname = '微信用户'; } $phone = trim((string) ($p['phone'] ?? ($wu['phone'] ?? ''))); $contact = trim((string) ($cfg['contactPerson'] ?? '运营')); if ($contact === '') { $contact = '运营'; } $source = (string) ($p['source'] ?? '小程序'); $now = date('Y-m-d H:i'); $text = "📋 新获客\n来源: {$source}\n对接人: {$contact}\n━━━━━━━━━━"; $text .= "\n姓名: {$nickname}"; if ($phone !== '') { $text .= "\n手机: {$phone}"; } $text .= "\n时间: {$now}"; if (!empty($p['extraLine'])) { $text .= "\n" . $p['extraLine']; } $lines = UserJourneyService::recentBehaviorLines($userId, 8); if (count($lines) > 0) { $text .= "\n━━━━━━━━━━\n最近行为:"; $i = 1; foreach ($lines as $line) { $text .= "\n {$i}. {$line}"; $i++; } } $ok = self::postWebhook($url, $text); if (!$ok) { self::rollbackDedup($dedupKey); } } /** * 订单来源文案(飞书「来源」与 HTTP 出站 Hook `sourceLabel` 共用) */ public static function sourceLabelForOrder(string $productType, string $title, int $amountFen): string { if ($productType === 'recharge') { return '企业余额·充值支付成功'; } $map = [ 'face' => '面相测试', 'mbti' => 'MBTI测试', 'sbti' => 'SBTI测试', 'disc' => 'DISC测试', 'pdp' => 'PDP测试', 'resume' => '简历分析', 'report' => '完整报告', 'team_analysis' => '团队分析', 'deep_personal' => '个人深度服务', 'deep_team' => '团队深度服务', 'vip' => 'VIP', ]; $label = $map[$productType] ?? strtoupper($productType); $suffix = '·支付成功'; if ($title !== '') { return $label . '·「' . self::oneLine($title, 40) . '」' . $suffix; } if ($amountFen === 100) { return $label . '·1元支付' . $suffix; } return $label . $suffix; } private static function oneLine(string $s, int $max): string { $s = preg_replace('/\s+/u', ' ', trim($s)); if (mb_strlen($s) > $max) { return mb_substr($s, 0, $max) . '…'; } return $s; } private static function beginDedup(string $dedupKey): bool { try { Db::name('delivery_dedup')->insert([ 'scene' => self::DEDUP_SCENE, 'dedupKey' => $dedupKey, 'createdAt' => date('Y-m-d H:i:s'), ]); return true; } catch (\Throwable $e) { return false; } } private static function rollbackDedup(string $dedupKey): void { try { Db::name('delivery_dedup') ->where('scene', self::DEDUP_SCENE) ->where('dedupKey', $dedupKey) ->delete(); } catch (\Throwable $e) { } } private static function postWebhook(string $url, string $text): bool { $payload = []; if (stripos($url, 'qyapi.weixin.qq.com') !== false) { $payload = [ 'msgtype' => 'text', 'text' => ['content' => $text], ]; } else { $payload = [ 'msg_type' => 'text', 'content' => ['text' => $text], ]; } $json = json_encode($payload, JSON_UNESCAPED_UNICODE); $ch = curl_init($url); if ($ch === false) { return false; } curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json; charset=utf-8']); curl_setopt($ch, CURLOPT_POSTFIELDS, $json); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 8); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); $body = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code >= 400) { return false; } if ($body !== false && $body !== '') { $resp = json_decode($body, true); if (is_array($resp)) { if (isset($resp['code']) && (int) $resp['code'] !== 0) { return false; } if (isset($resp['StatusCode']) && (int) $resp['StatusCode'] !== 0) { return false; } } } return true; } }