0:该企业专属;推送时若业务归属该企业且本行「对该事件可用」则优先用本行,否则回落 0 * * 事件:lead.order_paid、lead.phone_bound、test.result_completed */ class OutboundPushHookService { public const CONFIG_KEY = 'push_hook_outbound'; public const ASYNC_ROUTE = '/api/internal/outbound-push/dispatch'; /** 去重表 scene:出站 Hook(库中 dedupKey 为 _dedupKey 原值,不含 push_hook:) */ private const DEDUP_SCENE_OUTBOUND = 'outbound_hook'; /** @var string[] */ public const DEFAULT_EVENTS = [ 'lead.order_paid', 'lead.phone_bound', 'test.result_completed', ]; /** 首次绑手机与「刚完成的测评」合并推送时,测评落库时间距现在最长间隔(秒) */ private const TEST_PHONE_MERGE_MAX_AGE_SEC = 604800; /** * 读取指定作用域配置(不合并回落;回落在 getEffectiveConfigForEvent / dispatch 中处理) */ public static function getConfig(int $enterpriseId = 0): array { $def = [ 'enabled' => false, 'url' => '', 'secret' => '', 'events' => [], ]; $row = Db::name('system_config') ->where('key', self::CONFIG_KEY) ->where('enterprise_id', $enterpriseId) ->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; } $merged = array_merge($def, $v); // 兼容历史或误存:events 为 JSON 字符串时转为数组,否则 isEventEnabled 判断异常 $ev = $merged['events'] ?? []; if (is_string($ev)) { $decoded = json_decode($ev, true); $ev = is_array($decoded) ? $decoded : []; } if (!is_array($ev)) { $ev = []; } $merged['events'] = $ev; return $merged; } /** * 按业务上下文企业 ID 解析实际出站使用的配置(择一:本企业优先,否则全平台) * * @return array{config: array, configEnterpriseId: int, usedPlatformFallback: bool}|null */ public static function getEffectiveConfigForEvent(int $contextEnterpriseId, string $event): ?array { if ($contextEnterpriseId > 0) { $cfg = self::getConfig($contextEnterpriseId); if (self::isEventEnabled($event, $cfg)) { return [ 'config' => $cfg, 'configEnterpriseId' => $contextEnterpriseId, 'usedPlatformFallback' => false, ]; } } $cfg0 = self::getConfig(0); if (self::isEventEnabled($event, $cfg0)) { return [ 'config' => $cfg0, 'configEnterpriseId' => 0, 'usedPlatformFallback' => $contextEnterpriseId > 0, ]; } return null; } /** * 仅校验「启用 + 合法 URL」,忽略 events 订阅(用于连接测试) * * @return array{config: array, configEnterpriseId: int, usedPlatformFallback: bool}|null */ public static function resolveOutboundTransport(int $contextEnterpriseId): ?array { if ($contextEnterpriseId > 0) { $cfg = self::getConfig($contextEnterpriseId); if (self::isRowTransportReady($cfg)) { return [ 'config' => $cfg, 'configEnterpriseId' => $contextEnterpriseId, 'usedPlatformFallback' => false, ]; } } $cfg0 = self::getConfig(0); if (self::isRowTransportReady($cfg0)) { return [ 'config' => $cfg0, 'configEnterpriseId' => 0, 'usedPlatformFallback' => $contextEnterpriseId > 0, ]; } return null; } private static function isRowTransportReady(array $cfg): bool { if (empty($cfg['enabled'])) { return false; } $url = trim((string) ($cfg['url'] ?? '')); return $url !== '' && stripos($url, 'http') === 0; } /** 企业微信机器人 Webhook(text 用 msgtype) */ private static function isWeComBotWebhookUrl(string $url): bool { return stripos($url, 'qyapi.weixin.qq.com') !== false; } /** 飞书 / Lark 自定义机器人(text 用 msg_type,否则报 code=19002 等) */ private static function isFeishuBotWebhookUrl(string $url): bool { $u = strtolower($url); if (strpos($u, 'qyapi.weixin.qq.com') !== false) { return false; } return (strpos($u, 'open.feishu.cn') !== false || strpos($u, 'open.larksuite.com') !== false) && (strpos($u, '/bot/') !== false || strpos($u, 'hook') !== false); } private static function isThirdPartyBotTextUrl(string $url): bool { return self::isWeComBotWebhookUrl($url) || self::isFeishuBotWebhookUrl($url); } /** * 企微 / 飞书机器人仅接受各自文本协议,与通用 JSON 信封不同 */ private static function buildThirdPartyTextBody(string $url, string $text): string { if (self::isWeComBotWebhookUrl($url)) { return json_encode([ 'msgtype' => 'text', 'text' => ['content' => $text], ], JSON_UNESCAPED_UNICODE); } return json_encode([ 'msg_type' => 'text', 'content' => ['text' => $text], ], JSON_UNESCAPED_UNICODE); } /** * 将通用 envelope 压成多行纯文本(用于飞书/企微机器人) * * @param array $envelope */ private static function envelopeToBotPlainText(string $event, array $envelope): string { $tenant = isset($envelope['tenant']) && is_array($envelope['tenant']) ? $envelope['tenant'] : []; $eid = (int) ($tenant['enterpriseId'] ?? 0); $ename = trim((string) ($tenant['enterpriseName'] ?? '')); $head = ''; $payload = isset($envelope['payload']) && is_array($envelope['payload']) ? $envelope['payload'] : []; switch ($event) { case 'lead.order_paid': $paidTime = trim((string) ($payload['paidAt'] ?? '')); if ($paidTime === '') { $paidTime = trim((string) ($envelope['occurredAt'] ?? '')); } return $head . "💰 支付成功\n" . '订单号: ' . ($payload['orderNo'] ?? '') . "\n" . '用户: ' . ($payload['userName'] ?? '') . "\n" . '手机: ' . ($payload['phone'] ?? '') . "\n" . '金额: ¥' . ($payload['amountYuan'] ?? '') . "\n" . '商品: ' . self::truncatePlainText((string) ($payload['productTitle'] ?? ''), 60) . "\n" . '来源: ' . ($payload['sourceLabel'] ?? '') . "\n" . '支付时间: ' . $paidTime; case 'lead.phone_bound': $boundTime = trim((string) ($payload['boundAt'] ?? '')); if ($boundTime === '') { $boundTime = trim((string) ($envelope['occurredAt'] ?? '')); } return $head . "📋 首次绑定手机\n" . '用户: ' . ($payload['userName'] ?? '') . "\n" . '手机: ' . ($payload['phone'] ?? '') . "\n" . '绑定时间: ' . $boundTime; case 'test.result_completed': $testTime = trim((string) ($payload['completedAt'] ?? '')); if ($testTime === '') { $testTime = trim((string) ($envelope['occurredAt'] ?? '')); } $tt = (string) ($payload['testType'] ?? ''); $mergedBind = trim((string) ($payload['phoneBoundAt'] ?? '')) !== ''; $titleLine = $mergedBind ? "📊 测评完成·手机号已授权\n" : "📊 测评完成\n"; $lines = [ $head . $titleLine, '类型: ' . ($payload['testTypeLabel'] ?? $payload['testType'] ?? '') . "\n", '用户: ' . ($payload['userName'] ?? '') . "\n", '手机: ' . ($payload['phone'] ?? '') . "\n", ]; if (in_array($tt, ['face', 'ai'], true)) { $mb = trim((string) ($payload['resultMbti'] ?? '')); $pd = trim((string) ($payload['resultPdp'] ?? '')); $di = trim((string) ($payload['resultDisc'] ?? '')); if ($mb !== '') { $lines[] = 'MBTI测试结果: ' . $mb . "\n"; } if ($pd !== '') { $lines[] = 'PDP测试结果: ' . $pd . "\n"; } if ($di !== '') { $lines[] = 'DISC测试结果: ' . $di . "\n"; } if ($mb === '' && $pd === '' && $di === '') { $lines[] = '测试结果: ' . self::truncatePlainText((string) ($payload['resultSummary'] ?? ''), 100) . "\n"; } } else { $lines[] = '测试结果: ' . self::truncatePlainText((string) ($payload['resultSummary'] ?? ''), 100) . "\n"; } $lines[] = '测试时间: ' . $testTime; if ($mergedBind) { $lines[] = '授权手机时间: ' . trim((string) ($payload['phoneBoundAt'] ?? '')) . "\n"; } $body = implode('', $lines); $mgmtSummary = trim((string) ($payload['managementSummary'] ?? '')); if ($mgmtSummary !== '') { $body .= "\n━━━━━━━━━━\n用户管理:\n" . $mgmtSummary; } $um = $payload['userManagement'] ?? null; if (is_array($um) && !empty($um['openidTail6'])) { $body .= "\nOpenID尾号(脱敏): " . (string) $um['openidTail6']; } $beh = $payload['recentBehaviors'] ?? []; if (is_array($beh) && count($beh) > 0) { $body .= "\n━━━━━━━━━━\n最近行为:"; $i = 1; foreach ($beh as $bl) { if (!is_string($bl) || $bl === '') { continue; } $body .= "\n {$i}. {$bl}"; $i++; } } return $body; default: return $head . "\n事件: " . $event; } } private static function truncatePlainText(string $s, int $max): string { $s = preg_replace('/\s+/u', ' ', trim($s)); if ($s === '') { return ''; } if (function_exists('mb_strlen') && mb_strlen($s) > $max) { return mb_substr($s, 0, $max) . '…'; } return strlen($s) > $max ? substr($s, 0, $max) . '…' : $s; } /** * 发送一次 `hook.ping` 测试投递(不写去重表) * 配置解析与真实「测评完成」推送一致:须满足对 test.result_completed 的事件订阅(空数组表示全部)。 * * @return array */ public static function sendTestPing(int $contextEnterpriseId = 0): array { // 与 dispatch 一致,避免「仅测通 URL、但 events 未勾选测评」时误以为已配置 $resolved = self::getEffectiveConfigForEvent($contextEnterpriseId, 'test.result_completed'); if ($resolved === null) { return [ 'ok' => false, 'message' => '未找到对「测评完成」(test.result_completed) 有效的配置:请确认已启用、URL 以 http 开头,且「订阅事件」包含该项或留空表示全部(可先保存后重试,并检查全平台默认)', 'httpStatus' => 0, 'configEnterpriseId' => null, 'usedPlatformFallback' => null, 'responsePreview' => null, 'businessHint' => null, 'curlError' => null, ]; } $cfg = $resolved['config']; $url = trim((string) ($cfg['url'] ?? '')); $tenantEid = $contextEnterpriseId > 0 ? $contextEnterpriseId : 0; $tenant = self::tenantPayload($tenantEid); if (self::isThirdPartyBotTextUrl($url)) { $lines = "🔔 MBTI 出站 Hook 连接测试\n时间: " . date('Y-m-d H:i:s'); if ($tenantEid > 0) { $lines .= "\n企业: " . (!empty($tenant['enterpriseName']) ? (string) $tenant['enterpriseName'] : ('ID ' . $tenantEid)); } $lines .= "\n(已按飞书/企微机器人文本协议发送,非通用 JSON)"; $body = self::buildThirdPartyTextBody($url, $lines); $headers = ['Content-Type: application/json; charset=utf-8']; [$ok, $httpStatus, $responseBody, $curlErr, $bizHint] = self::httpPostJsonWithCode($url, $body, $headers); } else { $envelope = [ 'event' => 'hook.ping', 'occurredAt' => self::iso8601Cn(), 'environment' => self::appEnv(), 'hook' => [ 'configEnterpriseId' => $resolved['configEnterpriseId'], 'usedPlatformFallback' => $resolved['usedPlatformFallback'], 'test' => true, ], 'tenant' => $tenant, 'payload' => [ 'display' => [ 'title' => '连接测试', 'emoji' => '🔔', ], 'message' => 'MBTI 出站 Hook 模拟推送(可忽略业务语义)', 'sentAt' => date('Y-m-d H:i:s'), ], ]; $body = json_encode($envelope, JSON_UNESCAPED_UNICODE); if ($body === false) { return [ 'ok' => false, 'message' => 'JSON 编码失败', 'httpStatus' => 0, 'configEnterpriseId' => $resolved['configEnterpriseId'], 'usedPlatformFallback' => $resolved['usedPlatformFallback'], 'responsePreview' => null, 'businessHint' => null, 'curlError' => null, ]; } $deliveryId = self::uuidV4(); $headers = [ 'Content-Type: application/json; charset=utf-8', 'X-MBTI-Event: hook.ping', 'X-MBTI-Delivery-Id: ' . $deliveryId, ]; $secret = trim((string) ($cfg['secret'] ?? '')); if ($secret !== '') { $sig = hash_hmac('sha256', $body, $secret); $headers[] = 'X-MBTI-Signature: sha256=' . $sig; } [$ok, $httpStatus, $responseBody, $curlErr, $bizHint] = self::httpPostJsonWithCode($url, $body, $headers); } $preview = self::truncateForLog($responseBody, 800); if ($curlErr !== '') { Log::warning('OutboundPushHook test: curl error', ['url' => self::maskUrl($url), 'error' => $curlErr]); } elseif ($bizHint !== null) { Log::warning('OutboundPushHook test: business not ok', ['url' => self::maskUrl($url), 'hint' => $bizHint, 'preview' => $preview]); } $msgParts = []; if ($curlErr !== '') { $msgParts[] = '网络/cURL:' . $curlErr; } else { $msgParts[] = 'HTTP ' . $httpStatus; } if ($bizHint !== null) { $msgParts[] = '对端业务:' . $bizHint; } elseif ($ok) { $msgParts[] = '连接与响应体检查通过'; } else { $msgParts[] = '未通过(见 HTTP 状态或业务字段)'; } return [ 'ok' => $ok, 'message' => implode(';', $msgParts), 'httpStatus' => $httpStatus, 'configEnterpriseId' => $resolved['configEnterpriseId'], 'usedPlatformFallback' => $resolved['usedPlatformFallback'], 'responsePreview' => $preview !== '' ? $preview : null, 'businessHint' => $bizHint, 'curlError' => $curlErr !== '' ? $curlErr : null, ]; } /** * 主业务仅投递内部任务,不等待第三方推送完成,避免拖慢用户接口。 */ public static function triggerAsyncOrderPaid(int $orderDbId, int $userId): void { if ($orderDbId <= 0 || $userId <= 0) { return; } self::triggerAsyncInternalDispatch([ 'job' => 'lead.order_paid', 'orderId' => $orderDbId, 'userId' => $userId, ]); } /** * 测评结果写库后异步回调内部接口,避免在主提交流程里等待外部 Webhook。 */ public static function triggerAsyncTestResultCompleted(int $testResultId): void { if ($testResultId <= 0) { return; } self::triggerAsyncInternalDispatch([ 'job' => 'test.result_completed', 'testResultId' => $testResultId, ]); } /** * 内部接口验签:仅允许本服务自行投递的异步任务进入。 */ public static function verifyAsyncInternalDispatch(string $body, string $timestamp, string $signature): bool { $ts = ctype_digit($timestamp) ? (int) $timestamp : 0; if ($ts <= 0 || abs(time() - $ts) > 300) { return false; } if ($body === '' || $signature === '') { return false; } $expected = self::signAsyncInternalDispatch($body, $timestamp); if (function_exists('hash_equals')) { return hash_equals($expected, $signature); } return $expected === $signature; } /** * 神仙 AI 异步对话:与订单/测评异步同源,走独立 HTTP 请求执行,避免 PHP-FPM 在响应后提前掐断 register_shutdown_function。 * * @return bool 是否已成功把请求写出到 socket(不代表模型已跑完) */ public static function triggerAiChatDeferredJob(int $userId, int $conversationId, string $jobId): bool { if ($userId <= 0 || $conversationId <= 0 || $jobId === '' || strlen($jobId) > 64) { return false; } if (!preg_match('/^[a-f0-9]+$/', $jobId)) { return false; } return self::triggerAsyncInternalDispatch([ 'job' => 'ai.chat_turn', 'userId' => $userId, 'conversationId' => $conversationId, 'jobId' => $jobId, ]); } /** * @param array $payload */ private static function triggerAsyncInternalDispatch(array $payload): bool { $url = self::resolveAsyncDispatchUrl(); if ($url === '') { Log::warning('OutboundPushHook async enqueue skipped: no internal url', [ 'payload' => $payload, ]); return false; } $body = json_encode($payload, JSON_UNESCAPED_UNICODE); if ($body === false) { Log::warning('OutboundPushHook async enqueue skipped: json encode failed', [ 'payload' => $payload, ]); return false; } $timestamp = (string) time(); $headers = [ 'Content-Type: application/json; charset=utf-8', 'X-MBTI-Internal-Timestamp: ' . $timestamp, 'X-MBTI-Internal-Signature: ' . self::signAsyncInternalDispatch($body, $timestamp), ]; if (!self::postJsonAsyncNoWait($url, $body, $headers)) { // 偶发 TLS 握手/链路抖动:短间隔重试一次再回落 shutdown usleep(150000); if (!self::postJsonAsyncNoWait($url, $body, $headers)) { Log::warning('OutboundPushHook async enqueue failed', [ 'url' => self::maskUrl($url), 'payload' => $payload, ]); return false; } } return true; } private static function resolveAsyncDispatchUrl(): string { $override = trim((string) (env('MBTI_INTERNAL_DISPATCH_URL') ?: getenv('MBTI_INTERNAL_DISPATCH_URL') ?: '')); if ($override !== '') { return rtrim($override, '/') . self::ASYNC_ROUTE; } $host = trim((string) Request::server('HTTP_HOST', '')); if ($host !== '') { $scheme = Request::isSsl() ? 'https' : 'http'; return $scheme . '://' . $host . self::ASYNC_ROUTE; } $appHost = trim((string) config('app.app_host', '')); if ($appHost !== '') { if (stripos($appHost, 'http://') === 0 || stripos($appHost, 'https://') === 0) { return rtrim($appHost, '/') . self::ASYNC_ROUTE; } return 'https://' . trim($appHost, '/') . self::ASYNC_ROUTE; } return ''; } private static function signAsyncInternalDispatch(string $body, string $timestamp): string { return hash_hmac('sha256', $timestamp . "\n" . $body, self::asyncInternalDispatchSecret()); } private static function asyncInternalDispatchSecret(): string { $secret = (string) (env('jwt.secret', '') ?: getenv('JWT_SECRET') ?: ''); return $secret !== '' ? $secret : 'mbti-outbound-push'; } /** * fire-and-forget 异步 POST:只负责把请求投出去,不等待接口处理完成。 * * @param array $headers */ private static function postJsonAsyncNoWait(string $url, string $body, array $headers): bool { $parts = parse_url($url); if (!is_array($parts) || empty($parts['host'])) { return false; } $scheme = strtolower((string) ($parts['scheme'] ?? 'http')); $host = (string) $parts['host']; $port = isset($parts['port']) ? (int) $parts['port'] : ($scheme === 'https' ? 443 : 80); $path = (string) ($parts['path'] ?? '/'); if (!empty($parts['query'])) { $path .= '?' . $parts['query']; } $transport = $scheme === 'https' ? 'ssl://' : ''; // HTTPS 自调用:须带 SSL 上下文(SNI/校验策略与 AiCallService 侧 curl 一致),否则部分环境握手直接失败 $ctx = null; if ($scheme === 'https') { $ctx = stream_context_create([ 'ssl' => [ 'verify_peer' => false, 'verify_peer_name' => false, 'peer_name' => $host, 'SNI_enabled' => true, ], ]); } $socket = @stream_socket_client( $transport . $host . ':' . $port, $errno, $errstr, 3, STREAM_CLIENT_CONNECT, $ctx ); if (!is_resource($socket)) { Log::warning('OutboundPushHook async socket open failed', [ 'url' => self::maskUrl($url), 'errno' => $errno, 'error' => $errstr, ]); return false; } stream_set_timeout($socket, 3); $hostHeader = $host; if (($scheme === 'http' && $port !== 80) || ($scheme === 'https' && $port !== 443)) { $hostHeader .= ':' . $port; } $requestLines = [ 'POST ' . $path . ' HTTP/1.1', 'Host: ' . $hostHeader, 'Connection: Close', 'Content-Length: ' . strlen($body), ]; foreach ($headers as $header) { $requestLines[] = $header; } $rawRequest = implode("\r\n", $requestLines) . "\r\n\r\n" . $body; $written = @fwrite($socket, $rawRequest); @fclose($socket); return $written !== false; } /** * 将库表中的 enterpriseId 规范为 int(null/''/0 视为无) */ private static function intEnterpriseId($v): int { if ($v === null || $v === '') { return 0; } $n = (int) $v; return $n > 0 ? $n : 0; } /** * 测评行可能未写 enterpriseId(null),但用户已绑定企业:与 CrmReport 等一致,回落 wechat_users.enterpriseId * * @param array $testResultRow * @param array|null $wechatUser wechat_users 一行,须含 enterpriseId(可与昵称查询合并) */ private static function resolveEnterpriseIdForTestResult(array $testResultRow, ?array $wechatUser): int { $fromRow = self::intEnterpriseId($testResultRow['enterpriseId'] ?? null); if ($fromRow > 0) { return $fromRow; } if ($wechatUser !== null) { $fromUser = self::intEnterpriseId($wechatUser['enterpriseId'] ?? null); if ($fromUser > 0) { return $fromUser; } } return 0; } /** * 测评完成 JSON / 飞书文本:附加用户管理字段 + analytics 用户旅程(与 FeishuLeadWebhookService 获客卡片「最近行为」同源) * * @param array $payload 引用 * @param array $testResultRow test_results 一行 * @param array|null $wuArr wechat_users 行(可含 openid) */ private static function mergeTestResultUserJourneyPayload(array &$payload, int $userId, array $testResultRow, ?array $wuArr): void { if ($userId <= 0) { $payload['recentBehaviors'] = []; $payload['userManagement'] = [ 'wechatUserId' => 0, 'enterpriseId' => 0, 'enterpriseName' => null, 'openidTail6' => null, 'testScope' => (string) ($testResultRow['testScope'] ?? ''), ]; $payload['managementSummary'] = ''; return; } $eidResolved = self::resolveEnterpriseIdForTestResult($testResultRow, $wuArr); $tenant = self::tenantPayload($eidResolved); $openid = ''; if ($wuArr !== null && isset($wuArr['openid'])) { $openid = trim((string) $wuArr['openid']); } $tail = ''; if ($openid !== '') { $tail = strlen($openid) >= 6 ? substr($openid, -6) : $openid; } $payload['userManagement'] = [ 'wechatUserId' => $userId, 'enterpriseId' => $eidResolved, 'enterpriseName' => $tenant['enterpriseName'], 'openidTail6' => $tail !== '' ? $tail : null, 'testScope' => (string) ($testResultRow['testScope'] ?? ''), ]; $payload['recentBehaviors'] = UserJourneyService::recentBehaviorLines($userId, 12); $trEid = (int) ($testResultRow['enterpriseId'] ?? 0); $payload['managementSummary'] = UserJourneyService::managementSummaryLine($userId, $trEid); } public static function isEventEnabled(string $event, array $cfg): bool { if (empty($cfg['enabled'])) { return false; } $url = trim((string) ($cfg['url'] ?? '')); if ($url === '' || stripos($url, 'http') !== 0) { return false; } $ev = $cfg['events'] ?? []; if (!is_array($ev) || count($ev) === 0) { return true; } return in_array($event, $ev, true); } /** * 支付成功:与 FeishuLeadWebhookService::onOrderPaid 同路径触发 */ public static function onOrderPaid(int $orderDbId, int $userId): void { if ($orderDbId <= 0 || $userId <= 0) { return; } $order = Db::name('orders')->where('id', $orderDbId)->find(); if (!$order) { return; } $productType = (string) ($order['productType'] ?? ''); $amountFen = (int) ($order['amount'] ?? 0); $title = (string) ($order['productTitle'] ?? ''); $sourceLabel = FeishuLeadWebhookService::sourceLabelForOrder($productType, $title, $amountFen); $payTs = isset($order['payTime']) ? (int) $order['payTime'] : time(); $paidAt = date('Y-m-d H:i:s', $payTs); $wu = Db::name('wechat_users')->where('id', $userId)->field('nickname,phone')->find(); $userName = trim((string) ($wu['nickname'] ?? '')); if ($userName === '') { $userName = '微信用户'; } $phone = trim((string) ($wu['phone'] ?? '')); $eid = isset($order['enterpriseId']) ? (int) $order['enterpriseId'] : 0; $tenant = self::tenantPayload($eid); $payload = [ 'display' => [ 'title' => '用户购买成功(实时推送)', 'emoji' => '💰', ], 'orderId' => (int) $order['id'], 'orderNo' => (string) ($order['orderNo'] ?? ''), 'userId' => $userId, 'userName' => $userName, 'phone' => $phone, 'productTitle' => $title, 'productType' => $productType, 'amountYuan' => number_format($amountFen / 100, 2, '.', ''), 'amountFen' => $amountFen, 'status' => (string) ($order['status'] ?? 'paid'), 'paidAt' => $paidAt, 'sourceLabel' => $sourceLabel, ]; self::dispatch('lead.order_paid', [ 'event' => 'lead.order_paid', 'occurredAt' => self::iso8601Cn(), 'environment' => self::appEnv(), 'tenant' => $tenant, 'payload' => $payload, '_dedupKey' => 'lead.order_paid:' . $orderDbId, ], $eid); } public static function onPhoneBound(int $userId, string $phone): void { if ($userId <= 0 || trim($phone) === '') { return; } $latest = Db::name('test_results')->where('userId', $userId)->order('id', 'desc')->find(); if ($latest) { $tid = (int) ($latest['id'] ?? 0); $testTs = isset($latest['createdAt']) ? (int) $latest['createdAt'] : 0; if ($tid > 0 && $testTs > 0 && (time() - $testTs) <= self::TEST_PHONE_MERGE_MAX_AGE_SEC) { $boundAt = date('Y-m-d H:i:s'); $pack = self::assembleTestResultCompletedEnvelope($tid, $phone, $boundAt); if ($pack !== null) { self::dispatch('test.result_completed', $pack['envelope'], $pack['eid']); return; } } } $wu = Db::name('wechat_users')->where('id', $userId)->field('nickname,enterpriseId')->find(); $userName = trim((string) ($wu['nickname'] ?? '')); if ($userName === '') { $userName = '微信用户'; } $eid = isset($wu['enterpriseId']) ? (int) $wu['enterpriseId'] : 0; self::dispatch('lead.phone_bound', [ 'event' => 'lead.phone_bound', 'occurredAt' => self::iso8601Cn(), 'environment' => self::appEnv(), 'tenant' => self::tenantPayload($eid), 'payload' => [ 'display' => [ 'title' => '用户完成手机号授权', 'emoji' => '📋', ], 'userId' => $userId, 'userName' => $userName, 'phone' => $phone, 'boundAt' => date('Y-m-d H:i:s'), 'sourceLabel' => '测试完成·授权手机号', ], '_dedupKey' => 'lead.phone_bound:' . $userId, ], $eid); } /** * 飞书获客 webhook 生成与出站机器人一致的测评完成纯文本(不写去重、不发起 HTTP) */ public static function botPlainTextTestResultCompleted(int $testResultId, ?string $overridePhone = null, ?string $phoneBoundAt = null): ?string { $pack = self::assembleTestResultCompletedEnvelope($testResultId, $overridePhone, $phoneBoundAt); if ($pack === null) { return null; } $env = $pack['envelope']; unset($env['_dedupKey']); return self::envelopeToBotPlainText('test.result_completed', $env); } /** * @return array{eid:int,envelope:array}|null */ private static function assembleTestResultCompletedEnvelope(int $testResultId, ?string $overridePhone = null, ?string $phoneBoundAt = null): ?array { if ($testResultId <= 0) { return null; } $row = Db::name('test_results')->where('id', $testResultId)->find(); if (!$row) { return null; } $userId = (int) ($row['userId'] ?? 0); $testType = (string) ($row['testType'] ?? ''); $createdAt = isset($row['createdAt']) ? (int) $row['createdAt'] : time(); $completedAt = date('Y-m-d H:i:s', $createdAt); $wu = $userId > 0 ? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId,openid')->find() : null; $userName = $wu ? trim((string) ($wu['nickname'] ?? '')) : ''; if ($userName === '') { $userName = '微信用户'; } $phone = $wu ? trim((string) ($wu['phone'] ?? '')) : ''; if ($overridePhone !== null && trim($overridePhone) !== '') { $phone = trim($overridePhone); } $raw = $row['resultData'] ?? null; $data = is_string($raw) ? json_decode($raw, true) : $raw; if (!is_array($data)) { $data = []; } $summary = self::formatTestResultSummary($testType, $data); $label = self::testTypeLabel($testType); $wuArr = null; if ($wu !== null) { if (is_array($wu)) { $wuArr = $wu; } elseif (is_object($wu) && method_exists($wu, 'toArray')) { $wuArr = $wu->toArray(); } } $eid = self::resolveEnterpriseIdForTestResult($row, $wuArr); $payload = [ 'display' => [ 'title' => '用户测评完成(实时推送)', 'emoji' => '📊', ], 'testResultId' => $testResultId, 'userId' => $userId, 'userName' => $userName, 'phone' => $phone, 'testType' => $testType, 'testTypeLabel' => $label, 'resultSummary' => $summary, 'completedAt' => $completedAt, ]; if ($phoneBoundAt !== null && trim($phoneBoundAt) !== '') { $payload['phoneBoundAt'] = trim($phoneBoundAt); } if (in_array($testType, ['face', 'ai'], true)) { $dims = self::buildFaceAiBotDimensions($data); if ($dims['mbti'] !== '') { $payload['resultMbti'] = $dims['mbti']; } if ($dims['pdp'] !== '') { $payload['resultPdp'] = $dims['pdp']; } if ($dims['disc'] !== '') { $payload['resultDisc'] = $dims['disc']; } } self::mergeTestResultUserJourneyPayload($payload, $userId, $row, $wuArr); return [ 'eid' => $eid, 'envelope' => [ 'event' => 'test.result_completed', 'occurredAt' => self::iso8601Cn(), 'environment' => self::appEnv(), 'tenant' => self::tenantPayload($eid), 'payload' => $payload, '_dedupKey' => 'test.result_completed:' . $testResultId, ], ]; } /** * 测评记录落库后推送(问卷 submit / 分析写库) * 无手机号时不推送,待用户授权手机号后与测评合并为一条(见 onPhoneBound) */ public static function onTestResultCompleted(int $testResultId): void { $pack = self::assembleTestResultCompletedEnvelope($testResultId, null, null); if ($pack === null) { return; } $phone = trim((string) ($pack['envelope']['payload']['phone'] ?? '')); if ($phone === '') { return; } self::dispatch('test.result_completed', $pack['envelope'], $pack['eid']); } /** * 管理端调试入口:按真实业务数据重放 test.result_completed,可选强制清去重后再发。 * * @return array */ public static function replayTestResultForDebug(int $testResultId, bool $force = false): array { if ($testResultId <= 0) { return [ 'ok' => false, 'status' => 'invalid', 'message' => 'testResultId 非法', ]; } $pack = self::assembleTestResultCompletedEnvelope($testResultId, null, null); if ($pack === null) { return [ 'ok' => false, 'status' => 'not_found', 'message' => '测试记录不存在', 'testResultId' => $testResultId, ]; } return self::dispatchDetailed('test.result_completed', $pack['envelope'], $pack['eid'], $force); } /** * @param array $envelope 须含 event、payload,可选 _dedupKey * @param int $contextEnterpriseId 业务归属企业(订单/用户/测评行上的 enterpriseId,无则 0) */ public static function dispatch(string $event, array $envelope, int $contextEnterpriseId = 0): void { self::dispatchDetailed($event, $envelope, $contextEnterpriseId, false); } /** * @param array $envelope * @return array */ private static function dispatchDetailed(string $event, array $envelope, int $contextEnterpriseId = 0, bool $force = false): array { $rawDedupKey = (string) ($envelope['_dedupKey'] ?? ''); $fullDedupKey = $rawDedupKey !== '' ? 'push_hook:' . $rawDedupKey : ''; if ($force && $rawDedupKey !== '') { self::rollbackDedup($rawDedupKey); } $resolved = self::getEffectiveConfigForEvent($contextEnterpriseId, $event); if ($resolved === null) { Log::warning('OutboundPushHook skipped: no effective config', [ 'event' => $event, 'contextEnterpriseId' => $contextEnterpriseId, 'hint' => '检查本企业与全平台行的 enabled、url,以及 events 是否包含该事件(空数组表示全部)', ]); return [ 'ok' => false, 'status' => 'no_config', 'message' => '未找到对此事件有效的 Hook 配置', 'event' => $event, 'contextEnterpriseId' => $contextEnterpriseId, 'configEnterpriseId' => null, 'usedPlatformFallback' => null, 'dedupKey' => $fullDedupKey !== '' ? $fullDedupKey : null, 'forced' => $force, ]; } $cfg = $resolved['config']; $dedupKey = (string) ($envelope['_dedupKey'] ?? ''); unset($envelope['_dedupKey']); if ($dedupKey !== '' && !self::beginDedup($dedupKey)) { Log::warning('OutboundPushHook skipped: dedup duplicate', [ 'event' => $event, 'contextEnterpriseId' => $contextEnterpriseId, 'dedupKey' => $dedupKey, ]); return [ 'ok' => false, 'status' => 'duplicate', 'message' => '命中去重,已跳过发送', 'event' => $event, 'contextEnterpriseId' => $contextEnterpriseId, 'configEnterpriseId' => $resolved['configEnterpriseId'], 'usedPlatformFallback' => $resolved['usedPlatformFallback'], 'dedupKey' => 'push_hook:' . $dedupKey, 'forced' => $force, ]; } $envelope['hook'] = [ 'configEnterpriseId' => $resolved['configEnterpriseId'], 'usedPlatformFallback' => $resolved['usedPlatformFallback'], ]; $url = trim((string) ($cfg['url'] ?? '')); $httpStatus = 0; $respBody = ''; $curlErr = ''; $bizHint = null; if (self::isThirdPartyBotTextUrl($url)) { $plain = self::envelopeToBotPlainText($event, $envelope); $body = self::buildThirdPartyTextBody($url, $plain); $headers = ['Content-Type: application/json; charset=utf-8']; [$ok, $httpStatus, $respBody, $curlErr, $bizHint] = self::httpPostJsonWithCode($url, $body, $headers); } else { $body = json_encode($envelope, JSON_UNESCAPED_UNICODE); if ($body === false) { if ($dedupKey !== '') { self::rollbackDedup($dedupKey); } return [ 'ok' => false, 'status' => 'json_encode_failed', 'message' => 'JSON 编码失败', 'event' => $event, 'contextEnterpriseId' => $contextEnterpriseId, 'configEnterpriseId' => $resolved['configEnterpriseId'], 'usedPlatformFallback' => $resolved['usedPlatformFallback'], 'dedupKey' => $dedupKey !== '' ? ('push_hook:' . $dedupKey) : null, 'forced' => $force, ]; } $deliveryId = self::uuidV4(); $headers = [ 'Content-Type: application/json; charset=utf-8', 'X-MBTI-Event: ' . $event, 'X-MBTI-Delivery-Id: ' . $deliveryId, ]; $secret = trim((string) ($cfg['secret'] ?? '')); if ($secret !== '') { $sig = hash_hmac('sha256', $body, $secret); $headers[] = 'X-MBTI-Signature: sha256=' . $sig; } [$ok, $httpStatus, $respBody, $curlErr, $bizHint] = self::httpPostJsonWithCode($url, $body, $headers); } if (!$ok) { Log::warning('OutboundPushHook dispatch failed', [ 'event' => $event, 'url' => self::maskUrl($url), 'curl' => $curlErr, 'biz' => $bizHint, 'preview' => self::truncateForLog($respBody, 400), ]); } else { Log::info('OutboundPushHook dispatch ok', [ 'event' => $event, 'configEnterpriseId' => $resolved['configEnterpriseId'], 'contextEnterpriseId' => $contextEnterpriseId, 'url' => self::maskUrl($url), ]); } if (!$ok && $dedupKey !== '') { self::rollbackDedup($dedupKey); } return [ 'ok' => $ok, 'status' => $ok ? 'dispatched' : 'failed', 'message' => $ok ? '已发送到对端' : '发送失败', 'event' => $event, 'contextEnterpriseId' => $contextEnterpriseId, 'configEnterpriseId' => $resolved['configEnterpriseId'], 'usedPlatformFallback' => $resolved['usedPlatformFallback'], 'dedupKey' => $dedupKey !== '' ? ('push_hook:' . $dedupKey) : null, 'forced' => $force, 'httpStatus' => $httpStatus, 'responsePreview' => $respBody !== '' ? self::truncateForLog($respBody, 800) : null, 'businessHint' => $bizHint, 'curlError' => $curlErr !== '' ? $curlErr : null, 'targetUrl' => self::maskUrl($url), ]; } /** * 是否已存在出站去重记录(scene=outbound_hook)。 * * @param string $dedupKey envelope._dedupKey 原值,如 test.result_completed:123、lead.order_paid:456 */ public static function hasPushHookDedup(string $dedupKey): bool { if ($dedupKey === '') { return false; } try { return Db::name('delivery_dedup') ->where('scene', self::DEDUP_SCENE_OUTBOUND) ->where('dedupKey', $dedupKey) ->find() ? true : false; } catch (\Throwable $e) { return false; } } private static function tenantPayload(int $enterpriseId): array { if ($enterpriseId <= 0) { return [ 'enterpriseId' => 0, 'enterpriseName' => null, ]; } $name = Db::name('enterprises')->where('id', $enterpriseId)->value('name'); return [ 'enterpriseId' => $enterpriseId, 'enterpriseName' => $name !== null && $name !== '' ? (string) $name : null, ]; } private static function appEnv(): string { $e = (string) (env('app.env', '') ?: getenv('APP_ENV') ?: ''); return $e !== '' ? $e : 'production'; } private static function iso8601Cn(): string { $dt = new \DateTime('now', new \DateTimeZone('Asia/Shanghai')); return $dt->format('c'); } private static function uuidV4(): string { $b = random_bytes(16); $b[6] = chr(ord($b[6]) & 0x0f | 0x40); $b[8] = chr(ord($b[8]) & 0x3f | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4)); } private static function beginDedup(string $rawDedupKey): bool { if ($rawDedupKey === '') { return false; } try { Db::name('delivery_dedup')->insert([ 'scene' => self::DEDUP_SCENE_OUTBOUND, 'dedupKey' => $rawDedupKey, 'createdAt' => date('Y-m-d H:i:s'), ]); return true; } catch (\Throwable $e) { return false; } } private static function rollbackDedup(string $rawDedupKey): void { if ($rawDedupKey === '') { return; } try { Db::name('delivery_dedup') ->where('scene', self::DEDUP_SCENE_OUTBOUND) ->where('dedupKey', $rawDedupKey) ->delete(); } catch (\Throwable $e) { } } /** * @param array $headers */ /** * @return array{0: bool, 1: int, 2: string, 3: string, 4: ?string} ok, httpStatus, responseBody, curlError, businessHint */ private static function httpPostJsonWithCode(string $url, string $body, array $headers): array { $ch = curl_init($url); if ($ch === false) { return [false, 0, '', 'curl_init failed', null]; } curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); 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); $raw = curl_exec($ch); $errno = curl_errno($ch); $curlErr = $errno ? (string) curl_error($ch) : ''; $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $responseBody = is_string($raw) ? $raw : ''; if ($errno !== 0) { return [false, $code, $responseBody, $curlErr, null]; } $httpOk = $code >= 200 && $code < 300; $biz = self::interpretOutboundResponseBody($responseBody); $ok = $httpOk && $biz['ok']; return [$ok, $code, $responseBody, '', $biz['hint']]; } /** * 企微/飞书等常见接口:HTTP 200 但 body 内声明失败(与 FeishuLeadWebhookService::postWebhook 对齐) * * @return array{ok: bool, hint: ?string} */ private static function interpretOutboundResponseBody(string $body): array { $body = trim($body); if ($body === '') { return ['ok' => true, 'hint' => null]; } if ($body[0] === '<' || stripos($body, ' false, 'hint' => '对端返回 HTML 而非 JSON,多为 URL 填成网站首页或错误页']; } $resp = json_decode($body, true); if (!is_array($resp)) { return ['ok' => true, 'hint' => null]; } if (isset($resp['errcode']) && (int) $resp['errcode'] !== 0) { $msg = isset($resp['errmsg']) ? (string) $resp['errmsg'] : ''; return ['ok' => false, 'hint' => 'errcode=' . $resp['errcode'] . ($msg !== '' ? ' ' . $msg : '')]; } if (isset($resp['StatusCode']) && (int) $resp['StatusCode'] !== 0) { $msg = isset($resp['StatusMessage']) ? (string) $resp['StatusMessage'] : ''; return ['ok' => false, 'hint' => 'StatusCode=' . $resp['StatusCode'] . ($msg !== '' ? ' ' . $msg : '')]; } if (isset($resp['code']) && (int) $resp['code'] !== 0) { return ['ok' => false, 'hint' => 'code=' . $resp['code']]; } return ['ok' => true, 'hint' => null]; } private static function truncateForLog(string $s, int $max): string { if ($s === '') { return ''; } if (function_exists('mb_strlen') && mb_strlen($s) > $max) { return mb_substr($s, 0, $max) . '…'; } if (strlen($s) > $max) { return substr($s, 0, $max) . '…'; } return $s; } /** 日志中隐藏 query 敏感参数 */ private static function maskUrl(string $url): string { $url = trim($url); if ($url === '' || strpos($url, '?') === false) { return $url; } $p = parse_url($url); if (!is_array($p) || empty($p['scheme']) || empty($p['host'])) { return $url; } return ($p['scheme'] ?? 'https') . '://' . ($p['host'] ?? '') . ($p['path'] ?? '') . '?…'; } /** * 面相/人脸结果中的 MBTI、PDP、DISC 三维度文案(与小程序 resultData 结构一致)。 * * @param array $data * @return array{mbti: string, pdp: string, disc: string} */ private static function buildFaceAiBotDimensions(array $data): array { $mbti = ''; if (isset($data['mbti']['type'])) { $mbti = trim((string) $data['mbti']['type']); } elseif (isset($data['mbti']) && !is_array($data['mbti'])) { $mbti = trim((string) $data['mbti']); } elseif (!empty($data['mbtiType']) && is_string($data['mbtiType'])) { $mbti = trim($data['mbtiType']); } $pdp = ''; if (isset($data['pdp']) && is_array($data['pdp'])) { $p1 = trim((string) ($data['pdp']['primary'] ?? '')); $p2 = trim((string) ($data['pdp']['secondary'] ?? '')); $p1 = preg_replace('/型$/u', '', $p1); $p2 = preg_replace('/型$/u', '', $p2); if ($p1 !== '' && $p2 !== '' && $p1 !== $p2) { $pdp = $p1 . '+' . $p2 . '型'; } elseif ($p1 !== '') { $pdp = $p1; if (!preg_match('/型$/u', $pdp)) { $pdp .= '型'; } } } if ($pdp === '') { $pdp = PdpDiscResultText::pdpTopTwo($data); } $disc = ''; if (isset($data['disc']) && is_array($data['disc'])) { $d1 = trim((string) ($data['disc']['primary'] ?? '')); $d2 = trim((string) ($data['disc']['secondary'] ?? '')); $L1 = strtoupper(substr(preg_replace('/型$/u', '', $d1), 0, 1)); $L2 = strtoupper(substr(preg_replace('/型$/u', '', $d2), 0, 1)); if (in_array($L1, ['D', 'I', 'S', 'C'], true) && in_array($L2, ['D', 'I', 'S', 'C'], true) && $L1 !== $L2) { $disc = $L1 . '+' . $L2; } elseif (in_array($L1, ['D', 'I', 'S', 'C'], true)) { $disc = $L1; } } if ($disc === '') { $disc = PdpDiscResultText::discTopTwo($data); $disc = preg_replace('/型$/u', '', $disc); } return [ 'mbti' => $mbti, 'pdp' => $pdp, 'disc' => $disc, ]; } /** * @param array $data */ private static function formatTestResultSummary(string $testType, array $data): string { switch ($testType) { case 'mbti': return (string) ($data['mbtiType'] ?? $data['mbti'] ?? '未知'); case 'disc': $t = PdpDiscResultText::discTopTwo($data); if ($t !== '') { return $t; } $dominantType = $data['dominantType'] ?? $data['disc'] ?? '未知'; return (is_string($dominantType) || is_numeric($dominantType) ? (string) $dominantType : '未知') . '型'; case 'pdp': $t = PdpDiscResultText::pdpTopTwo($data); if ($t !== '') { return $t; } return (string) ($data['description']['type'] ?? $data['pdp'] ?? '未知'); case 'sbti': $r = (string) ($data['sbtiType'] ?? $data['finalType']['code'] ?? '未知'); if (!empty($data['sbtiCn'])) { $r .= '(' . $data['sbtiCn'] . ')'; } elseif (!empty($data['finalType']['cn'])) { $r .= '(' . $data['finalType']['cn'] . ')'; } return $r; case 'face': case 'ai': if (isset($data['mbti']['type'])) { return (string) $data['mbti']['type']; } if (isset($data['mbti']) && !is_array($data['mbti'])) { return (string) $data['mbti']; } if (!empty($data['mbtiType']) && is_string($data['mbtiType'])) { return (string) $data['mbtiType']; } if (!empty($data['faceAnalysis']) && is_string($data['faceAnalysis'])) { return self::truncatePlainText($data['faceAnalysis'], 100); } return '面相分析'; case 'resume': if (!empty($data['overview'])) { $s = strip_tags((string) $data['overview']); if (function_exists('mb_strlen') && mb_strlen($s) > 80) { return mb_substr($s, 0, 80) . '…'; } return strlen($s) > 80 ? substr($s, 0, 80) . '…' : $s; } return '简历综合分析'; default: return $testType !== '' ? $testType : '未知'; } } private static function testTypeLabel(string $testType): string { $map = [ 'mbti' => 'MBTI 性格测试', 'sbti' => 'SBTI 性格测试', 'disc' => 'DISC 性格测试', 'pdp' => 'PDP 行为偏好测试', 'face' => '面相分析', 'ai' => 'AI 人脸分析', 'resume' => '简历综合分析', ]; return $map[$testType] ?? strtoupper($testType); } }