From cf835ea58558838379c40f9793406a5005ea8240 Mon Sep 17 00:00:00 2001 From: Ghost <106998207@qq.com> Date: Wed, 15 Apr 2026 15:07:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E7=AB=AF=E6=8E=A8?= =?UTF-8?q?=E9=80=81=20Webhook=20=E9=85=8D=E7=BD=AE=E4=B8=8E=E5=87=BA?= =?UTF-8?q?=E7=AB=99=E6=8E=A8=E9=80=81=E9=93=BE=E8=B7=AF=E6=8E=A5=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1、修复了管理端设置在推送开关/配置项上的联动与保存问题。 2、新增 OutboundPushHookService、PushHook/InternalPushHook 接口、前端 PushHookConfigPanel 与小程序 pushHook 工具。 3、优化 Analyze/Auth/Test/支付回调触发路径,补充去重迁移脚本与实施文档。 Made-with: Cursor --- admin/src/views/admin/PushHookConfigPanel.vue | 394 +++++ admin/src/views/admin/Settings.vue | 12 +- admin/src/views/superadmin/Settings.vue | 10 +- .../service/FeishuLeadWebhookService.php | 17 +- .../service/OutboundPushHookService.php | 1349 +++++++++++++++++ api/app/controller/admin/Settings.php | 152 ++ api/app/controller/api/Analyze.php | 10 + api/app/controller/api/Auth.php | 5 + api/app/controller/api/InternalPushHook.php | 66 + api/app/controller/api/PushHook.php | 133 ++ api/app/controller/api/Test.php | 5 + api/app/controller/superadmin/Settings.php | 117 ++ .../migrations/add_feishu_lead_webhook.sql | 27 +- .../migrations/run_delivery_dedup.php | 38 + .../migrations/run_feishu_lead_dedup.php | 7 + api/route/api.php | 12 + api/scripts/run_push_hook_test.php | 28 + douyin-miniprogram/app.js | 7 +- douyin-miniprogram/pages/index/result.js | 2 + douyin-miniprogram/pages/result/resume.js | 4 + douyin-miniprogram/utils/payment.js | 3 + douyin-miniprogram/utils/pushHook.js | 45 + miniprogram/app.js | 7 +- miniprogram/pages/index/result.js | 2 + miniprogram/pages/result/resume.js | 4 + miniprogram/pages/result/resume.wxml | 192 +-- miniprogram/utils/payment.js | 2 + miniprogram/utils/pushHook.js | 45 + 开发文档/管理端推送Webhook扩展方案.md | 380 +++++ 29 files changed, 2964 insertions(+), 111 deletions(-) create mode 100644 admin/src/views/admin/PushHookConfigPanel.vue create mode 100644 api/app/common/service/OutboundPushHookService.php create mode 100644 api/app/controller/api/InternalPushHook.php create mode 100644 api/app/controller/api/PushHook.php create mode 100644 api/database/migrations/run_delivery_dedup.php create mode 100644 api/database/migrations/run_feishu_lead_dedup.php create mode 100644 api/scripts/run_push_hook_test.php create mode 100644 douyin-miniprogram/utils/pushHook.js create mode 100644 miniprogram/utils/pushHook.js create mode 100644 开发文档/管理端推送Webhook扩展方案.md diff --git a/admin/src/views/admin/PushHookConfigPanel.vue b/admin/src/views/admin/PushHookConfigPanel.vue new file mode 100644 index 0000000..dcd97ee --- /dev/null +++ b/admin/src/views/admin/PushHookConfigPanel.vue @@ -0,0 +1,394 @@ + + + + + diff --git a/admin/src/views/admin/Settings.vue b/admin/src/views/admin/Settings.vue index 3fa6884..e360b61 100644 --- a/admin/src/views/admin/Settings.vue +++ b/admin/src/views/admin/Settings.vue @@ -134,6 +134,10 @@ +
+ +
+
@@ -150,8 +154,9 @@ import { ElMessage } from 'element-plus' import { request } from '@/utils/request' import { getAdminRole } from '@/utils/authStorage' import Finance from './Finance.vue' +import PushHookConfigPanel from './PushHookConfigPanel.vue' -const TAB_IDS = ['account', 'features', 'finance'] as const +const TAB_IDS = ['account', 'pushhook', 'features', 'finance'] as const type TabId = (typeof TAB_IDS)[number] function isTabId(s: string): s is TabId { @@ -172,7 +177,10 @@ const canConfigureCunkebaoKeys = () => { } const tabs = computed(() => { - const rows: { label: string; value: TabId }[] = [{ label: '账号设置', value: 'account' }] + const rows: { label: string; value: TabId }[] = [ + { label: '账号设置', value: 'account' }, + { label: '出站推送', value: 'pushhook' } + ] if (isEnterpriseAdmin() || canConfigureCunkebaoKeys()) { rows.push({ label: '功能开关', value: 'features' }) } diff --git a/admin/src/views/superadmin/Settings.vue b/admin/src/views/superadmin/Settings.vue index 42847b1..76b711e 100644 --- a/admin/src/views/superadmin/Settings.vue +++ b/admin/src/views/superadmin/Settings.vue @@ -335,6 +335,10 @@ +
+ +
+
@@ -417,18 +421,21 @@ import { Document, ChatDotRound, Postcard, - Reading + Reading, + Connection } from '@element-plus/icons-vue' import { ElMessage } from 'element-plus' import { request } from '@/utils/request' import PosterEditor from './PosterEditor.vue' import Questions from './Questions.vue' +import PushHookConfigPanel from '../admin/PushHookConfigPanel.vue' const TAB_IDS = [ 'review', 'system', 'prompts', 'poster', + 'pushhook', 'security', 'questions' ] as const @@ -449,6 +456,7 @@ const tabs: { label: string; value: TabId; icon: any }[] = [ { label: '系统配置', value: 'system', icon: Setting }, { label: '提示词配置', value: 'prompts', icon: ChatDotRound }, { label: '海报配置', value: 'poster', icon: Postcard }, + { label: '出站推送', value: 'pushhook', icon: Connection }, { label: '账户安全', value: 'security', icon: Lock }, { label: '题库管理', value: 'questions', icon: Reading } ] diff --git a/api/app/common/service/FeishuLeadWebhookService.php b/api/app/common/service/FeishuLeadWebhookService.php index 973abe0..637361b 100644 --- a/api/app/common/service/FeishuLeadWebhookService.php +++ b/api/app/common/service/FeishuLeadWebhookService.php @@ -11,6 +11,9 @@ class FeishuLeadWebhookService { public const CONFIG_KEY = 'feishu_lead_webhook'; + /** 去重表 scene:飞书获客(与 OutboundPushHookService::DEDUP_SCENE_OUTBOUND 区分) */ + private const DEDUP_SCENE = 'feishu_lead'; + public static function getConfig(): array { $def = [ @@ -140,7 +143,10 @@ class FeishuLeadWebhookService } } - private static function sourceLabelForOrder(string $productType, string $title, int $amountFen): string + /** + * 订单来源文案(飞书「来源」与 HTTP 出站 Hook `sourceLabel` 共用) + */ + public static function sourceLabelForOrder(string $productType, string $title, int $amountFen): string { if ($productType === 'recharge') { return '企业余额·充值支付成功'; @@ -148,6 +154,7 @@ class FeishuLeadWebhookService $map = [ 'face' => '面相测试', 'mbti' => 'MBTI测试', + 'sbti' => 'SBTI测试', 'disc' => 'DISC测试', 'pdp' => 'PDP测试', 'resume' => '简历分析', @@ -259,7 +266,8 @@ class FeishuLeadWebhookService private static function beginDedup(string $dedupKey): bool { try { - Db::name('feishu_lead_dedup')->insert([ + Db::name('delivery_dedup')->insert([ + 'scene' => self::DEDUP_SCENE, 'dedupKey' => $dedupKey, 'createdAt' => date('Y-m-d H:i:s'), ]); @@ -272,7 +280,10 @@ class FeishuLeadWebhookService private static function rollbackDedup(string $dedupKey): void { try { - Db::name('feishu_lead_dedup')->where('dedupKey', $dedupKey)->delete(); + Db::name('delivery_dedup') + ->where('scene', self::DEDUP_SCENE) + ->where('dedupKey', $dedupKey) + ->delete(); } catch (\Throwable $e) { } } diff --git a/api/app/common/service/OutboundPushHookService.php b/api/app/common/service/OutboundPushHookService.php new file mode 100644 index 0000000..d10da1e --- /dev/null +++ b/api/app/common/service/OutboundPushHookService.php @@ -0,0 +1,1349 @@ +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', + ]; + + /** + * 读取指定作用域配置(不合并回落;回落在 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'] ?? ''); + $lines = [ + $head . "📊 测评完成\n", + '类型: ' . ($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; + + return implode('', $lines); + 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; + } + + /** + * @param array $payload + */ + private static function triggerAsyncInternalDispatch(array $payload): void + { + $url = self::resolveAsyncDispatchUrl(); + if ($url === '') { + Log::warning('OutboundPushHook async enqueue skipped: no internal url', [ + 'payload' => $payload, + ]); + + return; + } + + $body = json_encode($payload, JSON_UNESCAPED_UNICODE); + if ($body === false) { + Log::warning('OutboundPushHook async enqueue skipped: json encode failed', [ + 'payload' => $payload, + ]); + + return; + } + + $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)) { + Log::warning('OutboundPushHook async enqueue failed', [ + 'url' => self::maskUrl($url), + 'payload' => $payload, + ]); + } + } + + private static function resolveAsyncDispatchUrl(): string + { + $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://' : ''; + $socket = @stream_socket_client($transport . $host . ':' . $port, $errno, $errstr, 1); + 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, 1); + $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; + } + + 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; + } + $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); + } + + /** + * 测评记录落库后推送(问卷 submit / 分析写库) + */ + public static function onTestResultCompleted(int $testResultId): void + { + if ($testResultId <= 0) { + return; + } + $row = Db::name('test_results')->where('id', $testResultId)->find(); + if (!$row) { + return; + } + $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')->find() + : null; + $userName = $wu ? trim((string) ($wu['nickname'] ?? '')) : ''; + if ($userName === '') { + $userName = '微信用户'; + } + $phone = $wu ? trim((string) ($wu['phone'] ?? '')) : ''; + + $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 (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::dispatch('test.result_completed', [ + 'event' => 'test.result_completed', + 'occurredAt' => self::iso8601Cn(), + 'environment' => self::appEnv(), + 'tenant' => self::tenantPayload($eid), + 'payload' => $payload, + '_dedupKey' => 'test.result_completed:' . $testResultId, + ], $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 非法', + ]; + } + + $row = Db::name('test_results')->where('id', $testResultId)->find(); + if (!$row) { + return [ + 'ok' => false, + 'status' => 'not_found', + 'message' => '测试记录不存在', + 'testResultId' => $testResultId, + ]; + } + + $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')->find() + : null; + $userName = $wu ? trim((string) ($wu['nickname'] ?? '')) : ''; + if ($userName === '') { + $userName = '微信用户'; + } + $phone = $wu ? trim((string) ($wu['phone'] ?? '')) : ''; + + $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 (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']; + } + } + + return self::dispatchDetailed('test.result_completed', [ + 'event' => 'test.result_completed', + 'occurredAt' => self::iso8601Cn(), + 'environment' => self::appEnv(), + 'tenant' => self::tenantPayload($eid), + 'payload' => $payload, + '_dedupKey' => 'test.result_completed:' . $testResultId, + ], $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); + } +} diff --git a/api/app/controller/admin/Settings.php b/api/app/controller/admin/Settings.php index d8955f5..5f3b0ce 100644 --- a/api/app/controller/admin/Settings.php +++ b/api/app/controller/admin/Settings.php @@ -3,6 +3,7 @@ namespace app\controller\admin; use app\BaseController; use app\common\service\FeishuLeadWebhookService; +use app\common\service\OutboundPushHookService; use app\model\SystemConfig as SystemConfigModel; use app\model\User as UserModel; use think\facade\Request; @@ -459,6 +460,157 @@ class Settings extends BaseController return success(null, '已保存'); } + /** + * 当前管理员可编辑的作用域:有企业则读写 enterprise_id=本企业,否则读写全平台默认(0,与超管维护同一条) + * GET /api/v1/admin/settings/push-hook + */ + public function getPushHookConfig() + { + $user = $this->request->user ?? null; + if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) { + return error('无权限访问', 403); + } + $eid = $this->resolveAdminPushHookEnterpriseId(); + $cfg = OutboundPushHookService::getConfig($eid); + $events = $cfg['events'] ?? []; + if (!is_array($events)) { + $events = []; + } + $enterpriseName = null; + if ($eid > 0) { + $n = Db::name('enterprises')->where('id', $eid)->value('name'); + $enterpriseName = ($n !== null && $n !== '') ? (string) $n : null; + } + return success([ + 'scope' => $eid > 0 ? 'enterprise' : 'platform', + 'configEnterpriseId' => $eid, + 'enterpriseName' => $enterpriseName, + 'enabled' => !empty($cfg['enabled']), + 'url' => (string) ($cfg['url'] ?? ''), + 'secret' => (string) ($cfg['secret'] ?? ''), + 'events' => $events, + ]); + } + + /** + * PUT /api/v1/admin/settings/push-hook + */ + public function updatePushHookConfig() + { + $user = $this->request->user ?? null; + if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) { + return error('无权限访问', 403); + } + $raw = $this->request->getContent(); + $input = $raw ? json_decode($raw, true) : []; + if (!is_array($input)) { + $input = []; + } + $enabled = !empty($input['enabled']); + $url = trim((string) ($input['url'] ?? '')); + $secret = (string) ($input['secret'] ?? ''); + $eventsRaw = $input['events'] ?? null; + $events = []; + if (is_array($eventsRaw)) { + foreach ($eventsRaw as $ev) { + $ev = trim((string) $ev); + if ($ev !== '') { + $events[] = $ev; + } + } + } + if ($enabled && $url !== '' && stripos($url, 'http') !== 0) { + return error('URL 须以 http(s) 开头', 400); + } + $json = json_encode([ + 'enabled' => $enabled, + 'url' => $url, + 'secret' => $secret, + 'events' => $events, + ], JSON_UNESCAPED_UNICODE); + $now = time(); + $key = OutboundPushHookService::CONFIG_KEY; + $eid = $this->resolveAdminPushHookEnterpriseId(); + $desc = $eid > 0 ? '通用 HTTP 出站推送 Hook(企业专属)' : '通用 HTTP 出站推送 Hook(全平台默认)'; + $exists = Db::name('system_config')->where('key', $key)->where('enterprise_id', $eid)->find(); + if ($exists) { + Db::name('system_config') + ->where('key', $key) + ->where('enterprise_id', $eid) + ->update(['value' => $json, 'updatedAt' => $now]); + } else { + Db::name('system_config')->insert([ + 'key' => $key, + 'enterprise_id' => $eid, + 'value' => $json, + 'description' => $desc, + 'createdAt' => $now, + 'updatedAt' => $now, + ]); + } + return success(null, '已保存'); + } + + /** + * POST /api/v1/admin/settings/push-hook/test + * 向当前解析到的 URL 发送 hook.ping(不写去重表) + */ + public function testPushHookConfig() + { + $user = $this->request->user ?? null; + if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) { + return error('无权限访问', 403); + } + $ctx = $this->resolveAdminPushHookEnterpriseId(); + $r = OutboundPushHookService::sendTestPing($ctx); + + return success($r, $r['ok'] ? '测试推送已发出' : ($r['message'] ?? '测试失败')); + } + + /** + * POST /api/v1/admin/settings/push-hook/test-result + * 按真实业务数据重放一条 test.result_completed,支持 force=1 强制清去重。 + */ + public function testPushHookTestResult() + { + $user = $this->request->user ?? null; + if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) { + return error('无权限访问', 403); + } + + $testResultId = (int) Request::param('testResultId', 0); + $force = (int) Request::param('force', 0) === 1; + $r = OutboundPushHookService::replayTestResultForDebug($testResultId, $force); + + return success($r, $r['message'] ?? ($r['ok'] ? '已重放推送' : '重放失败')); + } + + /** + * 出站 Hook 可编辑行:JWT 或 users 表解析到的本企业 ID;无企业则为 0(与超管共用全平台默认行) + */ + private function resolveAdminPushHookEnterpriseId(): int + { + $user = $this->request->user ?? null; + if (!$user) { + return 0; + } + $eid = (int) ($user['enterpriseId'] ?? 0); + if ($eid > 0) { + return $eid; + } + $adminId = (int) ($user['userId'] ?? 0); + if ($adminId > 0) { + $v = Db::name('users')->where('id', $adminId)->value('enterpriseId'); + if ($v !== null && $v !== '') { + $x = (int) $v; + if ($x > 0) { + return $x; + } + } + } + return 0; + } + /** * 更新小程序配置 * 写入 text_config 行:enterprise_id={eid}(有企业)或 0(无企业) diff --git a/api/app/controller/api/Analyze.php b/api/app/controller/api/Analyze.php index 0eeabc7..7af9000 100644 --- a/api/app/controller/api/Analyze.php +++ b/api/app/controller/api/Analyze.php @@ -215,6 +215,12 @@ class Analyze extends BaseController } catch (\Throwable $e) { // 第三方开放平台失败不阻断 } + + // 出站 Webhook:与问卷 submit 一致,异步投递内部接口触发 test.result_completed(小程序亦会调 push-hook,去重表防双发) + try { + \app\common\service\OutboundPushHookService::triggerAsyncTestResultCompleted((int) $testResultId); + } catch (\Throwable $e) { + } } } catch (\Throwable $e) { // 写入失败不影响返回分析结果 @@ -396,6 +402,10 @@ class Analyze extends BaseController try { \app\controller\api\Distribution::settleTestCommission($testResultId, $userId, 'resume'); } catch (\Throwable $e) {} + try { + \app\common\service\OutboundPushHookService::triggerAsyncTestResultCompleted($testResultId); + } catch (\Throwable $e) { + } } $responseData = $resultStruct; diff --git a/api/app/controller/api/Auth.php b/api/app/controller/api/Auth.php index a5a7c6e..72417ba 100644 --- a/api/app/controller/api/Auth.php +++ b/api/app/controller/api/Auth.php @@ -7,6 +7,7 @@ use app\model\WechatUser; use app\common\service\JwtService; use app\common\service\WechatService; use app\common\service\FeishuLeadWebhookService; +use app\common\service\OutboundPushHookService; use app\common\service\ThirdPartyChannelService; use think\facade\Request; use think\facade\Db; @@ -542,6 +543,10 @@ class Auth extends BaseController FeishuLeadWebhookService::onPhoneBound($userId, $phone); } catch (\Throwable $e) { } + try { + OutboundPushHookService::onPhoneBound($userId, $phone); + } catch (\Throwable $e) { + } } $row = Db::name('wechat_users')->where('id', $userId)->find(); unset($row['sessionKey'], $row['openid']); diff --git a/api/app/controller/api/InternalPushHook.php b/api/app/controller/api/InternalPushHook.php new file mode 100644 index 0000000..a86b20b --- /dev/null +++ b/api/app/controller/api/InternalPushHook.php @@ -0,0 +1,66 @@ + Request::ip(), + 'hasSignature' => $signature !== '', + 'timestamp' => $timestamp, + ]); + + return error('forbidden', 403); + } + + $payload = json_decode($body, true); + if (!is_array($payload)) { + return error('invalid payload', 400); + } + + $job = (string) ($payload['job'] ?? ''); + + try { + switch ($job) { + case 'lead.order_paid': + OutboundPushHookService::onOrderPaid( + (int) ($payload['orderId'] ?? 0), + (int) ($payload['userId'] ?? 0) + ); + break; + case 'test.result_completed': + OutboundPushHookService::onTestResultCompleted((int) ($payload['testResultId'] ?? 0)); + break; + default: + return error('unsupported job', 400); + } + } catch (\Throwable $e) { + Log::error('OutboundPushHook async dispatch failed', [ + 'job' => $job, + 'payload' => $payload, + 'error' => $e->getMessage(), + ]); + + return error('dispatch failed', 500); + } + + return success(['accepted' => true], 'accepted'); + } +} diff --git a/api/app/controller/api/PushHook.php b/api/app/controller/api/PushHook.php new file mode 100644 index 0000000..420ecb3 --- /dev/null +++ b/api/app/controller/api/PushHook.php @@ -0,0 +1,133 @@ +request->user ?? null; + $user = is_array($user) ? $user : (array) $user; + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + $source = (string) ($user['source'] ?? ''); + + if ($userId <= 0 || $source !== 'wechat') { + return error('未登录', 401); + } + + $event = trim((string) Request::post('event', '')); + if ($event === '') { + return error('event 不能为空', 400); + } + + try { + switch ($event) { + case 'test.result_completed': + return $this->triggerTestResultCompleted($userId); + case 'lead.order_paid': + return $this->triggerOrderPaid($userId); + default: + return error('暂不支持的事件类型', 400); + } + } catch (\Throwable $e) { + Log::error('PushHook trigger failed', [ + 'event' => $event, + 'userId' => $userId, + 'error' => $e->getMessage(), + ]); + + return error('触发推送失败', 500); + } + } + + private function triggerTestResultCompleted(int $userId) + { + $testResultId = (int) Request::post('testResultId', 0); + if ($testResultId <= 0) { + return error('testResultId 不能为空', 400); + } + + $row = Db::name('test_results') + ->where('id', $testResultId) + ->field('id,userId,testType') + ->find(); + if (!$row) { + return error('测试记录不存在', 404); + } + if ((int) ($row['userId'] ?? 0) !== $userId) { + return error('无权触发该测试记录推送', 403); + } + + $rawDedupKey = 'test.result_completed:' . $testResultId; + if (OutboundPushHookService::hasPushHookDedup($rawDedupKey)) { + return success([ + 'accepted' => false, + 'event' => 'test.result_completed', + 'testResultId' => $testResultId, + 'status' => 'duplicate', + 'dedupKey' => 'push_hook:' . $rawDedupKey, + ], '该测试记录已推送过,已按去重规则跳过'); + } + + OutboundPushHookService::onTestResultCompleted($testResultId); + + return success([ + 'accepted' => true, + 'event' => 'test.result_completed', + 'status' => 'dispatched', + 'testResultId' => $testResultId, + ], '已触发推送'); + } + + private function triggerOrderPaid(int $userId) + { + $orderNo = trim((string) Request::post('orderId', '')); + if ($orderNo === '') { + return error('orderId 不能为空', 400); + } + + $order = Db::name('orders') + ->where('orderNo', $orderNo) + ->where('userId', $userId) + ->field('id,userId,status') + ->find(); + if (!$order) { + return error('订单不存在', 404); + } + if (!in_array((string) ($order['status'] ?? ''), ['paid', 'completed'], true)) { + return error('订单尚未支付成功', 409); + } + + $rawDedupKey = 'lead.order_paid:' . (int) $order['id']; + if (OutboundPushHookService::hasPushHookDedup($rawDedupKey)) { + return success([ + 'accepted' => false, + 'event' => 'lead.order_paid', + 'orderId' => $orderNo, + 'status' => 'duplicate', + 'dedupKey' => 'push_hook:' . $rawDedupKey, + ], '该订单已推送过,已按去重规则跳过'); + } + + OutboundPushHookService::onOrderPaid((int) $order['id'], $userId); + + return success([ + 'accepted' => true, + 'event' => 'lead.order_paid', + 'status' => 'dispatched', + 'orderId' => $orderNo, + ], '已触发推送'); + } +} diff --git a/api/app/controller/api/Test.php b/api/app/controller/api/Test.php index 0315f1c..a407300 100644 --- a/api/app/controller/api/Test.php +++ b/api/app/controller/api/Test.php @@ -842,6 +842,11 @@ class Test extends BaseController } catch (\Throwable $e) { // 对接失败不阻断 } + + try { + \app\common\service\OutboundPushHookService::triggerAsyncTestResultCompleted((int) $id); + } catch (\Throwable $e) { + } } } catch (\Throwable $e) { return error('保存测试结果失败', 500); diff --git a/api/app/controller/superadmin/Settings.php b/api/app/controller/superadmin/Settings.php index 8d23d40..fdef8ac 100644 --- a/api/app/controller/superadmin/Settings.php +++ b/api/app/controller/superadmin/Settings.php @@ -3,6 +3,7 @@ namespace app\controller\superadmin; use app\BaseController; use app\common\service\FeishuLeadWebhookService; +use app\common\service\OutboundPushHookService; use app\model\SystemConfig as SystemConfigModel; use app\model\User as UserModel; use app\model\Enterprise as EnterpriseModel; @@ -393,6 +394,122 @@ class Settings extends BaseController return success(null, '已保存'); } + /** + * 通用 HTTP 出站推送 Hook(仅维护全平台默认 enterprise_id=0;企业专属由企业管理端维护) + * GET /api/v1/superadmin/settings/push-hook + */ + public function getPushHookConfig() + { + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + $cfg = OutboundPushHookService::getConfig(0); + $events = $cfg['events'] ?? []; + if (!is_array($events)) { + $events = []; + } + return success([ + 'scope' => 'platform', + 'configEnterpriseId' => 0, + 'enterpriseName' => null, + 'enabled' => !empty($cfg['enabled']), + 'url' => (string) ($cfg['url'] ?? ''), + 'secret' => (string) ($cfg['secret'] ?? ''), + 'events' => $events, + ]); + } + + /** + * PUT /api/v1/superadmin/settings/push-hook + */ + public function updatePushHookConfig() + { + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + $raw = $this->request->getContent(); + $input = $raw ? json_decode($raw, true) : []; + if (!is_array($input)) { + $input = []; + } + $enabled = !empty($input['enabled']); + $url = trim((string) ($input['url'] ?? '')); + $secret = (string) ($input['secret'] ?? ''); + $eventsRaw = $input['events'] ?? null; + $events = []; + if (is_array($eventsRaw)) { + foreach ($eventsRaw as $ev) { + $ev = trim((string) $ev); + if ($ev !== '') { + $events[] = $ev; + } + } + } + if ($enabled && $url !== '' && stripos($url, 'http') !== 0) { + return error('URL 须以 http(s) 开头', 400); + } + $json = json_encode([ + 'enabled' => $enabled, + 'url' => $url, + 'secret' => $secret, + 'events' => $events, + ], JSON_UNESCAPED_UNICODE); + $now = time(); + $key = OutboundPushHookService::CONFIG_KEY; + $exists = Db::name('system_config')->where('key', $key)->where('enterprise_id', 0)->find(); + if ($exists) { + Db::name('system_config') + ->where('key', $key) + ->where('enterprise_id', 0) + ->update(['value' => $json, 'updatedAt' => $now]); + } else { + Db::name('system_config')->insert([ + 'key' => $key, + 'enterprise_id' => 0, + 'value' => $json, + 'description' => '通用 HTTP 出站推送 Hook(全平台默认)', + 'createdAt' => $now, + 'updatedAt' => $now, + ]); + } + return success(null, '已保存'); + } + + /** + * POST /api/v1/superadmin/settings/push-hook/test + * 按全平台默认配置发送 hook.ping(contextEnterpriseId=0) + */ + public function testPushHookConfig() + { + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + $r = OutboundPushHookService::sendTestPing(0); + + return success($r, $r['ok'] ? '测试推送已发出' : ($r['message'] ?? '测试失败')); + } + + /** + * POST /api/v1/superadmin/settings/push-hook/test-result + * 按真实业务数据重放一条 test.result_completed,支持 force=1 强制清去重。 + */ + public function testPushHookTestResult() + { + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $testResultId = (int) Request::param('testResultId', 0); + $force = (int) Request::param('force', 0) === 1; + $r = OutboundPushHookService::replayTestResultForDebug($testResultId, $force); + + return success($r, $r['message'] ?? ($r['ok'] ? '已重放推送' : '重放失败')); + } + /** * 更新超管账户信息 * @return \think\response\Json diff --git a/api/database/migrations/add_feishu_lead_webhook.sql b/api/database/migrations/add_feishu_lead_webhook.sql index 1549218..c4a6aed 100644 --- a/api/database/migrations/add_feishu_lead_webhook.sql +++ b/api/database/migrations/add_feishu_lead_webhook.sql @@ -1,11 +1,24 @@ --- 飞书获客 Webhook 去重表(前缀 mbti_ 与 .env DATABASE_PREFIX 一致) -CREATE TABLE IF NOT EXISTS `mbti_feishu_lead_dedup` ( +-- 多业务推送幂等去重表(前缀须与 .env DATABASE_PREFIX 一致,默认 mbti_) +-- 若库中尚无此表,可任选其一: +-- 1)在 api 目录执行:php database/migrations/run_delivery_dedup.php(自动读前缀建表) +-- 2)在数据库控制台执行本文件(或按需把表名前缀改成你的 DATABASE_PREFIX) +CREATE TABLE IF NOT EXISTS `mbti_delivery_dedup` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `dedupKey` varchar(160) NOT NULL COMMENT '如 order_paid:123、phone_bind:456', + `scene` varchar(32) NOT NULL COMMENT '场景:feishu_lead=飞书获客;outbound_hook=通用出站 Webhook;扩展时新增枚举值', + `dedupKey` varchar(255) NOT NULL COMMENT '该场景下幂等键(与 scene 联合唯一);出站场景为 envelope._dedupKey 原值,不含 push_hook: 前缀', `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), - UNIQUE KEY `uk_dedup_key` (`dedupKey`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='飞书获客推送幂等'; + UNIQUE KEY `uk_scene_dedup` (`scene`, `dedupKey`), + KEY `idx_scene` (`scene`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='多业务推送幂等去重(飞书获客、出站 Webhook 等共用)'; --- 可选:加速按用户查最近行为(若 idx_user_id 已存在会报错,忽略即可) --- ALTER TABLE `mbti_analytics_events` ADD INDEX `idx_user_id` (`userId`); +-- 若已从旧版建过 `mbti_feishu_lead_dedup`(仅 dedupKey 无 scene),可迁移后删旧表(执行前请备份): +-- INSERT IGNORE INTO `mbti_delivery_dedup` (`scene`, `dedupKey`, `createdAt`) +-- SELECT +-- CASE WHEN `dedupKey` LIKE 'push_hook:%' THEN 'outbound_hook' ELSE 'feishu_lead' END, +-- CASE WHEN `dedupKey` LIKE 'push_hook:%' THEN SUBSTRING(`dedupKey`, 11) ELSE `dedupKey` END, +-- `createdAt` +-- FROM `mbti_feishu_lead_dedup`; +-- DROP TABLE `mbti_feishu_lead_dedup`; + +-- 若旧表仅有 dedupKey 160 字符等,可先 ALTER 再迁移;或先建新表再 INSERT IGNORE 如上。 diff --git a/api/database/migrations/run_delivery_dedup.php b/api/database/migrations/run_delivery_dedup.php new file mode 100644 index 0000000..785b55b --- /dev/null +++ b/api/database/migrations/run_delivery_dedup.php @@ -0,0 +1,38 @@ +initialize(); + +$prefix = (string) config('database.connections.mysql.prefix', ''); +$table = $prefix . 'delivery_dedup'; + +$sql = <<getMessage() . "\n"); + exit(1); +} diff --git a/api/database/migrations/run_feishu_lead_dedup.php b/api/database/migrations/run_feishu_lead_dedup.php new file mode 100644 index 0000000..7283465 --- /dev/null +++ b/api/database/migrations/run_feishu_lead_dedup.php @@ -0,0 +1,7 @@ +middleware('cors'); // 小程序/前端运行配置与面相分析(可选 token) @@ -73,6 +75,8 @@ Route::group('api', function () { Route::post('distribution/withdrawals/query-transfer', 'api.Distribution/queryTransfer'); Route::get('distribution/qrcode', 'api.Distribution/qrcode'); Route::get('distribution/poster', 'api.Distribution/poster'); + // 小程序主动触发出站推送(与主业务接口解耦) + Route::post('push-hook/trigger', 'api.PushHook/trigger'); // 微信商家转账结果回调(无需登录,但需配置到微信商户平台) Route::post('wechat/transfer/notify', 'api.WechatTransferNotify/notify')->middleware('cors'); // 存客宝获客线索上报 @@ -135,6 +139,10 @@ Route::group('api/v1/admin', function () { Route::put('pricing', 'admin.Pricing/update'); // 系统设置(普通管理员,子路径放前面避免被 settings 吞掉) + Route::get('settings/push-hook', 'admin.Settings/getPushHookConfig'); + Route::put('settings/push-hook', 'admin.Settings/updatePushHookConfig'); + Route::post('settings/push-hook/test', 'admin.Settings/testPushHookConfig'); + Route::post('settings/push-hook/test-result', 'admin.Settings/testPushHookTestResult'); Route::get('settings/miniprogram', 'admin.Settings/getMiniprogramConfig'); Route::put('settings/miniprogram', 'admin.Settings/updateMiniprogramConfig'); Route::get('settings/poster', 'admin.Settings/getPosterConfig'); @@ -238,6 +246,10 @@ Route::group('api/v1/superadmin', function () { Route::post('upload/image', 'admin.Upload/image'); // 系统设置(超管专用,子路径放前面避免被 settings 吞掉) + Route::get('settings/push-hook', 'superadmin.Settings/getPushHookConfig'); + Route::put('settings/push-hook', 'superadmin.Settings/updatePushHookConfig'); + Route::post('settings/push-hook/test', 'superadmin.Settings/testPushHookConfig'); + Route::post('settings/push-hook/test-result', 'superadmin.Settings/testPushHookTestResult'); Route::get('settings/fonts', 'superadmin.Settings/getFonts'); Route::get('settings/poster', 'superadmin.Settings/getPosterConfig'); Route::put('settings/poster', 'superadmin.Settings/updatePosterConfig'); diff --git a/api/scripts/run_push_hook_test.php b/api/scripts/run_push_hook_test.php new file mode 100644 index 0000000..fd9bc10 --- /dev/null +++ b/api/scripts/run_push_hook_test.php @@ -0,0 +1,28 @@ +initialize(); + +$ctx = 0; +if (isset($argv[1]) && $argv[1] !== '' && ctype_digit((string) $argv[1])) { + $ctx = (int) $argv[1]; +} + +$r = OutboundPushHookService::sendTestPing($ctx); + +echo json_encode($r, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL; + +exit(!empty($r['ok']) ? 0 : 2); diff --git a/douyin-miniprogram/app.js b/douyin-miniprogram/app.js index 4d6a9bf..5c729d9 100644 --- a/douyin-miniprogram/app.js +++ b/douyin-miniprogram/app.js @@ -364,6 +364,7 @@ App({ }, saveTestResult(type, result) { + const { triggerTestResultCompleted } = require('./utils/pushHook.js') const key = `${type}Result` tt.setStorageSync(key, result) this.globalData[key] = result @@ -398,7 +399,11 @@ App({ }, success: (res) => { if (res.statusCode === 200 && res.data && res.data.code === 200 && res.data.data && typeof res.data.data === 'object') { - resolve(res.data.data) + const extra = res.data.data + if (extra && extra.id) { + triggerTestResultCompleted(extra.id) + } + resolve(extra) } else { resolve({}) } diff --git a/douyin-miniprogram/pages/index/result.js b/douyin-miniprogram/pages/index/result.js index 1ed3f15..c60a064 100644 --- a/douyin-miniprogram/pages/index/result.js +++ b/douyin-miniprogram/pages/index/result.js @@ -3,6 +3,7 @@ const app = getApp() const payment = require('../../utils/payment') const { hasPhone, bindPhoneByCode, isProfileComplete } = require('../../utils/phoneAuth.js') const { mbtiDescriptions } = require('../../utils/descriptions') +const { triggerTestResultCompleted } = require('../../utils/pushHook') function buildSceneFallback(baseResult) { const mbti = baseResult.mbti || '' @@ -392,6 +393,7 @@ Page({ // 记录本次测试记录ID(由 /api/analyze 返回) if (apiData && apiData._testResultId) { updates.testResultId = apiData._testResultId + triggerTestResultCompleted(apiData._testResultId) } this.setData(updates) diff --git a/douyin-miniprogram/pages/result/resume.js b/douyin-miniprogram/pages/result/resume.js index 0f10c85..70645ab 100644 --- a/douyin-miniprogram/pages/result/resume.js +++ b/douyin-miniprogram/pages/result/resume.js @@ -1,6 +1,7 @@ // pages/result/resume.js - 简历综合分析结果页 const app = getApp() const payment = require('../../utils/payment') +const { triggerTestResultCompleted } = require('../../utils/pushHook') Page({ data: { @@ -182,6 +183,9 @@ Page({ const requiresPayment = !!p.requiresPayment const amountYuan = p.amountYuan || 0 const resultId = d._testResultId || 0 + if (resultId) { + triggerTestResultCompleted(resultId) + } // 兼容:API 直接返回结构化对象,或把 JSON 放在 content 里 d = this.normalizeStructuredData(d) diff --git a/douyin-miniprogram/utils/payment.js b/douyin-miniprogram/utils/payment.js index 83b9d16..4a32da6 100644 --- a/douyin-miniprogram/utils/payment.js +++ b/douyin-miniprogram/utils/payment.js @@ -4,6 +4,7 @@ const app = getApp() const { getEnterpriseIdForApiPayload } = require('./enterpriseContext.js') +const { triggerOrderPaid } = require('./pushHook.js') function enterpriseIdForOrder() { const eid = getEnterpriseIdForApiPayload() @@ -112,6 +113,7 @@ function douyinPay(options) { pollOrderStatus(orderId, 5, 1000, (ok, order) => { if (ok) { try { reportCrmTestPaymentAfterSuccess(productType, testResultId, enterpriseId || 0) } catch (e) {} + try { triggerOrderPaid(orderId) } catch (e) {} try { require('./analytics').reportPayResult(true, { productType: productType || '', orderId, amount }) } catch (e) {} tt.showToast({ title: '支付成功', @@ -140,6 +142,7 @@ function douyinPay(options) { pollOrderStatus(orderId, 5, 1500, (ok, order) => { if (ok) { try { reportCrmTestPaymentAfterSuccess(productType, testResultId, enterpriseId || 0) } catch (e) {} + try { triggerOrderPaid(orderId) } catch (e) {} tt.showToast({ title: '支付成功', icon: 'success', duration: 2000 }) success && success({ payRes, order }) } else { diff --git a/douyin-miniprogram/utils/pushHook.js b/douyin-miniprogram/utils/pushHook.js new file mode 100644 index 0000000..1b5be72 --- /dev/null +++ b/douyin-miniprogram/utils/pushHook.js @@ -0,0 +1,45 @@ +const { request } = require('./request') + +function triggerPushHook(event, payload = {}) { + if (!event) return Promise.resolve(false) + + return new Promise((resolve) => { + request({ + url: '/api/push-hook/trigger', + method: 'POST', + data: { + event, + ...payload + }, + success(res) { + const ok = !!(res && res.statusCode === 200 && res.data && res.data.code === 200) + if (!ok) { + console.warn('[PushHook] trigger rejected', event, res && res.data) + } + resolve(ok) + }, + fail(err) { + console.warn('[PushHook] trigger failed', event, err) + resolve(false) + } + }) + }) +} + +function triggerTestResultCompleted(testResultId) { + const id = Number(testResultId || 0) + if (id <= 0) return Promise.resolve(false) + return triggerPushHook('test.result_completed', { testResultId: id }) +} + +function triggerOrderPaid(orderId) { + const no = String(orderId || '').trim() + if (!no) return Promise.resolve(false) + return triggerPushHook('lead.order_paid', { orderId: no }) +} + +module.exports = { + triggerPushHook, + triggerTestResultCompleted, + triggerOrderPaid +} diff --git a/miniprogram/app.js b/miniprogram/app.js index ff6ad56..7f81935 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -421,6 +421,7 @@ App({ // 保存测试结果(同步服务端后 resolve { id, testType },用于结果页 URL 与分享) saveTestResult(type, result) { const { getEnterpriseIdForApiPayload } = require('./utils/enterpriseContext.js') + const { triggerTestResultCompleted } = require('./utils/pushHook.js') const key = `${type}Result` wx.setStorageSync(key, result) this.globalData[key] = result @@ -449,7 +450,11 @@ App({ }, success: (res) => { if (res.statusCode === 200 && res.data && res.data.code === 200 && res.data.data && typeof res.data.data === 'object') { - resolve(res.data.data) + const extra = res.data.data + if (extra && extra.id) { + triggerTestResultCompleted(extra.id) + } + resolve(extra) } else { resolve({}) } diff --git a/miniprogram/pages/index/result.js b/miniprogram/pages/index/result.js index 65bad75..9265208 100644 --- a/miniprogram/pages/index/result.js +++ b/miniprogram/pages/index/result.js @@ -3,6 +3,7 @@ const app = getApp() const payment = require('../../utils/payment') const { hasPhone, bindPhoneByCode, isProfileComplete } = require('../../utils/phoneAuth.js') const { mbtiDescriptions } = require('../../utils/descriptions') +const { triggerTestResultCompleted } = require('../../utils/pushHook') function buildSceneFallback(baseResult) { const mbti = baseResult.mbti || '' @@ -393,6 +394,7 @@ Page({ // 记录本次测试记录ID(由 /api/analyze 返回) if (apiData && apiData._testResultId) { updates.testResultId = apiData._testResultId + triggerTestResultCompleted(apiData._testResultId) } this.setData(updates) diff --git a/miniprogram/pages/result/resume.js b/miniprogram/pages/result/resume.js index 24ef995..643505f 100644 --- a/miniprogram/pages/result/resume.js +++ b/miniprogram/pages/result/resume.js @@ -2,6 +2,7 @@ const app = getApp() const payment = require('../../utils/payment') const { getEnterpriseIdForApiPayload } = require('../../utils/enterpriseContext.js') +const { triggerTestResultCompleted } = require('../../utils/pushHook') Page({ data: { @@ -178,6 +179,9 @@ Page({ const requiresPayment = !!p.requiresPayment const amountYuan = p.amountYuan || 0 const resultId = d._testResultId || 0 + if (resultId) { + triggerTestResultCompleted(resultId) + } // 兼容:API 直接返回结构化对象,或把 JSON 放在 content 里 d = this.normalizeStructuredData(d) diff --git a/miniprogram/pages/result/resume.wxml b/miniprogram/pages/result/resume.wxml index b79d630..5bfb4d6 100644 --- a/miniprogram/pages/result/resume.wxml +++ b/miniprogram/pages/result/resume.wxml @@ -1,96 +1,96 @@ - - - - - - - - - {{analyzingTitle || '正在分析中'}} - {{analyzingTip}} - - - - 这个过程可能需要30秒到1分钟,请耐心等待... - - - - - - - - - {{error ? '⚠️' : '📋'}} - - {{error ? '生成失败' : '人才综合评估报告'}} - {{error ? '请检查后重新生成' : '基于测评结果 · 综合分析'}} - - - - - - 失败原因 - {{error}} - - 点击重新生成 - - - - - - 🔒 - 报告已生成,需付费解锁 - 完整的简历综合分析报告已生成\n支付后即可查看全部内容 - - 解锁价格 - ¥{{payInfo.amountYuan}} - - - {{paying ? '支付中...' : '立即支付解锁'}} - - - - - - 📝 - 完善资料后查看完整报告 - 请补全头像、昵称并绑定手机号\n即可查看简历综合分析全文 - - 去完善资料 - - - - - - - - {{item.icon}} - {{item.title}} - - - {{item.body}} - - - - - - - 综合分析综评 - {{content}} - - - - - - - 重新分析生成 - - - 返回首页 - - - - - + + + + + + + + + {{analyzingTitle || '正在分析中'}} + {{analyzingTip}} + + + + 这个过程可能需要30秒到1分钟,请耐心等待... + + + + + + + + + {{error ? '⚠️' : '📋'}} + + {{error ? '生成失败' : '人才综合评估报告'}} + {{error ? '请检查后重新生成' : '基于测评结果 · 综合分析'}} + + + + + + 失败原因 + {{error}} + + 点击重新生成 + + + + + + 🔒 + 报告已生成,需付费解锁 + 完整的简历综合分析报告已生成\n支付后即可查看全部内容 + + 解锁价格 + ¥{{payInfo.amountYuan}} + + + {{paying ? '支付中...' : '立即支付解锁'}} + + + + + + 📝 + 完善资料后查看完整报告 + 请补全头像、昵称并绑定手机号\n即可查看简历综合分析全文 + + 去完善资料 + + + + + + + + {{item.icon}} + {{item.title}} + + + {{item.body}} + + + + + + + 综合分析综评 + {{content}} + + + + + + + 重新分析生成 + + + 返回首页 + + + + + diff --git a/miniprogram/utils/payment.js b/miniprogram/utils/payment.js index b28428c..9a5ef87 100644 --- a/miniprogram/utils/payment.js +++ b/miniprogram/utils/payment.js @@ -3,6 +3,7 @@ const app = getApp() const { getEnterpriseIdForApiPayload } = require('./enterpriseContext.js') +const { triggerOrderPaid } = require('./pushHook.js') function paymentApiBase() { const b = (app.globalData && app.globalData.apiBase) ? String(app.globalData.apiBase) : '' @@ -131,6 +132,7 @@ function wxPay(options) { pollOrderStatus(orderId, 5, 1000, (ok, order) => { if (ok) { try { reportCrmTestPaymentAfterSuccess(productType, testResultId, enterpriseId || 0) } catch (e) {} + try { triggerOrderPaid(orderId) } catch (e) {} try { require('./analytics').reportPayResult(true, { productType: productType || '', orderId, amount }) } catch (e) {} wx.showToast({ title: '支付成功', diff --git a/miniprogram/utils/pushHook.js b/miniprogram/utils/pushHook.js new file mode 100644 index 0000000..1b5be72 --- /dev/null +++ b/miniprogram/utils/pushHook.js @@ -0,0 +1,45 @@ +const { request } = require('./request') + +function triggerPushHook(event, payload = {}) { + if (!event) return Promise.resolve(false) + + return new Promise((resolve) => { + request({ + url: '/api/push-hook/trigger', + method: 'POST', + data: { + event, + ...payload + }, + success(res) { + const ok = !!(res && res.statusCode === 200 && res.data && res.data.code === 200) + if (!ok) { + console.warn('[PushHook] trigger rejected', event, res && res.data) + } + resolve(ok) + }, + fail(err) { + console.warn('[PushHook] trigger failed', event, err) + resolve(false) + } + }) + }) +} + +function triggerTestResultCompleted(testResultId) { + const id = Number(testResultId || 0) + if (id <= 0) return Promise.resolve(false) + return triggerPushHook('test.result_completed', { testResultId: id }) +} + +function triggerOrderPaid(orderId) { + const no = String(orderId || '').trim() + if (!no) return Promise.resolve(false) + return triggerPushHook('lead.order_paid', { orderId: no }) +} + +module.exports = { + triggerPushHook, + triggerTestResultCompleted, + triggerOrderPaid +} diff --git a/开发文档/管理端推送Webhook扩展方案.md b/开发文档/管理端推送Webhook扩展方案.md new file mode 100644 index 0000000..20f6f06 --- /dev/null +++ b/开发文档/管理端推送Webhook扩展方案.md @@ -0,0 +1,380 @@ +# 管理端「通用推送 Hook」扩展方案(设计说明) + +> 状态:**已落地(含企业专属 + 全平台回落)** +> 背景:当前仅支持**飞书自定义机器人**获客推送;需在**超管端**与**企业管理端**提供可配置的**通用 HTTP 推送 Hook**(可与飞书并存或分阶段替代),便于对接企业微信、钉钉、自建中间层、Zapier 等。 + +--- + +## 一、现状摘要 + +### 1.1 飞书获客推送(已实现) + +| 项目 | 说明 | +|------|------| +| **后端服务** | `api/app/common/service/FeishuLeadWebhookService.php` | +| **配置键** | `system_config.key = feishu_lead_webhook` | +| **作用域** | 固定 `enterprise_id = 0`(**全局一条**,非按企业隔离) | +| **配置项** | `enabled`、`webhookUrl`(飞书机器人 URL)、`contactPerson`(卡片展示用) | +| **触发场景** | 支付成功(订单维度去重)、用户首次绑定手机号等;内部组文案后调用飞书 Bot API(`postWebhook`) | +| **去重** | 表 `delivery_dedup`(`scene` + `dedupKey` 联合唯一;飞书获客为 `scene=feishu_lead`,出站为 `scene=outbound_hook`) | + +### 1.2 管理端 API + +- **超管**:`superadmin/Settings` → `getFeishuLeadConfig` / `updateFeishuLeadConfig`(与下述共用同一配置行)。 +- **企业管理员**:`admin/Settings` → `GET/PUT /api/v1/admin/settings/feishu-lead` + - 权限:`admin` / `enterprise_admin` + - **读写仍为 `enterprise_id=0`**,即**企业端与超管端改的是同一份全局飞书配置**。 + +### 1.3 前端 + +- 存在组件 `admin/src/views/admin/FeishuLeadConfigPanel.vue`(调用 `/admin/settings/feishu-lead`)。 +- 需在**超管「系统设置」**与**企业「系统设置」**中明确挂载入口(若尚未挂载到路由 Tab,实现时需补全)。 + +### 1.4 本方案覆盖范围 + +1. **通用 HTTP JSON**:已实现 `OutboundPushHookService`,与飞书协议解耦。 +2. **多租户**:通用 Hook 已支持 **`enterprise_id` 分行 + 全平台回落**;飞书仍为全局一条(若未来要对飞书按企业隔离,需另起需求)。 + +--- + +## 二、目标能力(通用推送 Hook) + +### 2.1 定义 + +在关键业务事件发生时,向管理员配置的 **HTTPS URL** 发送 **HTTP POST**,请求体为**统一 JSON**(与飞书格式解耦),接收方可为: + +- 自建网关(再转发到飞书 / 企微 / 钉钉); +- Serverless / 自动化平台; +- 企业内 CRM / 数据仓库。 + +### 2.2 建议支持的事件类型(与现有飞书触发对齐,可迭代) + +| 事件编码 | 说明 | 备注 | +|----------|------|------| +| `lead.order_paid` | 订单支付成功 | 与 `onOrderPaid` 对齐,含订单号、金额、产品类型等 | +| `lead.phone_bound` | 用户首次绑定手机号 | 与 `onPhoneBound` 对齐 | +| **`test.result_completed`** | **用户完成测评且结果已落库** | 问卷提交(`/api/test/submit`)或带 token 的分析写库(如 `/api/analyze`)成功后;**每条测试记录仅推一次**(按 `testResultId` 去重) | +| (可选)`analytics.summary` | 周期性或关键行为汇总 | 二期 | + +**`test.result_completed` 建议触发点(实现时择需挂载,避免重复推送):** + +- 问卷类:`POST /api/test/submit` 成功写入 `test_results` 后; +- 人脸/AI 类:`Analyze` 等流程在**已写入 `mbti_test_results` / `test_results` 对应记录**且拿到主键 `id` 后; +- 若同一链路既写库又触发支付,**支付成功**仍走 `lead.order_paid`,**测评完成**走 `test.result_completed`,二者语义分离(先完成测评、后付费的场景下可能先后各推一条)。 + +### 2.3 推送内容规范(参考:用户购买成功 · 实时推送) + +业务侧希望**机器人/群消息**与 **HTTP JSON** 使用**同一套语义字段**,便于飞书、企微、通用 Hook 共用。下列对照参考常见「购买成功实时通知」样式(标题行 + 键值行)。 + +#### 2.3.1 事件 `lead.order_paid` — 字段对照表(对齐当前项目) + +| 展示文案(中文) | JSON 路径(建议) | 类型 | 说明 | +|------------------|-------------------|------|------| +| 标题行 | `payload.display.title` | string | 固定文案如:`用户购买成功(实时推送)`,前端可加前缀图标 `💰` | +| 订单号 | `payload.orderNo` | string | 与库表 **`orders.orderNo`** 一致,与小程序 `miniprogram/utils/payment.js` → **`generateOrderId(productType)`** 生成的商户单号相同;规则:**业务前缀 + `YYYYMMDDHHmmss` + 3 位随机**,总长 ≤32(与微信 `out_trade_no` 一致)。前缀示例:`FACE`(面相)、`MBTI`、`DISC`、`PDP`、`REPT`(完整报告)、`TEAM`(团队分析)、`RCG`(充值)、`DPER`/`DTEAM`(深度服务)、`VIP`、`TNUM` 等;未命中映射时用 `productType` 前 6 位大写(如 `SBTI`)。示例:`FACE20260414083927001` | +| 用户 | `payload.userName` | string | `wechat_users.nickname`,缺省可展示「微信用户」 | +| 手机 | `payload.phone` | string | 已绑定则展示,未绑定可为空字符串 | +| 商品 | `payload.productTitle` | string | 对应订单 **`productTitle`**,创建支付时来自前端 `description`;后端默认文案规则:`Payment::create` 在 `description` 为空时为 **`AI性格测试-{productType}`**(见 `api/app/controller/api/Payment.php`)。小程序侧示例:人脸完整报告为 `AI人脸性格分析完整报告`(`purchaseFaceTest`) | +| 金额 | `payload.amountYuan` | string | **元**,保留两位小数;库内 **`orders.amount` 为分**,展示时 `amountFen/100` | +| 状态 | `payload.status` | string | 与订单状态一致,支付成功推送场景一般为 `paid`(以实际 `orders.status` 落库值为准) | +| 支付时间 | `payload.paidAt` | string | `YYYY-MM-DD HH:mm:ss`(东八区),取支付成功写入时刻 | + +补充(机器处理用,可选展示):`payload.orderId`(`orders` 表主键)、`payload.userId`、`payload.amountFen`(分)、`payload.productType`。 + +**`productType`(当前项目常用值,与支付创建入参一致)**:`face`、`mbti`、`sbti`、`disc`、`pdp`、`resume`、`recharge`、`report`、`team_analysis`、`vip`、`test_count`、`single_test`、`deep_personal`、`deep_team` 等;以后端 `Payment` 与定价校验为准。 + +**`payload.sourceLabel`(推荐与飞书一致)**:推送文案中的「来源/业务说明」可与现有飞书 **`FeishuLeadWebhookService::sourceLabelForOrder()`** 使用同一套规则,例如: + +- `recharge` → `企业余额·充值支付成功` +- 其它类型:中文业务名 +(若有商品标题则带「标题」)+ `·支付成功`;无标题且金额恰为 1 元时可为 `xxx·1元支付·支付成功` + +这样 HTTP Hook、飞书机器人、后台列表语义一致。 + +#### 2.3.2 飞书 / 纯文本模板(与上表一致,便于复制实现) + +单条消息可拼为**多行文本**(与飞书 `text` 内容一致)。以下为**与本项目订单号规则、商品描述习惯一致**的示例(金额 1 元场景,人脸完整报告): + +```text +💰 用户购买成功(实时推送) +订单号: FACE20260414083927001 +用户: 微信用户 +手机: 18302257611 +商品: AI人脸性格分析完整报告 +金额: 1.00 +状态: paid +支付时间: 2026-04-14 08:39:27 +来源: 面相测试·「AI人脸性格分析完整报告」·支付成功 +``` + +说明: + +- **订单号**:勿用与本项目无关的 `MP…` 示例;应使用 **`FACE`/`MBTI`/… + 时间戳 + 三位随机** 格式(见上表)。 +- **商品**:填真实落库的 `productTitle`/`description`,如 MBTI 单次可能为后台定价返回的标题,或前端传入的说明字符串。 +- **来源**:可选单独一行,文案与 **`sourceLabelForOrder`** 一致,便于与飞书侧「来源: xxx」对照;若接收方不需要可省略。 +- 手机号为空时,`手机:` 行可写「未绑定」或省略该行(产品二选一并写死)。 +- `商品` 过长时可截断(如最多 80 字 + `…`),与 `FeishuLeadWebhookService::oneLine` 对标题的截断策略可统一为 **40 字**(飞书来源行)或 **80 字**(商品列),实现时二选一并写死。 + +#### 2.3.3 事件 `lead.phone_bound`(首次绑定手机,可选另一套标题) + +建议标题:`📋 新获客` 或 `用户完成手机号授权`,字段可含:`userName`、`phone`、`bindAt`、`sourceLabel`(与现有飞书获客文案对齐),具体 JSON 在实现时单独列 `payload` 子结构,避免与 `order_paid` 混用同一 schema。 + +#### 2.3.4 事件 `test.result_completed` — 字段对照表(用户测评结果 · 实时推送) + +与「购买成功」并列:**结果落库后即推**,便于运营侧即时看到「谁做完了什么测评、结果是什么」,不依赖是否付费。 + +| 展示文案(中文) | JSON 路径(建议) | 类型 | 说明 | +|------------------|-------------------|------|------| +| 标题行 | `payload.display.title` | string | 如:`用户测评完成(实时推送)`,图标建议 `📊` | +| 记录 ID | `payload.testResultId` | int/string | 对应库表 `test_results` / `mbti_test_results` 主键 `id`,便于溯源与去重 | +| 用户 | `payload.userName` | string | 昵称 | +| 手机 | `payload.phone` | string | 已绑定则展示,未绑定可为空或「未绑定」 | +| 测评类型 | `payload.testType` | string | 与库一致:`mbti` / `sbti` / `disc` / `pdp` / `face` / `ai` / `resume` 等 | +| 测评类型(中文) | `payload.testTypeLabel` | string | 可选,便于直接展示,如 `MBTI 性格测试`、`面相分析` | +| 结果摘要 | `payload.resultSummary` | string | 一行可读摘要,与小程序/后台列表「结果」列一致(如 MBTI 四字母、SBTI 类型+中文、PDP/DISC 主类型等) | +| 完成时间 | `payload.completedAt` | string | `YYYY-MM-DD HH:mm:ss`,东八区,取记录 `createdAt` 或提交成功时刻 | +| 企业 | `tenant.enterpriseId` / `tenant.enterpriseName` | — | 与全局 `tenant` 一致;个人版无企业则为 `null` 或 `0` | + +补充(可选、接收方高级用法):`payload.resultMeta`(结构化片段,与接口 `resultMeta` 对齐)、`payload.enterpriseId`(行内冗余)、`payload.userId`。 +**隐私**:若结果含敏感长文本,默认只推 `resultSummary`;完整 JSON 入 `resultData` **默认不下发**,需单独开关「推送完整结果」(合规评审后)。 + +#### 2.3.5 飞书 / 纯文本模板(`test.result_completed`) + +```text +📊 用户测评完成(实时推送) +记录ID: 8848 +用户: Ming871 +手机: 18302257611 +测评类型: MBTI +结果摘要: INTJ +完成时间: 2026-04-14 09:15:33 +``` + +- `测评类型` 行可同时展示英文 code + 中文标签,例如:`SBTI · BOSS(领导者)`,由 `testType` + `resultSummary` 组合策略决定(产品统一即可)。 +- 面相/AI 类:`结果摘要` 可为短文案(如 PDP/面相主类型),过长时截断(如 80 字)。 + +**去重**:`dedupKey` 建议 `test.result_completed:{testResultId}`(与 envelope `_dedupKey` 一致);表 `delivery_dedup`,出站场景 `scene=outbound_hook`,库内仅存 `_dedupKey` 原值(展示/API 仍可带 `push_hook:` 前缀)。 + +### 2.4 HTTP 请求格式(建议) + +- **Method**:`POST` +- **Header** + - `Content-Type: application/json` + - `X-MBTI-Event: lead.order_paid`(事件类型) + - `X-MBTI-Delivery-Id: `(投递 ID,便于接收方去重) + - `X-MBTI-Signature: sha256=`(可选,见安全) +- **Body**:根级除 `event`、`occurredAt`、`environment`、`tenant`、`payload` 外,增加 **`hook`**(见 §3.2 / 上文示例),用于区分**业务归属租户**与**实际用于签名的配置行**。 +- **Body(`lead.order_paid` 完整示例)** + +```json +{ + "event": "lead.order_paid", + "occurredAt": "2026-04-14T08:39:27+08:00", + "environment": "production", + "hook": { + "configEnterpriseId": 12, + "usedPlatformFallback": false + }, + "tenant": { + "enterpriseId": 0, + "enterpriseName": null + }, + "payload": { + "display": { + "title": "用户购买成功(实时推送)", + "emoji": "💰" + }, + "orderId": 456, + "orderNo": "FACE20260414083927001", + "userId": 789, + "userName": "微信用户", + "phone": "18302257611", + "productTitle": "AI人脸性格分析完整报告", + "productType": "face", + "amountYuan": "1.00", + "amountFen": 100, + "status": "paid", + "paidAt": "2026-04-14 08:39:27", + "sourceLabel": "面相测试·「AI人脸性格分析完整报告」·支付成功" + } +} +``` + +接收方若只需落库,优先解析 `payload` 内上表字段;若需渲染成飞书卡片,可用 `display.title` + 各键值行,或与 `2.3.2` 模板由服务端统一生成 `payload.textBody`(可选字段)供直接转发。 + +#### 2.4.1 Body 示例(`test.result_completed`) + +```json +{ + "event": "test.result_completed", + "occurredAt": "2026-04-14T09:15:33+08:00", + "environment": "production", + "hook": { + "configEnterpriseId": 0, + "usedPlatformFallback": true + }, + "tenant": { + "enterpriseId": 123, + "enterpriseName": "示例企业" + }, + "payload": { + "display": { + "title": "用户测评完成(实时推送)", + "emoji": "📊" + }, + "testResultId": 8848, + "userId": 789, + "userName": "Ming871", + "phone": "18302257611", + "testType": "mbti", + "testTypeLabel": "MBTI 性格测试", + "resultSummary": "INTJ", + "completedAt": "2026-04-14 09:15:33" + } +} +``` + +实际字段以后端 `OutboundPushHookService` 为准,保证版本升级时可加字段、兼容旧接收端。 + +- **`hook`(根级,投递元数据)** + - `configEnterpriseId`(int):**实际用于签名与 HTTP POST 的配置行**所属 `system_config.enterprise_id`(`0` = 全平台默认)。 + - `usedPlatformFallback`(bool):当业务归属企业 `>0`,但该企业配置**未对该事件生效**(未启用、无合法 URL、或未订阅该事件)而**改用全平台默认**时为 `true`;业务本身归属平台(无企业)且使用 `enterprise_id=0` 时为 `false`。 + - 说明:**`tenant.enterpriseId` 表示业务数据归属**;**`hook.configEnterpriseId` 表示请求发到哪个 URL 所用的配置**,二者可以不同(例如企业 B 无专属配置时仍推全平台 URL,但 `tenant` 仍带 B 的测评/订单上下文)。 + +--- + +## 三、配置模型与权限 + +### 3.1 存储(按企业分行 + 全平台默认) + +**独立配置键**(与飞书键分离): + +| `system_config.key` | `enterprise_id` | 说明 | +|---------------------|-----------------|------| +| `push_hook_outbound` | `0` | **全平台默认**(超管、以及「无企业归属」的企业管理账号维护同一条) | +| `push_hook_outbound` | `N`(`N>0`) | **企业 N 专属**(仅该企业管理员可编辑本行;超管接口不写入此行) | + +**单条 JSON value 结构(与行无关,字段相同):** + +```json +{ + "enabled": true, + "url": "https://example.com/hooks/mbti", + "secret": "", + "events": ["lead.order_paid", "lead.phone_bound", "test.result_completed"] +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `enabled` | bool | 是否启用本行配置 | +| `url` | string | 出站 POST 地址;启用时须 `http`/`https` 开头 | +| `secret` | string | 可选;非空则对 body 做 HMAC-SHA256,请求头 `X-MBTI-Signature: sha256=…` | +| `events` | string[] | 订阅的事件编码列表;**空数组或缺省表示订阅全部**(三类事件) | + +### 3.2 运行时选用哪一行配置(回落规则) + +对每个事件,先取**业务上下文企业 ID** `contextEnterpriseId`: + +| 事件 | `contextEnterpriseId` 来源 | +|------|---------------------------| +| `lead.order_paid` | `orders.enterpriseId`,无则 `0` | +| `lead.phone_bound` | `wechat_users.enterpriseId`,无则 `0` | +| `test.result_completed` | `test_results.enterpriseId`,无则 `0` | + +**择一推送(同一事件只发一次 HTTP):** + +1. 若 `contextEnterpriseId > 0`,先读 `push_hook_outbound` 且 `enterprise_id = contextEnterpriseId` 的配置;若对该事件 **`isEventEnabled` 为真**(启用 + 合法 URL + 事件订阅命中或 events 为空),则使用**本行**,`hook.configEnterpriseId = contextEnterpriseId`,`hook.usedPlatformFallback = false`。 +2. 否则读 **`enterprise_id = 0` 全平台默认**;若对该事件仍不可用,则**不推送**。若第 1 步未命中而第 2 步命中,则 `hook.usedPlatformFallback = true`(表示「业务归属某企业,但 URL 用的是平台默认」)。 + +**示例**:超管与 A 企业都配置了 URL;B 企业未配置或关闭。A 用户产生的订单/测评 → 走 A 的 URL;B 用户 → 回落到全平台 URL;纯个人无企业 → 仅全平台。 + +### 3.3 管理端读写范围 + +| 角色 | 接口 | 读写 `system_config` 行 | +|------|------|-------------------------| +| **超管** | `GET/PUT /api/v1/superadmin/settings/push-hook` | 仅 **`enterprise_id=0`**(全平台默认) | +| **企业管理员**(`enterprise_admin` 或已绑定企业的 `admin`) | `GET/PUT /api/v1/admin/settings/push-hook` | **`enterprise_id=本企业`**;若账号**无企业**则 **`enterprise_id=0`**(与超管同一条,与海报配置等一致) | + +GET 响应会带 `scope`:`platform` | `enterprise`,以及 `configEnterpriseId`、`enterpriseName`(企业专属时),便于前端展示文案。 + +### 3.4 与飞书获客的关系 + +- 飞书仍为 **`feishu_lead_webhook` + `enterprise_id=0` 全局一条**(与本 Hook 独立)。 +- 通用 Hook 与飞书可并行:同一业务事件可同时触发飞书卡片 + HTTP JSON(若两者均启用)。 + +--- + +## 四、管理端 UI 规划 + +### 4.1 超管端(`/superadmin/settings`) + +- Tab **「出站推送」**:维护 **全平台默认**(`enterprise_id=0`)。 +- 字段:启用、URL、签名密钥(可选)、事件多选。 +- (可选二期)**测试推送**按钮:向 URL POST `ping` 或示例事件。 + +### 4.2 企业管理端(`/admin/settings`) + +- Tab **「出站推送」**:有企业归属时编辑 **本企业专属行**(`enterprise_id=本企业`);无企业归属时与超管共用 **全平台默认** 行。 +- 界面展示 `scope`(本企业专属 / 全平台默认)及企业名;说明与飞书区别(本 Hook 为 **JSON 通用格式**)。 + +### 4.3 与飞书的关系 + +- **并存**:同一事件可同时推飞书(若启用)+ 通用 Hook(若启用),二者独立开关、独立失败重试策略(可选)。 +- **实现顺序**:先通用 Hook 服务类 + 超管/企业 API + 页面;飞书逻辑可逐步改为「内部也是一种 channel」或保持独立(避免大改时可并行调用)。 + +--- + +## 五、安全与运维 + +1. **HTTPS 强制**:与飞书相同,仅允许 `https://`(内网调试可配置白名单或开发环境放宽,生产建议强制)。 +2. **签名校验**(可选):用 `secret` 对 body 做 HMAC-SHA256,`X-MBTI-Signature` 传递;文档中给出验签示例(Node/PHP)。 +3. **超时与重试**:出站请求建议 3s 超时;失败可记日志 + 可选异步重试(二期)。 +4. **敏感信息**:手机号等是否在 JSON 全量下发,需与合规策略一致;可与飞书当前展示粒度对齐。 + +--- + +## 六、后端实现要点(已落地) + +1. **`OutboundPushHookService`**:`getConfig(int $enterpriseId)` 按行读取;`getEffectiveConfigForEvent($contextEnterpriseId, $event)` 实现 **企业优先 + 回落全平台**;`dispatch($event, $envelope, $contextEnterpriseId)` 写入根级 **`hook`** 后 POST。 +2. **`lead.order_paid`**:`Payment` 回调与查单路径,在飞书同路径旁调用;`contextEnterpriseId` 来自 **`orders.enterpriseId`**。 +3. **`lead.phone_bound`**:`Auth` 首次绑手机;`contextEnterpriseId` 来自 **`wechat_users.enterpriseId`**。 +4. **`test.result_completed`**:`Test::submit`、`Analyze`(面相 / 简历等写库);`contextEnterpriseId` 来自 **`test_results.enterpriseId`**。 +5. **管理端**:`admin.Settings` / `superadmin.Settings` 的 `GET/PUT .../push-hook`;超管仅写 `enterprise_id=0`,企业端按账号解析写 `0` 或本企业。 +6. **去重**:与飞书共用表 `delivery_dedup`,用 **`scene` 区分**:飞书 `feishu_lead`,出站 `outbound_hook`;出站库内键为 `_dedupKey` 原值,**同一业务事件只投递一次**(不因回落改变去重键)。 +7. (可选)投递日志表、重试队列为二期。 + +--- + +## 七、实施阶段建议 + +| 阶段 | 内容 | +|------|------| +| **P0(已完成)** | `push_hook_outbound` 多行存储;**企业专属 + 全平台回落**;三类事件 + 超管/企业后台配置页;根级 **`hook` 元数据** | +| **P1** | 管理端「测试推送」、更细粒度事件订阅、投递失败告警 | +| **P2** | 失败重试、投递日志、多 URL 列表 | + +--- + +## 八、验收清单(供测试) + +- [ ] 超管保存全平台默认:`system_config` 中 `key=push_hook_outbound` 且 **`enterprise_id=0`**。 +- [ ] 企业 A 管理员保存后存在 **`enterprise_id=A`** 的行;企业 B 未配置时,B 用户事件 **`hook.usedPlatformFallback=true`** 且请求发到全平台 URL。 +- [ ] A 用户产生的事件:若 A 行启用且合法,**`hook.configEnterpriseId=A`** 且 **`usedPlatformFallback=false`**。 +- [ ] 支付成功 / 绑手机 / 测评落库:接收端 JSON 含 **`hook`、`tenant`、`payload`**,且与本文字段一致。 +- [ ] 同一 `testResultId` / 订单不重复推送(去重生效)。 +- [ ] 关闭对应行开关或清空 URL 后,按回落规则不再向该行投递或改投全平台。 +- [ ] 与飞书并行时两者互不干扰。 + +--- + +## 九、参考代码位置(现有) + +- 飞书:`api/app/common/service/FeishuLeadWebhookService.php` +- 测评提交:`api/app/controller/api/Test.php`(`submit` 等)、`api/app/controller/api/Analyze.php`(分析写库) +- 超管配置:`api/app/controller/superadmin/Settings.php`(`getFeishuLeadConfig` / `updateFeishuLeadConfig`) +- 企业/管理员配置:`api/app/controller/admin/Settings.php`(同上) +- 前端:`admin/src/views/admin/PushHookConfigPanel.vue`(超管/企业共用,通过 `apiPrefix` 区分接口) + +--- + +*文档版本:2026-04-14(v5:**企业专属 Hook + 全平台回落**、`hook` 元数据、管理端 `scope` 字段;付款/测评字段同 v4)。*