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]); } } // 图片字段迁移至 doctor_note,从 params 中提取后再写入备注表 $newTongueImages = isset($params['tongue_images']) && is_array($params['tongue_images']) ? $params['tongue_images'] : []; $newReportFiles = isset($params['report_files']) && is_array($params['report_files']) ? $params['report_files'] : []; unset($params['tongue_images'], $params['report_files']); // 处理诊断日期 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) 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']); } } if (empty($params['create_source'])) { $params['create_source'] = 'admin'; } $model = Diagnosis::create($params); // 图片写入 doctor_note 表 if (!empty($newTongueImages) || !empty($newReportFiles)) { DoctorNoteLogic::addOrAppend([ 'diagnosis_id' => (int) $model->id, 'tongue_images' => $newTongueImages, 'report_files' => $newReportFiles, ]); } // 自动为患者创建 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('id') ?? 10000000; // 返回下一个患者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]); } } // 图片字段已迁移至 doctor_note 表管理,不再写入 tcm_diagnosis unset($params['tongue_images'], $params['report_files']); // 处理诊断日期 if (isset($params['diagnosis_date'])) { $params['diagnosis_date'] = strtotime($params['diagnosis_date']); } if (array_key_exists('show_card', $params)) { $params['show_card'] = (int) $params['show_card'] === 0 ? 0 : 1; } 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] = []; } } // 从 doctor_note 聚合图片(已迁移至备注表统一管理) $noteImages = DoctorNoteLogic::getAggregatedImages((int) $diagnosis['id']); if (!empty($noteImages['tongue_images']) || !empty($noteImages['report_files'])) { $diagnosis['tongue_images'] = $noteImages['tongue_images']; $diagnosis['report_files'] = $noteImages['report_files']; } else { // Fallback: 未迁移的旧数据仍从 tcm_diagnosis 读取 if (!empty($diagnosis['tongue_images'])) { $files = json_decode($diagnosis['tongue_images'], true); if (!is_array($files)) { $files = array_filter(explode(',', $diagnosis['tongue_images'])) ?: []; } $diagnosis['tongue_images'] = array_map(function($url) { return empty($url) ? $url : FileService::getFileUrl($url); }, $files); } else { $diagnosis['tongue_images'] = []; } if (!empty($diagnosis['report_files'])) { $files = json_decode($diagnosis['report_files'], true); if (!is_array($files)) { $files = array_filter(explode(',', $diagnosis['report_files'])) ?: []; } $diagnosis['report_files'] = array_map(function($url) { return empty($url) ? $url : FileService::getFileUrl($url); }, $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; } } /** * 诊单下用于指派日志快照的「最新」业务单:按 create_time 最新,其次 id(避免误用更早下单但 id 更大的记录)。 * * @return array{creator_id: int, create_time: int}|null */ private static function getLatestPrescriptionOrderSnapshotForDiagnosis(int $diagnosisId): ?array { if ($diagnosisId <= 0) { return null; } $row = Db::name('tcm_prescription_order') ->where('diagnosis_id', $diagnosisId) ->whereNull('delete_time') ->order('create_time', 'desc') ->order('id', 'desc') ->field(['creator_id', 'create_time']) ->find(); if ($row === null || $row === []) { return null; } return [ 'creator_id' => (int) ($row['creator_id'] ?? 0), 'create_time' => (int) ($row['create_time'] ?? 0), ]; } /** * 诊单 current assistant_id 为 0 时,从最近一条指派日志推断「原医助」(如发货释放后:上一条多为 from=X、to=0)。 * - 最近一条 to_assistant_id > 0:视为上一任持有人(库未同步时的兜底) * - 否则若 from_assistant_id > 0:视为释放/取消前的持有人 */ private static function inferPreviousAssistantIdFromLatestAssignLog(int $diagnosisId): int { if ($diagnosisId <= 0) { return 0; } $prev = Db::name('tcm_diagnosis_assign_log') ->where('diagnosis_id', $diagnosisId) ->order('id', 'desc') ->field(['from_assistant_id', 'to_assistant_id']) ->find(); if ($prev === null || $prev === []) { return 0; } $pTo = (int) ($prev['to_assistant_id'] ?? 0); $pFrom = (int) ($prev['from_assistant_id'] ?? 0); if ($pTo > 0) { return $pTo; } if ($pFrom > 0) { return $pFrom; } return 0; } public static function markAssignRead(int $diagnosisId, int $adminId): void { if ($diagnosisId <= 0 || $adminId <= 0) { return; } Db::name('tcm_diagnosis') ->where('id', $diagnosisId) ->where('assistant_id', $adminId) ->whereNull('assign_read_at') ->whereNull('delete_time') ->update(['assign_read_at' => time()]); } /** * @notes 指派医助(每次成功更新写入一条操作记录,批量指派为多次接口调用各记一条)。 * **原医助**:优先诊单 `assistant_id`;若为 0 且本次为指派到某人,则从**最近一条**指派日志推断(覆盖发货释放后库为 0、业务上仍有「前任」医助的情况)。 * @param array $params * @return bool */ public static function assign(array $params): bool { try { $id = (int) ($params['id'] ?? 0); $toAssistantId = (int) ($params['assistant_id'] ?? 0); $isInherit = (int) ($params['is_inherit'] ?? 0) === 1 && $toAssistantId > 0 ? 1 : 0; if ($id <= 0) { self::setError('诊单不存在'); return false; } $now = time(); $poSnap = self::getLatestPrescriptionOrderSnapshotForDiagnosis($id); $relatedPoCreatorId = 0; $relatedPoCreateTime = 0; if ($poSnap !== null) { $relatedPoCreatorId = $poSnap['creator_id']; $relatedPoCreateTime = $poSnap['create_time']; } if ($relatedPoCreateTime <= 0) { $relatedPoCreateTime = $now; $relatedPoCreatorId = 0; } Db::startTrans(); try { // 事务内 FOR UPDATE 直读库:确保「原医助」与 tcm_diagnosis.assistant_id 一致,避免模型缓存或并发覆盖 $diagLock = Db::name('tcm_diagnosis') ->where('id', $id) ->whereNull('delete_time') ->lock(true) ->field(['id', 'assistant_id']) ->find(); if ($diagLock === null || $diagLock === []) { Db::rollback(); self::setError('诊单不存在'); return false; } $fromAssistantId = (int) ($diagLock['assistant_id'] ?? 0); if ($fromAssistantId <= 0 && $toAssistantId > 0) { $fromAssistantId = self::inferPreviousAssistantIdFromLatestAssignLog($id); } Db::name('tcm_diagnosis') ->where('id', $id) ->whereNull('delete_time') ->update([ 'assistant_id' => $toAssistantId, 'assign_read_at' => $toAssistantId > 0 ? null : 0, ]); $req = request(); $admin = $req->adminInfo ?? []; // Db::insert 直写:与 assignLogList 一致,避免模型 allowField/schema 丢列 Db::name('tcm_diagnosis_assign_log')->insert([ '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() ?? ''), 'related_po_creator_id' => $relatedPoCreatorId, 'related_po_create_time' => $relatedPoCreateTime, 'is_inherit' => $isInherit, 'create_time' => $now, ]); } catch (\Throwable $e) { Db::rollback(); self::setError($e->getMessage()); return false; } Db::commit(); return true; } catch (\Exception $e) { Db::rollback(); self::setError($e->getMessage()); return false; } } /** * @notes 诊单指派医助操作记录列表 */ public static function assignLogList(int $diagnosisId): array { // Db 直查:避免 AssignLog 模型未维护 schema / fields_strict 时 toArray 丢列,导致前后端「医助」为空 $rows = Db::name('tcm_diagnosis_assign_log') ->where('diagnosis_id', $diagnosisId) ->order('id', 'desc') ->select() ->toArray(); $adminIds = []; foreach ($rows as $r) { $fid = (int) ($r['from_assistant_id'] ?? 0); $tid = (int) ($r['to_assistant_id'] ?? 0); if ($fid > 0) { $adminIds[] = $fid; } if ($tid > 0) { $adminIds[] = $tid; } $rcp = (int) ($r['related_po_creator_id'] ?? 0); if ($rcp > 0) { $adminIds[] = $rcp; } } $adminIds = array_values(array_unique(array_filter($adminIds))); $nameMap = []; if ($adminIds !== []) { $nameMap = 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']) : ''; $rcpId = (int) ($r['related_po_creator_id'] ?? 0); $r['related_po_creator_name'] = $rcpId > 0 ? (string) ($nameMap[$rcpId] ?? ('ID:' . $rcpId)) : ''; $rpct = (int) ($r['related_po_create_time'] ?? 0); $r['related_po_create_time_text'] = $rpct > 0 ? date('Y-m-d H:i:s', $rpct) : ''; $r['related_po_is_cleared_assistant_creator'] = ($rcpId > 0 && $fromId > 0 && $rcpId === $fromId) ? 1 : 0; $inherit = (int) ($r['is_inherit'] ?? 0); $r['is_inherit'] = $inherit; $r['is_inherit_text'] = $inherit === 1 ? '是' : '否'; } unset($r); return array_values($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 []; } $diagnosisId = (int)($diag['id'] ?? 0); // 增量优化:按 doctor_peer_account 维度取归档表最大时间作为 MinTime 起点, // 避免每次都从头拉取已归档的历史消息(节约腾讯云 admin_getroammsg 调用配额) $minTimeMap = []; if ($diagnosisId > 0) { $rows = ImChatMessage::field('doctor_peer_account, MAX(msg_time) AS max_time') ->where('diagnosis_id', $diagnosisId) ->group('doctor_peer_account') ->select() ->toArray(); foreach ($rows as $r) { $acct = (string)($r['doctor_peer_account'] ?? ''); if ($acct !== '') { $minTimeMap[$acct] = (int)($r['max_time'] ?? 0); } } } $imService = new \app\common\service\TencentImService(); $merged = []; foreach ($doctorAccounts as $docAccount) { // 已归档过:从最大已知 msg_time 起拉(含等于以兜底边界,配合 INSERT IGNORE 去重) $minTime = isset($minTimeMap[$docAccount]) ? max(0, $minTimeMap[$docAccount]) : 0; $batch = self::pullAllRoamMessages($imService, $docAccount, $patientImId, $minTime); 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, int $minTime = 0): array { $out = []; $lastKey = null; $lastTime = null; $guard = 0; $maxPages = 80; do { // 单页失败重试:避免网络抖动 / 限频导致整段会话被丢弃 $res = null; $attempt = 0; while ($attempt < 3) { $res = $svc->adminGetRoamMsg($operator, $peer, 100, $minTime, 4294967295, $lastKey, $lastTime); if (!empty($res['success'])) { break; } $attempt++; \think\facade\Log::warning('IM漫游消息拉取失败(待重试)', [ 'operator' => $operator, 'peer' => $peer, 'attempt' => $attempt, 'error' => $res['error'] ?? '', 'code' => $res['rawErrorCode'] ?? 0, 'page' => $guard + 1, ]); if ($attempt < 3) { usleep(300000); // 300ms 退避 } } if (empty($res['success'])) { \think\facade\Log::error('IM漫游消息拉取失败(重试耗尽,本轮中断)', [ 'operator' => $operator, 'peer' => $peer, 'page' => $guard + 1, 'fetched_so_far' => count($out), '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 >= $maxPages) { // 触顶安全网:增量优化后通常拉不满 80 页,触顶意味着首次全量或会话异常多 \think\facade\Log::warning('IM漫游消息拉取触发分页上限(可能未拉完)', [ 'operator' => $operator, 'peer' => $peer, 'min_time' => $minTime, 'pages_fetched' => $guard, 'fetched_so_far' => count($out), 'last_msg_time' => $lastTime, ]); 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'] ?? '')); $callRecordId = (int)($params['call_record_id'] ?? 0); if ($diagnosisId <= 0 || $adminId <= 0 || $fileUrl === '') { self::setError('参数错误'); return false; } $record = self::resolveCallRecordForAttachment($diagnosisId, $adminId, $callRecordId); 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 诊单回放视频分片上传,完成后显式关联到指定通话记录 * @param array $params * @return array|false */ public static function uploadCallRecording(array $params) { $uploadDir = ''; $mergedPath = ''; try { $diagnosisId = (int)($params['diagnosis_id'] ?? 0); $callRecordId = (int)($params['call_record_id'] ?? 0); $adminId = (int)($params['admin_id'] ?? 0); $uploadId = trim((string)($params['upload_id'] ?? '')); $fileName = trim((string)($params['file_name'] ?? '')); $fileSize = (int)($params['file_size'] ?? 0); $chunkIndex = (int)($params['chunk_index'] ?? -1); $chunkTotal = (int)($params['chunk_total'] ?? 0); if ( $diagnosisId <= 0 || $adminId <= 0 || $uploadId === '' || $fileName === '' || $fileSize <= 0 || $chunkIndex < 0 || $chunkTotal <= 0 ) { self::setError('上传参数不完整'); return false; } $ext = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION)); if ($ext === '' || !in_array($ext, config('project.file_video'), true)) { self::setError('视频格式不支持'); return false; } if ($callRecordId <= 0) { $record = self::createSyntheticCallRecord($diagnosisId, $adminId, $fileName); $callRecordId = (int)($record['id'] ?? 0); } else { $record = self::resolveCallRecordForAttachment($diagnosisId, $adminId, $callRecordId, false); } if (!$record) { self::setError('通话记录不存在'); return false; } $chunkFile = request()->file('file'); if (!$chunkFile) { self::setError('未找到上传分片'); return false; } $uploadDir = self::callRecordingChunkDir($uploadId); if (!is_dir($uploadDir) && !mkdir($uploadDir, 0777, true) && !is_dir($uploadDir)) { self::setError('创建上传目录失败'); return false; } file_put_contents($uploadDir . DIRECTORY_SEPARATOR . 'meta.json', json_encode([ 'diagnosis_id' => $diagnosisId, 'call_record_id' => $callRecordId, 'admin_id' => $adminId, 'file_name' => $fileName, 'file_size' => $fileSize, 'chunk_total' => $chunkTotal, ], JSON_UNESCAPED_UNICODE)); $chunkPath = $uploadDir . DIRECTORY_SEPARATOR . self::callRecordingChunkName($chunkIndex); $moved = $chunkFile->move($uploadDir, basename($chunkPath)); if (!$moved) { self::setError($chunkFile->getError() ?: '保存分片失败'); return false; } $uploadedCount = self::countUploadedCallRecordingChunks($uploadDir); if ($uploadedCount < $chunkTotal) { return [ 'completed' => false, 'uploaded_chunks' => $uploadedCount, 'chunk_total' => $chunkTotal, 'call_record_id' => $callRecordId, ]; } $mergedPath = $uploadDir . DIRECTORY_SEPARATOR . 'merged.' . $ext; self::mergeCallRecordingChunks($uploadDir, $mergedPath, $chunkTotal); if (!is_file($mergedPath) || filesize($mergedPath) <= 0) { self::setError('合并后的文件无效'); return false; } $uploadResult = self::storeMergedCallRecording($mergedPath, $fileName, $adminId); if (empty($uploadResult['uri'])) { self::setError('保存视频失败'); return false; } $attached = self::attachLocalCallRecording([ 'diagnosis_id' => $diagnosisId, 'call_record_id' => $callRecordId, 'admin_id' => $adminId, 'file_url' => (string)$uploadResult['uri'], ]); if (!$attached) { return false; } self::cleanupCallRecordingChunkDir($uploadDir); return [ 'completed' => true, 'uploaded_chunks' => $chunkTotal, 'chunk_total' => $chunkTotal, 'call_record_id' => $callRecordId, 'file_id' => $uploadResult['id'] ?? 0, 'file_url' => $uploadResult['uri'] ?? '', 'recording_status' => 2, ]; } catch (\Throwable $e) { if ($mergedPath !== '' && is_file($mergedPath)) { @unlink($mergedPath); } self::setError($e->getMessage()); return false; } } public static function createManualCallRecord(array $params) { $diagnosisId = (int)($params['diagnosis_id'] ?? 0); $adminId = (int)($params['admin_id'] ?? 0); if ($diagnosisId <= 0 || $adminId <= 0) { self::setError('参数错误'); return false; } $record = self::createSyntheticCallRecord($diagnosisId, $adminId, 'manual-upload'); if (!$record) { self::setError(self::getError() ?: '创建通话记录失败'); return false; } return [ 'id' => (int)($record['id'] ?? 0), 'diagnosis_id' => $diagnosisId, ]; } /** * @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列表查询诊单详情(show_card=0 的诊单不在统计端就诊卡列表中出现) $cardList = Diagnosis::whereIn('id', $viewRecords) ->where('delete_time', null) ->where('show_card', 1) ->field([ 'id', 'patient_id', 'patient_name', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'status', 'create_source', '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]); } } // 图片字段迁移至 doctor_note 表 $cardTongueImages = isset($params['tongue_images']) && is_array($params['tongue_images']) ? $params['tongue_images'] : []; $cardReportFiles = isset($params['report_files']) && is_array($params['report_files']) ? $params['report_files'] : []; unset($params['tongue_images'], $params['report_files']); 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); // 图片写入 doctor_note if (!empty($cardTongueImages) || !empty($cardReportFiles)) { DoctorNoteLogic::addOrAppend([ 'diagnosis_id' => (int) $model->id, 'tongue_images' => $cardTongueImages, 'report_files' => $cardReportFiles, ]); } // 关联 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'] ?? '', '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'])) { $updateData['diagnosis_date'] = strtotime($params['local_hospital_visit_date']); } // 执行更新 $diagnosis->save($updateData); // 图片写入 doctor_note 表 $cardTongueImages = isset($params['tongue_images']) && is_array($params['tongue_images']) ? $params['tongue_images'] : []; $cardReportFiles = isset($params['report_files']) && is_array($params['report_files']) ? $params['report_files'] : []; if (!empty($cardTongueImages) || !empty($cardReportFiles)) { DoctorNoteLogic::addOrAppend([ 'diagnosis_id' => (int) $diagnosisId, 'tongue_images' => $cardTongueImages, 'report_files' => $cardReportFiles, ]); } \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; } /** * @notes 二诊只读病例详情 * * 用途:医助从诊单列表「查看 / 双击」进入只读页(T1+T2),返回与接诊台同结构的 * 三块内容(appointment + diagnosis + 跟踪记录)。 * * 数据权限(与列表 lists 接口一致): * - 医助 (role_id=2):仅能访问 assistant_id == self 的诊单 * - DataScope 启用:assistant_id 须落在 visibleAdminIds * - 越权 → setError + 返回 [] * * @param array $params 至少含 id * @param int $adminId * @param array $adminInfo request->adminInfo * @return array */ public static function readonlyDetail(array $params, int $adminId, array $adminInfo): array { $diagnosisId = (int) ($params['id'] ?? 0); if ($diagnosisId <= 0) { self::setError('诊单 id 不能为空'); 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 []; } // 2) 诊单详情(含图片聚合等)+ 字典翻译 $diagnosis = self::detail(['id' => $diagnosisId]); if (empty($diagnosis)) { self::setError('诊单数据为空'); return []; } $diagnosis = AppointmentLogic::enrichDiagnosisLabels($diagnosis); // 3) 最近一条挂号(PatientInfoCard 显示用,无挂号则给默认空对象) $appointment = self::buildLatestAppointmentForReadonly((int) ($diagnosis['id'] ?? $diagnosisId), $diagnosis); // 4) 跟踪信息(血糖血压 / 饮食 / 运动)改为前端按窗口 lazy load, // 详见 DiagnosisController::trackingWindow。此处不再一次性返回。 // 5) 医生备注 $doctorNotes = DoctorNoteLogic::getByDiagnosis($diagnosisId); // 5.1) 跟踪备注(只读展示,按 note_date DESC) $trackingNotes = TrackingNoteLogic::getByDiagnosis($diagnosisId); // 6) 未服务天数:血糖 / 饮食 / 运动任一记录最近一条 record_date 为锚点 $maxBloodTs = (int) BloodRecord::where('diagnosis_id', $diagnosisId)->max('record_date'); $maxDietTs = (int) DietRecord::where('diagnosis_id', $diagnosisId)->max('record_date'); $maxExerciseTs = (int) ExerciseRecord::where('diagnosis_id', $diagnosisId)->max('record_date'); $maxRecordTs = max($maxBloodTs, $maxDietTs, $maxExerciseTs); $unservedDays = $maxRecordTs > 0 ? max(0, (int) floor((time() - $maxRecordTs) / 86400)) : null; $lastBloodRecordAt = $maxRecordTs > 0 ? date('Y-m-d', $maxRecordTs) : null; return [ 'appointment' => $appointment, 'diagnosis' => $diagnosis, 'doctor_notes' => $doctorNotes, 'tracking_notes' => $trackingNotes, 'unserved_days' => $unservedDays, 'last_blood_record_at' => $lastBloodRecordAt, ]; } /** * 取指定日期区间内的三类跟踪记录(血糖血压 / 饮食 / 运动),供 readonlyDetail 与 * 医生接诊台 reception 通过独立接口 lazy load。 * * 区间语义:闭区间 [startDate, endDate](Y-m-d),均不传则不限。 * * @return array{ * blood_records: array>, * diet_records: array>, * exercise_records: array>, * start_date: string, * end_date: string, * } */ public static function fetchTrackingWindow(int $diagnosisId, string $startDate = '', string $endDate = ''): array { $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), 'diet_records' => self::fetchDietRecordsForReadonly($diagnosisId, $sinceTs, $untilTs), 'exercise_records' => self::fetchExerciseRecordsForReadonly($diagnosisId, $sinceTs, $untilTs), 'start_date' => $startDate, 'end_date' => $endDate, ]; } /** * 拼一份「最近一条挂号」给只读页 PatientInfoCard 用;没有挂号则给默认空骨架。 * * @param int $diagnosisId * @param array $diagnosis 已字典翻译过的诊单数据 * @return array */ private static function buildLatestAppointmentForReadonly(int $diagnosisId, array $diagnosis): array { $apt = Appointment::where('patient_id', $diagnosisId) ->order('id', 'desc') ->find(); $patientName = (string) ($diagnosis['patient_name'] ?? ''); $patientPhone = (string) ($diagnosis['phone'] ?? ''); if (!$apt) { return [ 'id' => 0, 'patient_id' => $diagnosisId, 'patient_name' => $patientName, 'patient_phone' => $patientPhone, 'doctor_id' => 0, 'doctor_name' => '', 'assistant_id' => (int) ($diagnosis['assistant_id'] ?? 0), 'assistant_name' => self::resolveAssistantName((int) ($diagnosis['assistant_id'] ?? 0)), 'appointment_date' => '', 'appointment_time' => '', 'period' => '', 'status' => 0, 'status_desc' => '无挂号', 'has_prescription' => 0, 'remark' => '', ]; } $aptArr = $apt->toArray(); $aptArr['patient_name'] = $patientName; $aptArr['patient_phone'] = $patientPhone; // 医生姓名 $aptArr['doctor_name'] = ''; $docId = (int) ($aptArr['doctor_id'] ?? 0); if ($docId > 0) { $aptArr['doctor_name'] = (string) Admin::where('id', $docId)->value('name'); } $aptArr['assistant_id'] = (int) ($diagnosis['assistant_id'] ?? 0); $aptArr['assistant_name'] = self::resolveAssistantName((int) ($diagnosis['assistant_id'] ?? 0)); // status_desc 与现有列表口径保持简单映射 $statusMap = [ 0 => '未挂号', 1 => '已预约', 2 => '已取消', 3 => '已完成', 4 => '已过号', ]; $aptArr['status_desc'] = $statusMap[(int) ($aptArr['status'] ?? 0)] ?? ''; $aptArr['has_prescription'] = (int) ($diagnosis['has_prescription'] ?? 0); return $aptArr; } private static function resolveAssistantName(int $assistantId): string { if ($assistantId <= 0) { return ''; } return (string) Admin::where('id', $assistantId)->value('name'); } /** * @return array> */ private static function fetchBloodRecordsForReadonly(int $diagnosisId, int $sinceTs, int $untilTs = 0): array { $query = BloodRecord::where('diagnosis_id', $diagnosisId); if ($sinceTs > 0) { $query->where('record_date', '>=', $sinceTs); } if ($untilTs > 0) { $query->where('record_date', '<=', $untilTs); } $rows = $query->order('record_date', 'desc') ->order('record_time', 'desc') ->select() ->toArray(); // 给前端一个 Y-m-d 字符串方便按日期透视;保留原 record_date 整数戳作日期窗口比较 foreach ($rows as &$row) { $ts = (int) ($row['record_date'] ?? 0); $row['record_date_ts'] = $ts; $row['record_date'] = $ts > 0 ? date('Y-m-d', $ts) : ''; } return $rows; } /** * @return array> */ private static function fetchDietRecordsForReadonly(int $diagnosisId, int $sinceTs, int $untilTs = 0): array { $query = DietRecord::where('diagnosis_id', $diagnosisId); if ($sinceTs > 0) { $query->where('record_date', '>=', $sinceTs); } if ($untilTs > 0) { $query->where('record_date', '<=', $untilTs); } $rows = $query->order('record_date', 'desc')->select()->toArray(); foreach ($rows as &$row) { $ts = (int) ($row['record_date'] ?? 0); $row['record_date_ts'] = $ts; $row['record_date'] = $ts > 0 ? date('Y-m-d', $ts) : ''; } return $rows; } /** * @return array> */ private static function fetchExerciseRecordsForReadonly(int $diagnosisId, int $sinceTs, int $untilTs = 0): array { $query = ExerciseRecord::where('diagnosis_id', $diagnosisId); if ($sinceTs > 0) { $query->where('record_date', '>=', $sinceTs); } if ($untilTs > 0) { $query->where('record_date', '<=', $untilTs); } $rows = $query->order('record_date', 'desc') ->append(['intensity_text']) ->select() ->toArray(); foreach ($rows as &$row) { $ts = (int) ($row['record_date'] ?? 0); $row['record_date_ts'] = $ts; $row['record_date'] = $ts > 0 ? date('Y-m-d', $ts) : ''; } return $rows; } private static function resolveCallRecordForAttachment( int $diagnosisId, int $adminId, int $callRecordId = 0, bool $allowFallback = true ) { if ($callRecordId > 0) { $record = \app\common\model\tcm\CallRecord::where('id', $callRecordId) ->where('diagnosis_id', $diagnosisId) ->find(); if ($record) { return $record; } if (!$allowFallback) { return null; } } $record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId) ->where('caller_id', $adminId) ->order('id', 'desc') ->find(); if ($record || !$allowFallback) { return $record; } return \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId) ->order('id', 'desc') ->find(); } private static function createSyntheticCallRecord(int $diagnosisId, int $adminId, string $fileName = '') { $diagnosis = Diagnosis::where('id', $diagnosisId)->find(); if (!$diagnosis) { self::setError('诊单不存在'); return null; } $now = time(); $roomSeed = substr(md5($diagnosisId . '-' . $adminId . '-' . $fileName . '-' . $now), 0, 8); return \app\common\model\tcm\CallRecord::create([ 'diagnosis_id' => $diagnosisId, 'caller_id' => $adminId, 'caller_type' => 'doctor', 'callee_id' => (int)($diagnosis['patient_id'] ?? 0), 'callee_type' => 'patient', 'call_type' => 2, 'status' => 2, 'start_time' => $now, 'end_time' => $now, 'duration' => 0, 'room_id' => 'manual_upload_' . $roomSeed, 'recording_status' => 0, 'create_time' => $now, 'update_time' => $now, ]); } private static function callRecordingChunkDir(string $uploadId): string { $safeId = preg_replace('/[^a-zA-Z0-9_-]/', '', $uploadId); return runtime_path() . 'call_recording_chunks' . DIRECTORY_SEPARATOR . $safeId; } private static function callRecordingChunkName(int $chunkIndex): string { return sprintf('chunk_%06d.part', $chunkIndex); } private static function countUploadedCallRecordingChunks(string $uploadDir): int { $files = glob($uploadDir . DIRECTORY_SEPARATOR . 'chunk_*.part'); return is_array($files) ? count($files) : 0; } private static function mergeCallRecordingChunks(string $uploadDir, string $mergedPath, int $chunkTotal): void { $out = fopen($mergedPath, 'wb'); if ($out === false) { throw new \RuntimeException('创建合并文件失败'); } try { for ($i = 0; $i < $chunkTotal; $i++) { $chunkPath = $uploadDir . DIRECTORY_SEPARATOR . self::callRecordingChunkName($i); if (!is_file($chunkPath)) { throw new \RuntimeException('上传分片缺失,无法合并'); } $in = fopen($chunkPath, 'rb'); if ($in === false) { throw new \RuntimeException('读取上传分片失败'); } stream_copy_to_stream($in, $out); fclose($in); } } finally { fclose($out); } } private static function storeMergedCallRecording(string $mergedPath, string $fileName, int $adminId): array { $config = [ 'default' => ConfigService::get('storage', 'default', 'local'), 'engine' => ConfigService::get('storage') ?? ['local' => []], ]; $storageDriver = new StorageDriver($config); $storageDriver->setUploadFileByReal($mergedPath); $saveDir = 'uploads/video/' . date('Ymd'); if (!$storageDriver->upload($saveDir)) { throw new \RuntimeException($storageDriver->getError() ?: '上传视频到存储失败'); } $relativePath = $saveDir . '/' . str_replace('\\', '/', $storageDriver->getFileName()); $storedFile = FileModel::create([ 'cid' => 0, 'type' => FileEnum::VIDEO_TYPE, 'name' => mb_substr($fileName, 0, 128), 'uri' => $relativePath, 'source' => FileEnum::SOURCE_ADMIN, 'source_id' => $adminId, 'create_time' => time(), ]); return [ 'id' => (int)($storedFile['id'] ?? 0), 'uri' => FileService::getFileUrl($relativePath), 'url' => $relativePath, ]; } private static function cleanupCallRecordingChunkDir(string $uploadDir): void { if ($uploadDir === '' || !is_dir($uploadDir)) { return; } foreach ((array)glob($uploadDir . DIRECTORY_SEPARATOR . '*') as $item) { if (is_file($item)) { @unlink($item); } } @rmdir($uploadDir); } }