geng
This commit is contained in:
@@ -98,6 +98,114 @@ class DifyChatService
|
||||
], $startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式调用 Dify / OpenAI-compatible 接口。上游原始响应与凭据不会进入返回值。
|
||||
*
|
||||
* @param array<string,mixed> $inputs
|
||||
* @param callable(string):mixed $onDelta
|
||||
* @param callable():bool|null $shouldAbort
|
||||
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
|
||||
*/
|
||||
public static function streamChat(
|
||||
string $profile,
|
||||
array $inputs,
|
||||
string $query,
|
||||
string $user,
|
||||
callable $onDelta,
|
||||
?callable $shouldAbort = null
|
||||
): array {
|
||||
$config = config('prescription_ai') ?: [];
|
||||
if (empty($config['enable'])) {
|
||||
return self::error('CONFIG_DISABLED', 'AI 报告功能未启用');
|
||||
}
|
||||
|
||||
$modelConfig = self::resolveProfileConfig($config, $profile);
|
||||
if ($modelConfig === null) {
|
||||
return self::error('INVALID_PROFILE', '不支持的 AI 模型');
|
||||
}
|
||||
|
||||
$baseUrl = trim((string) ($config['base_url'] ?? ''));
|
||||
$rawApiKey = (string) ($modelConfig['api_key'] ?? '');
|
||||
$apiKey = trim($rawApiKey);
|
||||
if ($baseUrl === '' || $apiKey === '') {
|
||||
return self::error('CONFIG_MISSING', '该模型服务尚未完整配置');
|
||||
}
|
||||
if (!self::isValidBaseUrl($baseUrl) || strpbrk($rawApiKey, "\r\n") !== false) {
|
||||
return self::error('CONFIG_INVALID', 'AI 服务配置无效');
|
||||
}
|
||||
|
||||
$timeout = (int) ($config['timeout'] ?? 0);
|
||||
if (!self::isValidTimeout($timeout)) {
|
||||
return self::error('CONFIG_INVALID', 'AI 服务超时配置无效');
|
||||
}
|
||||
if (!function_exists('curl_init')) {
|
||||
return self::error('CURL_UNAVAILABLE', '服务器尚未启用 cURL 扩展');
|
||||
}
|
||||
|
||||
$model = trim((string) ($modelConfig['name'] ?? ''));
|
||||
if ($model === '') {
|
||||
return self::error('CONFIG_INVALID', 'AI 模型配置无效');
|
||||
}
|
||||
|
||||
$requestSpecs = self::buildRequestSpecs(
|
||||
$baseUrl,
|
||||
$model,
|
||||
$inputs,
|
||||
$query,
|
||||
$user,
|
||||
true
|
||||
);
|
||||
$startedAt = microtime(true);
|
||||
$lastResponse = null;
|
||||
|
||||
foreach ($requestSpecs as $index => $requestSpec) {
|
||||
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
|
||||
$remainingTimeout = $timeout - $elapsedSeconds;
|
||||
if ($remainingTimeout < self::MIN_TIMEOUT) {
|
||||
return self::error(
|
||||
'UPSTREAM_TIMEOUT',
|
||||
'模型响应超时,请稍后重试',
|
||||
self::elapsedMilliseconds($startedAt)
|
||||
);
|
||||
}
|
||||
|
||||
$response = self::sendStreamRequest(
|
||||
$requestSpec['protocol'],
|
||||
$requestSpec['url'],
|
||||
$requestSpec['payload'],
|
||||
$apiKey,
|
||||
$remainingTimeout,
|
||||
$onDelta,
|
||||
$shouldAbort
|
||||
);
|
||||
$lastResponse = $response;
|
||||
|
||||
// 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。
|
||||
$hasFallback = isset($requestSpecs[$index + 1]);
|
||||
if (
|
||||
$hasFallback
|
||||
&& empty($response['emitted'])
|
||||
&& in_array($response['http_code'], [404, 405], true)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return self::formatStreamResponse($response, $startedAt);
|
||||
}
|
||||
|
||||
return self::formatStreamResponse($lastResponse ?? [
|
||||
'errno' => 0,
|
||||
'http_code' => 0,
|
||||
'content' => '',
|
||||
'message_id' => '',
|
||||
'emitted' => false,
|
||||
'upstream_error' => false,
|
||||
'client_aborted' => false,
|
||||
'callback_error' => false,
|
||||
'finished' => false,
|
||||
], $startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $config
|
||||
* @return array<string,mixed>|null
|
||||
@@ -120,7 +228,8 @@ class DifyChatService
|
||||
string $model,
|
||||
array $inputs,
|
||||
string $query,
|
||||
string $user
|
||||
string $user,
|
||||
bool $streaming = false
|
||||
): array {
|
||||
$baseUrl = rtrim($baseUrl, '/');
|
||||
$path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? ''));
|
||||
@@ -131,7 +240,7 @@ class DifyChatService
|
||||
'payload' => [
|
||||
'inputs' => $inputs,
|
||||
'query' => $query,
|
||||
'response_mode' => 'blocking',
|
||||
'response_mode' => $streaming ? 'streaming' : 'blocking',
|
||||
'user' => $user,
|
||||
],
|
||||
];
|
||||
@@ -143,9 +252,14 @@ class DifyChatService
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => $query],
|
||||
],
|
||||
'stream' => $streaming,
|
||||
],
|
||||
];
|
||||
|
||||
if (!$streaming) {
|
||||
unset($openAiSpec['payload']['stream']);
|
||||
}
|
||||
|
||||
if (str_ends_with($path, '/chat-messages')) {
|
||||
return [$difySpec];
|
||||
}
|
||||
@@ -240,6 +354,357 @@ class DifyChatService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @param callable(string):mixed $onDelta
|
||||
* @param callable():bool|null $shouldAbort
|
||||
* @return array{
|
||||
* errno:int,http_code:int,content:string,message_id:string,emitted:bool,
|
||||
* upstream_error:bool,client_aborted:bool,callback_error:bool,finished:bool
|
||||
* }
|
||||
*/
|
||||
private static function sendStreamRequest(
|
||||
string $protocol,
|
||||
string $url,
|
||||
array $payload,
|
||||
string $apiKey,
|
||||
int $timeout,
|
||||
callable $onDelta,
|
||||
?callable $shouldAbort
|
||||
): array {
|
||||
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
if ($body === false) {
|
||||
return self::emptyStreamResponse(-1);
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
if ($ch === false) {
|
||||
return self::emptyStreamResponse(-2);
|
||||
}
|
||||
|
||||
$buffer = '';
|
||||
$state = self::newStreamState();
|
||||
$responseCode = 0;
|
||||
$header = static function ($handle, string $line) use (&$responseCode): int {
|
||||
if (preg_match('/^HTTP\/\S+\s+(\d{3})(?:\s|$)/i', trim($line), $matches) === 1) {
|
||||
$responseCode = (int) $matches[1];
|
||||
}
|
||||
return strlen($line);
|
||||
};
|
||||
$write = static function ($handle, string $chunk) use (
|
||||
$protocol,
|
||||
&$buffer,
|
||||
&$state,
|
||||
&$responseCode,
|
||||
$onDelta,
|
||||
$shouldAbort
|
||||
): int {
|
||||
if ($shouldAbort !== null && $shouldAbort()) {
|
||||
$state['client_aborted'] = true;
|
||||
return 0;
|
||||
}
|
||||
if ($responseCode < 200 || $responseCode >= 300) {
|
||||
// Never decode or forward an error response body. Besides preventing
|
||||
// leakage, this keeps 404/405 protocol fallback side-effect free.
|
||||
return strlen($chunk);
|
||||
}
|
||||
self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta);
|
||||
return $state['callback_error'] ? 0 : strlen($chunk);
|
||||
};
|
||||
$progress = static function () use (&$state, $shouldAbort): int {
|
||||
if ($shouldAbort !== null && $shouldAbort()) {
|
||||
$state['client_aborted'] = true;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_CONNECTTIMEOUT => min(8, max(1, (int) ceil($timeout / 4))),
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Accept: text/event-stream',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
],
|
||||
CURLOPT_HEADERFUNCTION => $header,
|
||||
CURLOPT_WRITEFUNCTION => $write,
|
||||
CURLOPT_NOPROGRESS => false,
|
||||
CURLOPT_XFERINFOFUNCTION => $progress,
|
||||
]);
|
||||
|
||||
curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if (!$state['client_aborted'] && !$state['callback_error']) {
|
||||
self::consumeStreamBytes($protocol, $buffer, '', $state, $onDelta, true);
|
||||
}
|
||||
|
||||
return [
|
||||
'errno' => $errno,
|
||||
'http_code' => $httpCode,
|
||||
'content' => $state['content'],
|
||||
'message_id' => $state['message_id'],
|
||||
'emitted' => $state['emitted'],
|
||||
'upstream_error' => $state['upstream_error'],
|
||||
'client_aborted' => $state['client_aborted'],
|
||||
'callback_error' => $state['callback_error'],
|
||||
'finished' => $state['finished'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* content:string,message_id:string,emitted:bool,upstream_error:bool,
|
||||
* client_aborted:bool,callback_error:bool,finished:bool
|
||||
* }
|
||||
*/
|
||||
private static function newStreamState(): array
|
||||
{
|
||||
return [
|
||||
'content' => '',
|
||||
'message_id' => '',
|
||||
'emitted' => false,
|
||||
'upstream_error' => false,
|
||||
'client_aborted' => false,
|
||||
'callback_error' => false,
|
||||
'finished' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 SSE 空行分帧;仅在完整 data frame 后 json_decode,因此可安全接收任意字节边界。
|
||||
*
|
||||
* @param array<string,mixed> $state
|
||||
* @param callable(string):mixed $onDelta
|
||||
*/
|
||||
private static function consumeStreamBytes(
|
||||
string $protocol,
|
||||
string &$buffer,
|
||||
string $chunk,
|
||||
array &$state,
|
||||
callable $onDelta,
|
||||
bool $final = false
|
||||
): void {
|
||||
$buffer .= $chunk;
|
||||
while (preg_match('/(?:\r\n|\r|\n){2}/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1) {
|
||||
$delimiter = $match[0][0];
|
||||
$offset = $match[0][1];
|
||||
$frame = substr($buffer, 0, $offset);
|
||||
$buffer = (string) substr($buffer, $offset + strlen($delimiter));
|
||||
self::consumeStreamFrame($protocol, $frame, $state, $onDelta);
|
||||
}
|
||||
if ($final && trim($buffer) !== '') {
|
||||
self::consumeStreamFrame($protocol, $buffer, $state, $onDelta);
|
||||
$buffer = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $state
|
||||
* @param callable(string):mixed $onDelta
|
||||
*/
|
||||
private static function consumeStreamFrame(
|
||||
string $protocol,
|
||||
string $frame,
|
||||
array &$state,
|
||||
callable $onDelta
|
||||
): void {
|
||||
if ($state['finished'] || $state['upstream_error'] || $state['callback_error']) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dataLines = [];
|
||||
foreach (preg_split('/\r\n|\r|\n/', $frame) ?: [] as $line) {
|
||||
if ($line === '' || str_starts_with($line, ':')) {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($line, 'data:')) {
|
||||
$dataLines[] = ltrim(substr($line, 5), ' ');
|
||||
}
|
||||
}
|
||||
if ($dataLines === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = implode("\n", $dataLines);
|
||||
if ($data === '[DONE]') {
|
||||
$state['finished'] = true;
|
||||
return;
|
||||
}
|
||||
$decoded = json_decode($data, true);
|
||||
if (!is_array($decoded)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$delta = '';
|
||||
if ($protocol === 'dify') {
|
||||
$event = strtolower((string) ($decoded['event'] ?? ''));
|
||||
if ($event === 'message_end') {
|
||||
$state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']);
|
||||
$state['finished'] = true;
|
||||
return;
|
||||
}
|
||||
if ($event === 'error') {
|
||||
$state['upstream_error'] = true;
|
||||
return;
|
||||
}
|
||||
if (!in_array($event, ['message', 'agent_message'], true)) {
|
||||
return;
|
||||
}
|
||||
$delta = is_string($decoded['answer'] ?? null) ? $decoded['answer'] : '';
|
||||
$state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']);
|
||||
} else {
|
||||
$delta = self::extractStreamDelta($decoded);
|
||||
$state['message_id'] = (string) ($decoded['id'] ?? $state['message_id']);
|
||||
}
|
||||
|
||||
if ($delta === '') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$accepted = $onDelta($delta);
|
||||
if ($accepted === false) {
|
||||
$state['callback_error'] = true;
|
||||
return;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$state['callback_error'] = true;
|
||||
return;
|
||||
}
|
||||
$state['content'] .= $delta;
|
||||
$state['emitted'] = true;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $decoded */
|
||||
private static function extractStreamDelta(array $decoded): string
|
||||
{
|
||||
$content = $decoded['choices'][0]['delta']['content'] ?? '';
|
||||
if (is_string($content)) {
|
||||
return $content;
|
||||
}
|
||||
if (!is_array($content)) {
|
||||
return '';
|
||||
}
|
||||
$parts = [];
|
||||
foreach ($content as $part) {
|
||||
if (is_array($part) && ($part['type'] ?? '') === 'text' && is_string($part['text'] ?? null)) {
|
||||
$parts[] = $part['text'];
|
||||
}
|
||||
}
|
||||
return implode('', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯解析测试入口:生产流与测试使用同一逐字节解码路径。
|
||||
*
|
||||
* @param array<int,string> $chunks
|
||||
* @return array{content:string,deltas:array<int,string>,message_id:string,finished:bool,upstream_error:bool}
|
||||
*/
|
||||
private static function decodeStreamChunks(string $protocol, array $chunks): array
|
||||
{
|
||||
$buffer = '';
|
||||
$state = self::newStreamState();
|
||||
$deltas = [];
|
||||
$onDelta = static function (string $delta) use (&$deltas): void {
|
||||
$deltas[] = $delta;
|
||||
};
|
||||
foreach ($chunks as $chunk) {
|
||||
self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta);
|
||||
}
|
||||
self::consumeStreamBytes($protocol, $buffer, '', $state, $onDelta, true);
|
||||
return [
|
||||
'content' => $state['content'],
|
||||
'deltas' => $deltas,
|
||||
'message_id' => $state['message_id'],
|
||||
'finished' => $state['finished'],
|
||||
'upstream_error' => $state['upstream_error'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* errno:int,http_code:int,content:string,message_id:string,emitted:bool,
|
||||
* upstream_error:bool,client_aborted:bool,callback_error:bool,finished:bool
|
||||
* }
|
||||
*/
|
||||
private static function emptyStreamResponse(int $errno): array
|
||||
{
|
||||
return [
|
||||
'errno' => $errno,
|
||||
'http_code' => 0,
|
||||
'content' => '',
|
||||
'message_id' => '',
|
||||
'emitted' => false,
|
||||
'upstream_error' => false,
|
||||
'client_aborted' => false,
|
||||
'callback_error' => false,
|
||||
'finished' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $response
|
||||
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
|
||||
*/
|
||||
private static function formatStreamResponse(array $response, float $startedAt): array
|
||||
{
|
||||
$latencyMs = self::elapsedMilliseconds($startedAt);
|
||||
if (!empty($response['client_aborted'])) {
|
||||
return self::error('CLIENT_DISCONNECTED', '客户端已断开连接', $latencyMs);
|
||||
}
|
||||
if (!empty($response['callback_error'])) {
|
||||
return self::error('STREAM_DELIVERY_FAILED', '流式响应已中止', $latencyMs);
|
||||
}
|
||||
|
||||
$errno = (int) ($response['errno'] ?? 0);
|
||||
if ($errno !== 0) {
|
||||
if ($errno === CURLE_OPERATION_TIMEDOUT) {
|
||||
return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', $latencyMs);
|
||||
}
|
||||
if ($errno === -1) {
|
||||
return self::error('REQUEST_BUILD_FAILED', '病例数据编码失败', $latencyMs);
|
||||
}
|
||||
if ($errno === -2) {
|
||||
return self::error('CURL_INIT_FAILED', '无法初始化 AI 请求', $latencyMs);
|
||||
}
|
||||
return self::error('UPSTREAM_UNAVAILABLE', '暂时无法连接 AI 服务,请稍后重试', $latencyMs);
|
||||
}
|
||||
|
||||
$httpCode = (int) ($response['http_code'] ?? 0);
|
||||
if ($httpCode === 401 || $httpCode === 403) {
|
||||
return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs);
|
||||
}
|
||||
if ($httpCode === 429 || $httpCode >= 500) {
|
||||
return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs);
|
||||
}
|
||||
if ($httpCode >= 400 || $httpCode < 200 || !empty($response['upstream_error'])) {
|
||||
return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs);
|
||||
}
|
||||
if (empty($response['finished'])) {
|
||||
return self::error('INCOMPLETE_RESPONSE', '模型响应不完整,请重试', $latencyMs);
|
||||
}
|
||||
|
||||
$content = (string) ($response['content'] ?? '');
|
||||
if (trim($content) === '') {
|
||||
return self::error('EMPTY_RESPONSE', '模型未返回报告内容,请重试', $latencyMs);
|
||||
}
|
||||
return [
|
||||
'ok' => true,
|
||||
'content' => $content,
|
||||
'message_id' => (string) ($response['message_id'] ?? ''),
|
||||
'latency_ms' => $latencyMs,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{body:string,errno:int,http_code:int} $response
|
||||
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
|
||||
|
||||
@@ -21,6 +21,8 @@ class MediaChannelService
|
||||
'update_time',
|
||||
];
|
||||
|
||||
public const GROUP_CODE_PREFIX = 'group:';
|
||||
|
||||
/** @var array<int, array<string, mixed>>|null */
|
||||
private static ?array $activeChannelRowsCache = null;
|
||||
|
||||
@@ -199,8 +201,9 @@ SQL;
|
||||
'code' => (string) ($row['channel_code'] ?? ''),
|
||||
'name' => (string) ($row['channel_name'] ?? ''),
|
||||
'tag_id' => (string) ($row['source_tag_id'] ?? ''),
|
||||
'group_name' => (string) ($row['source_group_name'] ?? ''),
|
||||
'group_name' => trim((string) ($row['source_group_name'] ?? '')),
|
||||
'customer_count' => (int) ($row['customer_count'] ?? 0),
|
||||
'kind' => 'channel',
|
||||
], self::getCurrentTagChannelRows());
|
||||
}
|
||||
|
||||
@@ -217,6 +220,11 @@ SQL;
|
||||
return null;
|
||||
}
|
||||
|
||||
$groupName = self::parseGroupName($channelCode);
|
||||
if ($groupName !== '') {
|
||||
return self::buildCurrentTagGroupChannel($groupName);
|
||||
}
|
||||
|
||||
foreach (self::getCurrentTagChannelRows() as $row) {
|
||||
if ((string) ($row['channel_code'] ?? '') === $channelCode) {
|
||||
return $row;
|
||||
@@ -226,6 +234,57 @@ SQL;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function isGroupCode(string $channelCode): bool
|
||||
{
|
||||
return self::parseGroupName($channelCode) !== '';
|
||||
}
|
||||
|
||||
public static function buildGroupCode(string $groupName): string
|
||||
{
|
||||
$groupName = trim($groupName);
|
||||
|
||||
return $groupName === '' ? '' : self::GROUP_CODE_PREFIX . $groupName;
|
||||
}
|
||||
|
||||
public static function parseGroupName(string $channelCode): string
|
||||
{
|
||||
$channelCode = trim($channelCode);
|
||||
if (!str_starts_with($channelCode, self::GROUP_CODE_PREFIX)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim(substr($channelCode, strlen(self::GROUP_CODE_PREFIX)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户消耗等事实表使用的真实渠道 code;分组筛选会展开为组内全部叶子渠道。
|
||||
*
|
||||
* @param array<string, mixed>|null $channel
|
||||
* @return string[]
|
||||
*/
|
||||
public static function getChannelCodesForStats(?array $channel): array
|
||||
{
|
||||
if ($channel === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$codes = [];
|
||||
if (isset($channel['channel_codes']) && is_array($channel['channel_codes'])) {
|
||||
foreach ($channel['channel_codes'] as $code) {
|
||||
$code = trim((string) $code);
|
||||
if ($code !== '' && !str_starts_with($code, self::GROUP_CODE_PREFIX)) {
|
||||
$codes[$code] = $code;
|
||||
}
|
||||
}
|
||||
}
|
||||
$code = trim((string) ($channel['channel_code'] ?? ''));
|
||||
if ($code !== '' && !str_starts_with($code, self::GROUP_CODE_PREFIX)) {
|
||||
$codes[$code] = $code;
|
||||
}
|
||||
|
||||
return array_values($codes);
|
||||
}
|
||||
|
||||
public static function getDefaultCode(): string
|
||||
{
|
||||
$rows = self::getActiveChannelRows();
|
||||
@@ -303,12 +362,21 @@ SQL;
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = array_values(array_unique(array_filter([
|
||||
$names = [
|
||||
trim((string) ($channel['channel_name'] ?? '')),
|
||||
trim((string) ($channel['source_tag_name'] ?? '')),
|
||||
trim((string) ($channel['legacy_channel_name'] ?? '')),
|
||||
trim((string) ($channel['legacy_source_tag_name'] ?? '')),
|
||||
])));
|
||||
];
|
||||
foreach (['channel_names', 'source_tag_names'] as $listKey) {
|
||||
if (!isset($channel[$listKey]) || !is_array($channel[$listKey])) {
|
||||
continue;
|
||||
}
|
||||
foreach ($channel[$listKey] as $name) {
|
||||
$names[] = trim((string) $name);
|
||||
}
|
||||
}
|
||||
$names = array_values(array_unique(array_filter($names, static fn (string $name): bool => $name !== '')));
|
||||
|
||||
if ($names === []) {
|
||||
return [];
|
||||
@@ -362,18 +430,22 @@ SQL;
|
||||
return;
|
||||
}
|
||||
|
||||
$tagId = trim((string) ($channel['source_tag_id'] ?? ''));
|
||||
if ($tagId !== '') {
|
||||
$tagIds = self::channelTagIds($channel);
|
||||
if ($tagIds !== []) {
|
||||
$tagTable = self::tableWithPrefix('qywx_external_contact_tag');
|
||||
$contactTable = self::tableWithPrefix('qywx_external_contact');
|
||||
$tagPredicate = count($tagIds) === 1
|
||||
? 'channel_tag.tag_id = ?'
|
||||
: 'channel_tag.tag_id IN (' . implode(', ', array_fill(0, count($tagIds), '?')) . ')';
|
||||
// 相关 EXISTS 走 (tag_id, external_userid) 索引,避免先物化整渠客户 ID 再 IN。
|
||||
$query->whereRaw(
|
||||
"{$field} IN ("
|
||||
. "SELECT channel_tag.external_userid FROM {$tagTable} channel_tag "
|
||||
. 'WHERE channel_tag.tag_id = ? '
|
||||
"EXISTS (SELECT 1 FROM {$tagTable} channel_tag "
|
||||
. "WHERE channel_tag.external_userid = {$field} "
|
||||
. "AND {$tagPredicate} "
|
||||
. "AND EXISTS (SELECT 1 FROM {$contactTable} active_channel_contact "
|
||||
. 'WHERE active_channel_contact.external_userid = channel_tag.external_userid '
|
||||
. 'AND active_channel_contact.delete_time IS NULL))',
|
||||
[$tagId]
|
||||
$tagIds
|
||||
);
|
||||
|
||||
return;
|
||||
@@ -768,16 +840,24 @@ SQL;
|
||||
private static function buildLikePatterns(array $channel): array
|
||||
{
|
||||
$patterns = [];
|
||||
$tagId = trim((string) ($channel['source_tag_id'] ?? ''));
|
||||
$tagName = trim((string) ($channel['source_tag_name'] ?? ''));
|
||||
|
||||
if ($tagId !== '') {
|
||||
foreach (self::channelTagIds($channel) as $tagId) {
|
||||
$escapedTagId = addcslashes($tagId, '%_\\');
|
||||
$patterns[] = '%"tag_id":"' . $escapedTagId . '"%';
|
||||
$patterns[] = '%"id":"' . $escapedTagId . '"%';
|
||||
}
|
||||
|
||||
if ($tagName !== '') {
|
||||
$tagNames = [trim((string) ($channel['source_tag_name'] ?? ''))];
|
||||
if (isset($channel['channel_names']) && is_array($channel['channel_names'])) {
|
||||
foreach ($channel['channel_names'] as $name) {
|
||||
$tagNames[] = trim((string) $name);
|
||||
}
|
||||
}
|
||||
if (isset($channel['source_tag_names']) && is_array($channel['source_tag_names'])) {
|
||||
foreach ($channel['source_tag_names'] as $name) {
|
||||
$tagNames[] = trim((string) $name);
|
||||
}
|
||||
}
|
||||
foreach (array_unique(array_filter($tagNames, static fn (string $name): bool => $name !== '')) as $tagName) {
|
||||
$escapedTagName = addcslashes($tagName, '%_\\');
|
||||
$patterns[] = '%"name":"' . $escapedTagName . '"%';
|
||||
$patterns[] = '%"tag_name":"' . $escapedTagName . '"%';
|
||||
@@ -786,6 +866,86 @@ SQL;
|
||||
return array_values(array_unique($patterns));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $channel
|
||||
* @return string[]
|
||||
*/
|
||||
private static function channelTagIds(array $channel): array
|
||||
{
|
||||
$tagIds = [];
|
||||
if (isset($channel['source_tag_ids']) && is_array($channel['source_tag_ids'])) {
|
||||
foreach ($channel['source_tag_ids'] as $tagId) {
|
||||
$tagId = trim((string) $tagId);
|
||||
if ($tagId !== '') {
|
||||
$tagIds[$tagId] = $tagId;
|
||||
}
|
||||
}
|
||||
}
|
||||
$tagId = trim((string) ($channel['source_tag_id'] ?? ''));
|
||||
if ($tagId !== '') {
|
||||
$tagIds[$tagId] = $tagId;
|
||||
}
|
||||
|
||||
return array_values($tagIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function buildCurrentTagGroupChannel(string $groupName): ?array
|
||||
{
|
||||
$groupName = trim($groupName);
|
||||
if ($groupName === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach (self::getCurrentTagChannelRows() as $row) {
|
||||
if (trim((string) ($row['source_group_name'] ?? '')) === $groupName) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
}
|
||||
if ($rows === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tagIds = [];
|
||||
$codes = [];
|
||||
$names = [];
|
||||
$customerCount = 0;
|
||||
foreach ($rows as $row) {
|
||||
$tagId = trim((string) ($row['source_tag_id'] ?? ''));
|
||||
if ($tagId !== '') {
|
||||
$tagIds[$tagId] = $tagId;
|
||||
}
|
||||
$code = trim((string) ($row['channel_code'] ?? ''));
|
||||
if ($code !== '' && !str_starts_with($code, self::GROUP_CODE_PREFIX)) {
|
||||
$codes[$code] = $code;
|
||||
}
|
||||
foreach (['channel_name', 'source_tag_name', 'legacy_channel_name', 'legacy_source_tag_name'] as $nameKey) {
|
||||
$name = trim((string) ($row[$nameKey] ?? ''));
|
||||
if ($name !== '') {
|
||||
$names[$name] = $name;
|
||||
}
|
||||
}
|
||||
$customerCount = max($customerCount, (int) ($row['customer_count'] ?? 0));
|
||||
}
|
||||
|
||||
return [
|
||||
'channel_code' => self::buildGroupCode($groupName),
|
||||
'channel_name' => $groupName,
|
||||
'source_group_name' => $groupName,
|
||||
'source_tag_id' => '',
|
||||
'source_tag_name' => $groupName,
|
||||
'source_tag_ids' => array_values($tagIds),
|
||||
'channel_codes' => array_values($codes),
|
||||
'channel_names' => array_values($names),
|
||||
'customer_count' => $customerCount,
|
||||
'is_group' => true,
|
||||
'status' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
private static function tableWithPrefix(string $table): string
|
||||
{
|
||||
$prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_');
|
||||
|
||||
Reference in New Issue
Block a user