geng
This commit is contained in:
@@ -4,9 +4,10 @@ namespace app\adminapi\controller\doctor;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\doctor\AppointmentLists;
|
||||
use app\adminapi\logic\doctor\AppointmentLogic;
|
||||
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
||||
use app\adminapi\validate\doctor\AppointmentValidate;
|
||||
use app\adminapi\logic\doctor\AppointmentLogic;
|
||||
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||
use app\adminapi\validate\doctor\AppointmentValidate;
|
||||
|
||||
/**
|
||||
* 医生预约控制器
|
||||
@@ -147,8 +148,11 @@ class AppointmentController extends BaseAdminController
|
||||
public function reception()
|
||||
{
|
||||
$params = (new AppointmentValidate())->goCheck('reception');
|
||||
$result = AppointmentLogic::reception($params);
|
||||
return $this->data($result);
|
||||
$result = AppointmentLogic::reception($params, $this->adminId, $this->adminInfo);
|
||||
if (empty($result)) {
|
||||
return $this->fail('预约记录不存在或无权访问');
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,10 +169,17 @@ class AppointmentController extends BaseAdminController
|
||||
return $this->success('通知已发送');
|
||||
}
|
||||
|
||||
public function addDoctorNote()
|
||||
{
|
||||
$params = (new AppointmentValidate())->post()->goCheck('addDoctorNote');
|
||||
$params['doctor_id'] = $this->adminId;
|
||||
public function addDoctorNote()
|
||||
{
|
||||
$params = (new AppointmentValidate())->post()->goCheck('addDoctorNote');
|
||||
if (!DiagnosisLogic::canViewReadonlyDiagnosis(
|
||||
(int) $params['diagnosis_id'],
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)) {
|
||||
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
|
||||
}
|
||||
$params['doctor_id'] = $this->adminId;
|
||||
$result = DoctorNoteLogic::addOrAppend($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(DoctorNoteLogic::getError());
|
||||
@@ -176,10 +187,17 @@ class AppointmentController extends BaseAdminController
|
||||
return $this->success('保存成功');
|
||||
}
|
||||
|
||||
public function doctorNotes()
|
||||
{
|
||||
$params = (new AppointmentValidate())->goCheck('doctorNotes');
|
||||
return $this->data(DoctorNoteLogic::getByDiagnosis((int) $params['diagnosis_id']));
|
||||
public function doctorNotes()
|
||||
{
|
||||
$params = (new AppointmentValidate())->goCheck('doctorNotes');
|
||||
if (!DiagnosisLogic::canViewReadonlyDiagnosis(
|
||||
(int) $params['diagnosis_id'],
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)) {
|
||||
return $this->fail('诊单不存在或无权访问');
|
||||
}
|
||||
return $this->data(DoctorNoteLogic::getByDiagnosis((int) $params['diagnosis_id']));
|
||||
}
|
||||
|
||||
public function deleteDoctorNoteImage()
|
||||
|
||||
@@ -21,7 +21,8 @@ use app\adminapi\logic\tcm\DiagnosisAiLogic;
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||
use app\adminapi\logic\tcm\PatientAiReportLogic;
|
||||
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||
use app\adminapi\validate\tcm\DiagnosisValidate;
|
||||
use app\adminapi\service\AssistantSseProtocol;
|
||||
use app\adminapi\validate\tcm\DiagnosisValidate;
|
||||
use app\common\model\Order;
|
||||
use app\common\model\WechatChatRecord;
|
||||
|
||||
@@ -168,10 +169,13 @@ class DiagnosisController extends BaseAdminController
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function trackingWindow()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->goCheck('trackingWindow');
|
||||
$result = DiagnosisLogic::fetchTrackingWindow(
|
||||
public function trackingWindow()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->goCheck('trackingWindow');
|
||||
if (!DiagnosisLogic::canViewReadonlyDiagnosis((int) $params['id'], $this->adminId, $this->adminInfo)) {
|
||||
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
|
||||
}
|
||||
$result = DiagnosisLogic::fetchTrackingWindow(
|
||||
(int) $params['id'],
|
||||
(string) ($params['start_date'] ?? ''),
|
||||
(string) ($params['end_date'] ?? '')
|
||||
@@ -865,9 +869,9 @@ class DiagnosisController extends BaseAdminController
|
||||
/**
|
||||
* @notes 基于当前授权诊单向 AI 助手提问,不接收客户端上游配置
|
||||
*/
|
||||
public function aiAssistant()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
|
||||
public function aiAssistant()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
|
||||
$result = DiagnosisAiLogic::assistant(
|
||||
(int) $params['id'],
|
||||
(string) $params['task'],
|
||||
@@ -877,11 +881,113 @@ class DiagnosisController extends BaseAdminController
|
||||
);
|
||||
if ($result === null) {
|
||||
return $this->fail(DiagnosisAiLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 基于当前授权诊单向 AI 助手提问(SSE 真流式)
|
||||
*
|
||||
* 路由:POST tcm.diagnosis/aiAssistantStream body: id, task, prompt
|
||||
*/
|
||||
public function aiAssistantStream()
|
||||
{
|
||||
// 登录由全局中间件完成;请求校验、旧助手权限与 DataScope 必须全部
|
||||
// 在任何 SSE header / start 事件之前完成,失败时仍返回标准 JSON。
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
|
||||
$prepared = DiagnosisAiLogic::prepareAssistant(
|
||||
(int) $params['id'],
|
||||
(string) $params['task'],
|
||||
(string) ($params['prompt'] ?? ''),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($prepared === null) {
|
||||
return $this->fail(DiagnosisAiLogic::getError());
|
||||
}
|
||||
|
||||
$this->runAssistantSse($prepared);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $prepared */
|
||||
private function runAssistantSse(array $prepared): void
|
||||
{
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
@ini_set('output_buffering', 'off');
|
||||
@ini_set('zlib.output_compression', '0');
|
||||
ignore_user_abort(true);
|
||||
if (function_exists('apache_setenv')) {
|
||||
@apache_setenv('no-gzip', '1');
|
||||
}
|
||||
|
||||
header('Content-Type: text/event-stream; charset=utf-8');
|
||||
header('Cache-Control: no-cache, no-transform');
|
||||
header('Connection: keep-alive');
|
||||
header('X-Accel-Buffering: no');
|
||||
header('Content-Encoding: none');
|
||||
|
||||
echo ':' . str_repeat(' ', 2048) . "\n\n";
|
||||
$this->flushSseOutput();
|
||||
|
||||
$protocol = new AssistantSseProtocol();
|
||||
$emit = function (string $event, array $payload) use ($protocol): bool {
|
||||
if ($protocol->isTerminal() || connection_aborted()) {
|
||||
return false;
|
||||
}
|
||||
$encoded = $protocol->encode($event, $payload);
|
||||
if ($encoded === null) {
|
||||
return false;
|
||||
}
|
||||
echo $encoded;
|
||||
$this->flushSseOutput();
|
||||
return !connection_aborted();
|
||||
};
|
||||
|
||||
$emit('start', [
|
||||
'task' => (string) ($prepared['task'] ?? ''),
|
||||
'model_key' => (string) ($prepared['profile'] ?? ''),
|
||||
'message' => '已连接,正在生成…',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = DiagnosisAiLogic::streamPreparedAssistant(
|
||||
$prepared,
|
||||
static fn (string $delta): bool => $emit('delta', ['text' => $delta]),
|
||||
static fn (): bool => connection_aborted() === 1
|
||||
);
|
||||
if (connection_aborted()) {
|
||||
exit;
|
||||
}
|
||||
if ($result === null) {
|
||||
$emit('error', [
|
||||
'code' => 'AI_ASSISTANT_FAILED',
|
||||
'message' => 'AI 助手暂时不可用,请稍后重试',
|
||||
]);
|
||||
} else {
|
||||
$emit('done', $result);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$emit('error', [
|
||||
'code' => 'AI_ASSISTANT_FAILED',
|
||||
'message' => 'AI 助手暂时不可用,请稍后重试',
|
||||
]);
|
||||
}
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
private function flushSseOutput(): void
|
||||
{
|
||||
if (function_exists('ob_flush')) {
|
||||
@ob_flush();
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 对当前授权诊单生成一次结构化 AI 智能分析,仅接受 qwen/openai 模型键
|
||||
*/
|
||||
public function aiAnalysis()
|
||||
|
||||
@@ -137,9 +137,12 @@ class PrescriptionController extends BaseAdminController
|
||||
$diagnosisId = (int)($this->request->get('diagnosis_id') ?? 0);
|
||||
if (!$diagnosisId) {
|
||||
return $this->fail('诊单ID不能为空');
|
||||
}
|
||||
$list = PrescriptionLogic::listByDiagnosis($diagnosisId);
|
||||
return $this->data($list);
|
||||
}
|
||||
$list = PrescriptionLogic::listByDiagnosis($diagnosisId, (int) $this->adminId, $this->adminInfo);
|
||||
if (PrescriptionLogic::getError() !== '') {
|
||||
return $this->fail(PrescriptionLogic::getError());
|
||||
}
|
||||
return $this->data($list);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,11 +74,13 @@ class AuthMiddleware
|
||||
// 全部路由
|
||||
$allUri = $this->formatUrl($adminAuthCache->getAllUri());
|
||||
|
||||
// 判断该当前访问的uri是否存在,不存在无需验证
|
||||
if (!in_array($accessUri, $allUri, true)
|
||||
&& !PharmacyUploadPermissionAlias::allows($accessUri, $allUri)) {
|
||||
return $next($request);
|
||||
}
|
||||
// 判断该当前访问的uri是否存在,不存在无需验证
|
||||
if (!in_array($accessUri, $allUri, true)
|
||||
&& !PharmacyUploadPermissionAlias::allows($accessUri, $allUri)
|
||||
&& !($accessUri === 'tcm.diagnosis/aiassistantstream'
|
||||
&& in_array('tcm.diagnosis/aiassistant', $allUri, true))) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// 当前管理员拥有的路由权限
|
||||
$AdminUris = $adminAuthCache->getAdminUri() ?? [];
|
||||
@@ -109,9 +111,16 @@ class AuthMiddleware
|
||||
* 日常记录权限域:前端统一收口到 tcm.diagnosis/dailyRecord,
|
||||
* 但待办/跟踪备注接口仍保留历史路由名,故在鉴权层做精确别名映射。
|
||||
*/
|
||||
private function matchPermissionAlias(string $accessUri, array $adminUris): bool
|
||||
{
|
||||
if (PharmacyUploadPermissionAlias::isControlled($accessUri)) {
|
||||
private function matchPermissionAlias(string $accessUri, array $adminUris): bool
|
||||
{
|
||||
// AI 助手流式端点与旧 blocking 端点共享同一权限;别名同时用于
|
||||
// allUri 判定和当前管理员权限判定,确保在 SSE headers 前完成鉴权。
|
||||
if ($accessUri === 'tcm.diagnosis/aiassistantstream'
|
||||
&& in_array('tcm.diagnosis/aiassistant', $adminUris, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (PharmacyUploadPermissionAlias::isControlled($accessUri)) {
|
||||
return PharmacyUploadPermissionAlias::allows($accessUri, $adminUris);
|
||||
}
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
||||
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
|
||||
->leftJoin('admin ad', 'a.doctor_id = ad.id')
|
||||
->leftJoin('admin asst', 'u.assistant_id = asst.id')
|
||||
->field('a.*, u.patient_name as patient_name, u.phone as patient_phone, u.gender as gender, u.age as age, u.weight as weight, u.height as height, u.assistant_id as assistant_id, ad.name as doctor_name, asst.name as assistant_name, u.id as diagnosis_id, a.assistant_id as appointment_assistant_id');
|
||||
->field('a.*, u.patient_id AS source_patient_id, u.patient_name as patient_name, u.phone as patient_phone, u.gender as gender, u.age as age, u.weight as weight, u.height as height, u.assistant_id as assistant_id, ad.name as doctor_name, asst.name as assistant_name, u.id as diagnosis_id, a.assistant_id as appointment_assistant_id');
|
||||
if ($this->searchWhere !== []) {
|
||||
$query->where($this->searchWhere);
|
||||
}
|
||||
|
||||
@@ -627,10 +627,35 @@ class AppointmentLogic extends BaseLogic
|
||||
* @param array $params
|
||||
* @return array
|
||||
*/
|
||||
public static function reception(array $params): array
|
||||
{
|
||||
// 1) 挂号详情(已包含 patient_name / patient_phone / doctor_name / status_desc 等)
|
||||
$appointment = self::detail($params);
|
||||
public static function reception(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
self::$error = '';
|
||||
$appointmentId = (int) ($params['id'] ?? 0);
|
||||
$appointmentRow = $appointmentId > 0
|
||||
? Appointment::where('id', $appointmentId)->field(['id', 'patient_id', 'doctor_id'])->find()
|
||||
: null;
|
||||
if (!$appointmentRow) {
|
||||
self::setError('预约记录不存在或无权访问');
|
||||
|
||||
return [];
|
||||
}
|
||||
$diagnosisRow = Diagnosis::where('id', (int) $appointmentRow->patient_id)
|
||||
->whereNull('delete_time')
|
||||
->field(['id', 'assistant_id'])
|
||||
->find();
|
||||
if (!self::appointmentRowManageableByAdmin(
|
||||
$appointmentRow,
|
||||
$diagnosisRow ?: null,
|
||||
$adminId,
|
||||
$adminInfo
|
||||
)) {
|
||||
self::setError('预约记录不存在或无权访问');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// 1) 挂号详情(已包含 patient_name / patient_phone / doctor_name / status_desc 等)
|
||||
$appointment = self::detail($params);
|
||||
if (empty($appointment)) {
|
||||
return [];
|
||||
}
|
||||
@@ -883,40 +908,62 @@ class AppointmentLogic extends BaseLogic
|
||||
/**
|
||||
* 与 AppointmentLists 一致的可见性(不含 progress_board / diag_scope_relax)
|
||||
*/
|
||||
private static function appointmentRowManageableByAdmin(
|
||||
Appointment $appointment,
|
||||
?Diagnosis $diag,
|
||||
int $adminId,
|
||||
array $adminInfo
|
||||
): bool {
|
||||
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
|
||||
|
||||
if (in_array(1, $roleIds, true) && (int) $appointment->doctor_id !== $adminId) {
|
||||
return false;
|
||||
}
|
||||
if (in_array(2, $roleIds, true)) {
|
||||
$asst = $diag ? (int) $diag->assistant_id : 0;
|
||||
if ($asst !== $adminId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!DataScopeService::isEnabled()) {
|
||||
return true;
|
||||
}
|
||||
$ids = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($ids === []) {
|
||||
return false;
|
||||
}
|
||||
if ($ids === null) {
|
||||
return true;
|
||||
}
|
||||
$docId = (int) $appointment->doctor_id;
|
||||
$asstId = $diag ? (int) $diag->assistant_id : 0;
|
||||
|
||||
return in_array($docId, $ids, true)
|
||||
|| ($asstId > 0 && in_array($asstId, $ids, true));
|
||||
}
|
||||
private static function appointmentRowManageableByAdmin(
|
||||
Appointment $appointment,
|
||||
?Diagnosis $diag,
|
||||
int $adminId,
|
||||
array $adminInfo
|
||||
): bool {
|
||||
$docId = (int) $appointment->doctor_id;
|
||||
$asstId = $diag ? (int) $diag->assistant_id : 0;
|
||||
$isRoot = !empty($adminInfo['root']) && (int) $adminInfo['root'] === 1;
|
||||
$roleIds = $isRoot
|
||||
? []
|
||||
: array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
|
||||
$visibleIds = null;
|
||||
if (!$isRoot && DataScopeService::isEnabled()) {
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
}
|
||||
|
||||
return self::appointmentRowManageableForScope(
|
||||
$docId,
|
||||
$asstId,
|
||||
$adminId,
|
||||
$roleIds,
|
||||
$visibleIds,
|
||||
$isRoot
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $roleIds
|
||||
* @param array<int, int>|null $visibleIds null 表示未启用数据范围或全量可见
|
||||
*/
|
||||
private static function appointmentRowManageableForScope(
|
||||
int $doctorId,
|
||||
int $assistantId,
|
||||
int $adminId,
|
||||
array $roleIds,
|
||||
?array $visibleIds,
|
||||
bool $isRoot
|
||||
): bool {
|
||||
if ($isRoot) {
|
||||
return true;
|
||||
}
|
||||
if (in_array(1, $roleIds, true) && $doctorId !== $adminId) {
|
||||
return false;
|
||||
}
|
||||
if (in_array(2, $roleIds, true) && $assistantId !== $adminId) {
|
||||
return false;
|
||||
}
|
||||
if ($visibleIds === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $visibleIds === null
|
||||
|| in_array($doctorId, $visibleIds, true)
|
||||
|| ($assistantId > 0 && in_array($assistantId, $visibleIds, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台编辑挂号(预约日期/时段/类型/状态/备注/医助)
|
||||
|
||||
@@ -25,8 +25,8 @@ class DoctorNoteLogic extends BaseLogic
|
||||
->find();
|
||||
|
||||
$newContent = trim($params['content'] ?? '');
|
||||
$newImages = array_map([self::class, 'toRelativePath'], self::parseJsonArray($params['tongue_images'] ?? []));
|
||||
$newReports = array_map([self::class, 'toRelativePath'], self::parseJsonArray($params['report_files'] ?? []));
|
||||
$newImages = self::normalizeNewAttachmentPaths($params['tongue_images'] ?? []);
|
||||
$newReports = self::normalizeNewAttachmentPaths($params['report_files'] ?? []);
|
||||
|
||||
if ($existing) {
|
||||
$data = [];
|
||||
@@ -169,15 +169,51 @@ class DoctorNoteLogic extends BaseLogic
|
||||
*/
|
||||
private static function toRelativePath(string $url): string
|
||||
{
|
||||
if (empty($url)) return $url;
|
||||
if (stripos($url, 'http://') !== 0 && stripos($url, 'https://') !== 0) {
|
||||
$url = trim($url);
|
||||
if ($url === '') return $url;
|
||||
|
||||
$urlParts = parse_url($url);
|
||||
if (!is_array($urlParts) || empty($urlParts['scheme'])) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$scheme = strtolower((string) $urlParts['scheme']);
|
||||
if (!in_array($scheme, ['http', 'https'], true)) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
// 获取当前存储域名
|
||||
$domain = self::getStorageDomain();
|
||||
if ($domain && stripos($url, rtrim($domain, '/')) === 0) {
|
||||
$relative = substr($url, strlen(rtrim($domain, '/')));
|
||||
return ltrim($relative, '/');
|
||||
$domain = rtrim(self::getStorageDomain(), '/');
|
||||
$domainParts = $domain !== '' ? parse_url($domain) : false;
|
||||
if (is_array($domainParts)) {
|
||||
$domainScheme = strtolower((string) ($domainParts['scheme'] ?? ''));
|
||||
$urlHost = strtolower(rtrim((string) ($urlParts['host'] ?? ''), '.'));
|
||||
$domainHost = strtolower(rtrim((string) ($domainParts['host'] ?? ''), '.'));
|
||||
$urlPort = (int) ($urlParts['port'] ?? ($scheme === 'https' ? 443 : 80));
|
||||
$domainPort = (int) (
|
||||
$domainParts['port'] ?? ($domainScheme === 'https' ? 443 : 80)
|
||||
);
|
||||
$urlPath = (string) ($urlParts['path'] ?? '');
|
||||
$domainPath = rtrim((string) ($domainParts['path'] ?? ''), '/');
|
||||
$pathInsideDomain = $domainPath === ''
|
||||
|| $urlPath === $domainPath
|
||||
|| str_starts_with($urlPath, $domainPath . '/');
|
||||
|
||||
if (
|
||||
$domainScheme === $scheme
|
||||
&& $domainHost !== ''
|
||||
&& $domainHost === $urlHost
|
||||
&& $domainPort === $urlPort
|
||||
&& $pathInsideDomain
|
||||
) {
|
||||
$relative = $domainPath === ''
|
||||
? $urlPath
|
||||
: substr($urlPath, strlen($domainPath));
|
||||
if (isset($urlParts['query']) && $urlParts['query'] !== '') {
|
||||
$relative .= '?' . $urlParts['query'];
|
||||
}
|
||||
return ltrim($relative, '/');
|
||||
}
|
||||
}
|
||||
// 非当前存储域名,保留完整 URL
|
||||
return $url;
|
||||
@@ -193,6 +229,36 @@ class DoctorNoteLogic extends BaseLogic
|
||||
return $storage ? ($storage['domain'] ?? '') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 备注附件只接受站内相对路径或当前存储域已上传的 URL。
|
||||
* 存储域 URL 先转为相对路径,避免将任意外部 URL 持久化到病例页。
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function normalizeNewAttachmentPaths($value): array
|
||||
{
|
||||
$paths = [];
|
||||
foreach (self::parseJsonArray($value) as $rawPath) {
|
||||
$path = trim((string) $rawPath);
|
||||
if ($path === '') {
|
||||
continue;
|
||||
}
|
||||
$path = self::toRelativePath($path);
|
||||
$scheme = parse_url($path, PHP_URL_SCHEME);
|
||||
if (
|
||||
(is_string($scheme) && $scheme !== '')
|
||||
|| str_starts_with($path, '//')
|
||||
|| str_contains($path, "\0")
|
||||
) {
|
||||
throw new \InvalidArgumentException('备注附件必须来自当前文件存储域');
|
||||
}
|
||||
$paths[] = $path;
|
||||
}
|
||||
|
||||
return array_values(array_unique($paths));
|
||||
}
|
||||
|
||||
private static function parseJsonArray($value): array
|
||||
{
|
||||
if (is_array($value)) return $value;
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\adminapi\logic\stats\ConversionLogic;
|
||||
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||
@@ -23,6 +24,9 @@ use think\facade\Db;
|
||||
class FirstVisitConversionLogic
|
||||
{
|
||||
private const ASSISTANT_ROLE_ID = 2;
|
||||
private const FINANCE_PERMISSION = 'firstvisit.conversion/viewFinance';
|
||||
private const FINANCE_ALWAYS_ROLE_NAMES = ['经理', '管理员', '系统管理员'];
|
||||
private const FINANCE_FIELD_KEYS = ['account_cost', 'cash_cost', 'roi'];
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||||
@@ -64,11 +68,11 @@ class FirstVisitConversionLogic
|
||||
$effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds);
|
||||
}
|
||||
|
||||
$selectedAssistantValid = $selectedAssistantId <= 0;
|
||||
if ($selectedAssistantId > 0) {
|
||||
$selectedAssistantValid = self::isActiveAssistant($selectedAssistantId)
|
||||
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
|
||||
$effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : [];
|
||||
$selectedAssistantValid = $selectedAssistantId <= 0;
|
||||
if ($selectedAssistantId > 0) {
|
||||
$selectedAssistantValid = self::isActiveAssistant($selectedAssistantId)
|
||||
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
|
||||
$effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : [];
|
||||
}
|
||||
$costAllocationAdminIds = self::costAllocationAdminIds(
|
||||
$effectiveAdminIds,
|
||||
@@ -137,23 +141,36 @@ class FirstVisitConversionLogic
|
||||
(int) $summary['total_open_count']
|
||||
);
|
||||
|
||||
$rankingKind = self::rankingKind($scopeValue, $selectedAssistantId);
|
||||
$rankingRows = self::rankingRows($rows, $rankingKind);
|
||||
$rankingKind = self::rankingKind($scopeValue, $selectedAssistantId);
|
||||
$rankingRows = self::rankingRows($rows, $rankingKind);
|
||||
// 目前只维护了部门月度目标;本人范围或筛选单个员工时不能拿整个部门目标冒充个人目标。
|
||||
$targetDeptIds = ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0)
|
||||
? []
|
||||
: self::resolveTargetDeptIds($allowedDeptSet, $selectedDeptIds, $selectedDeptId);
|
||||
$target = self::buildTargetProgress((int) date('Y'), $effectiveAdminIds, $targetDeptIds);
|
||||
|
||||
$selectedDeptName = $selectedDeptId > 0 && $deptSelectionValid
|
||||
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
$selectedAssistantName = $selectedAssistantId > 0 && $selectedAssistantValid
|
||||
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
$selectedDeptName = $selectedDeptId > 0 && $deptSelectionValid
|
||||
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
$selectedAssistantName = $selectedAssistantId > 0 && $selectedAssistantValid
|
||||
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
$selectedMediaChannelName = $selectedMediaChannelCode !== ''
|
||||
? (string) ($selectedMediaChannel['channel_name'] ?? $selectedMediaChannelCode)
|
||||
: '';
|
||||
if ($selectedMediaChannelName !== '' && !empty($selectedMediaChannel['is_group'])) {
|
||||
$selectedMediaChannelName .= '(全部)';
|
||||
}
|
||||
$canViewFinance = self::canViewFinance($adminId, $adminInfo);
|
||||
if (!$canViewFinance) {
|
||||
$summary = self::maskFinanceFields($summary);
|
||||
foreach ($rows as &$row) {
|
||||
if (is_array($row)) {
|
||||
$row = self::maskFinanceFields($row);
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
}
|
||||
return [
|
||||
'meta' => [
|
||||
'time_type' => $timeType,
|
||||
@@ -161,9 +178,9 @@ class FirstVisitConversionLogic
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'scope_value' => $scopeValue,
|
||||
'scope_label' => DataScopeService::scopeLabel($scopeValue),
|
||||
'ranking_kind' => $rankingKind,
|
||||
'scope_value' => $scopeValue,
|
||||
'scope_label' => DataScopeService::scopeLabel($scopeValue),
|
||||
'ranking_kind' => $rankingKind,
|
||||
'selected_dept_name' => $selectedDeptName,
|
||||
'selected_assistant_name' => $selectedAssistantName,
|
||||
'selected_media_channel_code' => $selectedMediaChannelCode,
|
||||
@@ -171,6 +188,7 @@ class FirstVisitConversionLogic
|
||||
'open_count_source' => $selectedMediaChannelCode === ''
|
||||
? '个人业绩录入'
|
||||
: '个人业绩录入(按渠道名称匹配)',
|
||||
'can_view_finance' => $canViewFinance,
|
||||
'appointment_rule' => '按预约日期统计,归属优先挂号医助、再回退诊单医助;仅含已预约、已完成和已过号',
|
||||
'registration_rule' => '按支付时间统计已支付且实收金额大于 0、低于 10 元的订单,每笔计 1 个挂号并按订单创建人归属',
|
||||
'performance_rule' => '按业务订单创建时间和创建人统计,排除取消、拒收、退款及已发生退款的订单',
|
||||
@@ -520,45 +538,101 @@ class FirstVisitConversionLogic
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = [
|
||||
$channelCode,
|
||||
$channel['channel_name'] ?? '',
|
||||
$channel['source_tag_name'] ?? '',
|
||||
$channel['legacy_channel_name'] ?? '',
|
||||
$channel['legacy_source_tag_name'] ?? '',
|
||||
];
|
||||
foreach (['channel_codes', 'channel_names', 'source_tag_names'] as $listKey) {
|
||||
if (!isset($channel[$listKey]) || !is_array($channel[$listKey])) {
|
||||
continue;
|
||||
}
|
||||
foreach ($channel[$listKey] as $item) {
|
||||
$values[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter(array_map(
|
||||
static fn ($value): string => trim((string) $value),
|
||||
[
|
||||
$channelCode,
|
||||
$channel['channel_name'] ?? '',
|
||||
$channel['source_tag_name'] ?? '',
|
||||
$channel['legacy_channel_name'] ?? '',
|
||||
$channel['legacy_source_tag_name'] ?? '',
|
||||
]
|
||||
), static fn (string $value): bool => $value !== '')));
|
||||
$values
|
||||
), static fn (string $value): bool => $value !== '' && !str_starts_with($value, MediaChannelService::GROUP_CODE_PREFIX))));
|
||||
}
|
||||
|
||||
/** 根据生效数据范围返回排行榜展示维度,不能把 scope_value 当作角色枚举。 */
|
||||
private static function rankingKind(int $scopeValue, int $selectedAssistantId = 0): string
|
||||
{
|
||||
if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) {
|
||||
return 'hidden';
|
||||
}
|
||||
|
||||
return $scopeValue === DataScopeService::SCOPE_DEPT ? 'member' : 'group';
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function rankingRows(array $rows, string $rankingKind): array
|
||||
{
|
||||
if ($rankingKind === 'hidden') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// “仅本部门”范围使用可见成员维度;更大范围使用当前可见组织根节点的
|
||||
// 直属下级,避免父子汇总同时参与占比。
|
||||
if ($rankingKind === 'member') {
|
||||
$members = [];
|
||||
self::collectRankingMembers($rows, $members);
|
||||
|
||||
return array_values($members);
|
||||
}
|
||||
|
||||
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量
|
||||
private static function canViewFinance(int $adminId, array $adminInfo): bool
|
||||
{
|
||||
if ((int) ($adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
foreach (self::roleNamesFromAdminInfo($adminInfo) as $roleName) {
|
||||
if (in_array($roleName, self::FINANCE_ALWAYS_ROLE_NAMES, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if ($adminId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(self::FINANCE_PERMISSION, AuthLogic::getAuthByAdminId($adminId), true);
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private static function roleNamesFromAdminInfo(array $adminInfo): array
|
||||
{
|
||||
$names = preg_split('/[\/,,、]/u', (string) ($adminInfo['role_name'] ?? '')) ?: [];
|
||||
|
||||
return array_values(array_filter(array_map('trim', $names), static fn (string $name): bool => $name !== ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entity
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function maskFinanceFields(array $entity): array
|
||||
{
|
||||
foreach (self::FINANCE_FIELD_KEYS as $key) {
|
||||
unset($entity[$key]);
|
||||
}
|
||||
if (isset($entity['children']) && is_array($entity['children'])) {
|
||||
foreach ($entity['children'] as &$child) {
|
||||
if (is_array($child)) {
|
||||
$child = self::maskFinanceFields($child);
|
||||
}
|
||||
}
|
||||
unset($child);
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/** 根据生效数据范围返回排行榜展示维度,不能把 scope_value 当作角色枚举。 */
|
||||
private static function rankingKind(int $scopeValue, int $selectedAssistantId = 0): string
|
||||
{
|
||||
if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) {
|
||||
return 'hidden';
|
||||
}
|
||||
|
||||
return $scopeValue === DataScopeService::SCOPE_DEPT ? 'member' : 'group';
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function rankingRows(array $rows, string $rankingKind): array
|
||||
{
|
||||
if ($rankingKind === 'hidden') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// “仅本部门”范围使用可见成员维度;更大范围使用当前可见组织根节点的
|
||||
// 直属下级,避免父子汇总同时参与占比。
|
||||
if ($rankingKind === 'member') {
|
||||
$members = [];
|
||||
self::collectRankingMembers($rows, $members);
|
||||
|
||||
return array_values($members);
|
||||
}
|
||||
|
||||
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量
|
||||
// 大于 1,导致原逻辑无法展开唯一的真实组织根节点,图表最终只显示医院汇总行。
|
||||
$visibleRows = array_values(array_filter($rows, static function (array $row): bool {
|
||||
return (int) ($row['id'] ?? 0) > 0 && !((bool) ($row['_virtual_bucket'] ?? false));
|
||||
@@ -583,59 +657,59 @@ class FirstVisitConversionLogic
|
||||
$chartRows[] = $row;
|
||||
}
|
||||
|
||||
return $chartRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $rows
|
||||
* @param array<int,array<string,mixed>> $members
|
||||
*/
|
||||
private static function collectRankingMembers(array $rows, array &$members): void
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
if ((string) ($row['type'] ?? '') === 'member') {
|
||||
$adminId = (int) ($row['admin_id'] ?? 0);
|
||||
if ($adminId > 0) {
|
||||
$members[$adminId] = $row;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
self::collectRankingMembers(
|
||||
is_array($row['children'] ?? null) ? $row['children'] : [],
|
||||
$members
|
||||
);
|
||||
}
|
||||
}
|
||||
return $chartRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $rows
|
||||
* @param array<int,array<string,mixed>> $members
|
||||
*/
|
||||
private static function collectRankingMembers(array $rows, array &$members): void
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
if ((string) ($row['type'] ?? '') === 'member') {
|
||||
$adminId = (int) ($row['admin_id'] ?? 0);
|
||||
if ($adminId > 0) {
|
||||
$members[$adminId] = $row;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
self::collectRankingMembers(
|
||||
is_array($row['children'] ?? null) ? $row['children'] : [],
|
||||
$members
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function topRows(array $rows, string $metric): array
|
||||
{
|
||||
$rows = array_values(array_filter($rows, static function (array $row): bool {
|
||||
if ((string) ($row['type'] ?? '') === 'member') {
|
||||
return (int) ($row['admin_id'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
return (int) ($row['id'] ?? 0) > 0;
|
||||
}));
|
||||
usort($rows, static function (array $left, array $right) use ($metric): int {
|
||||
$valueCompare = (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
|
||||
if ($valueCompare !== 0) {
|
||||
return $valueCompare;
|
||||
}
|
||||
$nameCompare = strnatcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||||
if ($nameCompare !== 0) {
|
||||
return $nameCompare;
|
||||
}
|
||||
|
||||
return strcmp((string) ($left['id'] ?? ''), (string) ($right['id'] ?? ''));
|
||||
});
|
||||
|
||||
return array_map(static fn (array $row): array => [
|
||||
'id' => $row['id'] ?? 0,
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'value' => round((float) ($row[$metric] ?? 0), 2),
|
||||
], $rows);
|
||||
}
|
||||
private static function topRows(array $rows, string $metric): array
|
||||
{
|
||||
$rows = array_values(array_filter($rows, static function (array $row): bool {
|
||||
if ((string) ($row['type'] ?? '') === 'member') {
|
||||
return (int) ($row['admin_id'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
return (int) ($row['id'] ?? 0) > 0;
|
||||
}));
|
||||
usort($rows, static function (array $left, array $right) use ($metric): int {
|
||||
$valueCompare = (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
|
||||
if ($valueCompare !== 0) {
|
||||
return $valueCompare;
|
||||
}
|
||||
$nameCompare = strnatcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||||
if ($nameCompare !== 0) {
|
||||
return $nameCompare;
|
||||
}
|
||||
|
||||
return strcmp((string) ($left['id'] ?? ''), (string) ($right['id'] ?? ''));
|
||||
});
|
||||
|
||||
return array_map(static fn (array $row): array => [
|
||||
'id' => $row['id'] ?? 0,
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'value' => round((float) ($row[$metric] ?? 0), 2),
|
||||
], $rows);
|
||||
}
|
||||
|
||||
/** @param int[]|null $baseVisibleAdminIds @param int[] $selectedDeptIds @return array<int,array{id:int,name:string}> */
|
||||
private static function assistantOptions(?array $baseVisibleAdminIds, array $selectedDeptIds, int $selectedDeptId): array
|
||||
|
||||
@@ -62,7 +62,9 @@ class ConversionLogic
|
||||
if ($mediaChannel === null && $requestedMediaChannelCode !== '') {
|
||||
$mediaChannel = MediaChannelService::getChannelByCode($requestedMediaChannelCode);
|
||||
}
|
||||
$mediaChannelCode = $mediaChannel !== null ? $requestedMediaChannelCode : '';
|
||||
$mediaChannelCodes = $mediaChannel !== null
|
||||
? MediaChannelService::getChannelCodesForStats($mediaChannel)
|
||||
: null;
|
||||
$filterEmptyEntities = $mediaChannel !== null;
|
||||
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
|
||||
$pageNo = max(1, (int)($params['page_no'] ?? 1));
|
||||
@@ -188,11 +190,11 @@ class ConversionLogic
|
||||
);
|
||||
// 数据隔离:可见部门 = 可见 admin 所属部门并集;用于 account_cost 与下游 cost 分摊。
|
||||
$visibleDeptIds = self::resolveVisibleDeptIds($visibleAdminIds);
|
||||
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode, $visibleDeptIds);
|
||||
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCodes, $visibleDeptIds);
|
||||
$supportsDeptBinding = AccountCost::supportsDeptBinding();
|
||||
$restrictAccountCostByDept = $supportsDeptBinding;
|
||||
$channelBoundDeptIds = $supportsDeptBinding && $mediaChannelCode !== ''
|
||||
? self::loadChannelBoundDeptIds($mediaChannelCode)
|
||||
$channelBoundDeptIds = $supportsDeptBinding && $mediaChannelCodes !== null && $mediaChannelCodes !== []
|
||||
? self::loadChannelBoundDeptIds($mediaChannelCodes)
|
||||
: [];
|
||||
// 渠道尚未维护投放成本时,不能把真实的加粉、挂号和订单一并过滤为空。
|
||||
// 已维护绑定关系的渠道继续按绑定部门收窄;成本本身仍只在实际成本部门内分摊。
|
||||
@@ -267,7 +269,7 @@ class ConversionLogic
|
||||
$startDate,
|
||||
$endDate,
|
||||
$mediaChannel,
|
||||
$mediaChannelCode,
|
||||
$mediaChannelCodes,
|
||||
$restrictAccountCostByDept,
|
||||
$eligibleDeptIds,
|
||||
$adminToDeptIds,
|
||||
@@ -824,17 +826,18 @@ class ConversionLogic
|
||||
/**
|
||||
* 渠道绑定部门不依赖当前统计区间,避免某天没有录入成本时把统计实体过滤为空。
|
||||
*
|
||||
* @param string[] $mediaChannelCodes
|
||||
* @return int[]
|
||||
*/
|
||||
private static function loadChannelBoundDeptIds(string $mediaChannelCode): array
|
||||
private static function loadChannelBoundDeptIds(array $mediaChannelCodes): array
|
||||
{
|
||||
$mediaChannelCode = trim($mediaChannelCode);
|
||||
if ($mediaChannelCode === '' || !AccountCost::supportsDeptBinding()) {
|
||||
$mediaChannelCodes = self::normalizeMediaChannelCodes($mediaChannelCodes);
|
||||
if ($mediaChannelCodes === [] || !AccountCost::supportsDeptBinding()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$deptIds = Db::name('account_cost')
|
||||
->where('media_channel_code', $mediaChannelCode)
|
||||
->whereIn('media_channel_code', $mediaChannelCodes)
|
||||
->where('dept_id', '>', 0)
|
||||
->distinct(true)
|
||||
->column('dept_id');
|
||||
@@ -1596,6 +1599,7 @@ class ConversionLogic
|
||||
* 账户消耗:来源于独立维护表 zyt_account_cost。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $entities
|
||||
* @param string[]|null $mediaChannelCodes null=不按渠道过滤;[]=已选渠道但无匹配 code,成本记 0
|
||||
* @param int[]|null $visibleDeptIds 可见部门集合(null = SCOPE_ALL,不收窄)
|
||||
* @return array{0: float, 1: int[]}
|
||||
*/
|
||||
@@ -1603,10 +1607,21 @@ class ConversionLogic
|
||||
array &$entities,
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
string $mediaChannelCode,
|
||||
?array $mediaChannelCodes,
|
||||
?array $visibleDeptIds = null
|
||||
): array
|
||||
{
|
||||
$mediaChannelCodes = $mediaChannelCodes === null ? null : self::normalizeMediaChannelCodes($mediaChannelCodes);
|
||||
if ($mediaChannelCodes === []) {
|
||||
foreach ($entities as &$entity) {
|
||||
$entity['account_cost'] = 0.0;
|
||||
$entity['_global_account_cost'] = 0.0;
|
||||
}
|
||||
unset($entity);
|
||||
|
||||
return [0.0, []];
|
||||
}
|
||||
|
||||
$supportsDeptBinding = AccountCost::supportsDeptBinding();
|
||||
$query = Db::name('account_cost')
|
||||
->where('cost_date', '>=', $startDate)
|
||||
@@ -1618,8 +1633,8 @@ class ConversionLogic
|
||||
$query->field('amount');
|
||||
}
|
||||
|
||||
if ($mediaChannelCode !== '') {
|
||||
$query->where('media_channel_code', $mediaChannelCode);
|
||||
if ($mediaChannelCodes !== null) {
|
||||
$query->whereIn('media_channel_code', $mediaChannelCodes);
|
||||
}
|
||||
|
||||
if ($supportsDeptBinding) {
|
||||
@@ -2049,7 +2064,7 @@ class ConversionLogic
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
?array $mediaChannel,
|
||||
string $mediaChannelCode,
|
||||
?array $mediaChannelCodes,
|
||||
bool $restrictAccountCostByDept,
|
||||
array $eligibleDeptIds,
|
||||
array $adminToDeptIds,
|
||||
@@ -2081,9 +2096,9 @@ class ConversionLogic
|
||||
if ($globalAccountCost < 0) {
|
||||
// 任选一个非空 entity 集合查一次即可——查询本身只与日期 / 渠道相关。
|
||||
if ($assistantIds !== []) {
|
||||
[$globalAccountCost] = self::hydrateAccountCostStats($assistantEntities, $startDate, $endDate, $mediaChannelCode);
|
||||
[$globalAccountCost] = self::hydrateAccountCostStats($assistantEntities, $startDate, $endDate, $mediaChannelCodes);
|
||||
} elseif ($doctorIds !== []) {
|
||||
[$globalAccountCost] = self::hydrateAccountCostStats($doctorEntities, $startDate, $endDate, $mediaChannelCode);
|
||||
[$globalAccountCost] = self::hydrateAccountCostStats($doctorEntities, $startDate, $endDate, $mediaChannelCodes);
|
||||
} else {
|
||||
$globalAccountCost = 0.0;
|
||||
}
|
||||
@@ -2905,10 +2920,29 @@ class ConversionLogic
|
||||
return [
|
||||
'code' => (string)($mediaChannel['channel_code'] ?? ''),
|
||||
'tag_id' => (string)($mediaChannel['source_tag_id'] ?? ''),
|
||||
'tag_ids' => $mediaChannel['source_tag_ids'] ?? [],
|
||||
'tag_name' => (string)($mediaChannel['source_tag_name'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|string[] $mediaChannelCode
|
||||
* @return string[]
|
||||
*/
|
||||
private static function normalizeMediaChannelCodes(string|array $mediaChannelCode): array
|
||||
{
|
||||
$values = is_array($mediaChannelCode) ? $mediaChannelCode : [$mediaChannelCode];
|
||||
$codes = [];
|
||||
foreach ($values as $value) {
|
||||
$code = trim((string)$value);
|
||||
if ($code !== '' && !str_starts_with($code, MediaChannelService::GROUP_CODE_PREFIX)) {
|
||||
$codes[$code] = $code;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($codes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[]|null $visibleAdminIds
|
||||
* @param int[] $eligibleDeptIds
|
||||
|
||||
@@ -4,13 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\tcm;
|
||||
|
||||
use app\common\cache\AdminAuthCache;
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\model\tcm\DiagnosisAiReport;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\DifyChatService;
|
||||
use app\common\cache\AdminAuthCache;
|
||||
use app\adminapi\logic\firstvisit\MyPatientLogic;
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\model\tcm\DiagnosisAiReport;
|
||||
use app\common\service\DifyChatService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
@@ -201,6 +200,44 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
string $prompt,
|
||||
int $adminId,
|
||||
array $adminInfo
|
||||
): ?array {
|
||||
$prepared = self::prepareAssistant($diagnosisId, $task, $prompt, $adminId, $adminInfo);
|
||||
if ($prepared === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = DifyChatService::chat(
|
||||
$prepared['profile'],
|
||||
$prepared['inputs'],
|
||||
$prepared['query'],
|
||||
$prepared['user']
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
self::logAssistantFailure($diagnosisId, $prepared['profile'], $adminId, $e);
|
||||
self::setError('AI 助手暂时不可用,请稍后重试');
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::formatAssistantResult($prepared, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 SSE headers 发出前完成参数、权限、DataScope、病例和模型选择预检。
|
||||
* 返回值只供同一请求内的流执行使用,绝不能直接序列化给客户端。
|
||||
*
|
||||
* @param array<string,mixed> $adminInfo
|
||||
* @return array{
|
||||
* diagnosis_id:int,profile:string,model_name:string,model_label:string,task:string,
|
||||
* inputs:array<string,mixed>,query:string,user:string,admin_id:int
|
||||
* }|null
|
||||
*/
|
||||
public static function prepareAssistant(
|
||||
int $diagnosisId,
|
||||
string $task,
|
||||
string $prompt,
|
||||
int $adminId,
|
||||
array $adminInfo
|
||||
): ?array {
|
||||
$diagnosis = self::loadAuthorizedDiagnosis(
|
||||
$diagnosisId,
|
||||
@@ -239,28 +276,63 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'profile' => $profile,
|
||||
'model_name' => $model,
|
||||
'model_label' => $modelLabel,
|
||||
'task' => $task,
|
||||
'inputs' => self::buildUpstreamInputs(
|
||||
$context,
|
||||
'病例问诊助手',
|
||||
self::ASSISTANT_PROMPT_VERSION
|
||||
),
|
||||
'query' => self::buildAssistantPrompt($context, $task, $prompt),
|
||||
'user' => 'admin-diagnosis-assistant-' . $adminId,
|
||||
'admin_id' => $adminId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $prepared prepareAssistant() 的内部返回值
|
||||
* @param callable(string):mixed $onDelta
|
||||
* @param callable():bool|null $shouldAbort
|
||||
* @return array{answer:string,model_key:string,model_label:string,model_name:string,task:string}|null
|
||||
*/
|
||||
public static function streamPreparedAssistant(
|
||||
array $prepared,
|
||||
callable $onDelta,
|
||||
?callable $shouldAbort = null
|
||||
): ?array {
|
||||
$diagnosisId = (int) ($prepared['diagnosis_id'] ?? 0);
|
||||
$profile = (string) ($prepared['profile'] ?? '');
|
||||
$adminId = (int) ($prepared['admin_id'] ?? 0);
|
||||
|
||||
try {
|
||||
$result = DifyChatService::chat(
|
||||
$result = DifyChatService::streamChat(
|
||||
$profile,
|
||||
self::buildUpstreamInputs(
|
||||
$context,
|
||||
'病例问诊助手',
|
||||
self::ASSISTANT_PROMPT_VERSION
|
||||
),
|
||||
self::buildAssistantPrompt($context, $task, $prompt),
|
||||
'admin-diagnosis-assistant-' . $adminId
|
||||
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
|
||||
(string) ($prepared['query'] ?? ''),
|
||||
(string) ($prepared['user'] ?? ''),
|
||||
$onDelta,
|
||||
$shouldAbort
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('diagnosis ai assistant upstream call failed', [
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'profile' => $profile,
|
||||
'admin_id' => $adminId,
|
||||
'exception_class' => get_class($e),
|
||||
]);
|
||||
self::logAssistantFailure($diagnosisId, $profile, $adminId, $e);
|
||||
self::setError('AI 助手暂时不可用,请稍后重试');
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::formatAssistantResult($prepared, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $prepared
|
||||
* @param array<string,mixed> $result
|
||||
* @return array{answer:string,model_key:string,model_label:string,model_name:string,task:string}|null
|
||||
*/
|
||||
private static function formatAssistantResult(array $prepared, array $result): ?array
|
||||
{
|
||||
if (empty($result['ok'])) {
|
||||
self::setError((string) ($result['error'] ?? 'AI 助手暂时不可用,请稍后重试'));
|
||||
return null;
|
||||
@@ -273,13 +345,27 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
|
||||
return [
|
||||
'answer' => $content,
|
||||
'model_key' => $profile,
|
||||
'model_label' => $modelLabel,
|
||||
'model_name' => $model,
|
||||
'task' => $task,
|
||||
'model_key' => (string) ($prepared['profile'] ?? ''),
|
||||
'model_label' => (string) ($prepared['model_label'] ?? ''),
|
||||
'model_name' => (string) ($prepared['model_name'] ?? ''),
|
||||
'task' => (string) ($prepared['task'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private static function logAssistantFailure(
|
||||
int $diagnosisId,
|
||||
string $profile,
|
||||
int $adminId,
|
||||
\Throwable $exception
|
||||
): void {
|
||||
Log::warning('diagnosis ai assistant upstream call failed', [
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'profile' => $profile,
|
||||
'admin_id' => $adminId,
|
||||
'exception_class' => get_class($exception),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 接诊台结构化 AI 智能分析。每次只调用客户端白名单键对应的服务端模型,
|
||||
* 上游失败或响应不符合契约时直接失败,不构造本地伪分析。
|
||||
@@ -604,28 +690,10 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
return null;
|
||||
}
|
||||
|
||||
$accessQuery = Diagnosis::where('id', $id)->whereNull('delete_time');
|
||||
$isRoot = !empty($adminInfo['root']) && (int) $adminInfo['root'] === 1;
|
||||
if (!$isRoot) {
|
||||
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
|
||||
if (in_array(2, $roleIds, true)) {
|
||||
$accessQuery->where('assistant_id', $adminId);
|
||||
}
|
||||
if (DataScopeService::isEnabled()) {
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds === []) {
|
||||
self::setError('诊单不存在或无权访问');
|
||||
return null;
|
||||
}
|
||||
if (is_array($visibleIds)) {
|
||||
$accessQuery->whereIn('assistant_id', $visibleIds);
|
||||
}
|
||||
}
|
||||
if (!MyPatientLogic::canAccessDiagnosis($id, $adminId, $adminInfo)) {
|
||||
self::setError('诊单不存在或无权访问');
|
||||
return null;
|
||||
}
|
||||
if (!$accessQuery->find()) {
|
||||
self::setError('诊单不存在或无权访问');
|
||||
return null;
|
||||
}
|
||||
|
||||
$diagnosis = DiagnosisLogic::detail(['id' => $id], $adminInfo);
|
||||
if ($diagnosis === [] || empty($diagnosis['id'])) {
|
||||
|
||||
@@ -28,10 +28,11 @@ use app\common\model\DiagnosisViewRecord;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
||||
use app\adminapi\logic\doctor\AppointmentLogic;
|
||||
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
||||
use app\adminapi\logic\doctor\AppointmentLogic;
|
||||
use app\adminapi\logic\firstvisit\MyPatientLogic;
|
||||
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||
use app\common\service\ConfigService;
|
||||
use app\common\service\FileService;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
@@ -4182,32 +4183,10 @@ class DiagnosisLogic extends BaseLogic
|
||||
return [];
|
||||
}
|
||||
|
||||
// 1) 数据权限闸 — 不通过则返回「不存在或无权访问」
|
||||
$accessQuery = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time');
|
||||
|
||||
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
|
||||
if (in_array(2, $roleIds, true)) {
|
||||
// 医助仅看自己被指派的
|
||||
$accessQuery->where('assistant_id', $adminId);
|
||||
}
|
||||
|
||||
if (DataScopeService::isEnabled()) {
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds === []) {
|
||||
self::setError('诊单不存在或无权访问');
|
||||
|
||||
return [];
|
||||
}
|
||||
if (is_array($visibleIds)) {
|
||||
$accessQuery->whereIn('assistant_id', $visibleIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$accessQuery->find()) {
|
||||
self::setError('诊单不存在或无权访问');
|
||||
|
||||
return [];
|
||||
}
|
||||
// 1) 数据权限闸 — 不通过则返回「不存在或无权访问」
|
||||
if (!self::canViewReadonlyDiagnosis($diagnosisId, $adminId, $adminInfo)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2) 诊单详情(含图片聚合等)+ 字典翻译
|
||||
$diagnosis = self::detail(['id' => $diagnosisId]);
|
||||
@@ -4238,24 +4217,43 @@ class DiagnosisLogic extends BaseLogic
|
||||
$unservedDays = $maxRecordTs > 0 ? max(0, (int) floor((time() - $maxRecordTs) / 86400)) : null;
|
||||
$lastBloodRecordAt = $maxRecordTs > 0 ? date('Y-m-d', $maxRecordTs) : null;
|
||||
|
||||
return [
|
||||
return [
|
||||
'appointment' => $appointment,
|
||||
'diagnosis' => $diagnosis,
|
||||
'doctor_notes' => $doctorNotes,
|
||||
'tracking_notes' => $trackingNotes,
|
||||
'unserved_days' => $unservedDays,
|
||||
'last_blood_record_at' => $lastBloodRecordAt,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 readonlyDetail 共用的诊单行级可见性入口。
|
||||
*
|
||||
* 复用“我的患者”统一行权策略:医生按有效接诊关系,医助按归属关系,
|
||||
* 团队管理角色才使用 DataScope。不存在与越权使用同一错误避免枚举。
|
||||
*/
|
||||
public static function canViewReadonlyDiagnosis(int $diagnosisId, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
self::$error = '';
|
||||
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $adminId, $adminInfo)) {
|
||||
self::setError('诊单不存在或无权访问');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取指定日期区间内的三类跟踪记录(血糖血压 / 饮食 / 运动),供 readonlyDetail 与
|
||||
* 医生接诊台 reception 通过独立接口 lazy load。
|
||||
*
|
||||
* 区间语义:闭区间 [startDate, endDate](Y-m-d),均不传则不限。
|
||||
*
|
||||
* @return array{
|
||||
* blood_records: array<int,array<string,mixed>>,
|
||||
* diagnosis_id: int,
|
||||
* blood_records: array<int,array<string,mixed>>,
|
||||
* diet_records: array<int,array<string,mixed>>,
|
||||
* exercise_records: array<int,array<string,mixed>>,
|
||||
* start_date: string,
|
||||
@@ -4267,10 +4265,11 @@ class DiagnosisLogic extends BaseLogic
|
||||
$sinceTs = $startDate !== '' ? (int) strtotime($startDate . ' 00:00:00') : 0;
|
||||
$untilTs = $endDate !== '' ? (int) strtotime($endDate . ' 23:59:59') : 0;
|
||||
$sinceTs = $sinceTs > 0 ? $sinceTs : 0;
|
||||
$untilTs = $untilTs > 0 ? $untilTs : 0;
|
||||
|
||||
return [
|
||||
'blood_records' => self::fetchBloodRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
|
||||
$untilTs = $untilTs > 0 ? $untilTs : 0;
|
||||
|
||||
return [
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'blood_records' => self::fetchBloodRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
|
||||
'diet_records' => self::fetchDietRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
|
||||
'exercise_records' => self::fetchExerciseRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
|
||||
'start_date' => $startDate,
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\tcm;
|
||||
|
||||
use app\common\model\auth\Admin;
|
||||
namespace app\adminapi\logic\tcm;
|
||||
|
||||
use app\adminapi\logic\firstvisit\MyPatientLogic;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\doctor\Medicine as DoctorMedicine;
|
||||
use app\common\model\tcm\Prescription;
|
||||
@@ -934,14 +935,35 @@ class PrescriptionLogic
|
||||
/**
|
||||
* 根据诊单ID获取处方列表
|
||||
*/
|
||||
public static function listByDiagnosis(int $diagnosisId): array
|
||||
{
|
||||
return Prescription::where('diagnosis_id', $diagnosisId)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
public static function listByDiagnosis(int $diagnosisId, int $viewerAdminId, array $viewerAdminInfo): array
|
||||
{
|
||||
self::$error = '';
|
||||
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $viewerAdminId, $viewerAdminInfo)) {
|
||||
self::setError('诊单不存在或无权访问');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = Prescription::where('diagnosis_id', $diagnosisId)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return self::filterViewablePrescriptions($rows, $viewerAdminId, $viewerAdminInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string,mixed>> $rows
|
||||
* @return array<int, array<string,mixed>>
|
||||
*/
|
||||
private static function filterViewablePrescriptions(array $rows, int $viewerAdminId, array $viewerAdminInfo): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$rows,
|
||||
static fn (array $row): bool => self::canViewPrescription($row, $viewerAdminId, $viewerAdminInfo)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据预约ID获取处方(带权限检查)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\service;
|
||||
|
||||
/**
|
||||
* 诊单 AI 助手 SSE 事件状态机:start -> delta* -> done|error。
|
||||
*/
|
||||
final class AssistantSseProtocol
|
||||
{
|
||||
private int $seq = 0;
|
||||
|
||||
private bool $started = false;
|
||||
|
||||
private bool $terminal = false;
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public function encode(string $event, array $payload): ?string
|
||||
{
|
||||
if ($this->terminal || !in_array($event, ['start', 'delta', 'done', 'error'], true)) {
|
||||
return null;
|
||||
}
|
||||
if ((!$this->started && $event !== 'start') || ($this->started && $event === 'start')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nextSeq = $this->seq + 1;
|
||||
$encoded = json_encode(
|
||||
['seq' => $nextSeq] + $payload,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE
|
||||
);
|
||||
if (!is_string($encoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->seq = $nextSeq;
|
||||
$this->started = true;
|
||||
if (in_array($event, ['done', 'error'], true)) {
|
||||
$this->terminal = true;
|
||||
}
|
||||
return 'event: ' . $event . "\n" . 'data: ' . $encoded . "\n\n";
|
||||
}
|
||||
|
||||
public function isTerminal(): bool
|
||||
{
|
||||
return $this->terminal;
|
||||
}
|
||||
}
|
||||
@@ -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