Files
zyt/server/app/adminapi/logic/tcm/DiagnosisLogic.php
T
2026-04-22 16:20:52 +08:00

3258 lines
121 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\adminapi\logic\tcm;
use app\common\logic\BaseLogic;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisAssignLog;
use app\common\model\tcm\ImChatMessage;
use app\common\model\DiagnosisViewRecord;
use think\facade\Db;
/**
* 中医辨房病因诊单逻辑
* Class DiagnosisLogic
* @package app\adminapi\logic\tcm
*/
class DiagnosisLogic extends BaseLogic
{
/**
* @notes 添加诊单
* @param array $params
* @return int|bool 成功返回ID,失败返回false
*/
public static function add(array $params)
{
try {
// 检查手机号是否重复
if (!empty($params['phone'])) {
$exists = Diagnosis::where('phone', $params['phone'])
->where('delete_time', null)
->find();
if ($exists) {
self::setError('该手机号已存在');
return false;
}
}
// 检查身份证号是否重复
if (!empty($params['id_card'])) {
$exists = Diagnosis::where('id_card', $params['id_card'])
->where('delete_time', null)
->find();
if ($exists) {
self::setError('该身份证号已存在');
return false;
}
}
// 生成患者ID
$params['patient_id'] = self::generatePatientId();
// 处理既往史数组
if (isset($params['past_history']) && is_array($params['past_history'])) {
$params['past_history'] = implode(',', $params['past_history']);
}
// 处理现病史多选字段
$multiSelectFields = [
'diet_condition', 'body_feeling', 'sleep_condition', 'eye_condition',
'head_feeling', 'sweat_condition', 'skin_condition', 'urine_condition',
'stool_condition', 'kidney_condition', 'local_hospital_diagnosis'
];
foreach ($multiSelectFields as $field) {
if (isset($params[$field]) && is_array($params[$field])) {
$params[$field] = implode(',', $params[$field]);
}
}
// 处理舌苔照片(JSON数组)
if (isset($params['tongue_images']) && is_array($params['tongue_images'])) {
$params['tongue_images'] = json_encode($params['tongue_images'], JSON_UNESCAPED_UNICODE);
}
// 处理检查报告(JSON数组)
if (isset($params['report_files']) && is_array($params['report_files'])) {
$params['report_files'] = json_encode($params['report_files'], JSON_UNESCAPED_UNICODE);
}
// 处理诊断日期
if (isset($params['diagnosis_date'])) {
$params['diagnosis_date'] = strtotime($params['diagnosis_date']);
}
$model = Diagnosis::create($params);
// 自动为患者创建 TRTC 账号
self::createPatientTrtcAccount($model->id);
return $model->id;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 生成患者ID
* @return int
*/
private static function generatePatientId(): int
{
// 获取当前最大的患者ID
$maxPatientId = Diagnosis::max('patient_id') ?? 100000;
// 返回下一个患者ID
return $maxPatientId + 1;
}
/**
* @notes 编辑诊单
* @param array $params
* @return bool
*/
public static function edit(array $params): bool
{
try {
// 检查手机号是否重复(排除当前记录)
if (!empty($params['phone'])) {
$exists = Diagnosis::where('phone', $params['phone'])
->where('id', '<>', $params['id'])
->where('delete_time', null)
->find();
if ($exists) {
self::setError('该手机号已存在');
return false;
}
}
// 检查身份证号是否重复(排除当前记录)
if (!empty($params['id_card'])) {
$exists = Diagnosis::where('id_card', $params['id_card'])
->where('id', '<>', $params['id'])
->where('delete_time', null)
->find();
if ($exists) {
self::setError('该身份证号已存在');
return false;
}
}
// 处理既往史数组
if (isset($params['past_history']) && is_array($params['past_history'])) {
$params['past_history'] = implode(',', $params['past_history']);
}
// 处理现病史多选字段
$multiSelectFields = [
'diet_condition', 'body_feeling', 'sleep_condition', 'eye_condition',
'head_feeling', 'sweat_condition', 'skin_condition', 'urine_condition',
'stool_condition', 'kidney_condition', 'local_hospital_diagnosis'
];
foreach ($multiSelectFields as $field) {
if (isset($params[$field]) && is_array($params[$field])) {
$params[$field] = implode(',', $params[$field]);
}
}
// 处理舌苔照片(JSON数组)
if (isset($params['tongue_images']) && is_array($params['tongue_images'])) {
$params['tongue_images'] = json_encode($params['tongue_images'], JSON_UNESCAPED_UNICODE);
}
// 处理检查报告(JSON数组)
if (isset($params['report_files']) && is_array($params['report_files'])) {
$params['report_files'] = json_encode($params['report_files'], JSON_UNESCAPED_UNICODE);
}
// 处理诊断日期
if (isset($params['diagnosis_date'])) {
$params['diagnosis_date'] = strtotime($params['diagnosis_date']);
}
Diagnosis::update($params);
// 如果是编辑,也确保患者有 TRTC 账号
$diagnosis = Diagnosis::find($params['id']);
if ($diagnosis) {
self::createPatientTrtcAccount($diagnosis->patient_id);
}
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 删除诊单
* @param array $params
* @return bool
*/
public static function delete(array $params): bool
{
try {
Diagnosis::destroy($params['id']);
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 诊单详情
* @param $params
* @return array
*/
public static function detail($params): array
{
$diagnosis = Diagnosis::findOrEmpty($params['id'])->toArray();
// 处理既往史为数组
if (!empty($diagnosis['past_history'])) {
$diagnosis['past_history'] = explode(',', $diagnosis['past_history']);
} else {
$diagnosis['past_history'] = [];
}
// 处理现病史多选字段为数组
$multiSelectFields = [
'diet_condition', 'body_feeling', 'sleep_condition', 'eye_condition',
'head_feeling', 'sweat_condition', 'skin_condition', 'urine_condition',
'stool_condition', 'kidney_condition', 'local_hospital_diagnosis'
];
foreach ($multiSelectFields as $field) {
if (!empty($diagnosis[$field])) {
$diagnosis[$field] = explode(',', $diagnosis[$field]);
} else {
$diagnosis[$field] = [];
}
}
if (!empty($diagnosis['tongue_images'])) {
// 先尝试 JSON 解析(兼容旧数据)
$files = json_decode($diagnosis['tongue_images'], true);
if (is_array($files)) {
$diagnosis['tongue_images'] = $files;
} else {
// JSON 解析失败则按逗号分割
$diagnosis['tongue_images'] = array_filter(explode(',', $diagnosis['tongue_images'])) ?: [];
}
// 为没有 http 前缀的图片添加域名
$domain = request()->domain();
$diagnosis['tongue_images'] = array_map(function($url) use ($domain) {
if (empty($url)) {
return $url;
}
// 如果已经包含 http:// 或 https://,则跳过
if (stripos($url, 'http://') === 0 || stripos($url, 'https://') === 0) {
return $url;
}
// 添加域名前缀
return $domain .'/'. $url;
}, $diagnosis['tongue_images']);
} else {
$diagnosis['tongue_images'] = [];
}
// 处理检查报告(JSON转数组)
if (!empty($diagnosis['report_files'])) {
// 先尝试 JSON 解析(兼容旧数据)
$files = json_decode($diagnosis['report_files'], true);
if (is_array($files)) {
$diagnosis['report_files'] = $files;
} else {
// JSON 解析失败则按逗号分割
$diagnosis['report_files'] = array_filter(explode(',', $diagnosis['report_files'])) ?: [];
}
// 为没有 http 前缀的文件添加域名
$domain = request()->domain();
$diagnosis['report_files'] = array_map(function($url) use ($domain) {
if (empty($url)) {
return $url;
}
// 如果已经包含 http:// 或 https://,则跳过
if (stripos($url, 'http://') === 0 || stripos($url, 'https://') === 0) {
return $url;
}
// 添加域名前缀
return $domain . $url;
}, $diagnosis['report_files']);
} else {
$diagnosis['report_files'] = [];
}
// 处理诊断日期格式
if (!empty($diagnosis['diagnosis_date'])) {
$diagnosis['diagnosis_date'] = date('Y-m-d', $diagnosis['diagnosis_date']);
}
// 如果local_hospital_visit_date为空但diagnosis_date有值,则使用diagnosis_date
if (empty($diagnosis['local_hospital_visit_date']) && !empty($diagnosis['diagnosis_date'])) {
$diagnosis['local_hospital_visit_date'] = $diagnosis['diagnosis_date'];
}
$diagnosis['is_view'] =DiagnosisViewRecord::where(['diagnosis_id'=> $diagnosis['id'], 'user_id' =>$params['user_id']??0])->find() ? 1 : 0;
return $diagnosis;
}
/**
* @notes 诊单详情(患者端)
* @param array $params
* @return array
*/
public static function diagnosisDetail(array $params): array
{
$diagnosisId = $params['id'] ?? 0;
$userId = $params['user_id'] ?? 0;
if (!$diagnosisId || !$userId) {
self::setError('参数不完整');
return [];
}
$diagnosis = Diagnosis::where('id', $diagnosisId)
->where('patient_id', $userId)
->findOrEmpty()
->toArray();
if (empty($diagnosis)) {
self::setError('诊单不存在或无权限访问');
return [];
}
// 处理既往史为数组
if (!empty($diagnosis['past_history'])) {
$diagnosis['past_history'] = explode(',', $diagnosis['past_history']);
} else {
$diagnosis['past_history'] = [];
}
// 处理现病史多选字段为数组
$multiSelectFields = [
'diet_condition', 'body_feeling', 'sleep_condition', 'eye_condition',
'head_feeling', 'sweat_condition', 'skin_condition', 'urine_condition',
'stool_condition', 'kidney_condition', 'local_hospital_diagnosis'
];
foreach ($multiSelectFields as $field) {
if (!empty($diagnosis[$field])) {
$diagnosis[$field] = explode(',', $diagnosis[$field]);
} else {
$diagnosis[$field] = [];
}
}
// 处理检查报告为数组
if (!empty($diagnosis['examination_report'])) {
$diagnosis['examination_report'] = explode(',', $diagnosis['examination_report']);
} else {
$diagnosis['examination_report'] = [];
}
// 处理舌苔照片为数组
if (!empty($diagnosis['tongue_photo'])) {
$diagnosis['tongue_photo'] = explode(',', $diagnosis['tongue_photo']);
} else {
$diagnosis['tongue_photo'] = [];
}
// 处理诊断日期格式
if (!empty($diagnosis['diagnosis_date'])) {
$diagnosis['diagnosis_date'] = date('Y-m-d', $diagnosis['diagnosis_date']);
}
// 如果local_hospital_visit_date为空但diagnosis_date有值,则使用diagnosis_date
if (empty($diagnosis['local_hospital_visit_date']) && !empty($diagnosis['diagnosis_date'])) {
$diagnosis['local_hospital_visit_date'] = $diagnosis['diagnosis_date'];
}
return $diagnosis;
}
/**
* @notes 检查手机号是否重复
* @param array $params
* @return array
*/
public static function checkPhone(array $params): array
{
$query = Diagnosis::where('phone', $params['phone'])
->where('delete_time', null);
// 编辑时排除当前记录
if (isset($params['id']) && $params['id']) {
$query->where('id', '<>', $params['id']);
}
$exists = $query->find();
return [
'exists' => !empty($exists),
'message' => $exists ? '该手机号已存在' : ''
];
}
/**
* @notes 检查身份证号是否重复
* @param array $params
* @return array
*/
public static function checkIdCard(array $params): array
{
$query = Diagnosis::where('id_card', $params['id_card'])
->where('delete_time', null);
// 编辑时排除当前记录
if (isset($params['id']) && $params['id']) {
$query->where('id', '<>', $params['id']);
}
$exists = $query->find();
return [
'exists' => !empty($exists),
'message' => $exists ? '该身份证号已存在' : ''
];
}
/**
* @notes 补全身份证号(自动计算年龄并更新)
* @param array $params id, id_card
* @return bool
*/
public static function fillIdCard(array $params): bool
{
try {
$diagnosis = Diagnosis::find($params['id']);
if (!$diagnosis) {
self::setError('诊单不存在');
return false;
}
$idCard = trim($params['id_card']);
$len = strlen($idCard);
if ($len === 15) {
$birthYear = 1900 + (int)substr($idCard, 6, 2);
$birthMonth = (int)substr($idCard, 8, 2);
$birthDay = (int)substr($idCard, 10, 2);
} elseif ($len === 18 && preg_match('/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/', $idCard)) {
$birthYear = (int)substr($idCard, 6, 4);
$birthMonth = (int)substr($idCard, 10, 2);
$birthDay = (int)substr($idCard, 12, 2);
} else {
self::setError('身份证号格式不正确');
return false;
}
// 从身份证提取出生日期计算年龄
$today = getdate();
$age = $today['year'] - $birthYear;
if ($today['mon'] < $birthMonth || ($today['mon'] == $birthMonth && $today['mday'] < $birthDay)) {
$age--;
}
$age = max(0, min(150, $age));
$diagnosis->id_card = $idCard;
$diagnosis->age = $age;
$diagnosis->save();
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 指派医助(每次成功更新写入一条操作记录,批量指派为多次接口调用各记一条)
* @param array $params
* @return bool
*/
public static function assign(array $params): bool
{
try {
$id = (int) ($params['id'] ?? 0);
$toAssistantId = (int) ($params['assistant_id'] ?? 0);
if ($id <= 0) {
self::setError('诊单不存在');
return false;
}
$diagnosis = Diagnosis::where('id', $id)->whereNull('delete_time')->find();
if (!$diagnosis) {
self::setError('诊单不存在');
return false;
}
$fromAssistantId = (int) ($diagnosis->getAttr('assistant_id') ?? 0);
Db::startTrans();
Diagnosis::where('id', $id)->whereNull('delete_time')->update([
'assistant_id' => $toAssistantId,
]);
$req = request();
$admin = $req->adminInfo ?? [];
DiagnosisAssignLog::create([
'diagnosis_id' => $id,
'from_assistant_id' => $fromAssistantId,
'to_assistant_id' => $toAssistantId,
'operator_admin_id' => (int) ($admin['admin_id'] ?? 0),
'operator_name' => (string) ($admin['name'] ?? ''),
'operator_account' => (string) ($admin['account'] ?? ''),
'ip' => (string) ($req->ip() ?? ''),
'create_time' => time(),
]);
Db::commit();
return true;
} catch (\Exception $e) {
Db::rollback();
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 诊单指派医助操作记录列表
*/
public static function assignLogList(int $diagnosisId): array
{
$rows = DiagnosisAssignLog::where('diagnosis_id', $diagnosisId)
->order('id', 'desc')
->select()
->toArray();
$adminIds = [];
foreach ($rows as $r) {
if (!empty($r['from_assistant_id'])) {
$adminIds[] = (int) $r['from_assistant_id'];
}
if (!empty($r['to_assistant_id'])) {
$adminIds[] = (int) $r['to_assistant_id'];
}
}
$adminIds = array_values(array_unique(array_filter($adminIds)));
$nameMap = [];
if ($adminIds !== []) {
$nameMap = \app\common\model\auth\Admin::whereIn('id', $adminIds)->column('name', 'id');
}
foreach ($rows as &$r) {
$fromId = (int) ($r['from_assistant_id'] ?? 0);
$toId = (int) ($r['to_assistant_id'] ?? 0);
$r['from_assistant_name'] = $fromId > 0
? (string) ($nameMap[$fromId] ?? ('ID:' . $fromId))
: '—';
$r['to_assistant_name'] = $toId > 0
? (string) ($nameMap[$toId] ?? ('ID:' . $toId))
: '—';
$r['create_time_text'] = !empty($r['create_time'])
? date('Y-m-d H:i:s', (int) $r['create_time'])
: '';
}
unset($r);
return $rows;
}
/**
* @notes 获取通话签名
* @param array $params
* @return array|bool
*/
public static function getCallSignature(array $params)
{
try {
// 获取配置
$config = self::getTrtcConfig();
if (!$config) {
self::setError('请先配置腾讯云TRTC参数');
return false;
}
// 获取当前管理员ID(从参数中获取)
$adminId = $params['admin_id'] ?? 0;
$patientId = $params['patient_id'] ?? 0;
if (!$adminId) {
self::setError('获取管理员信息失败');
return false;
}
if (!$patientId) {
self::setError('获取患者信息失败');
return false;
}
// 医生userId
$doctorUserId = 'doctor_' . $adminId;
$query = Diagnosis::where('id', $patientId)
->where('delete_time', null)->find();
if(!$query){
self::setError('患者诊单已被删除');
return false;
}
// 患者userId(必须与小程序端一致)
$patientUserId = 'patient_' . $patientId;
// 生成医生的 UserSig
$userSig = self::generateUserSig($config['sdkAppId'], $config['secretKey'], $doctorUserId);
if (!$userSig) {
self::setError('生成签名失败');
return false;
}
// 导入医生账号到IM
self::importDoctorAccountToIm($adminId, $doctorUserId);
// 确保患者账号也已导入IM(用于跨平台通话)
self::ensurePatientImAccount($patientId, $patientUserId);
return [
'sdkAppId' => (int)$config['sdkAppId'], // 确保返回整数
'userId' => $doctorUserId, // 医生的userId
'userSig' => $userSig,
'assistant_id'=>$query->assistant_id?'doctor_'.$query->assistant_id:'',
'patientUserId' => $patientUserId, // 患者的userId(用于发起通话)
'expireTime' => 86400, // 24小时
// 与 .env [trtc] ISLOCHOSTVOD 一致:true 允许浏览器本地录制并上传
'isLochostVod' => (bool)config('trtc.is_lochost_vod', false),
];
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 确保患者IM账号存在
* @param int $patientId
* @param string $userId
* @return bool
*/
private static function ensurePatientImAccount(int $patientId, string $userId): bool
{
try {
// 获取患者信息
$diagnosis = Diagnosis::where('patient_id', $patientId)->find();
$nick = $diagnosis ? $diagnosis->patient_name : '患者' . $patientId;
// 调用IM服务导入账号
$imService = new \app\common\service\TencentImService();
$result = $imService->importAccount($userId, $nick);
if ($result['success']) {
\think\facade\Log::info('确保患者IM账号存在 - patient_id: ' . $patientId . ', user_id: ' . $userId . ', nick: ' . $nick);
return true;
} else {
\think\facade\Log::warning('导入患者IM账号失败 - patient_id: ' . $patientId . ', error: ' . ($result['message'] ?? '未知错误'));
return false;
}
} catch (\Exception $e) {
\think\facade\Log::error('确保患者IM账号失败: ' . $e->getMessage());
return false;
}
}
/**
* @notes 导入医生账号到腾讯云IM
* @param int $adminId
* @param string $userId
* @return bool
*/
private static function importDoctorAccountToIm(int $adminId, string $userId): bool
{
try {
// 获取医生信息
$admin = \app\common\model\auth\Admin::find($adminId);
$nick = $admin ? $admin->name : '医生' . $adminId;
// 调用IM服务导入账号
$imService = new \app\common\service\TencentImService();
$result = $imService->importAccount($userId, $nick);
if ($result['success']) {
\think\facade\Log::info('导入医生IM账号成功 - admin_id: ' . $adminId . ', user_id: ' . $userId . ', nick: ' . $nick);
return true;
} else {
\think\facade\Log::warning('导入医生IM账号失败 - admin_id: ' . $adminId . ', user_id: ' . $userId . ', error: ' . $result['message']);
return false;
}
} catch (\Exception $e) {
\think\facade\Log::error('导入医生IM账号异常 - admin_id: ' . $adminId . ', user_id: ' . $userId . ', error: ' . $e->getMessage());
return false;
}
}
/**
* 所有可能以 doctor_{id} 登录 IM 的后台账号(医生 role_id=1、医助 role_id=2),用于合并会话漫游记录
*
* @return array<int, string> 如 ['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<int, string>}
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $archived
* @param array<int, array<string, mixed>> $live
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $diag
* @return array<int, array<string, mixed>>
*/
/**
* @param array<int, string> $doctorAccounts 已筛选的医生 IM 账号列表
*/
private static function pullLiveImChatMessagesForDiagnosis($diag, array $doctorAccounts = []): array
{
$patientId = (int)$diag['patient_id'];
if ($patientId <= 0) {
return [];
}
$patientImId = 'patient_' . $patientId;
if (empty($doctorAccounts)) {
return [];
}
$imService = new \app\common\service\TencentImService();
$merged = [];
foreach ($doctorAccounts as $docAccount) {
$batch = self::pullAllRoamMessages($imService, $docAccount, $patientImId);
foreach ($batch as $row) {
$merged[] = $row;
}
}
usort($merged, function ($a, $b) {
return ($a['time'] ?? 0) <=> ($b['time'] ?? 0);
});
$seen = [];
$unique = [];
foreach ($merged as $row) {
$k = $row['msg_id'] ?? '';
if ($k !== '' && isset($seen[$k])) {
continue;
}
if ($k !== '') {
$seen[$k] = true;
}
$unique[] = $row;
}
return $unique;
}
/**
* @param array<int, array<string, mixed>> $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<int, array<string, mixed>> $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<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
private static function pullAllRoamMessages(\app\common\service\TencentImService $svc, string $operator, string $peer): array
{
$out = [];
$lastKey = null;
$lastTime = null;
$guard = 0;
do {
$res = $svc->adminGetRoamMsg($operator, $peer, 100, 0, 4294967295, $lastKey, $lastTime);
if (!$res['success']) {
if (!empty($res['error'])) {
\think\facade\Log::warning('IM漫游消息拉取失败', [
'operator' => $operator,
'peer' => $peer,
'error' => $res['error'],
'code' => $res['rawErrorCode'] ?? 0,
]);
}
break;
}
foreach ($res['msgList'] as $raw) {
if (!is_array($raw)) {
continue;
}
$normalized = self::normalizeTimMessage($raw);
$normalized['doctor_peer_account'] = $operator;
$out[] = $normalized;
}
$complete = (int)$res['complete'];
if ($complete === 1) {
break;
}
$lastKey = $res['lastMsgKey'];
$lastTime = $res['lastMsgTime'];
if ($lastKey === null || $lastKey === '') {
break;
}
$guard++;
if ($guard > 80) {
break;
}
} while (true);
return $out;
}
/**
* @param array<string, mixed> $raw
* @return array<string, mixed>
*/
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<string, mixed>
*/
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) {
// 前端常重复回调 endCallafterCalling + 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_idVOD 311 回调常在挂断之后到达,需按 TaskId 关联写入 recording_urls
$record->save([
'status' => 2, // 2-已结束
'end_time' => $endTime,
'duration' => $duration,
'update_time' => time(),
]);
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 患者端挂断:停止云端录制并结束该诊单下对应通话记录(不依赖管理端浏览器是否触发 endCall)
* @param array{diagnosis_id?:int,patient_id:int,room_id?:string} $params patient_id 须与诊单 tcm_diagnosis.patient_id 一致;room_id 与 bindCallRoom 一致时可精准命中(小程序通话页独立时 chat 页可能已卸载,仅靠 room_id + patient_id 即可)
*/
public static function patientHangupVideoCall(array $params): bool
{
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$patientId = (int)($params['patient_id'] ?? 0);
$roomIdRaw = trim((string)($params['room_id'] ?? ''));
if ($patientId <= 0) {
self::setError('参数错误');
return false;
}
if ($diagnosisId <= 0 && $roomIdRaw === '') {
self::setError('诊单ID或房间号须至少传一项');
return false;
}
$record = null;
if ($roomIdRaw !== '') {
$roomCandidates = array_values(array_unique(array_filter([
$roomIdRaw,
ctype_digit($roomIdRaw) ? (string)((int)$roomIdRaw) : '',
])));
$record = \app\common\model\tcm\CallRecord::where('status', 1)
->whereIn('room_id', $roomCandidates)
->order('id', 'desc')
->find();
if ($record) {
$diagByRoom = Diagnosis::find((int)$record['diagnosis_id']);
if (!$diagByRoom || (int)($diagByRoom['patient_id'] ?? 0) !== $patientId) {
$record = null;
} else {
$diagnosisId = (int)$record['diagnosis_id'];
}
}
}
if (!$record && $diagnosisId > 0) {
$diag = Diagnosis::find($diagnosisId);
if (!$diag || (int)($diag['patient_id'] ?? 0) !== $patientId) {
self::setError('无权操作该诊单');
return false;
}
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('callee_id', $patientId)
->where('status', 1)
->order('id', 'desc')
->find();
if (!$record) {
$cnt = (int)\app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)->where('status', 1)->count();
if ($cnt === 1) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->order('id', 'desc')
->find();
}
}
}
if (!$record) {
return true;
}
$taskId = trim((string)($record['cloud_recording_task_id'] ?? ''));
if ($taskId !== '') {
\app\common\service\TrtcCloudRecordingService::stopRecording(
\app\common\service\TrtcCloudRecordingService::trtcSdkAppId(),
$taskId
);
}
$endTime = time();
$duration = $endTime - (int)$record->start_time;
$record->save([
'status' => 2,
'end_time' => $endTime,
'duration' => $duration,
'update_time' => $endTime,
]);
\think\facade\Log::info('patientHangupVideoCall: 已停录并结束通话记录', [
'call_record_id' => $record->id,
'diagnosis_id' => $diagnosisId,
]);
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
\think\facade\Log::warning('patientHangupVideoCall: ' . $e->getMessage());
return false;
}
}
/**
* @notes 获取通话记录
* @param array $params
* @return array
*/
public static function getCallRecords(array $params): array
{
try {
$records = \app\common\model\tcm\CallRecord::where('diagnosis_id', $params['diagnosis_id'])
->order('id', 'desc')
->select()
->toArray();
foreach ($records as &$record) {
$record['start_time_text'] = date('Y-m-d H:i:s', $record['start_time']);
$record['end_time_text'] = $record['end_time'] ? date('Y-m-d H:i:s', $record['end_time']) : '';
$record['duration_text'] = self::formatDuration($record['duration']);
$urls = [];
if (!empty($record['recording_urls'])) {
$decoded = json_decode((string)$record['recording_urls'], true);
if (is_array($decoded)) {
$urls = $decoded;
}
}
$record['recording_urls_list'] = $urls;
$record['recording_status_text'] = self::recordingStatusText((int)($record['recording_status'] ?? 0));
}
return $records;
} catch (\Exception $e) {
self::setError($e->getMessage());
return [];
}
}
/**
* @notes 将 TRTC 房间号写入当前诊单通话记录,并尝试 API 合流云端录制
* @return array{cloud_recording?:array}|false 成功返回 data 数组(供接口带给前端);失败 false
*/
public static function bindCallRoom(array $params)
{
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$roomId = trim((string)($params['room_id'] ?? ''));
if ($diagnosisId <= 0 || $roomId === '') {
self::setError('诊单ID或房间号不能为空');
return false;
}
$adminId = (int)($params['admin_id'] ?? 0);
// 必须与 startCloudRecording 使用同一条「进行中 + 当前管理员」记录写 room_id,否则会写到别的记录上,合流 API 读到 room_id 仍为空 → 关闭全局录制后无任何文件
$record = null;
if ($adminId > 0) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->where('caller_id', $adminId)
->order('id', 'desc')
->find();
}
if (!$record) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->order('id', 'desc')
->find();
}
if (!$record) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('room_id', '')
->order('id', 'desc')
->find();
}
if (!$record) {
self::setError('未找到可绑定的通话记录');
return false;
}
$record->save([
'room_id' => $roomId,
'update_time' => time(),
]);
$cloudPayload = [
'started' => false,
'task_id' => '',
'message' => '未尝试合流录制(admin_id 为空)',
];
if ($adminId > 0) {
$cloudRec = self::startCloudRecording([
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
], true);
if ($cloudRec === false) {
\think\facade\Log::warning('bindCallRoom: startCloudRecording 未执行或异常', [
'diagnosis_id' => $diagnosisId,
'room_id' => $roomId,
]);
$cloudPayload = [
'started' => false,
'task_id' => '',
'message' => '合流录制未启动(无匹配通话记录或异常,见服务端日志)',
];
} elseif (is_array($cloudRec)) {
$cloudPayload = [
'started' => !empty($cloudRec['started']),
'task_id' => (string)($cloudRec['task_id'] ?? ''),
'message' => (string)($cloudRec['message'] ?? ''),
];
if (empty($cloudRec['started'])) {
\think\facade\Log::warning('bindCallRoom: CreateCloudRecording 合流未启动', [
'diagnosis_id' => $diagnosisId,
'room_id' => $roomId,
'message' => $cloudPayload['message'],
]);
} else {
\think\facade\Log::info('bindCallRoom: 合流云端录制已发起', [
'diagnosis_id' => $diagnosisId,
'room_id' => $roomId,
'task_id' => $cloudPayload['task_id'],
]);
}
}
}
return ['cloud_recording' => $cloudPayload];
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 医生接通并绑定房间后:发起腾讯云云端混流录制(需 CAM 密钥 + 控制台开通录制 + 云点播)
* @param array $params diagnosis_id、admin_id
* @return array|false
*/
/**
* @param bool $silent 为 true 时不写入 BaseLogic 错误(供 bindCallRoom 内自动调用,避免污染绑定接口)
*/
public static function startCloudRecording(array $params, bool $silent = false)
{
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
if ($diagnosisId <= 0 || $adminId <= 0) {
if (!$silent) {
self::setError('参数错误');
}
return false;
}
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->where('status', 1)
->order('id', 'desc')
->find();
if (!$record) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->order('id', 'desc')
->find();
if ($record) {
\think\facade\Log::warning('startCloudRecording: 未找到 caller_id 匹配的进行中记录,已回退到该诊单最新进行中记录', [
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
'record_caller_id' => $record['caller_id'] ?? null,
]);
}
}
if (!$record) {
if (!$silent) {
self::setError('没有进行中的通话记录');
}
return false;
}
$roomId = trim((string)($record['room_id'] ?? ''));
if ($roomId === '') {
if (!$silent) {
self::setError('尚未同步房间号,无法开启云端录制');
}
return false;
}
$existingTask = trim((string)($record['cloud_recording_task_id'] ?? ''));
if ($existingTask !== '') {
return [
'started' => true,
'task_id' => $existingTask,
'message' => '已在录制中',
];
}
$prefix = (string)config('trtc.recording_bot_prefix', 'recorder_');
$botUserId = $prefix . $diagnosisId . '_' . (int)$record['id'];
try {
$imService = new \app\common\service\TencentImService();
$imService->importAccount($botUserId, '云端录制');
} catch (\Throwable $e) {
// 导入失败不阻断,部分环境仅 TRTC 亦可进房
}
$botSig = \app\common\service\TrtcCloudRecordingService::makeBotUserSig($botUserId);
$sdkAppId = \app\common\service\TrtcCloudRecordingService::trtcSdkAppId();
$vodMixPrefix = 'mix_' . $diagnosisId . '_' . (int)$record['id'];
$r = \app\common\service\TrtcCloudRecordingService::startMixRecording(
$sdkAppId,
$roomId,
$botUserId,
$botSig,
$vodMixPrefix
);
if (!$r['ok']) {
return [
'started' => false,
'message' => $r['message'] ?? '开启失败',
];
}
$record->save([
'cloud_recording_task_id' => $r['task_id'],
'recording_status' => 1,
'update_time' => time(),
]);
return [
'started' => true,
'task_id' => $r['task_id'],
'message' => '已发起云端录制',
];
} catch (\Exception $e) {
if (!$silent) {
self::setError($e->getMessage());
}
\think\facade\Log::warning('startCloudRecording: ' . $e->getMessage());
return false;
}
}
/**
* @notes 医生端浏览器本地录制上传后,将文件访问地址合并写入当前诊单下该医生的最近一条通话记录
*/
public static function attachLocalCallRecording(array $params): bool
{
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
$fileUrl = trim((string)($params['file_url'] ?? ''));
if ($diagnosisId <= 0 || $adminId <= 0 || $fileUrl === '') {
self::setError('参数错误');
return false;
}
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->order('id', 'desc')
->find();
// 与 startCall 的 caller_id 不一致或竞态时,回退到该诊单最新一条通话记录
if (!$record) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->order('id', 'desc')
->find();
}
if (!$record) {
self::setError('未找到通话记录');
return false;
}
$prev = [];
if (!empty($record->recording_urls)) {
$prev = json_decode((string)$record->recording_urls, true);
if (!is_array($prev)) {
$prev = [];
}
}
$merged = array_values(array_unique(array_merge($prev, [$fileUrl])));
$record->save([
'recording_urls' => json_encode($merged, JSON_UNESCAPED_UNICODE),
'recording_status' => 2,
'update_time' => time(),
]);
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 医助进入当前诊单 TRTC 房间旁观(仅拉流,与医生端 doctor_{id} 账号体系一致)
* @param array $params diagnosis_id、admin_id(当前登录后台用户)
* @return array|false
*/
public static function getAssistantWatchRoomParams(array $params)
{
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
if ($diagnosisId <= 0 || $adminId <= 0) {
self::setError('参数错误');
return false;
}
$diag = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
if (!$diag) {
self::setError('诊单不存在');
return false;
}
if ((int)($diag['assistant_id'] ?? 0) !== $adminId) {
self::setError('仅本诊单指派的医助可旁观该通话');
return false;
}
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('status', 1)
->order('id', 'desc')
->find();
$roomRaw = $record ? trim((string)$record['room_id']) : '';
if ($roomRaw === '') {
self::setError('当前无进行中的通话或尚未同步房间号,请待医生接通后再试');
return false;
}
$config = self::getTrtcConfig();
if (!$config) {
self::setError('请先配置腾讯云TRTC参数');
return false;
}
$doctorUserId = 'doctor_' . $adminId;
self::importDoctorAccountToIm($adminId, $doctorUserId);
$userSig = self::generateUserSig($config['sdkAppId'], $config['secretKey'], $doctorUserId);
if (!$userSig) {
self::setError('生成签名失败');
return false;
}
$payload = [
'sdkAppId' => (int)$config['sdkAppId'],
'userId' => $doctorUserId,
'userSig' => $userSig,
'patientName' => (string)($diag['patient_name'] ?? ''),
];
if (ctype_digit($roomRaw)) {
$payload['roomId'] = (int)$roomRaw;
} else {
$payload['strRoomId'] = $roomRaw;
}
return $payload;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
private static function recordingStatusText(int $status): string
{
$map = [
0 => '无录制',
1 => '录制中',
2 => '已生成',
3 => '录制失败',
];
return $map[$status] ?? '未知';
}
/**
* @notes 获取TRTC配置
* @return array|null
*/
private static function getTrtcConfig(): ?array
{
// 从配置文件或数据库读取
// 这里使用配置文件方式
$config = config('project.trtc');
if (empty($config['sdkAppId']) || empty($config['secretKey'])) {
return null;
}
return $config;
}
/**
* @notes 生成UserSig
* @param int $sdkAppId
* @param string $secretKey
* @param string $userId
* @param int $expire
* @return string|false
*/
private static function generateUserSig(int $sdkAppId, string $secretKey, string $userId, int $expire = 86400)
{
try {
// 使用官方的TLSSigAPIv2类生成UserSig
$api = new \app\common\service\TLSSigAPIv2($sdkAppId, $secretKey);
$userSig = $api->genUserSig($userId, $expire);
return $userSig;
} catch (\Exception $e) {
\think\facade\Log::error('生成UserSig失败: ' . $e->getMessage());
return false;
}
}
/**
* @notes 格式化通话时长
* @param int $seconds
* @return string
*/
private static function formatDuration(int $seconds): string
{
if ($seconds < 60) {
return $seconds . '秒';
} elseif ($seconds < 3600) {
$minutes = floor($seconds / 60);
$secs = $seconds % 60;
return $minutes . '分' . $secs . '秒';
} else {
$hours = floor($seconds / 3600);
$minutes = floor(($seconds % 3600) / 60);
$secs = $seconds % 60;
return $hours . '小时' . $minutes . '分' . $secs . '秒';
}
}
/**
* @notes 为患者创建 TRTC 账号(自动调用)
* @param int $patientId
* @return bool
*/
private static function createPatientTrtcAccount(int $patientId): bool
{
try {
$config = self::getTrtcConfig();
if (!$config || !$config['enable']) {
// 如果 TRTC 未启用,不创建账号
return false;
}
$userId = 'patient_' . $patientId;
// 检查是否已存在
$existingAccount = \app\common\model\tcm\PatientTrtc::where('patient_id', $patientId)
->where('delete_time', null)
->find();
// 生成 UserSig(长期有效,180天)
$expireTime = 86400 * 180;
$userSig = self::generateUserSig($config['sdkAppId'], $config['secretKey'], $userId, $expireTime);
if (!$userSig) {
return false;
}
$data = [
'patient_id' => $patientId,
'user_id' => $userId,
'user_sig' => $userSig,
'sdk_app_id' => $config['sdkAppId'],
'expire_time' => time() + $expireTime,
'is_active' => 1
];
if ($existingAccount) {
// 更新现有账号
$existingAccount->save($data);
\think\facade\Log::info('更新患者 TRTC 账号 - patient_id: ' . $patientId . ', user_id: ' . $userId);
} else {
// 创建新账号
\app\common\model\tcm\PatientTrtc::create($data);
\think\facade\Log::info('创建患者 TRTC 账号 - patient_id: ' . $patientId . ', user_id: ' . $userId);
}
// 导入账号到腾讯云IM(用于TUICallKit
self::importAccountToTencentIm($patientId, $userId);
return true;
} catch (\Exception $e) {
\think\facade\Log::error('创建患者 TRTC 账号失败 - patient_id: ' . $patientId . ', error: ' . $e->getMessage());
return false;
}
}
/**
* @notes 导入账号到腾讯云IM
* @param int $patientId
* @param string $userId
* @return bool
*/
private static function importAccountToTencentIm(int $patientId, string $userId): bool
{
try {
// 获取患者信息
$diagnosis = Diagnosis::where('patient_id', $patientId)
->order('id', 'desc')
->find();
$nick = $diagnosis ? $diagnosis->patient_name : '患者' . $patientId;
// 调用IM服务导入账号
$imService = new \app\common\service\TencentImService();
$result = $imService->importAccount($userId, $nick);
if ($result['success']) {
\think\facade\Log::info('导入患者IM账号成功 - patient_id: ' . $patientId . ', user_id: ' . $userId . ', nick: ' . $nick);
return true;
} else {
\think\facade\Log::warning('导入患者IM账号失败 - patient_id: ' . $patientId . ', user_id: ' . $userId . ', error: ' . $result['message']);
return false;
}
} catch (\Exception $e) {
\think\facade\Log::error('导入患者IM账号异常 - patient_id: ' . $patientId . ', user_id: ' . $userId . ', error: ' . $e->getMessage());
return false;
}
}
/**
* @notes 获取医助签名(供小程序调用)
* @param int $patientId
* @return array|bool
*/
public static function getDoctorSignature(int $patientId)
{
try {
// 获取配置
$config = self::getTrtcConfig();
if (!$config) {
self::setError('请先配置腾讯云TRTC参数');
return false;
}
// 医助userId(使用 doctor_ 前缀)
$doctorUserId = 'doctor_' . $patientId;
// 生成医助的 UserSig
$userSig = self::generateUserSig(
$config['sdkAppId'],
$config['secretKey'],
$doctorUserId
);
if (!$userSig) {
self::setError('生成签名失败');
return false;
}
// 确保医助账号已导入IM(使用正确的方法)
self::importDoctorAccountToIm($patientId, $doctorUserId);
\think\facade\Log::info('获取医助签名成功 - admin_id: ' . $patientId . ', user_id: ' . $doctorUserId);
return [
'sdkAppId' => (int)$config['sdkAppId'],
'userId' => $doctorUserId,
'userSig' => $userSig,
'expireTime' => 86400
];
} catch (\Exception $e) {
\think\facade\Log::error('获取医助签名失败 - admin_id: ' . $patientId . ', error: ' . $e->getMessage());
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 获取患者签名(供小程序调用)
* @param int $patientId
* @return array|bool
*/
public static function getPatientSignature(int $patientId)
{
try {
// 获取配置
$config = self::getTrtcConfig();
if (!$config) {
self::setError('请先配置腾讯云TRTC参数');
return false;
}
// 患者userId
$patientUserId = 'patient_' . $patientId;
// 生成患者的 UserSig
$userSig = self::generateUserSig(
$config['sdkAppId'],
$config['secretKey'],
$patientUserId
);
if (!$userSig) {
self::setError('生成签名失败');
return false;
}
//添加患者资料
// 确保患者账号已导入IM
self::ensurePatientImAccount($patientId, $patientUserId);
\think\facade\Log::info('小程序获取患者签名成功 - patient_id: ' . $patientId . ', user_id: ' . $patientUserId);
$data= Diagnosis::where('id', $patientId)->find();
return [
'sdkAppId' => (int)$config['sdkAppId'],
'userId' => $patientUserId,
'userSig' => $userSig,
'data'=>$data,
'expireTime' => 86400
];
} catch (\Exception $e) {
\think\facade\Log::error('获取患者签名失败 - patient_id: ' . $patientId . ', error: ' . $e->getMessage());
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 获取医助列表
* @return array
*/
public static function getAssistants()
{
try {
// 这里不要用 Admin 模型查询,否则会触发其 append(role_id/dept_id/jobs_id)
// 每行再发多次查询,形成严重 N+1,统计页会被拖慢到十几秒。
$assistants = \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')
->field(['a.id', 'a.name', 'a.account'])
->order('a.id', 'asc')
->distinct(true)
->select()
->toArray();
if ($assistants === []) {
return [];
}
$adminIds = array_column($assistants, 'id');
$adminDepts = \think\facade\Db::name('admin_dept')
->whereIn('admin_id', $adminIds)
->select()
->toArray();
$adminToDeptIds = [];
foreach ($adminDepts as $ad) {
$aid = (int) ($ad['admin_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$adminToDeptIds[$aid][] = (int) ($ad['dept_id'] ?? 0);
}
$allDeptIds = [];
foreach ($adminToDeptIds as $deptIdList) {
foreach ($deptIdList as $did) {
if ($did > 0) {
$allDeptIds[$did] = true;
}
}
}
$allDeptIds = array_keys($allDeptIds);
$deptNameMap = [];
if ($allDeptIds !== []) {
$deptNameMap = \think\facade\Db::name('dept')
->whereIn('id', $allDeptIds)
->whereNull('delete_time')
->column('name', 'id');
}
foreach ($assistants as &$row) {
$aid = (int) ($row['id'] ?? 0);
$idsForAdmin = [];
foreach ($adminToDeptIds[$aid] ?? [] as $did) {
if ($did > 0) {
$idsForAdmin[] = $did;
}
}
$row['dept_ids'] = array_values(array_unique($idsForAdmin));
$names = [];
foreach ($row['dept_ids'] as $did) {
if (!empty($deptNameMap[$did])) {
$names[] = (string) $deptNameMap[$did];
}
}
$row['dept_names'] = $names !== [] ? implode('、', array_unique($names)) : '';
}
unset($row);
return $assistants;
} catch (\Exception $e) {
\think\facade\Log::error('获取医助列表失败: ' . $e->getMessage());
return [];
}
}
/**
* @notes 获取医生列表
* @return array
*/
public static function getDoctors()
{
try {
// 同 getAssistants,避免 Admin 模型 append 触发的 N+1。
$doctors = \think\facade\Db::name('admin')
->alias('a')
->join('admin_role ar', 'a.id = ar.admin_id')
->where('ar.role_id', 1)
->where('a.disable', 0)
->whereNull('a.delete_time')
->field(['a.id', 'a.name', 'a.account'])
->order('a.id', 'asc')
->distinct(true)
->select()
->toArray();
return $doctors;
} catch (\Exception $e) {
\think\facade\Log::error('获取医生列表失败: ' . $e->getMessage());
return [];
}
}
/**
* @notes 生成小程序码
* @param array $params
* @return array|false
*/
public static function generateMiniProgramQrcode(array $params)
{
try {
$diagnosisId = $params['diagnosis_id'] ?? '';
$doctorId = $params['doctor_id'] ?? '';
$patientId = $params['patient_id'];
$shareUserId = $params['share_user_id'];
// 视频登录页(pages/login/login)需要传挂号医生id,其余场景传诊单id
$page = !empty($params['mini_program_path']) ? $params['mini_program_path'] : 'pages/order/monad/monad';
$sceneId = ($page === 'pages/login/login' && !empty($doctorId)) ? $doctorId : $diagnosisId;
if (empty($sceneId)) {
throw new \Exception($page === 'pages/login/login' ? '挂号医生ID不能为空' : '诊单ID不能为空');
}
// 获取小程序配置
$config = self::getMiniProgramConfig();
if (!$config) {
throw new \Exception('小程序配置未设置');
}
// 小程序 onLoad 解析 scene,须与前端 parsePageParams 约定一致(微信 scene 总长勿超过 32 字符)
$scene = "id={$sceneId}&sa={$shareUserId}&did={$doctorId}";
// 调用微信接口生成小程序码
$qrcodeUrl = self::generateWxQrcode($config, $page, $scene);
if (!$qrcodeUrl) {
throw new \Exception('生成小程序码失败: ' . self::getError());
}
return [
'qrcode_url' => $qrcodeUrl,
'diagnosis_id' => $diagnosisId,
'patient_id' => $patientId
];
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 生成订单小程序码
*/
public static function generateOrderQrcode(array $params)
{
try {
$orderNo = $params['order_no'] ?? '';
if (empty($orderNo)) {
throw new \Exception('订单号不能为空');
}
// 查询订单ID(使用ID代替订单号,更短)
$order = \app\common\model\Order::where('order_no', $orderNo)->find();
if (!$order) {
throw new \Exception('订单不存在');
}
$config = self::getMiniProgramConfig();
if (!$config) {
throw new \Exception('小程序配置未设置');
}
$page = 'pages/order/order';
// 微信小程序 scene 参数最大长度为32字符,使用订单ID代替订单号
$scene = "id={$order->id}";
$qrcodeUrl = self::generateWxQrcode($config, $page, $scene);
if (!$qrcodeUrl) {
throw new \Exception('生成小程序码失败: ' . self::getError());
}
return [
'qrcode_url' => $qrcodeUrl,
'order_no' => $orderNo
];
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 获取小程序配置
* @return array|null
*/
private static function getMiniProgramConfig(): ?array
{
try {
// 从配置表获取小程序配置
$appId = \app\common\service\ConfigService::get('mnp_setting', 'app_id', '');
$appSecret = \app\common\service\ConfigService::get('mnp_setting', 'app_secret', '');
if (empty($appId) || empty($appSecret)) {
return null;
}
return [
'app_id' => $appId,
'app_secret' => $appSecret
];
} catch (\Exception $e) {
return null;
}
}
/**
* @notes 生成微信小程序码
* @param array $config
* @param string $page
* @param string $scene
* @return string|false
*/
private static function generateWxQrcode(array $config, string $page, string $scene, int $retryCount = 0)
{
try {
// 记录请求参数
\think\facade\Log::info('开始生成小程序码', [
'page' => $page,
'scene' => $scene,
'app_id' => $config['app_id'],
'retry_count' => $retryCount
]);
// 获取access_token(如果是重试,强制刷新)
$forceRefresh = $retryCount > 0;
$accessToken = self::getWxAccessToken($config['app_id'], $config['app_secret'], $forceRefresh);
if (!$accessToken) {
throw new \Exception('获取access_token失败: ' . self::getError());
}
// 调用微信接口生成小程序码
$url = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={$accessToken}";
// 记录 scene 长度
\think\facade\Log::info('Scene参数信息', [
'scene' => $scene,
'scene_length' => strlen($scene),
'scene_urlencode_length' => strlen(urlencode($scene))
]);
$data = [
'scene' => $scene,
'page' => $page,
'check_path' => false,
'env_version' => 'release',
'width' => 280
];
$response = self::httpPost($url, json_encode($data));
// 检查是否返回错误(JSON格式)
$result = json_decode($response, true);
if (is_array($result) && isset($result['errcode']) && $result['errcode'] != 0) {
$errorMsg = "生成小程序码失败: errcode={$result['errcode']}, errmsg=" . ($result['errmsg'] ?? '未知错误');
\think\facade\Log::error($errorMsg);
// 如果是AccessToken错误且未重试过,自动重试一次
if (($result['errcode'] == 40001 || $result['errcode'] == 42001) && $retryCount < 1) {
\think\facade\Log::info('AccessToken失效,自动重试(第' . ($retryCount + 1) . '次)');
// 清除缓存
$cacheKey = 'wx_access_token_' . $config['app_id'];
cache($cacheKey, null);
// 递归调用,重试一次
return self::generateWxQrcode($config, $page, $scene, $retryCount + 1);
}
throw new \Exception($errorMsg);
}
// 如果不是JSON错误,说明返回的是图片二进制数据
// 保存图片
$filename = 'qrcode_' . $config['app_id'] . '_' . md5($scene) . '_' . time() . '.png';
$savePath = 'uploads/qrcode/' . date('Ymd') . '/';
$fullPath = public_path() . $savePath;
if (!is_dir($fullPath)) {
mkdir($fullPath, 0755, true);
}
$saved = file_put_contents($fullPath . $filename, $response);
if (!$saved) {
throw new \Exception('保存二维码图片失败');
}
$qrcodeUrl = request()->domain() . '/' . $savePath . $filename;
\think\facade\Log::info('小程序码生成成功', ['url' => $qrcodeUrl, 'retry_count' => $retryCount]);
return $qrcodeUrl;
} catch (\Exception $e) {
\think\facade\Log::error('generateWxQrcode异常: ' . $e->getMessage());
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 获取微信access_token
* @param string $appId
* @param string $appSecret
* @param bool $forceRefresh 是否强制刷新(不使用缓存)
* @return string|false
*/
private static function getWxAccessToken(string $appId, string $appSecret, bool $forceRefresh = false)
{
try {
$cacheKey = 'wx_access_token_' . $appId;
// 如果不是强制刷新,尝试从缓存获取
if (!$forceRefresh) {
$accessToken = cache($cacheKey);
if ($accessToken) {
\think\facade\Log::info('使用缓存的AccessToken');
return $accessToken;
}
} else {
\think\facade\Log::info('强制刷新AccessToken,清除缓存');
cache($cacheKey, null);
}
// 从微信服务器获取
$url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={$appId}&secret={$appSecret}";
$response = self::httpGet($url);
// 记录原始响应用于调试
\think\facade\Log::info('微信AccessToken响应: ' . $response);
$result = json_decode($response, true);
// 检查是否有错误
if (isset($result['errcode']) && $result['errcode'] != 0) {
$errorMsg = "获取AccessToken失败: errcode={$result['errcode']}, errmsg=" . ($result['errmsg'] ?? '未知错误');
\think\facade\Log::error($errorMsg);
throw new \Exception($errorMsg);
}
if (isset($result['access_token'])) {
// 缓存access_token,有效期7000秒(微信官方7200秒,提前200秒过期)
cache($cacheKey, $result['access_token'], 7000);
\think\facade\Log::info('AccessToken获取成功并已缓存');
return $result['access_token'];
}
throw new \Exception('获取access_token失败: 响应中没有access_token字段');
} catch (\Exception $e) {
\think\facade\Log::error('getWxAccessToken异常: ' . $e->getMessage());
self::setError($e->getMessage());
return false;
}
}
/**
* @notes HTTP GET请求
* @param string $url
* @return string|false
*/
private static function httpGet(string $url)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$response = curl_exec($ch);
curl_close($ch);
return $response;
}
/**
* @notes HTTP POST请求
* @param string $url
* @param string $data
* @return string|false
*/
private static function httpPost(string $url, string $data)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
$response = curl_exec($ch);
curl_close($ch);
return $response;
}
/**
* @notes 获取诊单详情并记录查看
* @param array $params
* @return array|false
*/
public static function diagnosisDetailWithRecord(array $params)
{
try {
$diagnosisId = $params['id'];
$userId = $params['user_id'] ?? 0;
$shareUserId = $params['share_user_id'] ?? 0;
// 获取诊单详情
$diagnosis = self::detail(['id' => $diagnosisId,'user_id' => $userId]);
if (!$diagnosis) {
throw new \Exception('诊单不存在');
}
// 记录查看行为
// self::recordDiagnosisView([
// 'user_id' => $userId,
// 'diagnosis_id' => $diagnosisId,
// 'patient_id' => $diagnosis['patient_id'] ?? 0,
// 'share_user_id' => $shareUserId
// ]);
return $diagnosis;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 记录诊单查看
* @param array $params
* @return bool
*/
private static function recordDiagnosisView(array $params)
{
try {
$userId = $params['user_id'];
$diagnosisId = $params['diagnosis_id'];
$patientId = $params['patient_id'];
$shareUserId = $params['share_user_id'];
// 查找是否已有记录
$record = \app\common\model\DiagnosisViewRecord::where([
'user_id' => $userId,
'diagnosis_id' => $diagnosisId
])->find();
$now = time();
if ($record) {
// 更新记录
$record->view_count = $record->view_count + 1;
$record->last_view_time = $now;
$record->update_time = $now;
$record->save();
} else {
// 创建新记录
\app\common\model\DiagnosisViewRecord::create([
'user_id' => $userId,
'diagnosis_id' => $diagnosisId,
'patient_id' => $patientId,
'share_user_id' => $shareUserId,
'view_count' => 1,
'first_view_time' => $now,
'last_view_time' => $now,
'is_confirmed' => 0,
'create_time' => $now,
'update_time' => $now
]);
}
return true;
} catch (\Exception $e) {
\think\facade\Log::error('记录诊单查看失败: ' . $e->getMessage());
return false;
}
}
/**
* @notes 确认诊单
* @param array $params
* @return bool
*/
public static function confirmDiagnosisRecord(array $params)
{
try {
$userId = $params['user_id'] ?? 0;
$diagnosisId = $params['id'];
$shareUserId = $params['share_user_id'] ?? 0;
if(!$userId){
throw new \Exception('请重新登录!');
}
// 查找查看记录
$record = \app\common\model\DiagnosisViewRecord::where([
'user_id' => $userId,
'diagnosis_id' => $diagnosisId
])->find();
$now = time();
if ($record) {
// 更新确认状态
$record->is_confirmed = 1;
$record->confirmed_time = $now;
$record->update_time = $now;
$record->save();
} else {
// 如果没有查看记录,创建一条已确认的记录
$diagnosis = Diagnosis::find($diagnosisId);
if (!$diagnosis) {
throw new \Exception('诊单不存在');
}
// 再次检查是否存在记录,防止并发创建重复记录
$existingRecord = \app\common\model\DiagnosisViewRecord::where([
'user_id' => $userId,
'diagnosis_id' => $diagnosisId
])->find();
if (!$existingRecord) {
\app\common\model\DiagnosisViewRecord::create([
'user_id' => $userId,
'diagnosis_id' => $diagnosisId,
'patient_id' => $diagnosis->patient_id ?? 0,
'share_user_id' => $shareUserId,
'view_count' => 1,
'first_view_time' => $now,
'last_view_time' => $now,
'is_confirmed' => 1,
'confirmed_time' => $now,
'create_time' => $now,
'update_time' => $now
]);
}
}
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 获取就诊卡列表(供小程序调用)
* @param int $patientId
* @return array|false
*/
public static function getCardList(int $user_id)
{
try {
if (!$user_id) {
self::setError('用户ID不能为空');
return false;
}
// 从诊断查看记录表中获取该用户查看过的诊断ID列表
$viewRecords = \think\facade\Db::name('diagnosis_view_records')
->where('user_id', $user_id)
->where('delete_time', null)
->column('diagnosis_id');
if (empty($viewRecords)) {
return [];
}
// 根据诊断ID列表查询诊单详情
$cardList = Diagnosis::whereIn('id', $viewRecords)
->where('delete_time', null)
->field([
'id', 'patient_id', 'patient_name', 'gender', 'age',
'diagnosis_date', 'diagnosis_type', 'syndrome_type',
'status', 'create_time', 'update_time'
])
->order('create_time', 'desc')
->select()
->toArray();
// 处理数据格式
foreach ($cardList as &$card) {
// 转换诊断日期为时间戳和格式化文本
if ($card['diagnosis_date']) {
$card['diagnosis_date_text'] = date('Y-m-d', $card['diagnosis_date']);
} else {
$card['diagnosis_date_text'] = '-';
}
// 添加性别描述
$card['gender_desc'] = $card['gender'] == 1 ? '男' : '女';
// 添加状态描述
$card['status_desc'] = $card['status'] == 1 ? '启用' : '禁用';
// 格式化创建时间
if ($card['create_time']) {
$card['create_time'] = date('Y-m-d H:i:s', $card['create_time']);
}
// 格式化更新时间
if ($card['update_time']) {
$card['update_time'] = date('Y-m-d H:i:s', $card['update_time']);
}
}
return $cardList;
} catch (\Exception $e) {
\think\facade\Log::error('获取就诊卡列表失败 - user_id: ' . $user_id . ', error: ' . $e->getMessage());
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 新建就诊卡(供小程序调用)
* patient_id 可选:有则复用(同患者多张卡),无则自动生成(新患者首张卡)
* @param array $params
* @return array|bool 成功返回 ['id'=>诊单ID, 'patient_id'=>患者ID],失败返回false
*/
public static function addCard(array $params)
{
try {
$userId = $params['user_id'] ?? 0;
unset($params['user_id']); // 诊单表无此字段,仅用于创建 view_record
$patientId = $params['patient_id'] ?? 0;
if (!$patientId) {
$params['patient_id'] = self::generatePatientId();
}
$params['status'] = 1;
// 处理既往史数组
if (isset($params['past_history']) && is_array($params['past_history'])) {
$params['past_history'] = implode(',', $params['past_history']);
}
$multiSelectFields = [
'diet_condition', 'body_feeling', 'sleep_condition', 'eye_condition',
'head_feeling', 'sweat_condition', 'skin_condition', 'urine_condition',
'stool_condition', 'kidney_condition', 'local_hospital_diagnosis'
];
foreach ($multiSelectFields as $field) {
if (isset($params[$field]) && is_array($params[$field])) {
$params[$field] = implode(',', $params[$field]);
}
}
if (isset($params['tongue_images']) && is_array($params['tongue_images'])) {
$params['tongue_images'] = json_encode($params['tongue_images'], JSON_UNESCAPED_UNICODE);
}
if (isset($params['report_files']) && is_array($params['report_files'])) {
$params['report_files'] = json_encode($params['report_files'], JSON_UNESCAPED_UNICODE);
}
if (isset($params['diagnosis_date'])) {
$params['diagnosis_date'] = is_numeric($params['diagnosis_date']) ? $params['diagnosis_date'] : strtotime($params['diagnosis_date']);
}
$model = Diagnosis::create($params);
self::createPatientTrtcAccount($model->patient_id);
// 关联 diagnosis_view_records,便于用户通过 User::with('diagnosis') 获取就诊卡
if ($userId) {
$now = time();
\app\common\model\DiagnosisViewRecord::create([
'user_id' => $userId,
'diagnosis_id' => $model->id,
'patient_id' => $model->patient_id,
'share_user_id' => 0,
'view_count' => 1,
'first_view_time' => $now,
'last_view_time' => $now,
'is_confirmed' => 0,
'create_time' => $now,
'update_time' => $now
]);
}
return ['id' => $model->id, 'patient_id' => $model->patient_id];
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 更新就诊卡(供小程序调用)
* @param array $params
* @return bool
*/
public static function updateCard(array $params)
{
try {
$diagnosisId = $params['id'] ?? 0;
$patientId = $params['patient_id'] ?? 0;
if (!$diagnosisId) {
self::setError('诊单ID不能为空');
return false;
}
if (!$patientId) {
self::setError('患者ID不能为空');
return false;
}
// 获取诊单信息
$diagnosis = Diagnosis::find($diagnosisId);
if (!$diagnosis) {
self::setError('诊单不存在');
return false;
}
// 权限验证:只能修改自己的诊单
if ($diagnosis->patient_id != $patientId) {
\think\facade\Log::warning('非法修改诊单 - diagnosis_id: ' . $diagnosisId . ', patient_id: ' . $patientId . ', actual_patient_id: ' . $diagnosis->patient_id);
self::setError('无权限修改此诊单');
return false;
}
// 准备更新数据
$updateData = [
'patient_name' => $params['patient_name'] ?? '',
'gender' => $params['gender'] ?? 1,
'age' => !empty($params['age']) ? intval($params['age']) : 0,
'id_card' => $params['id_card'] ?? '',
'height' => !empty($params['height']) ? floatval($params['height']) : null,
'weight' => !empty($params['weight']) ? floatval($params['weight']) : null,
'systolic_pressure' => !empty($params['systolic_pressure']) ? intval($params['systolic_pressure']) : null,
'diastolic_pressure' => !empty($params['diastolic_pressure']) ? intval($params['diastolic_pressure']) : null,
'fasting_blood_sugar' => !empty($params['fasting_blood_sugar']) ? floatval($params['fasting_blood_sugar']) : null,
'diabetes_discovery_year' => isset($params['diabetes_discovery_year'])
? (trim((string) $params['diabetes_discovery_year']) !== '' ? trim((string) $params['diabetes_discovery_year']) : null)
: null,
'local_hospital_diagnosis' => $params['local_hospital_diagnosis'] ?? '',
'local_hospital_name' => $params['local_hospital_name'] ?? '',
'local_hospital_visit_date' => $params['local_hospital_visit_date'] ?? '',
'diagnosis_type' => $params['diagnosis_type'] ?? '',
'syndrome_type' => $params['syndrome_type'] ?? '',
'diabetes_type' => $params['diabetes_type'] ?? '',
// 现病史字段
'appetite' => $params['appetite'] ?? '',
'water_intake' => $params['water_intake'] ?? '',
'diet_condition' => $params['diet_condition'] ?? '',
'weight_change' => $params['weight_change'] ?? '',
'body_feeling' => $params['body_feeling'] ?? '',
'sleep_condition' => $params['sleep_condition'] ?? '',
'eye_condition' => $params['eye_condition'] ?? '',
'head_feeling' => $params['head_feeling'] ?? '',
'sweat_condition' => $params['sweat_condition'] ?? '',
'skin_condition' => $params['skin_condition'] ?? '',
'urine_condition' => $params['urine_condition'] ?? '',
'stool_condition' => $params['stool_condition'] ?? '',
'kidney_condition' => $params['kidney_condition'] ?? '',
'fatty_liver_degree' => $params['fatty_liver_degree'] ?? '',
// 既往史字段
'past_history' => $params['past_history'] ?? '',
'trauma_history' => isset($params['trauma_history']) ? intval($params['trauma_history']) : 0,
'surgery_history' => isset($params['surgery_history']) ? intval($params['surgery_history']) : 0,
'allergy_history' => isset($params['allergy_history']) ? intval($params['allergy_history']) : 0,
'family_history' => isset($params['family_history']) ? intval($params['family_history']) : 0,
'pregnancy_history' => isset($params['pregnancy_history']) ? intval($params['pregnancy_history']) : 0,
// 临床信息
'symptoms' => $params['symptoms'] ?? '',
'tongue_coating' => $params['tongue_coating'] ?? '',
'tongue_images' => $params['tongue_images'] ?? '',
'report_files' => $params['report_files'] ?? '',
'pulse' => $params['pulse'] ?? '',
'treatment_principle' => $params['treatment_principle'] ?? '',
'prescription' => $params['prescription'] ?? '',
'doctor_advice' => $params['doctor_advice'] ?? '',
'remark' => $params['remark'] ?? '',
'update_time' => time()
];
// 处理诊断日期
if (!empty($params['diagnosis_date'])) {
$updateData['diagnosis_date'] = strtotime($params['diagnosis_date']);
} elseif (!empty($params['local_hospital_visit_date'])) {
// 如果没有diagnosis_date但有local_hospital_visit_date,则使用local_hospital_visit_date
$updateData['diagnosis_date'] = strtotime($params['local_hospital_visit_date']);
}
// 执行更新
$diagnosis->save($updateData);
\think\facade\Log::info('诊单更新成功 - diagnosis_id: ' . $diagnosisId . ', patient_id: ' . $patientId);
return true;
} catch (\Exception $e) {
\think\facade\Log::error('更新诊单失败 - error: ' . $e->getMessage());
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 获取患者的企微信息(基于会话内容存档接口)
*/
public static function getWechatExternalContact(int $patientId)
{
try {
$diagnosis = \app\common\model\tcm\Diagnosis::where('patient_id', $patientId)
->field(['id', 'patient_name', 'phone', 'external_userid'])
->findOrEmpty();
if ($diagnosis->isEmpty()) {
$diagnosis = \app\common\model\tcm\Diagnosis::where('id', $patientId)
->field(['id', 'patient_name', 'phone', 'external_userid'])
->findOrEmpty();
}
if ($diagnosis->isEmpty()) {
self::setError('未找到患者信息');
return false;
}
$corpId = env('work_wechat.corp_id', '');
$msgauditSecret = env('work_wechat.msgaudit_secret', '');
$result = [
'patient_name' => $diagnosis->patient_name,
'phone' => $diagnosis->phone,
'external_userid' => $diagnosis->external_userid ?? '',
'msgaudit_enabled' => !empty($corpId) && !empty($msgauditSecret),
'permit_users' => [],
'msgaudit_agree' => [],
];
if (empty($corpId) || empty($msgauditSecret)) {
return $result;
}
$accessToken = self::getAccessToken($corpId, $msgauditSecret, 'msgaudit');
if (!$accessToken) {
return $result;
}
// 获取开启会话存档的成员列表
$permitUsers = self::fetchMsgAuditPermitUsers($accessToken);
$result['permit_users'] = $permitUsers;
// 如果有 external_userid,检查同意存档情况
if (!empty($diagnosis->external_userid) && !empty($permitUsers)) {
$agreeInfo = self::checkMsgAuditAgree($accessToken, $permitUsers, $diagnosis->external_userid);
$result['msgaudit_agree'] = $agreeInfo;
}
return $result;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
// ========== 通用 access_token 获取 ==========
private static function getAccessToken(string $corpId, string $secret, string $type = 'app')
{
$cacheKey = "work_wechat_{$type}_token_" . md5($corpId . $secret);
$cached = \think\facade\Cache::get($cacheKey);
if ($cached) return $cached;
$url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={$corpId}&corpsecret={$secret}";
$response = self::wechatHttpGet($url);
if (!$response || ($response['errcode'] ?? -1) != 0) {
\think\facade\Log::error("获取企微access_token失败[{$type}]: " . json_encode($response));
return false;
}
\think\facade\Cache::set($cacheKey, $response['access_token'], $response['expires_in'] - 200);
return $response['access_token'];
}
// ========== 会话内容存档 msgaudit 接口 ==========
/**
* @notes 获取会话内容存档开启成员列表
*/
public static function getMsgAuditPermitUsers()
{
try {
$corpId = env('work_wechat.corp_id', '');
$msgauditSecret = env('work_wechat.msgaudit_secret', '');
if (empty($corpId) || empty($msgauditSecret)) {
self::setError('会话内容存档Secret未配置(MSGAUDIT_SECRET');
return false;
}
$accessToken = self::getAccessToken($corpId, $msgauditSecret, 'msgaudit');
if (!$accessToken) {
self::setError('获取会话内容存档access_token失败');
return false;
}
$ids = self::fetchMsgAuditPermitUsers($accessToken);
return ['ids' => $ids];
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
private static function fetchMsgAuditPermitUsers(string $accessToken): array
{
$url = "https://qyapi.weixin.qq.com/cgi-bin/msgaudit/get_permit_user_list?access_token={$accessToken}";
$response = self::wechatHttpPost($url, '{}');
if (!$response || ($response['errcode'] ?? -1) != 0) {
return [];
}
return $response['ids'] ?? [];
}
/**
* @notes 检查单聊会话同意存档情况
*/
private static function checkMsgAuditAgree(string $accessToken, array $permitUserIds, string $externalUserId)
{
$infoList = [];
foreach ($permitUserIds as $userId) {
$infoList[] = [
'userid' => $userId,
'exteranalopenid' => $externalUserId,
];
}
$url = "https://qyapi.weixin.qq.com/cgi-bin/msgaudit/check_single_agree?access_token={$accessToken}";
$response = self::wechatHttpPost($url, json_encode(['info' => $infoList]));
if (!$response || ($response['errcode'] ?? -1) != 0) {
return [];
}
return $response['agreeinfo'] ?? [];
}
// ========== HTTP 工具方法 ==========
private static function wechatHttpGet(string $url)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$result = curl_exec($ch);
curl_close($ch);
return $result ? json_decode($result, true) : null;
}
private static function wechatHttpPost(string $url, string $jsonBody)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonBody);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
$result = curl_exec($ch);
curl_close($ch);
return $result ? json_decode($result, true) : null;
}
/**
* @notes 医助理诊单统计(按部门维度,按人统计创建数量)
* @param array $params start_time, end_time, days(可选,默认7)
* @return array
*/
public static function assistantDiagnosisStats(array $params = [])
{
$days = isset($params['days']) ? (int)$params['days'] : 7;
$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 (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));
$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,
];
}
$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]],
],
];
}
}