Files
MBTI_wang/api/app/common/service/OutboundPushHookService.php
Ghost d07235b64c feat: 小程序 AI 底栏与聊天页、出站推送调试与 tabbar 迁移
- 自定义 tabBar:AI 入口图标与样式,相关页面与审核门控调整

- API:MpTabbar、出站推送 Hook、后台设置与迁移 SQL(含 FAB 居中排序)

- 管理端:推送 Hook 面板、出站推送调试工具与生产环境注释说明

Made-with: Cursor
2026-04-20 15:29:10 +08:00

1506 lines
58 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace app\common\service;
use app\common\PdpDiscResultText;
use think\facade\Db;
use think\facade\Log;
use think\facade\Request;
/**
* 通用 HTTP 出站推送JSON POST
* 配置system_config key=push_hook_outbound按 enterprise_id 分行:
* - enterprise_id=0全平台默认超管维护未配置本企业行时回落至此
* - enterprise_id>0该企业专属推送时若业务归属该企业且本行「对该事件可用」则优先用本行否则回落 0
*
* 事件lead.order_paid、lead.phone_bound、test.result_completed
*/
class OutboundPushHookService
{
public const CONFIG_KEY = 'push_hook_outbound';
public const ASYNC_ROUTE = '/api/internal/outbound-push/dispatch';
/** 去重表 scene出站 Hook库中 dedupKey 为 _dedupKey 原值,不含 push_hook: */
private const DEDUP_SCENE_OUTBOUND = 'outbound_hook';
/** @var string[] */
public const DEFAULT_EVENTS = [
'lead.order_paid',
'lead.phone_bound',
'test.result_completed',
];
/** 首次绑手机与「刚完成的测评」合并推送时,测评落库时间距现在最长间隔(秒) */
private const TEST_PHONE_MERGE_MAX_AGE_SEC = 604800;
/**
* 读取指定作用域配置(不合并回落;回落在 getEffectiveConfigForEvent / dispatch 中处理)
*/
public static function getConfig(int $enterpriseId = 0): array
{
$def = [
'enabled' => false,
'url' => '',
'secret' => '',
'events' => [],
];
$row = Db::name('system_config')
->where('key', self::CONFIG_KEY)
->where('enterprise_id', $enterpriseId)
->find();
if (!$row || empty($row['value'])) {
return $def;
}
$v = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
if (!is_array($v)) {
return $def;
}
$merged = array_merge($def, $v);
// 兼容历史或误存events 为 JSON 字符串时转为数组,否则 isEventEnabled 判断异常
$ev = $merged['events'] ?? [];
if (is_string($ev)) {
$decoded = json_decode($ev, true);
$ev = is_array($decoded) ? $decoded : [];
}
if (!is_array($ev)) {
$ev = [];
}
$merged['events'] = $ev;
return $merged;
}
/**
* 按业务上下文企业 ID 解析实际出站使用的配置(择一:本企业优先,否则全平台)
*
* @return array{config: array, configEnterpriseId: int, usedPlatformFallback: bool}|null
*/
public static function getEffectiveConfigForEvent(int $contextEnterpriseId, string $event): ?array
{
if ($contextEnterpriseId > 0) {
$cfg = self::getConfig($contextEnterpriseId);
if (self::isEventEnabled($event, $cfg)) {
return [
'config' => $cfg,
'configEnterpriseId' => $contextEnterpriseId,
'usedPlatformFallback' => false,
];
}
}
$cfg0 = self::getConfig(0);
if (self::isEventEnabled($event, $cfg0)) {
return [
'config' => $cfg0,
'configEnterpriseId' => 0,
'usedPlatformFallback' => $contextEnterpriseId > 0,
];
}
return null;
}
/**
* 仅校验「启用 + 合法 URL」忽略 events 订阅(用于连接测试)
*
* @return array{config: array, configEnterpriseId: int, usedPlatformFallback: bool}|null
*/
public static function resolveOutboundTransport(int $contextEnterpriseId): ?array
{
if ($contextEnterpriseId > 0) {
$cfg = self::getConfig($contextEnterpriseId);
if (self::isRowTransportReady($cfg)) {
return [
'config' => $cfg,
'configEnterpriseId' => $contextEnterpriseId,
'usedPlatformFallback' => false,
];
}
}
$cfg0 = self::getConfig(0);
if (self::isRowTransportReady($cfg0)) {
return [
'config' => $cfg0,
'configEnterpriseId' => 0,
'usedPlatformFallback' => $contextEnterpriseId > 0,
];
}
return null;
}
private static function isRowTransportReady(array $cfg): bool
{
if (empty($cfg['enabled'])) {
return false;
}
$url = trim((string) ($cfg['url'] ?? ''));
return $url !== '' && stripos($url, 'http') === 0;
}
/** 企业微信机器人 Webhooktext 用 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<string,mixed> $envelope
*/
private static function envelopeToBotPlainText(string $event, array $envelope): string
{
$tenant = isset($envelope['tenant']) && is_array($envelope['tenant']) ? $envelope['tenant'] : [];
$eid = (int) ($tenant['enterpriseId'] ?? 0);
$ename = trim((string) ($tenant['enterpriseName'] ?? ''));
$head = '';
$payload = isset($envelope['payload']) && is_array($envelope['payload']) ? $envelope['payload'] : [];
switch ($event) {
case 'lead.order_paid':
$paidTime = trim((string) ($payload['paidAt'] ?? ''));
if ($paidTime === '') {
$paidTime = trim((string) ($envelope['occurredAt'] ?? ''));
}
return $head . "💰 支付成功\n"
. '订单号: ' . ($payload['orderNo'] ?? '') . "\n"
. '用户: ' . ($payload['userName'] ?? '') . "\n"
. '手机: ' . ($payload['phone'] ?? '') . "\n"
. '金额: ¥' . ($payload['amountYuan'] ?? '') . "\n"
. '商品: ' . self::truncatePlainText((string) ($payload['productTitle'] ?? ''), 60) . "\n"
. '来源: ' . ($payload['sourceLabel'] ?? '') . "\n"
. '支付时间: ' . $paidTime;
case 'lead.phone_bound':
$boundTime = trim((string) ($payload['boundAt'] ?? ''));
if ($boundTime === '') {
$boundTime = trim((string) ($envelope['occurredAt'] ?? ''));
}
return $head . "📋 首次绑定手机\n"
. '用户: ' . ($payload['userName'] ?? '') . "\n"
. '手机: ' . ($payload['phone'] ?? '') . "\n"
. '绑定时间: ' . $boundTime;
case 'test.result_completed':
$testTime = trim((string) ($payload['completedAt'] ?? ''));
if ($testTime === '') {
$testTime = trim((string) ($envelope['occurredAt'] ?? ''));
}
$tt = (string) ($payload['testType'] ?? '');
$mergedBind = trim((string) ($payload['phoneBoundAt'] ?? '')) !== '';
$titleLine = $mergedBind ? "📊 测评完成·手机号已授权\n" : "📊 测评完成\n";
$lines = [
$head . $titleLine,
'类型: ' . ($payload['testTypeLabel'] ?? $payload['testType'] ?? '') . "\n",
'用户: ' . ($payload['userName'] ?? '') . "\n",
'手机: ' . ($payload['phone'] ?? '') . "\n",
];
if (in_array($tt, ['face', 'ai'], true)) {
$mb = trim((string) ($payload['resultMbti'] ?? ''));
$pd = trim((string) ($payload['resultPdp'] ?? ''));
$di = trim((string) ($payload['resultDisc'] ?? ''));
if ($mb !== '') {
$lines[] = 'MBTI测试结果: ' . $mb . "\n";
}
if ($pd !== '') {
$lines[] = 'PDP测试结果: ' . $pd . "\n";
}
if ($di !== '') {
$lines[] = 'DISC测试结果: ' . $di . "\n";
}
if ($mb === '' && $pd === '' && $di === '') {
$lines[] = '测试结果: ' . self::truncatePlainText((string) ($payload['resultSummary'] ?? ''), 100) . "\n";
}
} else {
$lines[] = '测试结果: ' . self::truncatePlainText((string) ($payload['resultSummary'] ?? ''), 100) . "\n";
}
$lines[] = '测试时间: ' . $testTime;
if ($mergedBind) {
$lines[] = '授权手机时间: ' . trim((string) ($payload['phoneBoundAt'] ?? '')) . "\n";
}
$body = implode('', $lines);
$mgmtSummary = trim((string) ($payload['managementSummary'] ?? ''));
if ($mgmtSummary !== '') {
$body .= "\n━━━━━━━━━━\n用户管理:\n" . $mgmtSummary;
}
$um = $payload['userManagement'] ?? null;
if (is_array($um) && !empty($um['openidTail6'])) {
$body .= "\nOpenID尾号(脱敏): " . (string) $um['openidTail6'];
}
$beh = $payload['recentBehaviors'] ?? [];
if (is_array($beh) && count($beh) > 0) {
$body .= "\n━━━━━━━━━━\n最近行为:";
$i = 1;
foreach ($beh as $bl) {
if (!is_string($bl) || $bl === '') {
continue;
}
$body .= "\n {$i}. {$bl}";
$i++;
}
}
return $body;
default:
return $head . "\n事件: " . $event;
}
}
private static function truncatePlainText(string $s, int $max): string
{
$s = preg_replace('/\s+/u', ' ', trim($s));
if ($s === '') {
return '';
}
if (function_exists('mb_strlen') && mb_strlen($s) > $max) {
return mb_substr($s, 0, $max) . '…';
}
return strlen($s) > $max ? substr($s, 0, $max) . '…' : $s;
}
/**
* 发送一次 `hook.ping` 测试投递(不写去重表)
* 配置解析与真实「测评完成」推送一致:须满足对 test.result_completed 的事件订阅(空数组表示全部)。
*
* @return array<string,mixed>
*/
public static function sendTestPing(int $contextEnterpriseId = 0): array
{
// 与 dispatch 一致,避免「仅测通 URL、但 events 未勾选测评」时误以为已配置
$resolved = self::getEffectiveConfigForEvent($contextEnterpriseId, 'test.result_completed');
if ($resolved === null) {
return [
'ok' => false,
'message' => '未找到对「测评完成」(test.result_completed) 有效的配置请确认已启用、URL 以 http 开头,且「订阅事件」包含该项或留空表示全部(可先保存后重试,并检查全平台默认)',
'httpStatus' => 0,
'configEnterpriseId' => null,
'usedPlatformFallback' => null,
'responsePreview' => null,
'businessHint' => null,
'curlError' => null,
];
}
$cfg = $resolved['config'];
$url = trim((string) ($cfg['url'] ?? ''));
$tenantEid = $contextEnterpriseId > 0 ? $contextEnterpriseId : 0;
$tenant = self::tenantPayload($tenantEid);
if (self::isThirdPartyBotTextUrl($url)) {
$lines = "🔔 MBTI 出站 Hook 连接测试\n时间: " . date('Y-m-d H:i:s');
if ($tenantEid > 0) {
$lines .= "\n企业: " . (!empty($tenant['enterpriseName']) ? (string) $tenant['enterpriseName'] : ('ID ' . $tenantEid));
}
$lines .= "\n(已按飞书/企微机器人文本协议发送,非通用 JSON";
$body = self::buildThirdPartyTextBody($url, $lines);
$headers = ['Content-Type: application/json; charset=utf-8'];
[$ok, $httpStatus, $responseBody, $curlErr, $bizHint] = self::httpPostJsonWithCode($url, $body, $headers);
} else {
$envelope = [
'event' => 'hook.ping',
'occurredAt' => self::iso8601Cn(),
'environment' => self::appEnv(),
'hook' => [
'configEnterpriseId' => $resolved['configEnterpriseId'],
'usedPlatformFallback' => $resolved['usedPlatformFallback'],
'test' => true,
],
'tenant' => $tenant,
'payload' => [
'display' => [
'title' => '连接测试',
'emoji' => '🔔',
],
'message' => 'MBTI 出站 Hook 模拟推送(可忽略业务语义)',
'sentAt' => date('Y-m-d H:i:s'),
],
];
$body = json_encode($envelope, JSON_UNESCAPED_UNICODE);
if ($body === false) {
return [
'ok' => false,
'message' => 'JSON 编码失败',
'httpStatus' => 0,
'configEnterpriseId' => $resolved['configEnterpriseId'],
'usedPlatformFallback' => $resolved['usedPlatformFallback'],
'responsePreview' => null,
'businessHint' => null,
'curlError' => null,
];
}
$deliveryId = self::uuidV4();
$headers = [
'Content-Type: application/json; charset=utf-8',
'X-MBTI-Event: hook.ping',
'X-MBTI-Delivery-Id: ' . $deliveryId,
];
$secret = trim((string) ($cfg['secret'] ?? ''));
if ($secret !== '') {
$sig = hash_hmac('sha256', $body, $secret);
$headers[] = 'X-MBTI-Signature: sha256=' . $sig;
}
[$ok, $httpStatus, $responseBody, $curlErr, $bizHint] = self::httpPostJsonWithCode($url, $body, $headers);
}
$preview = self::truncateForLog($responseBody, 800);
if ($curlErr !== '') {
Log::warning('OutboundPushHook test: curl error', ['url' => self::maskUrl($url), 'error' => $curlErr]);
} elseif ($bizHint !== null) {
Log::warning('OutboundPushHook test: business not ok', ['url' => self::maskUrl($url), 'hint' => $bizHint, 'preview' => $preview]);
}
$msgParts = [];
if ($curlErr !== '') {
$msgParts[] = '网络/cURL' . $curlErr;
} else {
$msgParts[] = 'HTTP ' . $httpStatus;
}
if ($bizHint !== null) {
$msgParts[] = '对端业务:' . $bizHint;
} elseif ($ok) {
$msgParts[] = '连接与响应体检查通过';
} else {
$msgParts[] = '未通过(见 HTTP 状态或业务字段)';
}
return [
'ok' => $ok,
'message' => implode('', $msgParts),
'httpStatus' => $httpStatus,
'configEnterpriseId' => $resolved['configEnterpriseId'],
'usedPlatformFallback' => $resolved['usedPlatformFallback'],
'responsePreview' => $preview !== '' ? $preview : null,
'businessHint' => $bizHint,
'curlError' => $curlErr !== '' ? $curlErr : null,
];
}
/**
* 主业务仅投递内部任务,不等待第三方推送完成,避免拖慢用户接口。
*/
public static function triggerAsyncOrderPaid(int $orderDbId, int $userId): void
{
if ($orderDbId <= 0 || $userId <= 0) {
return;
}
self::triggerAsyncInternalDispatch([
'job' => 'lead.order_paid',
'orderId' => $orderDbId,
'userId' => $userId,
]);
}
/**
* 测评结果写库后异步回调内部接口,避免在主提交流程里等待外部 Webhook。
*/
public static function triggerAsyncTestResultCompleted(int $testResultId): void
{
if ($testResultId <= 0) {
return;
}
self::triggerAsyncInternalDispatch([
'job' => 'test.result_completed',
'testResultId' => $testResultId,
]);
}
/**
* 内部接口验签:仅允许本服务自行投递的异步任务进入。
*/
public static function verifyAsyncInternalDispatch(string $body, string $timestamp, string $signature): bool
{
$ts = ctype_digit($timestamp) ? (int) $timestamp : 0;
if ($ts <= 0 || abs(time() - $ts) > 300) {
return false;
}
if ($body === '' || $signature === '') {
return false;
}
$expected = self::signAsyncInternalDispatch($body, $timestamp);
if (function_exists('hash_equals')) {
return hash_equals($expected, $signature);
}
return $expected === $signature;
}
/**
* 神仙 AI 异步对话:与订单/测评异步同源,走独立 HTTP 请求执行,避免 PHP-FPM 在响应后提前掐断 register_shutdown_function。
*
* @return bool 是否已成功把请求写出到 socket不代表模型已跑完
*/
public static function triggerAiChatDeferredJob(int $userId, int $conversationId, string $jobId): bool
{
if ($userId <= 0 || $conversationId <= 0 || $jobId === '' || strlen($jobId) > 64) {
return false;
}
if (!preg_match('/^[a-f0-9]+$/', $jobId)) {
return false;
}
return self::triggerAsyncInternalDispatch([
'job' => 'ai.chat_turn',
'userId' => $userId,
'conversationId' => $conversationId,
'jobId' => $jobId,
]);
}
/**
* @param array<string,mixed> $payload
*/
private static function triggerAsyncInternalDispatch(array $payload): bool
{
$url = self::resolveAsyncDispatchUrl();
if ($url === '') {
Log::warning('OutboundPushHook async enqueue skipped: no internal url', [
'payload' => $payload,
]);
return false;
}
$body = json_encode($payload, JSON_UNESCAPED_UNICODE);
if ($body === false) {
Log::warning('OutboundPushHook async enqueue skipped: json encode failed', [
'payload' => $payload,
]);
return false;
}
$timestamp = (string) time();
$headers = [
'Content-Type: application/json; charset=utf-8',
'X-MBTI-Internal-Timestamp: ' . $timestamp,
'X-MBTI-Internal-Signature: ' . self::signAsyncInternalDispatch($body, $timestamp),
];
$verifyHttp2xx = (($payload['job'] ?? '') === 'ai.chat_turn');
if (!self::postJsonAsyncNoWait($url, $body, $headers, $verifyHttp2xx)) {
// 偶发 TLS 握手/链路抖动:短间隔重试一次再回落 shutdown
usleep(150000);
if (!self::postJsonAsyncNoWait($url, $body, $headers, $verifyHttp2xx)) {
Log::warning('OutboundPushHook async enqueue failed', [
'url' => self::maskUrl($url),
'payload' => $payload,
]);
return false;
}
}
return true;
}
private static function resolveAsyncDispatchUrl(): string
{
$override = trim((string) (env('MBTI_INTERNAL_DISPATCH_URL') ?: getenv('MBTI_INTERNAL_DISPATCH_URL') ?: ''));
if ($override !== '') {
return rtrim($override, '/') . self::ASYNC_ROUTE;
}
$host = trim((string) Request::server('HTTP_HOST', ''));
if ($host !== '') {
$scheme = Request::isSsl() ? 'https' : 'http';
return $scheme . '://' . $host . self::ASYNC_ROUTE;
}
$appHost = trim((string) config('app.app_host', ''));
if ($appHost !== '') {
if (stripos($appHost, 'http://') === 0 || stripos($appHost, 'https://') === 0) {
return rtrim($appHost, '/') . self::ASYNC_ROUTE;
}
return 'https://' . trim($appHost, '/') . self::ASYNC_ROUTE;
}
return '';
}
private static function signAsyncInternalDispatch(string $body, string $timestamp): string
{
return hash_hmac('sha256', $timestamp . "\n" . $body, self::asyncInternalDispatchSecret());
}
private static function asyncInternalDispatchSecret(): string
{
$secret = (string) (env('jwt.secret', '') ?: getenv('JWT_SECRET') ?: '');
return $secret !== '' ? $secret : 'mbti-outbound-push';
}
/**
* fire-and-forget 异步 POST把请求写出可选读取 HTTP 首行状态码。
* - verifyHttp2xx=false与旧版一致仅判断 fwrite其它 job 可能同步较慢,不宜等响应头)。
* - verifyHttp2xx=true用于 ai.chat_turndispatch 须先快速返回 2xx否则会误判失败且无法触发 AiChat shutdown 兜底)。
*
* @param array<int,string> $headers
*/
private static function postJsonAsyncNoWait(string $url, string $body, array $headers, bool $verifyHttp2xx = false): bool
{
$parts = parse_url($url);
if (!is_array($parts) || empty($parts['host'])) {
return false;
}
$scheme = strtolower((string) ($parts['scheme'] ?? 'http'));
$host = (string) $parts['host'];
$port = isset($parts['port']) ? (int) $parts['port'] : ($scheme === 'https' ? 443 : 80);
$path = (string) ($parts['path'] ?? '/');
if (!empty($parts['query'])) {
$path .= '?' . $parts['query'];
}
$transport = $scheme === 'https' ? 'ssl://' : '';
// HTTPS 自调用:须带 SSL 上下文SNI/校验策略与 AiCallService 侧 curl 一致),否则部分环境握手直接失败
$ctx = null;
if ($scheme === 'https') {
$ctx = stream_context_create([
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
'peer_name' => $host,
'SNI_enabled' => true,
],
]);
}
$socket = @stream_socket_client(
$transport . $host . ':' . $port,
$errno,
$errstr,
3,
STREAM_CLIENT_CONNECT,
$ctx
);
if (!is_resource($socket)) {
Log::warning('OutboundPushHook async socket open failed', [
'url' => self::maskUrl($url),
'errno' => $errno,
'error' => $errstr,
]);
return false;
}
stream_set_timeout($socket, 3);
$hostHeader = $host;
if (($scheme === 'http' && $port !== 80) || ($scheme === 'https' && $port !== 443)) {
$hostHeader .= ':' . $port;
}
$requestLines = [
'POST ' . $path . ' HTTP/1.1',
'Host: ' . $hostHeader,
'Connection: Close',
'Content-Length: ' . strlen($body),
];
foreach ($headers as $header) {
$requestLines[] = $header;
}
$rawRequest = implode("\r\n", $requestLines) . "\r\n\r\n" . $body;
$written = @fwrite($socket, $rawRequest);
if ($written === false) {
@fclose($socket);
return false;
}
if (!$verifyHttp2xx) {
@fclose($socket);
return true;
}
$statusLine = @fgets($socket);
@fclose($socket);
if ($statusLine === false || $statusLine === '') {
Log::warning('OutboundPushHook async: no HTTP status line', ['url' => self::maskUrl($url)]);
return false;
}
if (!preg_match('#HTTP/\d\.\d\s+(\d{3})#', $statusLine, $m)) {
Log::warning('OutboundPushHook async: bad status line', [
'url' => self::maskUrl($url),
'prefix' => mb_substr($statusLine, 0, 80),
]);
return false;
}
$code = (int) $m[1];
if ($code < 200 || $code >= 300) {
Log::warning('OutboundPushHook async: non-2xx from internal dispatch', [
'url' => self::maskUrl($url),
'http' => $code,
]);
return false;
}
return true;
}
/**
* 将库表中的 enterpriseId 规范为 intnull/''/0 视为无)
*/
private static function intEnterpriseId($v): int
{
if ($v === null || $v === '') {
return 0;
}
$n = (int) $v;
return $n > 0 ? $n : 0;
}
/**
* 测评行可能未写 enterpriseIdnull但用户已绑定企业与 CrmReport 等一致,回落 wechat_users.enterpriseId
*
* @param array<string,mixed> $testResultRow
* @param array<string,mixed>|null $wechatUser wechat_users 一行,须含 enterpriseId可与昵称查询合并
*/
private static function resolveEnterpriseIdForTestResult(array $testResultRow, ?array $wechatUser): int
{
$fromRow = self::intEnterpriseId($testResultRow['enterpriseId'] ?? null);
if ($fromRow > 0) {
return $fromRow;
}
if ($wechatUser !== null) {
$fromUser = self::intEnterpriseId($wechatUser['enterpriseId'] ?? null);
if ($fromUser > 0) {
return $fromUser;
}
}
return 0;
}
/**
* 测评完成 JSON / 飞书文本:附加用户管理字段 + analytics 用户旅程(与 FeishuLeadWebhookService 获客卡片「最近行为」同源)
*
* @param array<string,mixed> $payload 引用
* @param array<string,mixed> $testResultRow test_results 一行
* @param array<string,mixed>|null $wuArr wechat_users 行(可含 openid
*/
private static function mergeTestResultUserJourneyPayload(array &$payload, int $userId, array $testResultRow, ?array $wuArr): void
{
if ($userId <= 0) {
$payload['recentBehaviors'] = [];
$payload['userManagement'] = [
'wechatUserId' => 0,
'enterpriseId' => 0,
'enterpriseName' => null,
'openidTail6' => null,
'testScope' => (string) ($testResultRow['testScope'] ?? ''),
];
$payload['managementSummary'] = '';
return;
}
$eidResolved = self::resolveEnterpriseIdForTestResult($testResultRow, $wuArr);
$tenant = self::tenantPayload($eidResolved);
$openid = '';
if ($wuArr !== null && isset($wuArr['openid'])) {
$openid = trim((string) $wuArr['openid']);
}
$tail = '';
if ($openid !== '') {
$tail = strlen($openid) >= 6 ? substr($openid, -6) : $openid;
}
$payload['userManagement'] = [
'wechatUserId' => $userId,
'enterpriseId' => $eidResolved,
'enterpriseName' => $tenant['enterpriseName'],
'openidTail6' => $tail !== '' ? $tail : null,
'testScope' => (string) ($testResultRow['testScope'] ?? ''),
];
$payload['recentBehaviors'] = UserJourneyService::recentBehaviorLines($userId, 12);
$trEid = (int) ($testResultRow['enterpriseId'] ?? 0);
$payload['managementSummary'] = UserJourneyService::managementSummaryLine($userId, $trEid);
}
public static function isEventEnabled(string $event, array $cfg): bool
{
if (empty($cfg['enabled'])) {
return false;
}
$url = trim((string) ($cfg['url'] ?? ''));
if ($url === '' || stripos($url, 'http') !== 0) {
return false;
}
$ev = $cfg['events'] ?? [];
if (!is_array($ev) || count($ev) === 0) {
return true;
}
return in_array($event, $ev, true);
}
/**
* 支付成功:与 FeishuLeadWebhookService::onOrderPaid 同路径触发
*/
public static function onOrderPaid(int $orderDbId, int $userId): void
{
if ($orderDbId <= 0 || $userId <= 0) {
return;
}
$order = Db::name('orders')->where('id', $orderDbId)->find();
if (!$order) {
return;
}
$productType = (string) ($order['productType'] ?? '');
$amountFen = (int) ($order['amount'] ?? 0);
$title = (string) ($order['productTitle'] ?? '');
$sourceLabel = FeishuLeadWebhookService::sourceLabelForOrder($productType, $title, $amountFen);
$payTs = isset($order['payTime']) ? (int) $order['payTime'] : time();
$paidAt = date('Y-m-d H:i:s', $payTs);
$wu = Db::name('wechat_users')->where('id', $userId)->field('nickname,phone')->find();
$userName = trim((string) ($wu['nickname'] ?? ''));
if ($userName === '') {
$userName = '微信用户';
}
$phone = trim((string) ($wu['phone'] ?? ''));
$eid = isset($order['enterpriseId']) ? (int) $order['enterpriseId'] : 0;
$tenant = self::tenantPayload($eid);
$payload = [
'display' => [
'title' => '用户购买成功(实时推送)',
'emoji' => '💰',
],
'orderId' => (int) $order['id'],
'orderNo' => (string) ($order['orderNo'] ?? ''),
'userId' => $userId,
'userName' => $userName,
'phone' => $phone,
'productTitle' => $title,
'productType' => $productType,
'amountYuan' => number_format($amountFen / 100, 2, '.', ''),
'amountFen' => $amountFen,
'status' => (string) ($order['status'] ?? 'paid'),
'paidAt' => $paidAt,
'sourceLabel' => $sourceLabel,
];
self::dispatch('lead.order_paid', [
'event' => 'lead.order_paid',
'occurredAt' => self::iso8601Cn(),
'environment' => self::appEnv(),
'tenant' => $tenant,
'payload' => $payload,
'_dedupKey' => 'lead.order_paid:' . $orderDbId,
], $eid);
}
public static function onPhoneBound(int $userId, string $phone): void
{
if ($userId <= 0 || trim($phone) === '') {
return;
}
$latest = Db::name('test_results')->where('userId', $userId)->order('id', 'desc')->find();
if ($latest) {
$tid = (int) ($latest['id'] ?? 0);
$testTs = isset($latest['createdAt']) ? (int) $latest['createdAt'] : 0;
if ($tid > 0 && $testTs > 0 && (time() - $testTs) <= self::TEST_PHONE_MERGE_MAX_AGE_SEC) {
$boundAt = date('Y-m-d H:i:s');
$pack = self::assembleTestResultCompletedEnvelope($tid, $phone, $boundAt);
if ($pack !== null) {
self::dispatch('test.result_completed', $pack['envelope'], $pack['eid']);
return;
}
}
}
$wu = Db::name('wechat_users')->where('id', $userId)->field('nickname,enterpriseId')->find();
$userName = trim((string) ($wu['nickname'] ?? ''));
if ($userName === '') {
$userName = '微信用户';
}
$eid = isset($wu['enterpriseId']) ? (int) $wu['enterpriseId'] : 0;
self::dispatch('lead.phone_bound', [
'event' => 'lead.phone_bound',
'occurredAt' => self::iso8601Cn(),
'environment' => self::appEnv(),
'tenant' => self::tenantPayload($eid),
'payload' => [
'display' => [
'title' => '用户完成手机号授权',
'emoji' => '📋',
],
'userId' => $userId,
'userName' => $userName,
'phone' => $phone,
'boundAt' => date('Y-m-d H:i:s'),
'sourceLabel' => '测试完成·授权手机号',
],
'_dedupKey' => 'lead.phone_bound:' . $userId,
], $eid);
}
/**
* 飞书获客 webhook 生成与出站机器人一致的测评完成纯文本(不写去重、不发起 HTTP
*/
public static function botPlainTextTestResultCompleted(int $testResultId, ?string $overridePhone = null, ?string $phoneBoundAt = null): ?string
{
$pack = self::assembleTestResultCompletedEnvelope($testResultId, $overridePhone, $phoneBoundAt);
if ($pack === null) {
return null;
}
$env = $pack['envelope'];
unset($env['_dedupKey']);
return self::envelopeToBotPlainText('test.result_completed', $env);
}
/**
* @return array{eid:int,envelope:array<string,mixed>}|null
*/
private static function assembleTestResultCompletedEnvelope(int $testResultId, ?string $overridePhone = null, ?string $phoneBoundAt = null): ?array
{
if ($testResultId <= 0) {
return null;
}
$row = Db::name('test_results')->where('id', $testResultId)->find();
if (!$row) {
return null;
}
$userId = (int) ($row['userId'] ?? 0);
$testType = (string) ($row['testType'] ?? '');
$createdAt = isset($row['createdAt']) ? (int) $row['createdAt'] : time();
$completedAt = date('Y-m-d H:i:s', $createdAt);
$wu = $userId > 0
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId,openid')->find()
: null;
$userName = $wu ? trim((string) ($wu['nickname'] ?? '')) : '';
if ($userName === '') {
$userName = '微信用户';
}
$phone = $wu ? trim((string) ($wu['phone'] ?? '')) : '';
if ($overridePhone !== null && trim($overridePhone) !== '') {
$phone = trim($overridePhone);
}
$raw = $row['resultData'] ?? null;
$data = is_string($raw) ? json_decode($raw, true) : $raw;
if (!is_array($data)) {
$data = [];
}
$summary = self::formatTestResultSummary($testType, $data);
$label = self::testTypeLabel($testType);
$wuArr = null;
if ($wu !== null) {
if (is_array($wu)) {
$wuArr = $wu;
} elseif (is_object($wu) && method_exists($wu, 'toArray')) {
$wuArr = $wu->toArray();
}
}
$eid = self::resolveEnterpriseIdForTestResult($row, $wuArr);
$payload = [
'display' => [
'title' => '用户测评完成(实时推送)',
'emoji' => '📊',
],
'testResultId' => $testResultId,
'userId' => $userId,
'userName' => $userName,
'phone' => $phone,
'testType' => $testType,
'testTypeLabel' => $label,
'resultSummary' => $summary,
'completedAt' => $completedAt,
];
if ($phoneBoundAt !== null && trim($phoneBoundAt) !== '') {
$payload['phoneBoundAt'] = trim($phoneBoundAt);
}
if (in_array($testType, ['face', 'ai'], true)) {
$dims = self::buildFaceAiBotDimensions($data);
if ($dims['mbti'] !== '') {
$payload['resultMbti'] = $dims['mbti'];
}
if ($dims['pdp'] !== '') {
$payload['resultPdp'] = $dims['pdp'];
}
if ($dims['disc'] !== '') {
$payload['resultDisc'] = $dims['disc'];
}
}
self::mergeTestResultUserJourneyPayload($payload, $userId, $row, $wuArr);
return [
'eid' => $eid,
'envelope' => [
'event' => 'test.result_completed',
'occurredAt' => self::iso8601Cn(),
'environment' => self::appEnv(),
'tenant' => self::tenantPayload($eid),
'payload' => $payload,
'_dedupKey' => 'test.result_completed:' . $testResultId,
],
];
}
/**
* 测评记录落库后推送(问卷 submit / 分析写库)
* 无手机号时不推送,待用户授权手机号后与测评合并为一条(见 onPhoneBound
*/
public static function onTestResultCompleted(int $testResultId): void
{
$pack = self::assembleTestResultCompletedEnvelope($testResultId, null, null);
if ($pack === null) {
return;
}
$phone = trim((string) ($pack['envelope']['payload']['phone'] ?? ''));
if ($phone === '') {
return;
}
self::dispatch('test.result_completed', $pack['envelope'], $pack['eid']);
}
/**
* 管理端调试入口:按真实业务数据重放 test.result_completed可选强制清去重后再发。
*
* @return array<string,mixed>
*/
public static function replayTestResultForDebug(int $testResultId, bool $force = false): array
{
if ($testResultId <= 0) {
return [
'ok' => false,
'status' => 'invalid',
'message' => 'testResultId 非法',
];
}
$pack = self::assembleTestResultCompletedEnvelope($testResultId, null, null);
if ($pack === null) {
return [
'ok' => false,
'status' => 'not_found',
'message' => '测试记录不存在',
'testResultId' => $testResultId,
];
}
return self::dispatchDetailed('test.result_completed', $pack['envelope'], $pack['eid'], $force);
}
/**
* @param array<string,mixed> $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<string,mixed> $envelope
* @return array<string,mixed>
*/
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<string,mixed> $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, '<!DOCTYPE') !== false || stripos($body, '<html') !== false) {
return ['ok' => 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<string,mixed> $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<string,mixed> $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);
}
}