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.body}}
-
-
-
-
-
-
- 综合分析综评
- {{content}}
-
-
-
-
-
-
- 重新分析生成
-
-
- 返回首页
-
-
-
-
-
+
+
+
+
+
+
+
+
+ {{analyzingTitle || '正在分析中'}}
+ {{analyzingTip}}
+
+
+
+ 这个过程可能需要30秒到1分钟,请耐心等待...
+
+
+
+
+
+
+
+
+ {{error ? '⚠️' : '📋'}}
+
+ {{error ? '生成失败' : '人才综合评估报告'}}
+ {{error ? '请检查后重新生成' : '基于测评结果 · 综合分析'}}
+
+
+
+
+
+ 失败原因
+ {{error}}
+
+ 点击重新生成
+
+
+
+
+
+ 🔒
+ 报告已生成,需付费解锁
+ 完整的简历综合分析报告已生成\n支付后即可查看全部内容
+
+ 解锁价格
+ ¥{{payInfo.amountYuan}}
+
+
+ {{paying ? '支付中...' : '立即支付解锁'}}
+
+
+
+
+
+ 📝
+ 完善资料后查看完整报告
+ 请补全头像、昵称并绑定手机号\n即可查看简历综合分析全文
+
+ 去完善资料
+
+
+
+
+
+
+
+
+ {{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)。*