where('delete_time', null) ->find(); if ($exists) { self::setError('该手机号已存在'); return false; } } // 检查身份证号是否重复 if (!empty($params['id_card'])) { $exists = Diagnosis::where('id_card', $params['id_card']) ->where('delete_time', null) ->find(); if ($exists) { self::setError('该身份证号已存在'); return false; } } // 生成患者ID $params['patient_id'] = self::generatePatientId(); // 处理既往史数组 if (isset($params['past_history']) && is_array($params['past_history'])) { $params['past_history'] = implode(',', $params['past_history']); } // 处理现病史多选字段 $multiSelectFields = [ 'diet_condition', 'body_feeling', 'sleep_condition', 'eye_condition', 'head_feeling', 'sweat_condition', 'skin_condition', 'urine_condition', 'stool_condition', 'kidney_condition', 'local_hospital_diagnosis' ]; foreach ($multiSelectFields as $field) { if (isset($params[$field]) && is_array($params[$field])) { $params[$field] = implode(',', $params[$field]); } } // 处理舌苔照片(JSON数组) if (isset($params['tongue_images']) && is_array($params['tongue_images'])) { $params['tongue_images'] = json_encode($params['tongue_images'], JSON_UNESCAPED_UNICODE); } // 处理检查报告(JSON数组) if (isset($params['report_files']) && is_array($params['report_files'])) { $params['report_files'] = json_encode($params['report_files'], JSON_UNESCAPED_UNICODE); } // 处理诊断日期 if (isset($params['diagnosis_date'])) { $params['diagnosis_date'] = strtotime($params['diagnosis_date']); } // 表未增加 admin_id 列时勿写入,避免 SQL 报错(见 sql/1.9.20260421/add_tcm_diagnosis_admin_id.sql) // 注意:Model::getConnection() 在 think-orm 中返回连接名(string),须用 Db 查询对象取字段 if (array_key_exists('admin_id', $params)) { $fieldNames = Db::name('tcm_diagnosis')->getTableFields(); if (! is_array($fieldNames) || ! in_array('admin_id', $fieldNames, true)) { unset($params['admin_id']); } } $model = Diagnosis::create($params); // 自动为患者创建 TRTC 账号 self::createPatientTrtcAccount($model->id); return $model->id; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } /** * @notes 生成患者ID * @return int */ private static function generatePatientId(): int { // 获取当前最大的患者ID $maxPatientId = Diagnosis::max('patient_id') ?? 100000; // 返回下一个患者ID return $maxPatientId + 1; } /** * @notes 编辑诊单 * @param array $params * @return bool */ public static function edit(array $params): bool { try { // 检查手机号是否重复(排除当前记录) if (!empty($params['phone'])) { $exists = Diagnosis::where('phone', $params['phone']) ->where('id', '<>', $params['id']) ->where('delete_time', null) ->find(); if ($exists) { self::setError('该手机号已存在'); return false; } } // 检查身份证号是否重复(排除当前记录) if (!empty($params['id_card'])) { $exists = Diagnosis::where('id_card', $params['id_card']) ->where('id', '<>', $params['id']) ->where('delete_time', null) ->find(); if ($exists) { self::setError('该身份证号已存在'); return false; } } // 处理既往史数组 if (isset($params['past_history']) && is_array($params['past_history'])) { $params['past_history'] = implode(',', $params['past_history']); } // 处理现病史多选字段 $multiSelectFields = [ 'diet_condition', 'body_feeling', 'sleep_condition', 'eye_condition', 'head_feeling', 'sweat_condition', 'skin_condition', 'urine_condition', 'stool_condition', 'kidney_condition', 'local_hospital_diagnosis' ]; foreach ($multiSelectFields as $field) { if (isset($params[$field]) && is_array($params[$field])) { $params[$field] = implode(',', $params[$field]); } } // 处理舌苔照片(JSON数组) if (isset($params['tongue_images']) && is_array($params['tongue_images'])) { $params['tongue_images'] = json_encode($params['tongue_images'], JSON_UNESCAPED_UNICODE); } // 处理检查报告(JSON数组) if (isset($params['report_files']) && is_array($params['report_files'])) { $params['report_files'] = json_encode($params['report_files'], JSON_UNESCAPED_UNICODE); } // 处理诊断日期 if (isset($params['diagnosis_date'])) { $params['diagnosis_date'] = strtotime($params['diagnosis_date']); } Diagnosis::update($params); // 如果是编辑,也确保患者有 TRTC 账号 $diagnosis = Diagnosis::find($params['id']); if ($diagnosis) { self::createPatientTrtcAccount($diagnosis->patient_id); } return true; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } /** * @notes 删除诊单 * @param array $params * @return bool */ public static function delete(array $params): bool { try { Diagnosis::destroy($params['id']); return true; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } /** * @notes 诊单详情 * @param $params * @return array */ public static function detail($params): array { $diagnosis = Diagnosis::findOrEmpty($params['id'])->toArray(); // 处理既往史为数组 if (!empty($diagnosis['past_history'])) { $diagnosis['past_history'] = explode(',', $diagnosis['past_history']); } else { $diagnosis['past_history'] = []; } // 处理现病史多选字段为数组 $multiSelectFields = [ 'diet_condition', 'body_feeling', 'sleep_condition', 'eye_condition', 'head_feeling', 'sweat_condition', 'skin_condition', 'urine_condition', 'stool_condition', 'kidney_condition', 'local_hospital_diagnosis' ]; foreach ($multiSelectFields as $field) { if (!empty($diagnosis[$field])) { $diagnosis[$field] = explode(',', $diagnosis[$field]); } else { $diagnosis[$field] = []; } } if (!empty($diagnosis['tongue_images'])) { // 先尝试 JSON 解析(兼容旧数据) $files = json_decode($diagnosis['tongue_images'], true); if (is_array($files)) { $diagnosis['tongue_images'] = $files; } else { // JSON 解析失败则按逗号分割 $diagnosis['tongue_images'] = array_filter(explode(',', $diagnosis['tongue_images'])) ?: []; } // 为没有 http 前缀的图片添加域名 $domain = request()->domain(); $diagnosis['tongue_images'] = array_map(function($url) use ($domain) { if (empty($url)) { return $url; } // 如果已经包含 http:// 或 https://,则跳过 if (stripos($url, 'http://') === 0 || stripos($url, 'https://') === 0) { return $url; } // 添加域名前缀 return $domain .'/'. $url; }, $diagnosis['tongue_images']); } else { $diagnosis['tongue_images'] = []; } // 处理检查报告(JSON转数组) if (!empty($diagnosis['report_files'])) { // 先尝试 JSON 解析(兼容旧数据) $files = json_decode($diagnosis['report_files'], true); if (is_array($files)) { $diagnosis['report_files'] = $files; } else { // JSON 解析失败则按逗号分割 $diagnosis['report_files'] = array_filter(explode(',', $diagnosis['report_files'])) ?: []; } // 为没有 http 前缀的文件添加域名 $domain = request()->domain(); $diagnosis['report_files'] = array_map(function($url) use ($domain) { if (empty($url)) { return $url; } // 如果已经包含 http:// 或 https://,则跳过 if (stripos($url, 'http://') === 0 || stripos($url, 'https://') === 0) { return $url; } // 添加域名前缀 return $domain . $url; }, $diagnosis['report_files']); } else { $diagnosis['report_files'] = []; } // 处理诊断日期格式 if (!empty($diagnosis['diagnosis_date'])) { $diagnosis['diagnosis_date'] = date('Y-m-d', $diagnosis['diagnosis_date']); } // 如果local_hospital_visit_date为空但diagnosis_date有值,则使用diagnosis_date if (empty($diagnosis['local_hospital_visit_date']) && !empty($diagnosis['diagnosis_date'])) { $diagnosis['local_hospital_visit_date'] = $diagnosis['diagnosis_date']; } $diagnosis['is_view'] =DiagnosisViewRecord::where(['diagnosis_id'=> $diagnosis['id'], 'user_id' =>$params['user_id']??0])->find() ? 1 : 0; return $diagnosis; } /** * @notes 诊单详情(患者端) * @param array $params * @return array */ public static function diagnosisDetail(array $params): array { $diagnosisId = $params['id'] ?? 0; $userId = $params['user_id'] ?? 0; if (!$diagnosisId || !$userId) { self::setError('参数不完整'); return []; } $diagnosis = Diagnosis::where('id', $diagnosisId) ->where('patient_id', $userId) ->findOrEmpty() ->toArray(); if (empty($diagnosis)) { self::setError('诊单不存在或无权限访问'); return []; } // 处理既往史为数组 if (!empty($diagnosis['past_history'])) { $diagnosis['past_history'] = explode(',', $diagnosis['past_history']); } else { $diagnosis['past_history'] = []; } // 处理现病史多选字段为数组 $multiSelectFields = [ 'diet_condition', 'body_feeling', 'sleep_condition', 'eye_condition', 'head_feeling', 'sweat_condition', 'skin_condition', 'urine_condition', 'stool_condition', 'kidney_condition', 'local_hospital_diagnosis' ]; foreach ($multiSelectFields as $field) { if (!empty($diagnosis[$field])) { $diagnosis[$field] = explode(',', $diagnosis[$field]); } else { $diagnosis[$field] = []; } } // 处理检查报告为数组 if (!empty($diagnosis['examination_report'])) { $diagnosis['examination_report'] = explode(',', $diagnosis['examination_report']); } else { $diagnosis['examination_report'] = []; } // 处理舌苔照片为数组 if (!empty($diagnosis['tongue_photo'])) { $diagnosis['tongue_photo'] = explode(',', $diagnosis['tongue_photo']); } else { $diagnosis['tongue_photo'] = []; } // 处理诊断日期格式 if (!empty($diagnosis['diagnosis_date'])) { $diagnosis['diagnosis_date'] = date('Y-m-d', $diagnosis['diagnosis_date']); } // 如果local_hospital_visit_date为空但diagnosis_date有值,则使用diagnosis_date if (empty($diagnosis['local_hospital_visit_date']) && !empty($diagnosis['diagnosis_date'])) { $diagnosis['local_hospital_visit_date'] = $diagnosis['diagnosis_date']; } return $diagnosis; } /** * @notes 检查手机号是否重复 * @param array $params * @return array */ public static function checkPhone(array $params): array { $query = Diagnosis::where('phone', $params['phone']) ->where('delete_time', null); // 编辑时排除当前记录 if (isset($params['id']) && $params['id']) { $query->where('id', '<>', $params['id']); } $exists = $query->find(); return [ 'exists' => !empty($exists), 'message' => $exists ? '该手机号已存在' : '' ]; } /** * @notes 检查身份证号是否重复 * @param array $params * @return array */ public static function checkIdCard(array $params): array { $query = Diagnosis::where('id_card', $params['id_card']) ->where('delete_time', null); // 编辑时排除当前记录 if (isset($params['id']) && $params['id']) { $query->where('id', '<>', $params['id']); } $exists = $query->find(); return [ 'exists' => !empty($exists), 'message' => $exists ? '该身份证号已存在' : '' ]; } /** * @notes 补全身份证号(自动计算年龄并更新) * @param array $params id, id_card * @return bool */ public static function fillIdCard(array $params): bool { try { $diagnosis = Diagnosis::find($params['id']); if (!$diagnosis) { self::setError('诊单不存在'); return false; } $idCard = trim($params['id_card']); $len = strlen($idCard); if ($len === 15) { $birthYear = 1900 + (int)substr($idCard, 6, 2); $birthMonth = (int)substr($idCard, 8, 2); $birthDay = (int)substr($idCard, 10, 2); } elseif ($len === 18 && preg_match('/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/', $idCard)) { $birthYear = (int)substr($idCard, 6, 4); $birthMonth = (int)substr($idCard, 10, 2); $birthDay = (int)substr($idCard, 12, 2); } else { self::setError('身份证号格式不正确'); return false; } // 从身份证提取出生日期计算年龄 $today = getdate(); $age = $today['year'] - $birthYear; if ($today['mon'] < $birthMonth || ($today['mon'] == $birthMonth && $today['mday'] < $birthDay)) { $age--; } $age = max(0, min(150, $age)); $diagnosis->id_card = $idCard; $diagnosis->age = $age; $diagnosis->save(); return true; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } /** * @notes 指派医助(每次成功更新写入一条操作记录,批量指派为多次接口调用各记一条) * @param array $params * @return bool */ public static function assign(array $params): bool { try { $id = (int) ($params['id'] ?? 0); $toAssistantId = (int) ($params['assistant_id'] ?? 0); if ($id <= 0) { self::setError('诊单不存在'); return false; } $diagnosis = Diagnosis::where('id', $id)->whereNull('delete_time')->find(); if (!$diagnosis) { self::setError('诊单不存在'); return false; } $fromAssistantId = (int) ($diagnosis->getAttr('assistant_id') ?? 0); Db::startTrans(); Diagnosis::where('id', $id)->whereNull('delete_time')->update([ 'assistant_id' => $toAssistantId, ]); $req = request(); $admin = $req->adminInfo ?? []; DiagnosisAssignLog::create([ 'diagnosis_id' => $id, 'from_assistant_id' => $fromAssistantId, 'to_assistant_id' => $toAssistantId, 'operator_admin_id' => (int) ($admin['admin_id'] ?? 0), 'operator_name' => (string) ($admin['name'] ?? ''), 'operator_account' => (string) ($admin['account'] ?? ''), 'ip' => (string) ($req->ip() ?? ''), 'create_time' => time(), ]); Db::commit(); return true; } catch (\Exception $e) { Db::rollback(); self::setError($e->getMessage()); return false; } } /** * @notes 诊单指派医助操作记录列表 */ public static function assignLogList(int $diagnosisId): array { $rows = DiagnosisAssignLog::where('diagnosis_id', $diagnosisId) ->order('id', 'desc') ->select() ->toArray(); $adminIds = []; foreach ($rows as $r) { if (!empty($r['from_assistant_id'])) { $adminIds[] = (int) $r['from_assistant_id']; } if (!empty($r['to_assistant_id'])) { $adminIds[] = (int) $r['to_assistant_id']; } } $adminIds = array_values(array_unique(array_filter($adminIds))); $nameMap = []; if ($adminIds !== []) { $nameMap = \app\common\model\auth\Admin::whereIn('id', $adminIds)->column('name', 'id'); } foreach ($rows as &$r) { $fromId = (int) ($r['from_assistant_id'] ?? 0); $toId = (int) ($r['to_assistant_id'] ?? 0); $r['from_assistant_name'] = $fromId > 0 ? (string) ($nameMap[$fromId] ?? ('ID:' . $fromId)) : '—'; $r['to_assistant_name'] = $toId > 0 ? (string) ($nameMap[$toId] ?? ('ID:' . $toId)) : '—'; $r['create_time_text'] = !empty($r['create_time']) ? date('Y-m-d H:i:s', (int) $r['create_time']) : ''; } unset($r); return $rows; } /** * @notes 获取通话签名 * @param array $params * @return array|bool */ public static function getCallSignature(array $params) { try { // 获取配置 $config = self::getTrtcConfig(); if (!$config) { self::setError('请先配置腾讯云TRTC参数'); return false; } // 获取当前管理员ID(从参数中获取) $adminId = $params['admin_id'] ?? 0; $patientId = $params['patient_id'] ?? 0; if (!$adminId) { self::setError('获取管理员信息失败'); return false; } if (!$patientId) { self::setError('获取患者信息失败'); return false; } // 医生userId $doctorUserId = 'doctor_' . $adminId; $query = Diagnosis::where('id', $patientId) ->where('delete_time', null)->find(); if(!$query){ self::setError('患者诊单已被删除'); return false; } // 患者userId(必须与小程序端一致) $patientUserId = 'patient_' . $patientId; // 生成医生的 UserSig $userSig = self::generateUserSig($config['sdkAppId'], $config['secretKey'], $doctorUserId); if (!$userSig) { self::setError('生成签名失败'); return false; } // 导入医生账号到IM self::importDoctorAccountToIm($adminId, $doctorUserId); // 确保患者账号也已导入IM(用于跨平台通话) self::ensurePatientImAccount($patientId, $patientUserId); return [ 'sdkAppId' => (int)$config['sdkAppId'], // 确保返回整数 'userId' => $doctorUserId, // 医生的userId 'userSig' => $userSig, 'assistant_id'=>$query->assistant_id?'doctor_'.$query->assistant_id:'', 'patientUserId' => $patientUserId, // 患者的userId(用于发起通话) 'expireTime' => 86400, // 24小时 // 与 .env [trtc] ISLOCHOSTVOD 一致:true 允许浏览器本地录制并上传 'isLochostVod' => (bool)config('trtc.is_lochost_vod', false), ]; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } /** * @notes 确保患者IM账号存在 * @param int $patientId * @param string $userId * @return bool */ private static function ensurePatientImAccount(int $patientId, string $userId): bool { try { // 获取患者信息 $diagnosis = Diagnosis::where('patient_id', $patientId)->find(); $nick = $diagnosis ? $diagnosis->patient_name : '患者' . $patientId; // 调用IM服务导入账号 $imService = new \app\common\service\TencentImService(); $result = $imService->importAccount($userId, $nick); if ($result['success']) { \think\facade\Log::info('确保患者IM账号存在 - patient_id: ' . $patientId . ', user_id: ' . $userId . ', nick: ' . $nick); return true; } else { \think\facade\Log::warning('导入患者IM账号失败 - patient_id: ' . $patientId . ', error: ' . ($result['message'] ?? '未知错误')); return false; } } catch (\Exception $e) { \think\facade\Log::error('确保患者IM账号失败: ' . $e->getMessage()); return false; } } /** * @notes 导入医生账号到腾讯云IM * @param int $adminId * @param string $userId * @return bool */ private static function importDoctorAccountToIm(int $adminId, string $userId): bool { try { // 获取医生信息 $admin = \app\common\model\auth\Admin::find($adminId); $nick = $admin ? $admin->name : '医生' . $adminId; // 调用IM服务导入账号 $imService = new \app\common\service\TencentImService(); $result = $imService->importAccount($userId, $nick); if ($result['success']) { \think\facade\Log::info('导入医生IM账号成功 - admin_id: ' . $adminId . ', user_id: ' . $userId . ', nick: ' . $nick); return true; } else { \think\facade\Log::warning('导入医生IM账号失败 - admin_id: ' . $adminId . ', user_id: ' . $userId . ', error: ' . $result['message']); return false; } } catch (\Exception $e) { \think\facade\Log::error('导入医生IM账号异常 - admin_id: ' . $adminId . ', user_id: ' . $userId . ', error: ' . $e->getMessage()); return false; } } /** * 所有可能以 doctor_{id} 登录 IM 的后台账号(医生 role_id=1、医助 role_id=2),用于合并会话漫游记录 * * @return array 如 ['doctor_1','doctor_2'] */ private static function collectAllDoctorImPeerAccounts(): array { try { $ids = \app\common\model\auth\Admin::alias('a') ->join('admin_role ar', 'a.id = ar.admin_id') ->whereIn('ar.role_id', [1, 2]) ->where('a.disable', 0) ->group('a.id') ->column('a.id'); $accounts = []; foreach ($ids as $id) { $accounts[] = 'doctor_' . (int)$id; } return array_values(array_unique($accounts)); } catch (\Exception $e) { \think\facade\Log::error('collectAllDoctorImPeerAccounts: ' . $e->getMessage()); return []; } } /** * @notes 拉取与本诊单相关的 IM 单聊记录:本地归档 + 腾讯云漫游合并(归档突破云端约 7 天限制) * * @param int $diagnosisId * @param bool $onlyArchived 只读取本地归档,不请求腾讯云(用于首次打开快速展示) */ public static function getImChatMessagesForDiagnosis(int $diagnosisId, bool $onlyArchived = false) { try { $diag = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find(); if (!$diag) { self::setError('诊单不存在'); return false; } $patientId = (int)$diag['patient_id']; if ($patientId <= 0) { self::setError('诊单缺少患者信息'); return false; } $patientImId = 'patient_' . $patientId; $archived = self::loadArchivedImChatRows($diagnosisId); if ($onlyArchived) { $lists = self::enrichImMessagesWithStaffNames($archived); return [ 'lists' => $lists, 'patient_im_id' => $patientImId, 'patient_name' => $diag['patient_name'] ?? '', 'doctor_accounts_queried' => [], 'only_archived' => true, ]; } $config = self::getTrtcConfig(); if (!$config) { if (empty($archived)) { self::setError('请先配置腾讯云 TRTC / IM 参数'); return false; } $lists = self::enrichImMessagesWithStaffNames($archived); return [ 'lists' => $lists, 'patient_im_id' => $patientImId, 'patient_name' => $diag['patient_name'] ?? '', 'doctor_accounts_queried' => [], ]; } $doctorAccounts = self::collectAllDoctorImPeerAccounts(); $assistantId = isset($diag['assistant_id']) ? (int)$diag['assistant_id'] : 0; if ($assistantId > 0) { $doctorAccounts[] = 'doctor_' . $assistantId; } $doctorAccounts = array_values(array_unique($doctorAccounts)); if (empty($doctorAccounts)) { self::setError('未找到医生/医助角色账号,无法拉取 IM 记录'); return false; } $live = self::pullLiveImChatMessagesForDiagnosis($diag, $doctorAccounts); $merged = self::mergeImMessagesByMsgId($archived, $live); $merged = self::enrichImMessagesWithStaffNames($merged); // 首次云端拉取后异步落库,下一次即可直接读归档,无需再全量扫描医生账号 if (!empty($live)) { try { self::persistImChatArchiveRows($diagnosisId, $patientId, $live); } catch (\Throwable $e) { \think\facade\Log::warning('archive im chat on-the-fly failed: ' . $e->getMessage()); } } return [ 'lists' => $merged, 'patient_im_id' => $patientImId, 'patient_name' => $diag['patient_name'] ?? '', 'doctor_accounts_queried' => $doctorAccounts, ]; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } /** * 定时任务:从腾讯云拉取漫游消息写入归档表 * * @return array{inserted:int, skipped_live_empty:bool, error?:string} */ public static function syncImChatArchiveForDiagnosis(int $diagnosisId): array { $out = ['inserted' => 0, 'skipped_live_empty' => false]; try { if (!self::getTrtcConfig()) { $out['error'] = 'TRTC/IM 未配置'; return $out; } $diag = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find(); if (!$diag) { $out['error'] = '诊单不存在'; return $out; } $accounts = self::collectAllDoctorImPeerAccounts(); $assistantId = isset($diag['assistant_id']) ? (int)$diag['assistant_id'] : 0; if ($assistantId > 0) { $accounts[] = 'doctor_' . $assistantId; $accounts = array_values(array_unique($accounts)); } $live = self::pullLiveImChatMessagesForDiagnosis($diag, $accounts); if (empty($live)) { $out['skipped_live_empty'] = true; return $out; } $live = self::enrichImMessagesWithStaffNames($live); $patientId = (int)$diag['patient_id']; $out['inserted'] = self::persistImChatArchiveRows($diagnosisId, $patientId, $live); return $out; } catch (\Exception $e) { $out['error'] = $e->getMessage(); return $out; } } /** * @return array{diagnoses:int, inserted:int, errors:array} */ public static function syncImChatArchiveBatch(int $sinceDays, int $limit, ?int $onlyDiagnosisId): array { $stats = ['diagnoses' => 0, 'inserted' => 0, 'errors' => []]; $limit = max(1, min(500, $limit)); $q = Diagnosis::where('delete_time', null); if ($onlyDiagnosisId !== null && $onlyDiagnosisId > 0) { $q->where('id', $onlyDiagnosisId); } elseif ($sinceDays > 0) { $q->where('update_time', '>=', time() - $sinceDays * 86400); } $ids = $q->order('id', 'desc')->limit($limit)->column('id'); foreach ($ids as $id) { $stats['diagnoses']++; $r = self::syncImChatArchiveForDiagnosis((int)$id); if (!empty($r['error'])) { $stats['errors'][] = 'diagnosis ' . $id . ': ' . $r['error']; continue; } $stats['inserted'] += (int)($r['inserted'] ?? 0); } return $stats; } /** * @return array> */ private static function loadArchivedImChatRows(int $diagnosisId): array { if ($diagnosisId <= 0) { return []; } $list = ImChatMessage::where('diagnosis_id', $diagnosisId) ->order('msg_time', 'asc') ->order('id', 'asc') ->select() ->toArray(); $out = []; foreach ($list as $row) { $out[] = [ 'msg_id' => (string)($row['msg_id'] ?? ''), 'from_account' => (string)($row['from_account'] ?? ''), 'to_account' => (string)($row['to_account'] ?? ''), 'time' => (int)($row['msg_time'] ?? 0), 'is_from_doctor' => !empty($row['is_from_doctor']), 'msg_type' => (string)($row['msg_type'] ?? ''), 'text' => (string)($row['text'] ?? ''), 'image_url' => (string)($row['image_url'] ?? ''), 'file_url' => (string)($row['file_url'] ?? ''), 'file_name' => (string)($row['file_name'] ?? ''), 'raw_elem_type' => (string)($row['raw_elem_type'] ?? ''), 'from_staff_name' => (string)($row['from_staff_name'] ?? ''), 'doctor_peer_account' => (string)($row['doctor_peer_account'] ?? ''), ]; } return $out; } /** * @param array> $archived * @param array> $live * @return array> */ private static function mergeImMessagesByMsgId(array $archived, array $live): array { $map = []; $tail = []; foreach ($archived as $r) { $k = (string)($r['msg_id'] ?? ''); if ($k !== '') { $map[$k] = $r; } else { $tail[] = $r; } } foreach ($live as $r) { $k = (string)($r['msg_id'] ?? ''); if ($k !== '') { $map[$k] = $r; } else { $tail[] = $r; } } $merged = array_values($map); $merged = array_merge($merged, $tail); usort($merged, function ($a, $b) { return ($a['time'] ?? 0) <=> ($b['time'] ?? 0); }); return $merged; } /** * @param Diagnosis|array $diag * @return array> */ /** * @param array $doctorAccounts 已筛选的医生 IM 账号列表 */ private static function pullLiveImChatMessagesForDiagnosis($diag, array $doctorAccounts = []): array { $patientId = (int)$diag['patient_id']; if ($patientId <= 0) { return []; } $patientImId = 'patient_' . $patientId; if (empty($doctorAccounts)) { return []; } $imService = new \app\common\service\TencentImService(); $merged = []; foreach ($doctorAccounts as $docAccount) { $batch = self::pullAllRoamMessages($imService, $docAccount, $patientImId); foreach ($batch as $row) { $merged[] = $row; } } usort($merged, function ($a, $b) { return ($a['time'] ?? 0) <=> ($b['time'] ?? 0); }); $seen = []; $unique = []; foreach ($merged as $row) { $k = $row['msg_id'] ?? ''; if ($k !== '' && isset($seen[$k])) { continue; } if ($k !== '') { $seen[$k] = true; } $unique[] = $row; } return $unique; } /** * @param array> $rows */ private static function persistImChatArchiveRows(int $diagnosisId, int $patientId, array $rows): int { $now = time(); $chunks = []; foreach ($rows as $r) { $msgId = (string)($r['msg_id'] ?? ''); if ($msgId === '') { continue; } $chunks[] = [ 'diagnosis_id' => $diagnosisId, 'patient_id' => $patientId, 'msg_id' => $msgId, 'from_account' => (string)($r['from_account'] ?? ''), 'to_account' => (string)($r['to_account'] ?? ''), 'msg_time' => (int)($r['time'] ?? 0), 'is_from_doctor' => !empty($r['is_from_doctor']) ? 1 : 0, 'msg_type' => (string)($r['msg_type'] ?? ''), 'text' => (string)($r['text'] ?? ''), 'image_url' => (string)($r['image_url'] ?? ''), 'file_url' => (string)($r['file_url'] ?? ''), 'file_name' => (string)($r['file_name'] ?? ''), 'raw_elem_type' => (string)($r['raw_elem_type'] ?? ''), 'from_staff_name' => (string)($r['from_staff_name'] ?? ''), 'doctor_peer_account' => (string)($r['doctor_peer_account'] ?? ''), 'create_time' => $now, ]; } $inserted = 0; foreach (array_chunk($chunks, 80) as $chunk) { $inserted += self::insertIgnoreImChatBatch($chunk); } return $inserted; } /** * @param array> $chunk */ private static function insertIgnoreImChatBatch(array $chunk): int { if (empty($chunk)) { return 0; } $table = (new ImChatMessage())->getTable(); $cols = array_keys($chunk[0]); $colSql = '`' . implode('`,`', $cols) . '`'; $rowPh = '(' . implode(',', array_fill(0, count($cols), '?')) . ')'; $allPh = implode(',', array_fill(0, count($chunk), $rowPh)); $flat = []; foreach ($chunk as $row) { foreach ($cols as $c) { $flat[] = $row[$c]; } } $sql = 'INSERT IGNORE INTO `' . $table . '` (' . $colSql . ') VALUES ' . $allPh; return (int)Db::execute($sql, $flat); } /** * 为 from_account = doctor_{id} 的消息补充后台姓名,便于前端区分不同医生 * * @param array> $rows * @return array> */ private static function enrichImMessagesWithStaffNames(array $rows): array { $ids = []; foreach ($rows as $r) { $f = (string)($r['from_account'] ?? ''); if (preg_match('/^doctor_(\d+)$/', $f, $m)) { $ids[] = (int)$m[1]; } } $ids = array_unique($ids); if (empty($ids)) { return $rows; } $map = \app\common\model\auth\Admin::whereIn('id', $ids)->column('name', 'id'); foreach ($rows as $k => $r) { $f = (string)($r['from_account'] ?? ''); if (preg_match('/^doctor_(\d+)$/', $f, $m)) { $aid = (int)$m[1]; $rows[$k]['from_staff_name'] = $map[$aid] ?? ''; } } return $rows; } /** * @return array> */ private static function pullAllRoamMessages(\app\common\service\TencentImService $svc, string $operator, string $peer): array { $out = []; $lastKey = null; $lastTime = null; $guard = 0; do { $res = $svc->adminGetRoamMsg($operator, $peer, 100, 0, 4294967295, $lastKey, $lastTime); if (!$res['success']) { if (!empty($res['error'])) { \think\facade\Log::warning('IM漫游消息拉取失败', [ 'operator' => $operator, 'peer' => $peer, 'error' => $res['error'], 'code' => $res['rawErrorCode'] ?? 0, ]); } break; } foreach ($res['msgList'] as $raw) { if (!is_array($raw)) { continue; } $normalized = self::normalizeTimMessage($raw); $normalized['doctor_peer_account'] = $operator; $out[] = $normalized; } $complete = (int)$res['complete']; if ($complete === 1) { break; } $lastKey = $res['lastMsgKey']; $lastTime = $res['lastMsgTime']; if ($lastKey === null || $lastKey === '') { break; } $guard++; if ($guard > 80) { break; } } while (true); return $out; } /** * @param array $raw * @return array */ private static function normalizeTimMessage(array $raw): array { $from = (string)($raw['From_Account'] ?? ''); $to = (string)($raw['To_Account'] ?? ''); $time = (int)($raw['MsgTimeStamp'] ?? $raw['MsgTime'] ?? 0); $seq = $raw['MsgSeq'] ?? ''; $rand = $raw['MsgRandom'] ?? ''; $msgId = $seq . '_' . $rand . '_' . $from; $isDoctor = strpos($from, 'doctor_') === 0; $parsed = self::parseTimMsgBody($raw['MsgBody'] ?? []); return array_merge( [ 'msg_id' => $msgId, 'from_account' => $from, 'to_account' => $to, 'time' => $time, 'is_from_doctor' => $isDoctor, ], $parsed ); } /** * @param mixed $body * @return array */ private static function parseTimMsgBody($body): array { $out = [ 'msg_type' => 'other', 'text' => '', 'image_url' => '', 'file_url' => '', 'file_name' => '', 'raw_elem_type' => '', ]; if (!is_array($body) || !$body) { return $out; } foreach ($body as $elem) { if (!is_array($elem)) { continue; } $type = (string)($elem['MsgType'] ?? ''); $out['raw_elem_type'] = $type; $content = $elem['MsgContent'] ?? []; if (!is_array($content)) { $content = []; } switch ($type) { case 'TIMTextElem': $out['msg_type'] = 'text'; $out['text'] = (string)($content['Text'] ?? ''); return $out; case 'TIMImageElem': $out['msg_type'] = 'image'; $arr = $content['ImageInfoArray'] ?? []; if (is_array($arr)) { foreach ($arr as $info) { if (is_array($info) && !empty($info['URL'])) { $out['image_url'] = (string)$info['URL']; break; } } } return $out; case 'TIMFileElem': $out['msg_type'] = 'file'; $out['file_url'] = (string)($content['Url'] ?? ''); $out['file_name'] = (string)($content['FileName'] ?? ''); return $out; case 'TIMSoundElem': $out['msg_type'] = 'sound'; $out['file_url'] = (string)($content['Url'] ?? ''); return $out; case 'TIMVideoFileElem': $out['msg_type'] = 'video'; $out['file_url'] = (string)($content['VideoUrl'] ?? ''); return $out; case 'TIMVideoElem': $out['msg_type'] = 'video'; $out['file_url'] = (string)($content['VideoUrl'] ?? ''); return $out; case 'TIMLocationElem': $out['msg_type'] = 'location'; $out['text'] = (string)($content['Desc'] ?? ''); return $out; case 'TIMCustomElem': $out['msg_type'] = 'custom'; $out['text'] = (string)($content['Data'] ?? ''); return $out; case 'TIMFaceElem': $out['msg_type'] = 'face'; $out['text'] = (string)($content['Index'] ?? ''); return $out; default: break; } } return $out; } /** * @notes 发起通话 * @param array $params * @return bool */ public static function startCall(array $params): bool { try { // 获取当前管理员ID(从参数中获取) $adminId = $params['admin_id'] ?? 0; if (!$adminId) { self::setError('获取管理员信息失败'); return false; } // 创建通话记录 \app\common\model\tcm\CallRecord::create([ 'diagnosis_id' => $params['diagnosis_id'], 'caller_id' => $adminId, 'caller_type' => 'doctor', 'callee_id' => $params['patient_id'] ?? 0, 'callee_type' => 'patient', 'call_type' => $params['call_type'] ?? 2, // 1-语音 2-视频 'status' => 1, // 1-进行中 'start_time' => time() ]); return true; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } /** * @notes 结束通话 * @param array $params * @return bool */ public static function endCall(array $params): bool { try { // 获取当前管理员ID(从参数中获取) $adminId = (int)($params['admin_id'] ?? 0); $diagnosisId = (int)($params['diagnosis_id'] ?? 0); if ($adminId <= 0) { self::setError('获取管理员信息失败'); return false; } if ($diagnosisId <= 0) { self::setError('诊单ID无效'); return false; } // 优先匹配「当前医生 + 进行中」,与 startCloudRecording / bindCallRoom 一致 $record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId) ->where('caller_id', $adminId) ->where('status', 1) ->order('id', 'desc') ->find(); if (!$record) { $record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId) ->where('status', 1) ->order('id', 'desc') ->find(); if ($record) { \think\facade\Log::warning('endCall: 未匹配 caller_id,已回退到该诊单最新进行中记录', [ 'diagnosis_id' => $diagnosisId, 'admin_id' => $adminId, 'record_caller_id' => $record['caller_id'] ?? null, 'call_record_id' => $record['id'] ?? null, ]); } } if (!$record) { // 前端常重复回调 endCall(afterCalling + Store idle),第一条已结束则不再告警 $latest = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId) ->order('id', 'desc') ->find(); $justEnded = $latest && (int)($latest['status'] ?? 0) === 2 && (int)($latest['end_time'] ?? 0) > 0 && (time() - (int)$latest['end_time']) < 120; if (!$justEnded) { \think\facade\Log::warning('endCall: 无进行中通话记录,未调用 DeleteCloudRecording', [ 'diagnosis_id' => $diagnosisId, 'admin_id' => $adminId, ]); } return true; } $taskId = trim((string)($record['cloud_recording_task_id'] ?? '')); if ($taskId !== '') { $stopped = \app\common\service\TrtcCloudRecordingService::stopRecording( \app\common\service\TrtcCloudRecordingService::trtcSdkAppId(), $taskId ); if (empty($stopped['ok'])) { \think\facade\Log::warning('endCall: DeleteCloudRecording 失败', [ 'message' => (string)($stopped['message'] ?? ''), 'task_id' => $taskId, 'call_record_id' => $record['id'] ?? null, 'room_id' => $record['room_id'] ?? '', ]); } } else { \think\facade\Log::warning('endCall: cloud_recording_task_id 为空,未调用 DeleteCloudRecording;控制台「房间尚未结束」常见于录制机器人仍在房或未走 API 合流录制', [ 'call_record_id' => $record['id'] ?? null, 'diagnosis_id' => $diagnosisId, 'room_id' => $record['room_id'] ?? '', ]); } $endTime = time(); $duration = $endTime - (int)$record->start_time; // 保留 cloud_recording_task_id:VOD 311 回调常在挂断之后到达,需按 TaskId 关联写入 recording_urls $record->save([ 'status' => 2, // 2-已结束 'end_time' => $endTime, 'duration' => $duration, 'update_time' => time(), ]); return true; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } /** * @notes 患者端挂断:停止云端录制并结束该诊单下对应通话记录(不依赖管理端浏览器是否触发 endCall) * @param array{diagnosis_id?:int,patient_id:int,room_id?:string} $params patient_id 须与诊单 tcm_diagnosis.patient_id 一致;room_id 与 bindCallRoom 一致时可精准命中(小程序通话页独立时 chat 页可能已卸载,仅靠 room_id + patient_id 即可) */ public static function patientHangupVideoCall(array $params): bool { try { $diagnosisId = (int)($params['diagnosis_id'] ?? 0); $patientId = (int)($params['patient_id'] ?? 0); $roomIdRaw = trim((string)($params['room_id'] ?? '')); if ($patientId <= 0) { self::setError('参数错误'); return false; } if ($diagnosisId <= 0 && $roomIdRaw === '') { self::setError('诊单ID或房间号须至少传一项'); return false; } $record = null; if ($roomIdRaw !== '') { $roomCandidates = array_values(array_unique(array_filter([ $roomIdRaw, ctype_digit($roomIdRaw) ? (string)((int)$roomIdRaw) : '', ]))); $record = \app\common\model\tcm\CallRecord::where('status', 1) ->whereIn('room_id', $roomCandidates) ->order('id', 'desc') ->find(); if ($record) { $diagByRoom = Diagnosis::find((int)$record['diagnosis_id']); if (!$diagByRoom || (int)($diagByRoom['patient_id'] ?? 0) !== $patientId) { $record = null; } else { $diagnosisId = (int)$record['diagnosis_id']; } } } if (!$record && $diagnosisId > 0) { $diag = Diagnosis::find($diagnosisId); if (!$diag || (int)($diag['patient_id'] ?? 0) !== $patientId) { self::setError('无权操作该诊单'); return false; } $record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId) ->where('callee_id', $patientId) ->where('status', 1) ->order('id', 'desc') ->find(); if (!$record) { $cnt = (int)\app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)->where('status', 1)->count(); if ($cnt === 1) { $record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId) ->where('status', 1) ->order('id', 'desc') ->find(); } } } if (!$record) { return true; } $taskId = trim((string)($record['cloud_recording_task_id'] ?? '')); if ($taskId !== '') { \app\common\service\TrtcCloudRecordingService::stopRecording( \app\common\service\TrtcCloudRecordingService::trtcSdkAppId(), $taskId ); } $endTime = time(); $duration = $endTime - (int)$record->start_time; $record->save([ 'status' => 2, 'end_time' => $endTime, 'duration' => $duration, 'update_time' => $endTime, ]); \think\facade\Log::info('patientHangupVideoCall: 已停录并结束通话记录', [ 'call_record_id' => $record->id, 'diagnosis_id' => $diagnosisId, ]); return true; } catch (\Exception $e) { self::setError($e->getMessage()); \think\facade\Log::warning('patientHangupVideoCall: ' . $e->getMessage()); return false; } } /** * @notes 获取通话记录 * @param array $params * @return array */ public static function getCallRecords(array $params): array { try { $records = \app\common\model\tcm\CallRecord::where('diagnosis_id', $params['diagnosis_id']) ->order('id', 'desc') ->select() ->toArray(); foreach ($records as &$record) { $record['start_time_text'] = date('Y-m-d H:i:s', $record['start_time']); $record['end_time_text'] = $record['end_time'] ? date('Y-m-d H:i:s', $record['end_time']) : ''; $record['duration_text'] = self::formatDuration($record['duration']); $urls = []; if (!empty($record['recording_urls'])) { $decoded = json_decode((string)$record['recording_urls'], true); if (is_array($decoded)) { $urls = $decoded; } } $record['recording_urls_list'] = $urls; $record['recording_status_text'] = self::recordingStatusText((int)($record['recording_status'] ?? 0)); } return $records; } catch (\Exception $e) { self::setError($e->getMessage()); return []; } } /** * @notes 将 TRTC 房间号写入当前诊单通话记录,并尝试 API 合流云端录制 * @return array{cloud_recording?:array}|false 成功返回 data 数组(供接口带给前端);失败 false */ public static function bindCallRoom(array $params) { try { $diagnosisId = (int)($params['diagnosis_id'] ?? 0); $roomId = trim((string)($params['room_id'] ?? '')); if ($diagnosisId <= 0 || $roomId === '') { self::setError('诊单ID或房间号不能为空'); return false; } $adminId = (int)($params['admin_id'] ?? 0); // 必须与 startCloudRecording 使用同一条「进行中 + 当前管理员」记录写 room_id,否则会写到别的记录上,合流 API 读到 room_id 仍为空 → 关闭全局录制后无任何文件 $record = null; if ($adminId > 0) { $record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId) ->where('status', 1) ->where('caller_id', $adminId) ->order('id', 'desc') ->find(); } if (!$record) { $record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId) ->where('status', 1) ->order('id', 'desc') ->find(); } if (!$record) { $record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId) ->where('room_id', '') ->order('id', 'desc') ->find(); } if (!$record) { self::setError('未找到可绑定的通话记录'); return false; } $record->save([ 'room_id' => $roomId, 'update_time' => time(), ]); $cloudPayload = [ 'started' => false, 'task_id' => '', 'message' => '未尝试合流录制(admin_id 为空)', ]; if ($adminId > 0) { $cloudRec = self::startCloudRecording([ 'diagnosis_id' => $diagnosisId, 'admin_id' => $adminId, ], true); if ($cloudRec === false) { \think\facade\Log::warning('bindCallRoom: startCloudRecording 未执行或异常', [ 'diagnosis_id' => $diagnosisId, 'room_id' => $roomId, ]); $cloudPayload = [ 'started' => false, 'task_id' => '', 'message' => '合流录制未启动(无匹配通话记录或异常,见服务端日志)', ]; } elseif (is_array($cloudRec)) { $cloudPayload = [ 'started' => !empty($cloudRec['started']), 'task_id' => (string)($cloudRec['task_id'] ?? ''), 'message' => (string)($cloudRec['message'] ?? ''), ]; if (empty($cloudRec['started'])) { \think\facade\Log::warning('bindCallRoom: CreateCloudRecording 合流未启动', [ 'diagnosis_id' => $diagnosisId, 'room_id' => $roomId, 'message' => $cloudPayload['message'], ]); } else { \think\facade\Log::info('bindCallRoom: 合流云端录制已发起', [ 'diagnosis_id' => $diagnosisId, 'room_id' => $roomId, 'task_id' => $cloudPayload['task_id'], ]); } } } return ['cloud_recording' => $cloudPayload]; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } /** * @notes 医生接通并绑定房间后:发起腾讯云云端混流录制(需 CAM 密钥 + 控制台开通录制 + 云点播) * @param array $params diagnosis_id、admin_id * @return array|false */ /** * @param bool $silent 为 true 时不写入 BaseLogic 错误(供 bindCallRoom 内自动调用,避免污染绑定接口) */ public static function startCloudRecording(array $params, bool $silent = false) { try { $diagnosisId = (int)($params['diagnosis_id'] ?? 0); $adminId = (int)($params['admin_id'] ?? 0); if ($diagnosisId <= 0 || $adminId <= 0) { if (!$silent) { self::setError('参数错误'); } return false; } $record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId) ->where('caller_id', $adminId) ->where('status', 1) ->order('id', 'desc') ->find(); if (!$record) { $record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId) ->where('status', 1) ->order('id', 'desc') ->find(); if ($record) { \think\facade\Log::warning('startCloudRecording: 未找到 caller_id 匹配的进行中记录,已回退到该诊单最新进行中记录', [ 'diagnosis_id' => $diagnosisId, 'admin_id' => $adminId, 'record_caller_id' => $record['caller_id'] ?? null, ]); } } if (!$record) { if (!$silent) { self::setError('没有进行中的通话记录'); } return false; } $roomId = trim((string)($record['room_id'] ?? '')); if ($roomId === '') { if (!$silent) { self::setError('尚未同步房间号,无法开启云端录制'); } return false; } $existingTask = trim((string)($record['cloud_recording_task_id'] ?? '')); if ($existingTask !== '') { return [ 'started' => true, 'task_id' => $existingTask, 'message' => '已在录制中', ]; } $prefix = (string)config('trtc.recording_bot_prefix', 'recorder_'); $botUserId = $prefix . $diagnosisId . '_' . (int)$record['id']; try { $imService = new \app\common\service\TencentImService(); $imService->importAccount($botUserId, '云端录制'); } catch (\Throwable $e) { // 导入失败不阻断,部分环境仅 TRTC 亦可进房 } $botSig = \app\common\service\TrtcCloudRecordingService::makeBotUserSig($botUserId); $sdkAppId = \app\common\service\TrtcCloudRecordingService::trtcSdkAppId(); $vodMixPrefix = 'mix_' . $diagnosisId . '_' . (int)$record['id']; $r = \app\common\service\TrtcCloudRecordingService::startMixRecording( $sdkAppId, $roomId, $botUserId, $botSig, $vodMixPrefix ); if (!$r['ok']) { return [ 'started' => false, 'message' => $r['message'] ?? '开启失败', ]; } $record->save([ 'cloud_recording_task_id' => $r['task_id'], 'recording_status' => 1, 'update_time' => time(), ]); return [ 'started' => true, 'task_id' => $r['task_id'], 'message' => '已发起云端录制', ]; } catch (\Exception $e) { if (!$silent) { self::setError($e->getMessage()); } \think\facade\Log::warning('startCloudRecording: ' . $e->getMessage()); return false; } } /** * @notes 医生端浏览器本地录制上传后,将文件访问地址合并写入当前诊单下该医生的最近一条通话记录 */ public static function attachLocalCallRecording(array $params): bool { try { $diagnosisId = (int)($params['diagnosis_id'] ?? 0); $adminId = (int)($params['admin_id'] ?? 0); $fileUrl = trim((string)($params['file_url'] ?? '')); if ($diagnosisId <= 0 || $adminId <= 0 || $fileUrl === '') { self::setError('参数错误'); return false; } $record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId) ->where('caller_id', $adminId) ->order('id', 'desc') ->find(); // 与 startCall 的 caller_id 不一致或竞态时,回退到该诊单最新一条通话记录 if (!$record) { $record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId) ->order('id', 'desc') ->find(); } if (!$record) { self::setError('未找到通话记录'); return false; } $prev = []; if (!empty($record->recording_urls)) { $prev = json_decode((string)$record->recording_urls, true); if (!is_array($prev)) { $prev = []; } } $merged = array_values(array_unique(array_merge($prev, [$fileUrl]))); $record->save([ 'recording_urls' => json_encode($merged, JSON_UNESCAPED_UNICODE), 'recording_status' => 2, 'update_time' => time(), ]); return true; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } /** * @notes 医助进入当前诊单 TRTC 房间旁观(仅拉流,与医生端 doctor_{id} 账号体系一致) * @param array $params diagnosis_id、admin_id(当前登录后台用户) * @return array|false */ public static function getAssistantWatchRoomParams(array $params) { try { $diagnosisId = (int)($params['diagnosis_id'] ?? 0); $adminId = (int)($params['admin_id'] ?? 0); if ($diagnosisId <= 0 || $adminId <= 0) { self::setError('参数错误'); return false; } $diag = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find(); if (!$diag) { self::setError('诊单不存在'); return false; } if ((int)($diag['assistant_id'] ?? 0) !== $adminId) { self::setError('仅本诊单指派的医助可旁观该通话'); return false; } $record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId) ->where('status', 1) ->order('id', 'desc') ->find(); $roomRaw = $record ? trim((string)$record['room_id']) : ''; if ($roomRaw === '') { self::setError('当前无进行中的通话或尚未同步房间号,请待医生接通后再试'); return false; } $config = self::getTrtcConfig(); if (!$config) { self::setError('请先配置腾讯云TRTC参数'); return false; } $doctorUserId = 'doctor_' . $adminId; self::importDoctorAccountToIm($adminId, $doctorUserId); $userSig = self::generateUserSig($config['sdkAppId'], $config['secretKey'], $doctorUserId); if (!$userSig) { self::setError('生成签名失败'); return false; } $payload = [ 'sdkAppId' => (int)$config['sdkAppId'], 'userId' => $doctorUserId, 'userSig' => $userSig, 'patientName' => (string)($diag['patient_name'] ?? ''), ]; if (ctype_digit($roomRaw)) { $payload['roomId'] = (int)$roomRaw; } else { $payload['strRoomId'] = $roomRaw; } return $payload; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } private static function recordingStatusText(int $status): string { $map = [ 0 => '无录制', 1 => '录制中', 2 => '已生成', 3 => '录制失败', ]; return $map[$status] ?? '未知'; } /** * @notes 获取TRTC配置 * @return array|null */ private static function getTrtcConfig(): ?array { // 从配置文件或数据库读取 // 这里使用配置文件方式 $config = config('project.trtc'); if (empty($config['sdkAppId']) || empty($config['secretKey'])) { return null; } return $config; } /** * @notes 生成UserSig * @param int $sdkAppId * @param string $secretKey * @param string $userId * @param int $expire * @return string|false */ private static function generateUserSig(int $sdkAppId, string $secretKey, string $userId, int $expire = 86400) { try { // 使用官方的TLSSigAPIv2类生成UserSig $api = new \app\common\service\TLSSigAPIv2($sdkAppId, $secretKey); $userSig = $api->genUserSig($userId, $expire); return $userSig; } catch (\Exception $e) { \think\facade\Log::error('生成UserSig失败: ' . $e->getMessage()); return false; } } /** * @notes 格式化通话时长 * @param int $seconds * @return string */ private static function formatDuration(int $seconds): string { if ($seconds < 60) { return $seconds . '秒'; } elseif ($seconds < 3600) { $minutes = floor($seconds / 60); $secs = $seconds % 60; return $minutes . '分' . $secs . '秒'; } else { $hours = floor($seconds / 3600); $minutes = floor(($seconds % 3600) / 60); $secs = $seconds % 60; return $hours . '小时' . $minutes . '分' . $secs . '秒'; } } /** * @notes 为患者创建 TRTC 账号(自动调用) * @param int $patientId * @return bool */ private static function createPatientTrtcAccount(int $patientId): bool { try { $config = self::getTrtcConfig(); if (!$config || !$config['enable']) { // 如果 TRTC 未启用,不创建账号 return false; } $userId = 'patient_' . $patientId; // 检查是否已存在 $existingAccount = \app\common\model\tcm\PatientTrtc::where('patient_id', $patientId) ->where('delete_time', null) ->find(); // 生成 UserSig(长期有效,180天) $expireTime = 86400 * 180; $userSig = self::generateUserSig($config['sdkAppId'], $config['secretKey'], $userId, $expireTime); if (!$userSig) { return false; } $data = [ 'patient_id' => $patientId, 'user_id' => $userId, 'user_sig' => $userSig, 'sdk_app_id' => $config['sdkAppId'], 'expire_time' => time() + $expireTime, 'is_active' => 1 ]; if ($existingAccount) { // 更新现有账号 $existingAccount->save($data); \think\facade\Log::info('更新患者 TRTC 账号 - patient_id: ' . $patientId . ', user_id: ' . $userId); } else { // 创建新账号 \app\common\model\tcm\PatientTrtc::create($data); \think\facade\Log::info('创建患者 TRTC 账号 - patient_id: ' . $patientId . ', user_id: ' . $userId); } // 导入账号到腾讯云IM(用于TUICallKit) self::importAccountToTencentIm($patientId, $userId); return true; } catch (\Exception $e) { \think\facade\Log::error('创建患者 TRTC 账号失败 - patient_id: ' . $patientId . ', error: ' . $e->getMessage()); return false; } } /** * @notes 导入账号到腾讯云IM * @param int $patientId * @param string $userId * @return bool */ private static function importAccountToTencentIm(int $patientId, string $userId): bool { try { // 获取患者信息 $diagnosis = Diagnosis::where('patient_id', $patientId) ->order('id', 'desc') ->find(); $nick = $diagnosis ? $diagnosis->patient_name : '患者' . $patientId; // 调用IM服务导入账号 $imService = new \app\common\service\TencentImService(); $result = $imService->importAccount($userId, $nick); if ($result['success']) { \think\facade\Log::info('导入患者IM账号成功 - patient_id: ' . $patientId . ', user_id: ' . $userId . ', nick: ' . $nick); return true; } else { \think\facade\Log::warning('导入患者IM账号失败 - patient_id: ' . $patientId . ', user_id: ' . $userId . ', error: ' . $result['message']); return false; } } catch (\Exception $e) { \think\facade\Log::error('导入患者IM账号异常 - patient_id: ' . $patientId . ', user_id: ' . $userId . ', error: ' . $e->getMessage()); return false; } } /** * @notes 获取医助签名(供小程序调用) * @param int $patientId * @return array|bool */ public static function getDoctorSignature(int $patientId) { try { // 获取配置 $config = self::getTrtcConfig(); if (!$config) { self::setError('请先配置腾讯云TRTC参数'); return false; } // 医助userId(使用 doctor_ 前缀) $doctorUserId = 'doctor_' . $patientId; // 生成医助的 UserSig $userSig = self::generateUserSig( $config['sdkAppId'], $config['secretKey'], $doctorUserId ); if (!$userSig) { self::setError('生成签名失败'); return false; } // 确保医助账号已导入IM(使用正确的方法) self::importDoctorAccountToIm($patientId, $doctorUserId); \think\facade\Log::info('获取医助签名成功 - admin_id: ' . $patientId . ', user_id: ' . $doctorUserId); return [ 'sdkAppId' => (int)$config['sdkAppId'], 'userId' => $doctorUserId, 'userSig' => $userSig, 'expireTime' => 86400 ]; } catch (\Exception $e) { \think\facade\Log::error('获取医助签名失败 - admin_id: ' . $patientId . ', error: ' . $e->getMessage()); self::setError($e->getMessage()); return false; } } /** * @notes 获取患者签名(供小程序调用) * @param int $patientId * @return array|bool */ public static function getPatientSignature(int $patientId) { try { // 获取配置 $config = self::getTrtcConfig(); if (!$config) { self::setError('请先配置腾讯云TRTC参数'); return false; } // 患者userId $patientUserId = 'patient_' . $patientId; // 生成患者的 UserSig $userSig = self::generateUserSig( $config['sdkAppId'], $config['secretKey'], $patientUserId ); if (!$userSig) { self::setError('生成签名失败'); return false; } //添加患者资料 // 确保患者账号已导入IM self::ensurePatientImAccount($patientId, $patientUserId); \think\facade\Log::info('小程序获取患者签名成功 - patient_id: ' . $patientId . ', user_id: ' . $patientUserId); $data= Diagnosis::where('id', $patientId)->find(); return [ 'sdkAppId' => (int)$config['sdkAppId'], 'userId' => $patientUserId, 'userSig' => $userSig, 'data'=>$data, 'expireTime' => 86400 ]; } catch (\Exception $e) { \think\facade\Log::error('获取患者签名失败 - patient_id: ' . $patientId . ', error: ' . $e->getMessage()); self::setError($e->getMessage()); return false; } } /** * @notes 获取医助列表 * @param int $adminId 当前管理员 id(用于数据范围;0 表示不过滤) * @param array|null $adminInfo 当前管理员信息(用于数据范围;null 表示不过滤) * @return array */ public static function getAssistants(int $adminId = 0, ?array $adminInfo = null) { try { // 这里不要用 Admin 模型查询,否则会触发其 append(role_id/dept_id/jobs_id) // 每行再发多次查询,形成严重 N+1,统计页会被拖慢到十几秒。 $query = \think\facade\Db::name('admin') ->alias('a') ->join('admin_role ar', 'a.id = ar.admin_id') ->where('ar.role_id', 2) ->where('a.disable', 0) ->whereNull('a.delete_time'); // 数据范围:仅展示当前管理员可见的医助;null = 全部不过滤 if ($adminInfo !== null && $adminId > 0) { $visibleIds = \app\common\service\DataScope\DataScopeService::getVisibleAdminIds($adminId, $adminInfo); if ($visibleIds !== null) { if ($visibleIds === []) { return []; } $query->whereIn('a.id', $visibleIds); } } $assistants = $query ->field(['a.id', 'a.name', 'a.account']) ->order('a.id', 'asc') ->distinct(true) ->select() ->toArray(); if ($assistants === []) { return []; } $adminIds = array_column($assistants, 'id'); $adminDepts = \think\facade\Db::name('admin_dept') ->whereIn('admin_id', $adminIds) ->select() ->toArray(); $adminToDeptIds = []; foreach ($adminDepts as $ad) { $aid = (int) ($ad['admin_id'] ?? 0); if ($aid <= 0) { continue; } $adminToDeptIds[$aid][] = (int) ($ad['dept_id'] ?? 0); } $allDeptIds = []; foreach ($adminToDeptIds as $deptIdList) { foreach ($deptIdList as $did) { if ($did > 0) { $allDeptIds[$did] = true; } } } $allDeptIds = array_keys($allDeptIds); $deptNameMap = []; if ($allDeptIds !== []) { $deptNameMap = \think\facade\Db::name('dept') ->whereIn('id', $allDeptIds) ->whereNull('delete_time') ->column('name', 'id'); } foreach ($assistants as &$row) { $aid = (int) ($row['id'] ?? 0); $idsForAdmin = []; foreach ($adminToDeptIds[$aid] ?? [] as $did) { if ($did > 0) { $idsForAdmin[] = $did; } } $row['dept_ids'] = array_values(array_unique($idsForAdmin)); $names = []; foreach ($row['dept_ids'] as $did) { if (!empty($deptNameMap[$did])) { $names[] = (string) $deptNameMap[$did]; } } $row['dept_names'] = $names !== [] ? implode('、', array_unique($names)) : ''; } unset($row); return $assistants; } catch (\Exception $e) { \think\facade\Log::error('获取医助列表失败: ' . $e->getMessage()); return []; } } /** * @notes 获取医生列表 * @return array */ public static function getDoctors() { try { // 同 getAssistants,避免 Admin 模型 append 触发的 N+1。 $doctors = \think\facade\Db::name('admin') ->alias('a') ->join('admin_role ar', 'a.id = ar.admin_id') ->where('ar.role_id', 1) ->where('a.disable', 0) ->whereNull('a.delete_time') ->field(['a.id', 'a.name', 'a.account']) ->order('a.id', 'asc') ->distinct(true) ->select() ->toArray(); return $doctors; } catch (\Exception $e) { \think\facade\Log::error('获取医生列表失败: ' . $e->getMessage()); return []; } } /** * @notes 生成小程序码 * @param array $params * @return array|false */ public static function generateMiniProgramQrcode(array $params) { try { $diagnosisId = $params['diagnosis_id'] ?? ''; $doctorId = $params['doctor_id'] ?? ''; $patientId = $params['patient_id']; $shareUserId = $params['share_user_id']; // 视频登录页(pages/login/login)需要传挂号医生id,其余场景传诊单id $page = !empty($params['mini_program_path']) ? $params['mini_program_path'] : 'pages/order/monad/monad'; $sceneId = ($page === 'pages/login/login' && !empty($doctorId)) ? $doctorId : $diagnosisId; if (empty($sceneId)) { throw new \Exception($page === 'pages/login/login' ? '挂号医生ID不能为空' : '诊单ID不能为空'); } // 获取小程序配置 $config = self::getMiniProgramConfig(); if (!$config) { throw new \Exception('小程序配置未设置'); } // 小程序 onLoad 解析 scene,须与前端 parsePageParams 约定一致(微信 scene 总长勿超过 32 字符) $scene = "id={$sceneId}&sa={$shareUserId}&did={$doctorId}"; // 调用微信接口生成小程序码 $qrcodeUrl = self::generateWxQrcode($config, $page, $scene); if (!$qrcodeUrl) { throw new \Exception('生成小程序码失败: ' . self::getError()); } return [ 'qrcode_url' => $qrcodeUrl, 'diagnosis_id' => $diagnosisId, 'patient_id' => $patientId ]; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } /** * @notes 生成订单小程序码 */ public static function generateOrderQrcode(array $params) { try { $orderNo = $params['order_no'] ?? ''; if (empty($orderNo)) { throw new \Exception('订单号不能为空'); } // 查询订单ID(使用ID代替订单号,更短) $order = \app\common\model\Order::where('order_no', $orderNo)->find(); if (!$order) { throw new \Exception('订单不存在'); } $config = self::getMiniProgramConfig(); if (!$config) { throw new \Exception('小程序配置未设置'); } $page = 'pages/order/order'; // 微信小程序 scene 参数最大长度为32字符,使用订单ID代替订单号 $scene = "id={$order->id}"; $qrcodeUrl = self::generateWxQrcode($config, $page, $scene); if (!$qrcodeUrl) { throw new \Exception('生成小程序码失败: ' . self::getError()); } return [ 'qrcode_url' => $qrcodeUrl, 'order_no' => $orderNo ]; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } /** * @notes 获取小程序配置 * @return array|null */ private static function getMiniProgramConfig(): ?array { try { // 从配置表获取小程序配置 $appId = \app\common\service\ConfigService::get('mnp_setting', 'app_id', ''); $appSecret = \app\common\service\ConfigService::get('mnp_setting', 'app_secret', ''); if (empty($appId) || empty($appSecret)) { return null; } return [ 'app_id' => $appId, 'app_secret' => $appSecret ]; } catch (\Exception $e) { return null; } } /** * @notes 生成微信小程序码 * @param array $config * @param string $page * @param string $scene * @return string|false */ private static function generateWxQrcode(array $config, string $page, string $scene, int $retryCount = 0) { try { // 记录请求参数 \think\facade\Log::info('开始生成小程序码', [ 'page' => $page, 'scene' => $scene, 'app_id' => $config['app_id'], 'retry_count' => $retryCount ]); // 获取access_token(如果是重试,强制刷新) $forceRefresh = $retryCount > 0; $accessToken = self::getWxAccessToken($config['app_id'], $config['app_secret'], $forceRefresh); if (!$accessToken) { throw new \Exception('获取access_token失败: ' . self::getError()); } // 调用微信接口生成小程序码 $url = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={$accessToken}"; // 记录 scene 长度 \think\facade\Log::info('Scene参数信息', [ 'scene' => $scene, 'scene_length' => strlen($scene), 'scene_urlencode_length' => strlen(urlencode($scene)) ]); $data = [ 'scene' => $scene, 'page' => $page, 'check_path' => false, 'env_version' => 'release', 'width' => 280 ]; $response = self::httpPost($url, json_encode($data)); // 检查是否返回错误(JSON格式) $result = json_decode($response, true); if (is_array($result) && isset($result['errcode']) && $result['errcode'] != 0) { $errorMsg = "生成小程序码失败: errcode={$result['errcode']}, errmsg=" . ($result['errmsg'] ?? '未知错误'); \think\facade\Log::error($errorMsg); // 如果是AccessToken错误且未重试过,自动重试一次 if (($result['errcode'] == 40001 || $result['errcode'] == 42001) && $retryCount < 1) { \think\facade\Log::info('AccessToken失效,自动重试(第' . ($retryCount + 1) . '次)'); // 清除缓存 $cacheKey = 'wx_access_token_' . $config['app_id']; cache($cacheKey, null); // 递归调用,重试一次 return self::generateWxQrcode($config, $page, $scene, $retryCount + 1); } throw new \Exception($errorMsg); } // 如果不是JSON错误,说明返回的是图片二进制数据 // 保存图片 $filename = 'qrcode_' . $config['app_id'] . '_' . md5($scene) . '_' . time() . '.png'; $savePath = 'uploads/qrcode/' . date('Ymd') . '/'; $fullPath = public_path() . $savePath; if (!is_dir($fullPath)) { mkdir($fullPath, 0755, true); } $saved = file_put_contents($fullPath . $filename, $response); if (!$saved) { throw new \Exception('保存二维码图片失败'); } $qrcodeUrl = request()->domain() . '/' . $savePath . $filename; \think\facade\Log::info('小程序码生成成功', ['url' => $qrcodeUrl, 'retry_count' => $retryCount]); return $qrcodeUrl; } catch (\Exception $e) { \think\facade\Log::error('generateWxQrcode异常: ' . $e->getMessage()); self::setError($e->getMessage()); return false; } } /** * @notes 获取微信access_token * @param string $appId * @param string $appSecret * @param bool $forceRefresh 是否强制刷新(不使用缓存) * @return string|false */ private static function getWxAccessToken(string $appId, string $appSecret, bool $forceRefresh = false) { try { $cacheKey = 'wx_access_token_' . $appId; // 如果不是强制刷新,尝试从缓存获取 if (!$forceRefresh) { $accessToken = cache($cacheKey); if ($accessToken) { \think\facade\Log::info('使用缓存的AccessToken'); return $accessToken; } } else { \think\facade\Log::info('强制刷新AccessToken,清除缓存'); cache($cacheKey, null); } // 从微信服务器获取 $url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={$appId}&secret={$appSecret}"; $response = self::httpGet($url); // 记录原始响应用于调试 \think\facade\Log::info('微信AccessToken响应: ' . $response); $result = json_decode($response, true); // 检查是否有错误 if (isset($result['errcode']) && $result['errcode'] != 0) { $errorMsg = "获取AccessToken失败: errcode={$result['errcode']}, errmsg=" . ($result['errmsg'] ?? '未知错误'); \think\facade\Log::error($errorMsg); throw new \Exception($errorMsg); } if (isset($result['access_token'])) { // 缓存access_token,有效期7000秒(微信官方7200秒,提前200秒过期) cache($cacheKey, $result['access_token'], 7000); \think\facade\Log::info('AccessToken获取成功并已缓存'); return $result['access_token']; } throw new \Exception('获取access_token失败: 响应中没有access_token字段'); } catch (\Exception $e) { \think\facade\Log::error('getWxAccessToken异常: ' . $e->getMessage()); self::setError($e->getMessage()); return false; } } /** * @notes HTTP GET请求 * @param string $url * @return string|false */ private static function httpGet(string $url) { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); $response = curl_exec($ch); curl_close($ch); return $response; } /** * @notes HTTP POST请求 * @param string $url * @param string $data * @return string|false */ private static function httpPost(string $url, string $data) { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, $data); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); $response = curl_exec($ch); curl_close($ch); return $response; } /** * @notes 获取诊单详情并记录查看 * @param array $params * @return array|false */ public static function diagnosisDetailWithRecord(array $params) { try { $diagnosisId = $params['id']; $userId = $params['user_id'] ?? 0; $shareUserId = $params['share_user_id'] ?? 0; // 获取诊单详情 $diagnosis = self::detail(['id' => $diagnosisId,'user_id' => $userId]); if (!$diagnosis) { throw new \Exception('诊单不存在'); } // 记录查看行为 // self::recordDiagnosisView([ // 'user_id' => $userId, // 'diagnosis_id' => $diagnosisId, // 'patient_id' => $diagnosis['patient_id'] ?? 0, // 'share_user_id' => $shareUserId // ]); return $diagnosis; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } /** * @notes 记录诊单查看 * @param array $params * @return bool */ private static function recordDiagnosisView(array $params) { try { $userId = $params['user_id']; $diagnosisId = $params['diagnosis_id']; $patientId = $params['patient_id']; $shareUserId = $params['share_user_id']; // 查找是否已有记录 $record = \app\common\model\DiagnosisViewRecord::where([ 'user_id' => $userId, 'diagnosis_id' => $diagnosisId ])->find(); $now = time(); if ($record) { // 更新记录 $record->view_count = $record->view_count + 1; $record->last_view_time = $now; $record->update_time = $now; $record->save(); } else { // 创建新记录 \app\common\model\DiagnosisViewRecord::create([ 'user_id' => $userId, 'diagnosis_id' => $diagnosisId, 'patient_id' => $patientId, 'share_user_id' => $shareUserId, 'view_count' => 1, 'first_view_time' => $now, 'last_view_time' => $now, 'is_confirmed' => 0, 'create_time' => $now, 'update_time' => $now ]); } return true; } catch (\Exception $e) { \think\facade\Log::error('记录诊单查看失败: ' . $e->getMessage()); return false; } } /** * @notes 确认诊单 * @param array $params * @return bool */ public static function confirmDiagnosisRecord(array $params) { try { $userId = $params['user_id'] ?? 0; $diagnosisId = $params['id']; $shareUserId = $params['share_user_id'] ?? 0; if(!$userId){ throw new \Exception('请重新登录!'); } // 查找查看记录 $record = \app\common\model\DiagnosisViewRecord::where([ 'user_id' => $userId, 'diagnosis_id' => $diagnosisId ])->find(); $now = time(); if ($record) { // 更新确认状态 $record->is_confirmed = 1; $record->confirmed_time = $now; $record->update_time = $now; $record->save(); } else { // 如果没有查看记录,创建一条已确认的记录 $diagnosis = Diagnosis::find($diagnosisId); if (!$diagnosis) { throw new \Exception('诊单不存在'); } // 再次检查是否存在记录,防止并发创建重复记录 $existingRecord = \app\common\model\DiagnosisViewRecord::where([ 'user_id' => $userId, 'diagnosis_id' => $diagnosisId ])->find(); if (!$existingRecord) { \app\common\model\DiagnosisViewRecord::create([ 'user_id' => $userId, 'diagnosis_id' => $diagnosisId, 'patient_id' => $diagnosis->patient_id ?? 0, 'share_user_id' => $shareUserId, 'view_count' => 1, 'first_view_time' => $now, 'last_view_time' => $now, 'is_confirmed' => 1, 'confirmed_time' => $now, 'create_time' => $now, 'update_time' => $now ]); } } return true; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } /** * @notes 获取就诊卡列表(供小程序调用) * @param int $patientId * @return array|false */ public static function getCardList(int $user_id) { try { if (!$user_id) { self::setError('用户ID不能为空'); return false; } // 从诊断查看记录表中获取该用户查看过的诊断ID列表 $viewRecords = \think\facade\Db::name('diagnosis_view_records') ->where('user_id', $user_id) ->where('delete_time', null) ->column('diagnosis_id'); if (empty($viewRecords)) { return []; } // 根据诊断ID列表查询诊单详情 $cardList = Diagnosis::whereIn('id', $viewRecords) ->where('delete_time', null) ->field([ 'id', 'patient_id', 'patient_name', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'status', 'create_time', 'update_time' ]) ->order('create_time', 'desc') ->select() ->toArray(); // 处理数据格式 foreach ($cardList as &$card) { // 转换诊断日期为时间戳和格式化文本 if ($card['diagnosis_date']) { $card['diagnosis_date_text'] = date('Y-m-d', $card['diagnosis_date']); } else { $card['diagnosis_date_text'] = '-'; } // 添加性别描述 $card['gender_desc'] = $card['gender'] == 1 ? '男' : '女'; // 添加状态描述 $card['status_desc'] = $card['status'] == 1 ? '启用' : '禁用'; // 格式化创建时间 if ($card['create_time']) { $card['create_time'] = date('Y-m-d H:i:s', $card['create_time']); } // 格式化更新时间 if ($card['update_time']) { $card['update_time'] = date('Y-m-d H:i:s', $card['update_time']); } } return $cardList; } catch (\Exception $e) { \think\facade\Log::error('获取就诊卡列表失败 - user_id: ' . $user_id . ', error: ' . $e->getMessage()); self::setError($e->getMessage()); return false; } } /** * @notes 新建就诊卡(供小程序调用) * patient_id 可选:有则复用(同患者多张卡),无则自动生成(新患者首张卡) * @param array $params * @return array|bool 成功返回 ['id'=>诊单ID, 'patient_id'=>患者ID],失败返回false */ public static function addCard(array $params) { try { $userId = $params['user_id'] ?? 0; unset($params['user_id']); // 诊单表无此字段,仅用于创建 view_record $patientId = $params['patient_id'] ?? 0; if (!$patientId) { $params['patient_id'] = self::generatePatientId(); } $params['status'] = 1; // 处理既往史数组 if (isset($params['past_history']) && is_array($params['past_history'])) { $params['past_history'] = implode(',', $params['past_history']); } $multiSelectFields = [ 'diet_condition', 'body_feeling', 'sleep_condition', 'eye_condition', 'head_feeling', 'sweat_condition', 'skin_condition', 'urine_condition', 'stool_condition', 'kidney_condition', 'local_hospital_diagnosis' ]; foreach ($multiSelectFields as $field) { if (isset($params[$field]) && is_array($params[$field])) { $params[$field] = implode(',', $params[$field]); } } if (isset($params['tongue_images']) && is_array($params['tongue_images'])) { $params['tongue_images'] = json_encode($params['tongue_images'], JSON_UNESCAPED_UNICODE); } if (isset($params['report_files']) && is_array($params['report_files'])) { $params['report_files'] = json_encode($params['report_files'], JSON_UNESCAPED_UNICODE); } if (isset($params['diagnosis_date'])) { $params['diagnosis_date'] = is_numeric($params['diagnosis_date']) ? $params['diagnosis_date'] : strtotime($params['diagnosis_date']); } $model = Diagnosis::create($params); self::createPatientTrtcAccount($model->patient_id); // 关联 diagnosis_view_records,便于用户通过 User::with('diagnosis') 获取就诊卡 if ($userId) { $now = time(); \app\common\model\DiagnosisViewRecord::create([ 'user_id' => $userId, 'diagnosis_id' => $model->id, 'patient_id' => $model->patient_id, 'share_user_id' => 0, 'view_count' => 1, 'first_view_time' => $now, 'last_view_time' => $now, 'is_confirmed' => 0, 'create_time' => $now, 'update_time' => $now ]); } return ['id' => $model->id, 'patient_id' => $model->patient_id]; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } /** * @notes 更新就诊卡(供小程序调用) * @param array $params * @return bool */ public static function updateCard(array $params) { try { $diagnosisId = $params['id'] ?? 0; $patientId = $params['patient_id'] ?? 0; if (!$diagnosisId) { self::setError('诊单ID不能为空'); return false; } if (!$patientId) { self::setError('患者ID不能为空'); return false; } // 获取诊单信息 $diagnosis = Diagnosis::find($diagnosisId); if (!$diagnosis) { self::setError('诊单不存在'); return false; } // 权限验证:只能修改自己的诊单 if ($diagnosis->patient_id != $patientId) { \think\facade\Log::warning('非法修改诊单 - diagnosis_id: ' . $diagnosisId . ', patient_id: ' . $patientId . ', actual_patient_id: ' . $diagnosis->patient_id); self::setError('无权限修改此诊单'); return false; } // 准备更新数据 $updateData = [ 'patient_name' => $params['patient_name'] ?? '', 'gender' => $params['gender'] ?? 1, 'age' => !empty($params['age']) ? intval($params['age']) : 0, 'id_card' => $params['id_card'] ?? '', 'height' => !empty($params['height']) ? floatval($params['height']) : null, 'weight' => !empty($params['weight']) ? floatval($params['weight']) : null, 'systolic_pressure' => !empty($params['systolic_pressure']) ? intval($params['systolic_pressure']) : null, 'diastolic_pressure' => !empty($params['diastolic_pressure']) ? intval($params['diastolic_pressure']) : null, 'fasting_blood_sugar' => !empty($params['fasting_blood_sugar']) ? floatval($params['fasting_blood_sugar']) : null, 'diabetes_discovery_year' => isset($params['diabetes_discovery_year']) ? (trim((string) $params['diabetes_discovery_year']) !== '' ? trim((string) $params['diabetes_discovery_year']) : null) : null, 'local_hospital_diagnosis' => $params['local_hospital_diagnosis'] ?? '', 'local_hospital_name' => $params['local_hospital_name'] ?? '', 'local_hospital_visit_date' => $params['local_hospital_visit_date'] ?? '', 'diagnosis_type' => $params['diagnosis_type'] ?? '', 'syndrome_type' => $params['syndrome_type'] ?? '', 'diabetes_type' => $params['diabetes_type'] ?? '', // 现病史字段 'appetite' => $params['appetite'] ?? '', 'water_intake' => $params['water_intake'] ?? '', 'diet_condition' => $params['diet_condition'] ?? '', 'weight_change' => $params['weight_change'] ?? '', 'body_feeling' => $params['body_feeling'] ?? '', 'sleep_condition' => $params['sleep_condition'] ?? '', 'eye_condition' => $params['eye_condition'] ?? '', 'head_feeling' => $params['head_feeling'] ?? '', 'sweat_condition' => $params['sweat_condition'] ?? '', 'skin_condition' => $params['skin_condition'] ?? '', 'urine_condition' => $params['urine_condition'] ?? '', 'stool_condition' => $params['stool_condition'] ?? '', 'kidney_condition' => $params['kidney_condition'] ?? '', 'fatty_liver_degree' => $params['fatty_liver_degree'] ?? '', // 既往史字段 'past_history' => $params['past_history'] ?? '', 'trauma_history' => isset($params['trauma_history']) ? intval($params['trauma_history']) : 0, 'surgery_history' => isset($params['surgery_history']) ? intval($params['surgery_history']) : 0, 'allergy_history' => isset($params['allergy_history']) ? intval($params['allergy_history']) : 0, 'family_history' => isset($params['family_history']) ? intval($params['family_history']) : 0, 'pregnancy_history' => isset($params['pregnancy_history']) ? intval($params['pregnancy_history']) : 0, // 临床信息 'symptoms' => $params['symptoms'] ?? '', 'tongue_coating' => $params['tongue_coating'] ?? '', 'tongue_images' => $params['tongue_images'] ?? '', 'report_files' => $params['report_files'] ?? '', 'pulse' => $params['pulse'] ?? '', 'treatment_principle' => $params['treatment_principle'] ?? '', 'prescription' => $params['prescription'] ?? '', 'doctor_advice' => $params['doctor_advice'] ?? '', 'remark' => $params['remark'] ?? '', 'update_time' => time() ]; // 处理诊断日期 if (!empty($params['diagnosis_date'])) { $updateData['diagnosis_date'] = strtotime($params['diagnosis_date']); } elseif (!empty($params['local_hospital_visit_date'])) { // 如果没有diagnosis_date但有local_hospital_visit_date,则使用local_hospital_visit_date $updateData['diagnosis_date'] = strtotime($params['local_hospital_visit_date']); } // 执行更新 $diagnosis->save($updateData); \think\facade\Log::info('诊单更新成功 - diagnosis_id: ' . $diagnosisId . ', patient_id: ' . $patientId); return true; } catch (\Exception $e) { \think\facade\Log::error('更新诊单失败 - error: ' . $e->getMessage()); self::setError($e->getMessage()); return false; } } /** * @notes 获取患者的企微信息(基于会话内容存档接口) */ public static function getWechatExternalContact(int $patientId) { try { $diagnosis = \app\common\model\tcm\Diagnosis::where('patient_id', $patientId) ->field(['id', 'patient_name', 'phone', 'external_userid']) ->findOrEmpty(); if ($diagnosis->isEmpty()) { $diagnosis = \app\common\model\tcm\Diagnosis::where('id', $patientId) ->field(['id', 'patient_name', 'phone', 'external_userid']) ->findOrEmpty(); } if ($diagnosis->isEmpty()) { self::setError('未找到患者信息'); return false; } $corpId = env('work_wechat.corp_id', ''); $msgauditSecret = env('work_wechat.msgaudit_secret', ''); $result = [ 'patient_name' => $diagnosis->patient_name, 'phone' => $diagnosis->phone, 'external_userid' => $diagnosis->external_userid ?? '', 'msgaudit_enabled' => !empty($corpId) && !empty($msgauditSecret), 'permit_users' => [], 'msgaudit_agree' => [], ]; if (empty($corpId) || empty($msgauditSecret)) { return $result; } $accessToken = self::getAccessToken($corpId, $msgauditSecret, 'msgaudit'); if (!$accessToken) { return $result; } // 获取开启会话存档的成员列表 $permitUsers = self::fetchMsgAuditPermitUsers($accessToken); $result['permit_users'] = $permitUsers; // 如果有 external_userid,检查同意存档情况 if (!empty($diagnosis->external_userid) && !empty($permitUsers)) { $agreeInfo = self::checkMsgAuditAgree($accessToken, $permitUsers, $diagnosis->external_userid); $result['msgaudit_agree'] = $agreeInfo; } return $result; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } // ========== 通用 access_token 获取 ========== private static function getAccessToken(string $corpId, string $secret, string $type = 'app') { $cacheKey = "work_wechat_{$type}_token_" . md5($corpId . $secret); $cached = \think\facade\Cache::get($cacheKey); if ($cached) return $cached; $url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={$corpId}&corpsecret={$secret}"; $response = self::wechatHttpGet($url); if (!$response || ($response['errcode'] ?? -1) != 0) { \think\facade\Log::error("获取企微access_token失败[{$type}]: " . json_encode($response)); return false; } \think\facade\Cache::set($cacheKey, $response['access_token'], $response['expires_in'] - 200); return $response['access_token']; } // ========== 会话内容存档 msgaudit 接口 ========== /** * @notes 获取会话内容存档开启成员列表 */ public static function getMsgAuditPermitUsers() { try { $corpId = env('work_wechat.corp_id', ''); $msgauditSecret = env('work_wechat.msgaudit_secret', ''); if (empty($corpId) || empty($msgauditSecret)) { self::setError('会话内容存档Secret未配置(MSGAUDIT_SECRET)'); return false; } $accessToken = self::getAccessToken($corpId, $msgauditSecret, 'msgaudit'); if (!$accessToken) { self::setError('获取会话内容存档access_token失败'); return false; } $ids = self::fetchMsgAuditPermitUsers($accessToken); return ['ids' => $ids]; } catch (\Exception $e) { self::setError($e->getMessage()); return false; } } private static function fetchMsgAuditPermitUsers(string $accessToken): array { $url = "https://qyapi.weixin.qq.com/cgi-bin/msgaudit/get_permit_user_list?access_token={$accessToken}"; $response = self::wechatHttpPost($url, '{}'); if (!$response || ($response['errcode'] ?? -1) != 0) { return []; } return $response['ids'] ?? []; } /** * @notes 检查单聊会话同意存档情况 */ private static function checkMsgAuditAgree(string $accessToken, array $permitUserIds, string $externalUserId) { $infoList = []; foreach ($permitUserIds as $userId) { $infoList[] = [ 'userid' => $userId, 'exteranalopenid' => $externalUserId, ]; } $url = "https://qyapi.weixin.qq.com/cgi-bin/msgaudit/check_single_agree?access_token={$accessToken}"; $response = self::wechatHttpPost($url, json_encode(['info' => $infoList])); if (!$response || ($response['errcode'] ?? -1) != 0) { return []; } return $response['agreeinfo'] ?? []; } // ========== HTTP 工具方法 ========== private static function wechatHttpGet(string $url) { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_TIMEOUT, 10); $result = curl_exec($ch); curl_close($ch); return $result ? json_decode($result, true) : null; } private static function wechatHttpPost(string $url, string $jsonBody) { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonBody); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); $result = curl_exec($ch); curl_close($ch); return $result ? json_decode($result, true) : null; } /** * @notes 医助理诊单统计(按部门维度,按人统计创建数量) * @param array $params start_time, end_time, days(可选,默认7) * @return array */ public static function assistantDiagnosisStats(array $params = [], int $adminId = 0, ?array $adminInfo = null) { $days = isset($params['days']) ? (int)$params['days'] : 7; // 数据范围:当前用户可见 admin id 集合;null 表示全部 $visibleAdminIds = ($adminInfo !== null && $adminId > 0) ? \app\common\service\DataScope\DataScopeService::getVisibleAdminIds($adminId, $adminInfo) : null; $endTime = !empty($params['end_time']) ? strtotime($params['end_time']) : time(); if ($days === 0) { $startTime = strtotime(date('Y-m-d')); } else { $days = $days > 0 ? min($days, 90) : 7; $startTime = !empty($params['start_time']) ? strtotime($params['start_time']) : strtotime("-{$days} days", $endTime); if ($startTime > $endTime) { $startTime = strtotime("-{$days} days", $endTime); } } $depts = \app\common\model\dept\Dept::where('status', 1) ->whereNull('delete_time') ->order('sort desc, id asc') ->column('name', 'id'); $assistantIds = \app\common\model\auth\AdminRole::where('role_id', 2) ->column('admin_id'); // 数据范围交集:仅展示「医助 ∩ 当前用户可见」 if ($visibleAdminIds !== null) { $assistantIds = array_values(array_intersect( array_map('intval', $assistantIds), array_map('intval', $visibleAdminIds) )); } if (empty($assistantIds)) { return [ 'date_range' => [date('Y-m-d', $startTime), date('Y-m-d', $endTime)], 'today_total' => 0, 'today_ranking' => [], 'departments' => [], 'ranking' => [], 'chart' => ['xAxis' => [], 'series' => []], ]; } $adminDepts = \app\common\model\auth\AdminDept::whereIn('admin_id', $assistantIds) ->select() ->toArray(); $adminToDepts = []; foreach ($adminDepts as $ad) { $adminId = $ad['admin_id']; if (!isset($adminToDepts[$adminId])) { $adminToDepts[$adminId] = []; } $adminToDepts[$adminId][] = (int)$ad['dept_id']; } $admins = \app\common\model\auth\Admin::whereIn('id', $assistantIds) ->whereNull('delete_time') ->column('name', 'id'); $countRows = Diagnosis::where('assistant_id', 'in', $assistantIds) ->where('create_time', 'between', [$startTime, $endTime]) ->whereNull('delete_time') ->field('assistant_id, count(*) as cnt') ->group('assistant_id') ->select() ->toArray(); $counts = []; foreach ($countRows as $row) { $counts[$row['assistant_id']] = (int)$row['cnt']; } $todayStart = strtotime(date('Y-m-d')); $todayEnd = time(); $todayCountRows = Diagnosis::where('assistant_id', 'in', $assistantIds) ->where('create_time', 'between', [$todayStart, $todayEnd]) ->whereNull('delete_time') ->field('assistant_id, count(*) as cnt') ->group('assistant_id') ->select() ->toArray(); $todayCounts = []; foreach ($todayCountRows as $row) { $todayCounts[$row['assistant_id']] = (int)$row['cnt']; } $todayTotal = array_sum($todayCounts); $todayRanking = []; foreach ($assistantIds as $adminId) { $cnt = (int)($todayCounts[$adminId] ?? 0); $todayRanking[] = [ 'id' => (int)$adminId, 'name' => $admins[$adminId] ?? '未知', 'count' => $cnt, ]; } usort($todayRanking, fn($a, $b) => $b['count'] <=> $a['count']); $todayRanking = array_values(array_filter($todayRanking, fn($a) => $a['count'] > 0)); // 数据范围启用时(非 root/全部范围),无可见成员的部门不渲染 $hideEmptyDept = $visibleAdminIds !== null; $deptData = []; $assignedAdminIds = []; foreach ($depts as $deptId => $deptName) { $assistants = []; foreach ($adminToDepts as $adminId => $deptIds) { if (!in_array((int)$deptId, $deptIds)) { continue; } $assignedAdminIds[] = $adminId; $cnt = (int)($counts[$adminId] ?? 0); $assistants[] = [ 'id' => (int)$adminId, 'name' => $admins[$adminId] ?? '未知', 'count' => $cnt, ]; } if ($hideEmptyDept && $assistants === []) { continue; } $deptData[] = [ 'dept_id' => (int)$deptId, 'dept_name' => $deptName, 'assistants' => $assistants, 'total' => array_sum(array_column($assistants, 'count')), ]; } $unassignedIds = array_diff($assistantIds, array_unique($assignedAdminIds)); if (!empty($unassignedIds)) { $assistants = []; foreach ($unassignedIds as $adminId) { $cnt = (int)($counts[$adminId] ?? 0); $assistants[] = [ 'id' => (int)$adminId, 'name' => $admins[$adminId] ?? '未知', 'count' => $cnt, ]; } $deptData[] = [ 'dept_id' => 0, 'dept_name' => '未分配部门', 'assistants' => $assistants, 'total' => array_sum(array_column($assistants, 'count')), ]; } $ranking = []; foreach ($assistantIds as $adminId) { $ranking[] = [ 'id' => (int)$adminId, 'name' => $admins[$adminId] ?? '未知', 'count' => (int)($counts[$adminId] ?? 0), ]; } usort($ranking, fn($a, $b) => $b['count'] <=> $a['count']); $allAssistantNames = []; $allAssistantData = []; foreach ($ranking as $i => $a) { $allAssistantNames[] = ($i + 1) . '.' . $a['name']; $allAssistantData[] = $a['count']; } return [ 'date_range' => [date('Y-m-d', $startTime), date('Y-m-d', $endTime)], 'today_total' => $todayTotal, 'today_ranking' => $todayRanking, 'departments' => $deptData, 'ranking' => $ranking, 'chart' => [ 'xAxis' => $allAssistantNames, 'series' => [['name' => '诊单数', 'data' => $allAssistantData]], ], ]; } /** * @notes 诊单挂号 / 取消挂号 操作日志(后台) * * @return array> */ public static function guahaoLogList(int $diagnosisId): array { self::$error = ''; $exists = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->find(); if (!$exists) { self::setError('诊单不存在'); return []; } $logs = DiagnosisGuahaoLog::where('diagnosis_id', $diagnosisId) ->order('id', 'desc') ->limit(200) ->select() ->toArray(); foreach ($logs as &$r) { $t = (int) ($r['create_time'] ?? 0); $r['create_time_text'] = $t > 0 ? date('Y-m-d H:i:s', $t) : ''; $act = (string) ($r['action'] ?? ''); $r['action_desc'] = $act === 'cancel' ? '取消挂号' : ($act === 'create' ? '挂号' : $act); } unset($r); return $logs; } }