3615 lines
135 KiB
PHP
3615 lines
135 KiB
PHP
<?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\DiagnosisGuahaoLog;
|
||
use app\common\model\tcm\DiagnosisAssignLog;
|
||
use app\common\model\tcm\ImChatMessage;
|
||
use app\common\model\tcm\BloodRecord;
|
||
use app\common\model\tcm\DietRecord;
|
||
use app\common\model\tcm\ExerciseRecord;
|
||
use app\common\model\DiagnosisViewRecord;
|
||
use app\common\model\doctor\Appointment;
|
||
use app\common\model\auth\Admin;
|
||
use app\common\model\auth\AdminRole;
|
||
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
||
use app\adminapi\logic\doctor\AppointmentLogic;
|
||
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||
use app\common\service\FileService;
|
||
use app\common\service\DataScope\DataScopeService;
|
||
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]);
|
||
}
|
||
}
|
||
|
||
// 图片字段迁移至 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']);
|
||
}
|
||
}
|
||
|
||
$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']);
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @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'];
|
||
}
|
||
$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 = \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'])
|
||
: '';
|
||
$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;
|
||
}
|
||
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) {
|
||
// 前端常重复回调 endCall(afterCalling + Store idle),第一条已结束则不再告警
|
||
$latest = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
|
||
->order('id', 'desc')
|
||
->find();
|
||
$justEnded = $latest
|
||
&& (int)($latest['status'] ?? 0) === 2
|
||
&& (int)($latest['end_time'] ?? 0) > 0
|
||
&& (time() - (int)$latest['end_time']) < 120;
|
||
if (!$justEnded) {
|
||
\think\facade\Log::warning('endCall: 无进行中通话记录,未调用 DeleteCloudRecording', [
|
||
'diagnosis_id' => $diagnosisId,
|
||
'admin_id' => $adminId,
|
||
]);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
$taskId = trim((string)($record['cloud_recording_task_id'] ?? ''));
|
||
if ($taskId !== '') {
|
||
$stopped = \app\common\service\TrtcCloudRecordingService::stopRecording(
|
||
\app\common\service\TrtcCloudRecordingService::trtcSdkAppId(),
|
||
$taskId
|
||
);
|
||
if (empty($stopped['ok'])) {
|
||
\think\facade\Log::warning('endCall: DeleteCloudRecording 失败', [
|
||
'message' => (string)($stopped['message'] ?? ''),
|
||
'task_id' => $taskId,
|
||
'call_record_id' => $record['id'] ?? null,
|
||
'room_id' => $record['room_id'] ?? '',
|
||
]);
|
||
}
|
||
} else {
|
||
\think\facade\Log::warning('endCall: cloud_recording_task_id 为空,未调用 DeleteCloudRecording;控制台「房间尚未结束」常见于录制机器人仍在房或未走 API 合流录制', [
|
||
'call_record_id' => $record['id'] ?? null,
|
||
'diagnosis_id' => $diagnosisId,
|
||
'room_id' => $record['room_id'] ?? '',
|
||
]);
|
||
}
|
||
|
||
$endTime = time();
|
||
$duration = $endTime - (int)$record->start_time;
|
||
|
||
// 保留 cloud_recording_task_id:VOD 311 回调常在挂断之后到达,需按 TaskId 关联写入 recording_urls
|
||
$record->save([
|
||
'status' => 2, // 2-已结束
|
||
'end_time' => $endTime,
|
||
'duration' => $duration,
|
||
'update_time' => time(),
|
||
]);
|
||
|
||
return true;
|
||
} catch (\Exception $e) {
|
||
self::setError($e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 患者端挂断:停止云端录制并结束该诊单下对应通话记录(不依赖管理端浏览器是否触发 endCall)
|
||
* @param array{diagnosis_id?:int,patient_id:int,room_id?:string} $params patient_id 须与诊单 tcm_diagnosis.patient_id 一致;room_id 与 bindCallRoom 一致时可精准命中(小程序通话页独立时 chat 页可能已卸载,仅靠 room_id + patient_id 即可)
|
||
*/
|
||
public static function patientHangupVideoCall(array $params): bool
|
||
{
|
||
try {
|
||
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
|
||
$patientId = (int)($params['patient_id'] ?? 0);
|
||
$roomIdRaw = trim((string)($params['room_id'] ?? ''));
|
||
if ($patientId <= 0) {
|
||
self::setError('参数错误');
|
||
|
||
return false;
|
||
}
|
||
if ($diagnosisId <= 0 && $roomIdRaw === '') {
|
||
self::setError('诊单ID或房间号须至少传一项');
|
||
|
||
return false;
|
||
}
|
||
|
||
$record = null;
|
||
if ($roomIdRaw !== '') {
|
||
$roomCandidates = array_values(array_unique(array_filter([
|
||
$roomIdRaw,
|
||
ctype_digit($roomIdRaw) ? (string)((int)$roomIdRaw) : '',
|
||
])));
|
||
$record = \app\common\model\tcm\CallRecord::where('status', 1)
|
||
->whereIn('room_id', $roomCandidates)
|
||
->order('id', 'desc')
|
||
->find();
|
||
if ($record) {
|
||
$diagByRoom = Diagnosis::find((int)$record['diagnosis_id']);
|
||
if (!$diagByRoom || (int)($diagByRoom['patient_id'] ?? 0) !== $patientId) {
|
||
$record = null;
|
||
} else {
|
||
$diagnosisId = (int)$record['diagnosis_id'];
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!$record && $diagnosisId > 0) {
|
||
$diag = Diagnosis::find($diagnosisId);
|
||
if (!$diag || (int)($diag['patient_id'] ?? 0) !== $patientId) {
|
||
self::setError('无权操作该诊单');
|
||
|
||
return false;
|
||
}
|
||
|
||
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
|
||
->where('callee_id', $patientId)
|
||
->where('status', 1)
|
||
->order('id', 'desc')
|
||
->find();
|
||
if (!$record) {
|
||
$cnt = (int)\app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)->where('status', 1)->count();
|
||
if ($cnt === 1) {
|
||
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
|
||
->where('status', 1)
|
||
->order('id', 'desc')
|
||
->find();
|
||
}
|
||
}
|
||
}
|
||
if (!$record) {
|
||
return true;
|
||
}
|
||
|
||
$taskId = trim((string)($record['cloud_recording_task_id'] ?? ''));
|
||
if ($taskId !== '') {
|
||
\app\common\service\TrtcCloudRecordingService::stopRecording(
|
||
\app\common\service\TrtcCloudRecordingService::trtcSdkAppId(),
|
||
$taskId
|
||
);
|
||
}
|
||
$endTime = time();
|
||
$duration = $endTime - (int)$record->start_time;
|
||
$record->save([
|
||
'status' => 2,
|
||
'end_time' => $endTime,
|
||
'duration' => $duration,
|
||
'update_time' => $endTime,
|
||
]);
|
||
\think\facade\Log::info('patientHangupVideoCall: 已停录并结束通话记录', [
|
||
'call_record_id' => $record->id,
|
||
'diagnosis_id' => $diagnosisId,
|
||
]);
|
||
|
||
return true;
|
||
} catch (\Exception $e) {
|
||
self::setError($e->getMessage());
|
||
\think\facade\Log::warning('patientHangupVideoCall: ' . $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 获取通话记录
|
||
* @param array $params
|
||
* @return array
|
||
*/
|
||
public static function getCallRecords(array $params): array
|
||
{
|
||
try {
|
||
$records = \app\common\model\tcm\CallRecord::where('diagnosis_id', $params['diagnosis_id'])
|
||
->order('id', 'desc')
|
||
->select()
|
||
->toArray();
|
||
|
||
foreach ($records as &$record) {
|
||
$record['start_time_text'] = date('Y-m-d H:i:s', $record['start_time']);
|
||
$record['end_time_text'] = $record['end_time'] ? date('Y-m-d H:i:s', $record['end_time']) : '';
|
||
$record['duration_text'] = self::formatDuration($record['duration']);
|
||
$urls = [];
|
||
if (!empty($record['recording_urls'])) {
|
||
$decoded = json_decode((string)$record['recording_urls'], true);
|
||
if (is_array($decoded)) {
|
||
$urls = $decoded;
|
||
}
|
||
}
|
||
$record['recording_urls_list'] = $urls;
|
||
$record['recording_status_text'] = self::recordingStatusText((int)($record['recording_status'] ?? 0));
|
||
}
|
||
|
||
return $records;
|
||
} catch (\Exception $e) {
|
||
self::setError($e->getMessage());
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 将 TRTC 房间号写入当前诊单通话记录,并尝试 API 合流云端录制
|
||
* @return array{cloud_recording?:array}|false 成功返回 data 数组(供接口带给前端);失败 false
|
||
*/
|
||
public static function bindCallRoom(array $params)
|
||
{
|
||
try {
|
||
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
|
||
$roomId = trim((string)($params['room_id'] ?? ''));
|
||
if ($diagnosisId <= 0 || $roomId === '') {
|
||
self::setError('诊单ID或房间号不能为空');
|
||
return false;
|
||
}
|
||
|
||
$adminId = (int)($params['admin_id'] ?? 0);
|
||
|
||
// 必须与 startCloudRecording 使用同一条「进行中 + 当前管理员」记录写 room_id,否则会写到别的记录上,合流 API 读到 room_id 仍为空 → 关闭全局录制后无任何文件
|
||
$record = null;
|
||
if ($adminId > 0) {
|
||
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
|
||
->where('status', 1)
|
||
->where('caller_id', $adminId)
|
||
->order('id', 'desc')
|
||
->find();
|
||
}
|
||
if (!$record) {
|
||
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
|
||
->where('status', 1)
|
||
->order('id', 'desc')
|
||
->find();
|
||
}
|
||
if (!$record) {
|
||
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
|
||
->where('room_id', '')
|
||
->order('id', 'desc')
|
||
->find();
|
||
}
|
||
if (!$record) {
|
||
self::setError('未找到可绑定的通话记录');
|
||
return false;
|
||
}
|
||
|
||
$record->save([
|
||
'room_id' => $roomId,
|
||
'update_time' => time(),
|
||
]);
|
||
|
||
$cloudPayload = [
|
||
'started' => false,
|
||
'task_id' => '',
|
||
'message' => '未尝试合流录制(admin_id 为空)',
|
||
];
|
||
if ($adminId > 0) {
|
||
$cloudRec = self::startCloudRecording([
|
||
'diagnosis_id' => $diagnosisId,
|
||
'admin_id' => $adminId,
|
||
], true);
|
||
if ($cloudRec === false) {
|
||
\think\facade\Log::warning('bindCallRoom: startCloudRecording 未执行或异常', [
|
||
'diagnosis_id' => $diagnosisId,
|
||
'room_id' => $roomId,
|
||
]);
|
||
$cloudPayload = [
|
||
'started' => false,
|
||
'task_id' => '',
|
||
'message' => '合流录制未启动(无匹配通话记录或异常,见服务端日志)',
|
||
];
|
||
} elseif (is_array($cloudRec)) {
|
||
$cloudPayload = [
|
||
'started' => !empty($cloudRec['started']),
|
||
'task_id' => (string)($cloudRec['task_id'] ?? ''),
|
||
'message' => (string)($cloudRec['message'] ?? ''),
|
||
];
|
||
if (empty($cloudRec['started'])) {
|
||
\think\facade\Log::warning('bindCallRoom: CreateCloudRecording 合流未启动', [
|
||
'diagnosis_id' => $diagnosisId,
|
||
'room_id' => $roomId,
|
||
'message' => $cloudPayload['message'],
|
||
]);
|
||
} else {
|
||
\think\facade\Log::info('bindCallRoom: 合流云端录制已发起', [
|
||
'diagnosis_id' => $diagnosisId,
|
||
'room_id' => $roomId,
|
||
'task_id' => $cloudPayload['task_id'],
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
return ['cloud_recording' => $cloudPayload];
|
||
} catch (\Exception $e) {
|
||
self::setError($e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 医生接通并绑定房间后:发起腾讯云云端混流录制(需 CAM 密钥 + 控制台开通录制 + 云点播)
|
||
* @param array $params diagnosis_id、admin_id
|
||
* @return array|false
|
||
*/
|
||
/**
|
||
* @param bool $silent 为 true 时不写入 BaseLogic 错误(供 bindCallRoom 内自动调用,避免污染绑定接口)
|
||
*/
|
||
public static function startCloudRecording(array $params, bool $silent = false)
|
||
{
|
||
try {
|
||
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
|
||
$adminId = (int)($params['admin_id'] ?? 0);
|
||
if ($diagnosisId <= 0 || $adminId <= 0) {
|
||
if (!$silent) {
|
||
self::setError('参数错误');
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
|
||
->where('caller_id', $adminId)
|
||
->where('status', 1)
|
||
->order('id', 'desc')
|
||
->find();
|
||
if (!$record) {
|
||
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
|
||
->where('status', 1)
|
||
->order('id', 'desc')
|
||
->find();
|
||
if ($record) {
|
||
\think\facade\Log::warning('startCloudRecording: 未找到 caller_id 匹配的进行中记录,已回退到该诊单最新进行中记录', [
|
||
'diagnosis_id' => $diagnosisId,
|
||
'admin_id' => $adminId,
|
||
'record_caller_id' => $record['caller_id'] ?? null,
|
||
]);
|
||
}
|
||
}
|
||
if (!$record) {
|
||
if (!$silent) {
|
||
self::setError('没有进行中的通话记录');
|
||
}
|
||
|
||
return false;
|
||
}
|
||
$roomId = trim((string)($record['room_id'] ?? ''));
|
||
if ($roomId === '') {
|
||
if (!$silent) {
|
||
self::setError('尚未同步房间号,无法开启云端录制');
|
||
}
|
||
|
||
return false;
|
||
}
|
||
$existingTask = trim((string)($record['cloud_recording_task_id'] ?? ''));
|
||
if ($existingTask !== '') {
|
||
return [
|
||
'started' => true,
|
||
'task_id' => $existingTask,
|
||
'message' => '已在录制中',
|
||
];
|
||
}
|
||
|
||
$prefix = (string)config('trtc.recording_bot_prefix', 'recorder_');
|
||
$botUserId = $prefix . $diagnosisId . '_' . (int)$record['id'];
|
||
try {
|
||
$imService = new \app\common\service\TencentImService();
|
||
$imService->importAccount($botUserId, '云端录制');
|
||
} catch (\Throwable $e) {
|
||
// 导入失败不阻断,部分环境仅 TRTC 亦可进房
|
||
}
|
||
$botSig = \app\common\service\TrtcCloudRecordingService::makeBotUserSig($botUserId);
|
||
$sdkAppId = \app\common\service\TrtcCloudRecordingService::trtcSdkAppId();
|
||
|
||
$vodMixPrefix = 'mix_' . $diagnosisId . '_' . (int)$record['id'];
|
||
$r = \app\common\service\TrtcCloudRecordingService::startMixRecording(
|
||
$sdkAppId,
|
||
$roomId,
|
||
$botUserId,
|
||
$botSig,
|
||
$vodMixPrefix
|
||
);
|
||
if (!$r['ok']) {
|
||
return [
|
||
'started' => false,
|
||
'message' => $r['message'] ?? '开启失败',
|
||
];
|
||
}
|
||
|
||
$record->save([
|
||
'cloud_recording_task_id' => $r['task_id'],
|
||
'recording_status' => 1,
|
||
'update_time' => time(),
|
||
]);
|
||
|
||
return [
|
||
'started' => true,
|
||
'task_id' => $r['task_id'],
|
||
'message' => '已发起云端录制',
|
||
];
|
||
} catch (\Exception $e) {
|
||
if (!$silent) {
|
||
self::setError($e->getMessage());
|
||
}
|
||
\think\facade\Log::warning('startCloudRecording: ' . $e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 医生端浏览器本地录制上传后,将文件访问地址合并写入当前诊单下该医生的最近一条通话记录
|
||
*/
|
||
public static function attachLocalCallRecording(array $params): bool
|
||
{
|
||
try {
|
||
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
|
||
$adminId = (int)($params['admin_id'] ?? 0);
|
||
$fileUrl = trim((string)($params['file_url'] ?? ''));
|
||
if ($diagnosisId <= 0 || $adminId <= 0 || $fileUrl === '') {
|
||
self::setError('参数错误');
|
||
|
||
return false;
|
||
}
|
||
|
||
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
|
||
->where('caller_id', $adminId)
|
||
->order('id', 'desc')
|
||
->find();
|
||
// 与 startCall 的 caller_id 不一致或竞态时,回退到该诊单最新一条通话记录
|
||
if (!$record) {
|
||
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
|
||
->order('id', 'desc')
|
||
->find();
|
||
}
|
||
if (!$record) {
|
||
self::setError('未找到通话记录');
|
||
|
||
return false;
|
||
}
|
||
|
||
$prev = [];
|
||
if (!empty($record->recording_urls)) {
|
||
$prev = json_decode((string)$record->recording_urls, true);
|
||
if (!is_array($prev)) {
|
||
$prev = [];
|
||
}
|
||
}
|
||
$merged = array_values(array_unique(array_merge($prev, [$fileUrl])));
|
||
|
||
$record->save([
|
||
'recording_urls' => json_encode($merged, JSON_UNESCAPED_UNICODE),
|
||
'recording_status' => 2,
|
||
'update_time' => time(),
|
||
]);
|
||
|
||
return true;
|
||
} catch (\Exception $e) {
|
||
self::setError($e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 医助进入当前诊单 TRTC 房间旁观(仅拉流,与医生端 doctor_{id} 账号体系一致)
|
||
* @param array $params diagnosis_id、admin_id(当前登录后台用户)
|
||
* @return array|false
|
||
*/
|
||
public static function getAssistantWatchRoomParams(array $params)
|
||
{
|
||
try {
|
||
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
|
||
$adminId = (int)($params['admin_id'] ?? 0);
|
||
if ($diagnosisId <= 0 || $adminId <= 0) {
|
||
self::setError('参数错误');
|
||
return false;
|
||
}
|
||
|
||
$diag = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
|
||
if (!$diag) {
|
||
self::setError('诊单不存在');
|
||
return false;
|
||
}
|
||
|
||
if ((int)($diag['assistant_id'] ?? 0) !== $adminId) {
|
||
self::setError('仅本诊单指派的医助可旁观该通话');
|
||
return false;
|
||
}
|
||
|
||
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
|
||
->where('status', 1)
|
||
->order('id', 'desc')
|
||
->find();
|
||
|
||
$roomRaw = $record ? trim((string)$record['room_id']) : '';
|
||
if ($roomRaw === '') {
|
||
self::setError('当前无进行中的通话或尚未同步房间号,请待医生接通后再试');
|
||
return false;
|
||
}
|
||
|
||
$config = self::getTrtcConfig();
|
||
if (!$config) {
|
||
self::setError('请先配置腾讯云TRTC参数');
|
||
return false;
|
||
}
|
||
|
||
$doctorUserId = 'doctor_' . $adminId;
|
||
self::importDoctorAccountToIm($adminId, $doctorUserId);
|
||
|
||
$userSig = self::generateUserSig($config['sdkAppId'], $config['secretKey'], $doctorUserId);
|
||
if (!$userSig) {
|
||
self::setError('生成签名失败');
|
||
return false;
|
||
}
|
||
|
||
$payload = [
|
||
'sdkAppId' => (int)$config['sdkAppId'],
|
||
'userId' => $doctorUserId,
|
||
'userSig' => $userSig,
|
||
'patientName' => (string)($diag['patient_name'] ?? ''),
|
||
];
|
||
|
||
if (ctype_digit($roomRaw)) {
|
||
$payload['roomId'] = (int)$roomRaw;
|
||
} else {
|
||
$payload['strRoomId'] = $roomRaw;
|
||
}
|
||
|
||
return $payload;
|
||
} catch (\Exception $e) {
|
||
self::setError($e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private static function recordingStatusText(int $status): string
|
||
{
|
||
$map = [
|
||
0 => '无录制',
|
||
1 => '录制中',
|
||
2 => '已生成',
|
||
3 => '录制失败',
|
||
];
|
||
|
||
return $map[$status] ?? '未知';
|
||
}
|
||
|
||
/**
|
||
* @notes 获取TRTC配置
|
||
* @return array|null
|
||
*/
|
||
private static function getTrtcConfig(): ?array
|
||
{
|
||
// 从配置文件或数据库读取
|
||
// 这里使用配置文件方式
|
||
$config = config('project.trtc');
|
||
|
||
if (empty($config['sdkAppId']) || empty($config['secretKey'])) {
|
||
return null;
|
||
}
|
||
|
||
return $config;
|
||
}
|
||
|
||
/**
|
||
* @notes 生成UserSig
|
||
* @param int $sdkAppId
|
||
* @param string $secretKey
|
||
* @param string $userId
|
||
* @param int $expire
|
||
* @return string|false
|
||
*/
|
||
private static function generateUserSig(int $sdkAppId, string $secretKey, string $userId, int $expire = 86400)
|
||
{
|
||
try {
|
||
// 使用官方的TLSSigAPIv2类生成UserSig
|
||
$api = new \app\common\service\TLSSigAPIv2($sdkAppId, $secretKey);
|
||
$userSig = $api->genUserSig($userId, $expire);
|
||
|
||
return $userSig;
|
||
} catch (\Exception $e) {
|
||
\think\facade\Log::error('生成UserSig失败: ' . $e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 格式化通话时长
|
||
* @param int $seconds
|
||
* @return string
|
||
*/
|
||
private static function formatDuration(int $seconds): string
|
||
{
|
||
if ($seconds < 60) {
|
||
return $seconds . '秒';
|
||
} elseif ($seconds < 3600) {
|
||
$minutes = floor($seconds / 60);
|
||
$secs = $seconds % 60;
|
||
return $minutes . '分' . $secs . '秒';
|
||
} else {
|
||
$hours = floor($seconds / 3600);
|
||
$minutes = floor(($seconds % 3600) / 60);
|
||
$secs = $seconds % 60;
|
||
return $hours . '小时' . $minutes . '分' . $secs . '秒';
|
||
}
|
||
}
|
||
/**
|
||
* @notes 为患者创建 TRTC 账号(自动调用)
|
||
* @param int $patientId
|
||
* @return bool
|
||
*/
|
||
private static function createPatientTrtcAccount(int $patientId): bool
|
||
{
|
||
try {
|
||
$config = self::getTrtcConfig();
|
||
|
||
if (!$config || !$config['enable']) {
|
||
// 如果 TRTC 未启用,不创建账号
|
||
return false;
|
||
}
|
||
|
||
$userId = 'patient_' . $patientId;
|
||
|
||
// 检查是否已存在
|
||
$existingAccount = \app\common\model\tcm\PatientTrtc::where('patient_id', $patientId)
|
||
->where('delete_time', null)
|
||
->find();
|
||
|
||
// 生成 UserSig(长期有效,180天)
|
||
$expireTime = 86400 * 180;
|
||
$userSig = self::generateUserSig($config['sdkAppId'], $config['secretKey'], $userId, $expireTime);
|
||
|
||
if (!$userSig) {
|
||
return false;
|
||
}
|
||
|
||
$data = [
|
||
'patient_id' => $patientId,
|
||
'user_id' => $userId,
|
||
'user_sig' => $userSig,
|
||
'sdk_app_id' => $config['sdkAppId'],
|
||
'expire_time' => time() + $expireTime,
|
||
'is_active' => 1
|
||
];
|
||
|
||
if ($existingAccount) {
|
||
// 更新现有账号
|
||
$existingAccount->save($data);
|
||
\think\facade\Log::info('更新患者 TRTC 账号 - patient_id: ' . $patientId . ', user_id: ' . $userId);
|
||
} else {
|
||
// 创建新账号
|
||
\app\common\model\tcm\PatientTrtc::create($data);
|
||
\think\facade\Log::info('创建患者 TRTC 账号 - patient_id: ' . $patientId . ', user_id: ' . $userId);
|
||
}
|
||
|
||
// 导入账号到腾讯云IM(用于TUICallKit)
|
||
self::importAccountToTencentIm($patientId, $userId);
|
||
|
||
return true;
|
||
} catch (\Exception $e) {
|
||
\think\facade\Log::error('创建患者 TRTC 账号失败 - patient_id: ' . $patientId . ', error: ' . $e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 导入账号到腾讯云IM
|
||
* @param int $patientId
|
||
* @param string $userId
|
||
* @return bool
|
||
*/
|
||
private static function importAccountToTencentIm(int $patientId, string $userId): bool
|
||
{
|
||
try {
|
||
// 获取患者信息
|
||
$diagnosis = Diagnosis::where('patient_id', $patientId)
|
||
->order('id', 'desc')
|
||
->find();
|
||
|
||
$nick = $diagnosis ? $diagnosis->patient_name : '患者' . $patientId;
|
||
|
||
// 调用IM服务导入账号
|
||
$imService = new \app\common\service\TencentImService();
|
||
$result = $imService->importAccount($userId, $nick);
|
||
|
||
if ($result['success']) {
|
||
\think\facade\Log::info('导入患者IM账号成功 - patient_id: ' . $patientId . ', user_id: ' . $userId . ', nick: ' . $nick);
|
||
return true;
|
||
} else {
|
||
\think\facade\Log::warning('导入患者IM账号失败 - patient_id: ' . $patientId . ', user_id: ' . $userId . ', error: ' . $result['message']);
|
||
return false;
|
||
}
|
||
} catch (\Exception $e) {
|
||
\think\facade\Log::error('导入患者IM账号异常 - patient_id: ' . $patientId . ', user_id: ' . $userId . ', error: ' . $e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
/**
|
||
* @notes 获取医助签名(供小程序调用)
|
||
* @param int $patientId
|
||
* @return array|bool
|
||
*/
|
||
public static function getDoctorSignature(int $patientId)
|
||
{
|
||
|
||
try {
|
||
// 获取配置
|
||
$config = self::getTrtcConfig();
|
||
|
||
if (!$config) {
|
||
self::setError('请先配置腾讯云TRTC参数');
|
||
return false;
|
||
}
|
||
|
||
// 医助userId(使用 doctor_ 前缀)
|
||
$doctorUserId = 'doctor_' . $patientId;
|
||
|
||
// 生成医助的 UserSig
|
||
$userSig = self::generateUserSig(
|
||
$config['sdkAppId'],
|
||
$config['secretKey'],
|
||
$doctorUserId
|
||
);
|
||
|
||
if (!$userSig) {
|
||
self::setError('生成签名失败');
|
||
return false;
|
||
}
|
||
|
||
// 确保医助账号已导入IM(使用正确的方法)
|
||
self::importDoctorAccountToIm($patientId, $doctorUserId);
|
||
|
||
\think\facade\Log::info('获取医助签名成功 - admin_id: ' . $patientId . ', user_id: ' . $doctorUserId);
|
||
|
||
return [
|
||
'sdkAppId' => (int)$config['sdkAppId'],
|
||
'userId' => $doctorUserId,
|
||
'userSig' => $userSig,
|
||
'expireTime' => 86400
|
||
];
|
||
} catch (\Exception $e) {
|
||
\think\facade\Log::error('获取医助签名失败 - admin_id: ' . $patientId . ', error: ' . $e->getMessage());
|
||
self::setError($e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 获取患者签名(供小程序调用)
|
||
* @param int $patientId
|
||
* @return array|bool
|
||
*/
|
||
public static function getPatientSignature(int $patientId)
|
||
{
|
||
try {
|
||
// 获取配置
|
||
$config = self::getTrtcConfig();
|
||
|
||
if (!$config) {
|
||
self::setError('请先配置腾讯云TRTC参数');
|
||
return false;
|
||
}
|
||
|
||
// 患者userId
|
||
$patientUserId = 'patient_' . $patientId;
|
||
|
||
// 生成患者的 UserSig
|
||
$userSig = self::generateUserSig(
|
||
$config['sdkAppId'],
|
||
$config['secretKey'],
|
||
$patientUserId
|
||
);
|
||
|
||
if (!$userSig) {
|
||
self::setError('生成签名失败');
|
||
return false;
|
||
}
|
||
//添加患者资料
|
||
// 确保患者账号已导入IM
|
||
self::ensurePatientImAccount($patientId, $patientUserId);
|
||
|
||
\think\facade\Log::info('小程序获取患者签名成功 - patient_id: ' . $patientId . ', user_id: ' . $patientUserId);
|
||
$data= Diagnosis::where('id', $patientId)->find();
|
||
return [
|
||
'sdkAppId' => (int)$config['sdkAppId'],
|
||
'userId' => $patientUserId,
|
||
'userSig' => $userSig,
|
||
'data'=>$data,
|
||
'expireTime' => 86400
|
||
];
|
||
} catch (\Exception $e) {
|
||
\think\facade\Log::error('获取患者签名失败 - patient_id: ' . $patientId . ', error: ' . $e->getMessage());
|
||
self::setError($e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 获取医助列表
|
||
* @param int $adminId 当前管理员 id(用于数据范围;0 表示不过滤)
|
||
* @param array|null $adminInfo 当前管理员信息(用于数据范围;null 表示不过滤)
|
||
* @return array
|
||
*/
|
||
public static function getAssistants(int $adminId = 0, ?array $adminInfo = null)
|
||
{
|
||
try {
|
||
// 这里不要用 Admin 模型查询,否则会触发其 append(role_id/dept_id/jobs_id)
|
||
// 每行再发多次查询,形成严重 N+1,统计页会被拖慢到十几秒。
|
||
$query = \think\facade\Db::name('admin')
|
||
->alias('a')
|
||
->join('admin_role ar', 'a.id = ar.admin_id')
|
||
->where('ar.role_id', 2)
|
||
->where('a.disable', 0)
|
||
->whereNull('a.delete_time');
|
||
|
||
// 数据范围:仅展示当前管理员可见的医助;null = 全部不过滤
|
||
if ($adminInfo !== null && $adminId > 0) {
|
||
$visibleIds = \app\common\service\DataScope\DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||
if ($visibleIds !== null) {
|
||
if ($visibleIds === []) {
|
||
return [];
|
||
}
|
||
$query->whereIn('a.id', $visibleIds);
|
||
}
|
||
}
|
||
|
||
$assistants = $query
|
||
->field(['a.id', 'a.name', 'a.account'])
|
||
->order('a.id', 'asc')
|
||
->distinct(true)
|
||
->select()
|
||
->toArray();
|
||
|
||
if ($assistants === []) {
|
||
return [];
|
||
}
|
||
|
||
$adminIds = array_column($assistants, 'id');
|
||
$adminDepts = \think\facade\Db::name('admin_dept')
|
||
->whereIn('admin_id', $adminIds)
|
||
->select()
|
||
->toArray();
|
||
$adminToDeptIds = [];
|
||
foreach ($adminDepts as $ad) {
|
||
$aid = (int) ($ad['admin_id'] ?? 0);
|
||
if ($aid <= 0) {
|
||
continue;
|
||
}
|
||
$adminToDeptIds[$aid][] = (int) ($ad['dept_id'] ?? 0);
|
||
}
|
||
$allDeptIds = [];
|
||
foreach ($adminToDeptIds as $deptIdList) {
|
||
foreach ($deptIdList as $did) {
|
||
if ($did > 0) {
|
||
$allDeptIds[$did] = true;
|
||
}
|
||
}
|
||
}
|
||
$allDeptIds = array_keys($allDeptIds);
|
||
$deptNameMap = [];
|
||
if ($allDeptIds !== []) {
|
||
$deptNameMap = \think\facade\Db::name('dept')
|
||
->whereIn('id', $allDeptIds)
|
||
->whereNull('delete_time')
|
||
->column('name', 'id');
|
||
}
|
||
foreach ($assistants as &$row) {
|
||
$aid = (int) ($row['id'] ?? 0);
|
||
$idsForAdmin = [];
|
||
foreach ($adminToDeptIds[$aid] ?? [] as $did) {
|
||
if ($did > 0) {
|
||
$idsForAdmin[] = $did;
|
||
}
|
||
}
|
||
$row['dept_ids'] = array_values(array_unique($idsForAdmin));
|
||
$names = [];
|
||
foreach ($row['dept_ids'] as $did) {
|
||
if (!empty($deptNameMap[$did])) {
|
||
$names[] = (string) $deptNameMap[$did];
|
||
}
|
||
}
|
||
$row['dept_names'] = $names !== [] ? implode('、', array_unique($names)) : '';
|
||
}
|
||
unset($row);
|
||
|
||
return $assistants;
|
||
} catch (\Exception $e) {
|
||
\think\facade\Log::error('获取医助列表失败: ' . $e->getMessage());
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 获取医生列表
|
||
* @return array
|
||
*/
|
||
public static function getDoctors()
|
||
{
|
||
try {
|
||
// 同 getAssistants,避免 Admin 模型 append 触发的 N+1。
|
||
$doctors = \think\facade\Db::name('admin')
|
||
->alias('a')
|
||
->join('admin_role ar', 'a.id = ar.admin_id')
|
||
->where('ar.role_id', 1)
|
||
->where('a.disable', 0)
|
||
->whereNull('a.delete_time')
|
||
->field(['a.id', 'a.name', 'a.account'])
|
||
->order('a.id', 'asc')
|
||
->distinct(true)
|
||
->select()
|
||
->toArray();
|
||
|
||
return $doctors;
|
||
} catch (\Exception $e) {
|
||
\think\facade\Log::error('获取医生列表失败: ' . $e->getMessage());
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 生成小程序码
|
||
* @param array $params
|
||
* @return array|false
|
||
*/
|
||
public static function generateMiniProgramQrcode(array $params)
|
||
{
|
||
|
||
try {
|
||
$diagnosisId = $params['diagnosis_id'] ?? '';
|
||
$doctorId = $params['doctor_id'] ?? '';
|
||
$patientId = $params['patient_id'];
|
||
$shareUserId = $params['share_user_id'];
|
||
|
||
// 视频登录页(pages/login/login)需要传挂号医生id,其余场景传诊单id
|
||
$page = !empty($params['mini_program_path']) ? $params['mini_program_path'] : 'pages/order/monad/monad';
|
||
$sceneId = ($page === 'pages/login/login' && !empty($doctorId)) ? $doctorId : $diagnosisId;
|
||
if (empty($sceneId)) {
|
||
throw new \Exception($page === 'pages/login/login' ? '挂号医生ID不能为空' : '诊单ID不能为空');
|
||
}
|
||
|
||
// 获取小程序配置
|
||
$config = self::getMiniProgramConfig();
|
||
|
||
if (!$config) {
|
||
throw new \Exception('小程序配置未设置');
|
||
}
|
||
|
||
// 小程序 onLoad 解析 scene,须与前端 parsePageParams 约定一致(微信 scene 总长勿超过 32 字符)
|
||
$scene = "id={$sceneId}&sa={$shareUserId}&did={$doctorId}";
|
||
|
||
// 调用微信接口生成小程序码
|
||
$qrcodeUrl = self::generateWxQrcode($config, $page, $scene);
|
||
|
||
if (!$qrcodeUrl) {
|
||
throw new \Exception('生成小程序码失败: ' . self::getError());
|
||
}
|
||
|
||
return [
|
||
'qrcode_url' => $qrcodeUrl,
|
||
'diagnosis_id' => $diagnosisId,
|
||
'patient_id' => $patientId
|
||
];
|
||
} catch (\Exception $e) {
|
||
self::setError($e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 生成订单小程序码
|
||
*/
|
||
public static function generateOrderQrcode(array $params)
|
||
{
|
||
try {
|
||
$orderNo = $params['order_no'] ?? '';
|
||
if (empty($orderNo)) {
|
||
throw new \Exception('订单号不能为空');
|
||
}
|
||
|
||
// 查询订单ID(使用ID代替订单号,更短)
|
||
$order = \app\common\model\Order::where('order_no', $orderNo)->find();
|
||
if (!$order) {
|
||
throw new \Exception('订单不存在');
|
||
}
|
||
|
||
$config = self::getMiniProgramConfig();
|
||
if (!$config) {
|
||
throw new \Exception('小程序配置未设置');
|
||
}
|
||
|
||
$page = 'pages/order/order';
|
||
// 微信小程序 scene 参数最大长度为32字符,使用订单ID代替订单号
|
||
$scene = "id={$order->id}";
|
||
|
||
$qrcodeUrl = self::generateWxQrcode($config, $page, $scene);
|
||
|
||
if (!$qrcodeUrl) {
|
||
throw new \Exception('生成小程序码失败: ' . self::getError());
|
||
}
|
||
|
||
return [
|
||
'qrcode_url' => $qrcodeUrl,
|
||
'order_no' => $orderNo
|
||
];
|
||
} catch (\Exception $e) {
|
||
self::setError($e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 获取小程序配置
|
||
* @return array|null
|
||
*/
|
||
private static function getMiniProgramConfig(): ?array
|
||
{
|
||
try {
|
||
// 从配置表获取小程序配置
|
||
$appId = \app\common\service\ConfigService::get('mnp_setting', 'app_id', '');
|
||
$appSecret = \app\common\service\ConfigService::get('mnp_setting', 'app_secret', '');
|
||
|
||
if (empty($appId) || empty($appSecret)) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'app_id' => $appId,
|
||
'app_secret' => $appSecret
|
||
];
|
||
} catch (\Exception $e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 生成微信小程序码
|
||
* @param array $config
|
||
* @param string $page
|
||
* @param string $scene
|
||
* @return string|false
|
||
*/
|
||
private static function generateWxQrcode(array $config, string $page, string $scene, int $retryCount = 0)
|
||
{
|
||
try {
|
||
// 记录请求参数
|
||
\think\facade\Log::info('开始生成小程序码', [
|
||
'page' => $page,
|
||
'scene' => $scene,
|
||
'app_id' => $config['app_id'],
|
||
'retry_count' => $retryCount
|
||
]);
|
||
|
||
// 获取access_token(如果是重试,强制刷新)
|
||
$forceRefresh = $retryCount > 0;
|
||
$accessToken = self::getWxAccessToken($config['app_id'], $config['app_secret'], $forceRefresh);
|
||
|
||
if (!$accessToken) {
|
||
throw new \Exception('获取access_token失败: ' . self::getError());
|
||
}
|
||
|
||
// 调用微信接口生成小程序码
|
||
$url = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={$accessToken}";
|
||
|
||
// 记录 scene 长度
|
||
\think\facade\Log::info('Scene参数信息', [
|
||
'scene' => $scene,
|
||
'scene_length' => strlen($scene),
|
||
'scene_urlencode_length' => strlen(urlencode($scene))
|
||
]);
|
||
|
||
$data = [
|
||
'scene' => $scene,
|
||
'page' => $page,
|
||
'check_path' => false,
|
||
'env_version' => 'release',
|
||
'width' => 280
|
||
];
|
||
|
||
|
||
$response = self::httpPost($url, json_encode($data));
|
||
|
||
// 检查是否返回错误(JSON格式)
|
||
$result = json_decode($response, true);
|
||
if (is_array($result) && isset($result['errcode']) && $result['errcode'] != 0) {
|
||
$errorMsg = "生成小程序码失败: errcode={$result['errcode']}, errmsg=" . ($result['errmsg'] ?? '未知错误');
|
||
\think\facade\Log::error($errorMsg);
|
||
|
||
// 如果是AccessToken错误且未重试过,自动重试一次
|
||
if (($result['errcode'] == 40001 || $result['errcode'] == 42001) && $retryCount < 1) {
|
||
\think\facade\Log::info('AccessToken失效,自动重试(第' . ($retryCount + 1) . '次)');
|
||
|
||
// 清除缓存
|
||
$cacheKey = 'wx_access_token_' . $config['app_id'];
|
||
cache($cacheKey, null);
|
||
|
||
// 递归调用,重试一次
|
||
return self::generateWxQrcode($config, $page, $scene, $retryCount + 1);
|
||
}
|
||
|
||
throw new \Exception($errorMsg);
|
||
}
|
||
|
||
// 如果不是JSON错误,说明返回的是图片二进制数据
|
||
// 保存图片
|
||
$filename = 'qrcode_' . $config['app_id'] . '_' . md5($scene) . '_' . time() . '.png';
|
||
$savePath = 'uploads/qrcode/' . date('Ymd') . '/';
|
||
$fullPath = public_path() . $savePath;
|
||
|
||
if (!is_dir($fullPath)) {
|
||
mkdir($fullPath, 0755, true);
|
||
}
|
||
|
||
$saved = file_put_contents($fullPath . $filename, $response);
|
||
|
||
if (!$saved) {
|
||
throw new \Exception('保存二维码图片失败');
|
||
}
|
||
|
||
$qrcodeUrl = request()->domain() . '/' . $savePath . $filename;
|
||
\think\facade\Log::info('小程序码生成成功', ['url' => $qrcodeUrl, 'retry_count' => $retryCount]);
|
||
|
||
return $qrcodeUrl;
|
||
} catch (\Exception $e) {
|
||
\think\facade\Log::error('generateWxQrcode异常: ' . $e->getMessage());
|
||
self::setError($e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 获取微信access_token
|
||
* @param string $appId
|
||
* @param string $appSecret
|
||
* @param bool $forceRefresh 是否强制刷新(不使用缓存)
|
||
* @return string|false
|
||
*/
|
||
private static function getWxAccessToken(string $appId, string $appSecret, bool $forceRefresh = false)
|
||
{
|
||
try {
|
||
$cacheKey = 'wx_access_token_' . $appId;
|
||
|
||
// 如果不是强制刷新,尝试从缓存获取
|
||
if (!$forceRefresh) {
|
||
$accessToken = cache($cacheKey);
|
||
|
||
if ($accessToken) {
|
||
\think\facade\Log::info('使用缓存的AccessToken');
|
||
return $accessToken;
|
||
}
|
||
} else {
|
||
\think\facade\Log::info('强制刷新AccessToken,清除缓存');
|
||
cache($cacheKey, null);
|
||
}
|
||
|
||
// 从微信服务器获取
|
||
$url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={$appId}&secret={$appSecret}";
|
||
$response = self::httpGet($url);
|
||
|
||
// 记录原始响应用于调试
|
||
\think\facade\Log::info('微信AccessToken响应: ' . $response);
|
||
|
||
$result = json_decode($response, true);
|
||
|
||
// 检查是否有错误
|
||
if (isset($result['errcode']) && $result['errcode'] != 0) {
|
||
$errorMsg = "获取AccessToken失败: errcode={$result['errcode']}, errmsg=" . ($result['errmsg'] ?? '未知错误');
|
||
\think\facade\Log::error($errorMsg);
|
||
throw new \Exception($errorMsg);
|
||
}
|
||
|
||
if (isset($result['access_token'])) {
|
||
// 缓存access_token,有效期7000秒(微信官方7200秒,提前200秒过期)
|
||
cache($cacheKey, $result['access_token'], 7000);
|
||
\think\facade\Log::info('AccessToken获取成功并已缓存');
|
||
return $result['access_token'];
|
||
}
|
||
|
||
throw new \Exception('获取access_token失败: 响应中没有access_token字段');
|
||
} catch (\Exception $e) {
|
||
\think\facade\Log::error('getWxAccessToken异常: ' . $e->getMessage());
|
||
self::setError($e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes HTTP GET请求
|
||
* @param string $url
|
||
* @return string|false
|
||
*/
|
||
private static function httpGet(string $url)
|
||
{
|
||
$ch = curl_init();
|
||
curl_setopt($ch, CURLOPT_URL, $url);
|
||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||
$response = curl_exec($ch);
|
||
curl_close($ch);
|
||
return $response;
|
||
}
|
||
|
||
/**
|
||
* @notes HTTP POST请求
|
||
* @param string $url
|
||
* @param string $data
|
||
* @return string|false
|
||
*/
|
||
private static function httpPost(string $url, string $data)
|
||
{
|
||
$ch = curl_init();
|
||
curl_setopt($ch, CURLOPT_URL, $url);
|
||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||
curl_setopt($ch, CURLOPT_POST, 1);
|
||
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
|
||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||
$response = curl_exec($ch);
|
||
|
||
curl_close($ch);
|
||
return $response;
|
||
}
|
||
|
||
/**
|
||
* @notes 获取诊单详情并记录查看
|
||
* @param array $params
|
||
* @return array|false
|
||
*/
|
||
public static function diagnosisDetailWithRecord(array $params)
|
||
{
|
||
try {
|
||
$diagnosisId = $params['id'];
|
||
$userId = $params['user_id'] ?? 0;
|
||
$shareUserId = $params['share_user_id'] ?? 0;
|
||
|
||
// 获取诊单详情
|
||
$diagnosis = self::detail(['id' => $diagnosisId,'user_id' => $userId]);
|
||
|
||
if (!$diagnosis) {
|
||
throw new \Exception('诊单不存在');
|
||
}
|
||
|
||
// 记录查看行为
|
||
// self::recordDiagnosisView([
|
||
// 'user_id' => $userId,
|
||
// 'diagnosis_id' => $diagnosisId,
|
||
// 'patient_id' => $diagnosis['patient_id'] ?? 0,
|
||
// 'share_user_id' => $shareUserId
|
||
// ]);
|
||
|
||
return $diagnosis;
|
||
} catch (\Exception $e) {
|
||
self::setError($e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 记录诊单查看
|
||
* @param array $params
|
||
* @return bool
|
||
*/
|
||
private static function recordDiagnosisView(array $params)
|
||
{
|
||
try {
|
||
$userId = $params['user_id'];
|
||
$diagnosisId = $params['diagnosis_id'];
|
||
$patientId = $params['patient_id'];
|
||
$shareUserId = $params['share_user_id'];
|
||
|
||
// 查找是否已有记录
|
||
$record = \app\common\model\DiagnosisViewRecord::where([
|
||
'user_id' => $userId,
|
||
'diagnosis_id' => $diagnosisId
|
||
])->find();
|
||
|
||
$now = time();
|
||
|
||
if ($record) {
|
||
// 更新记录
|
||
$record->view_count = $record->view_count + 1;
|
||
$record->last_view_time = $now;
|
||
$record->update_time = $now;
|
||
$record->save();
|
||
} else {
|
||
// 创建新记录
|
||
\app\common\model\DiagnosisViewRecord::create([
|
||
'user_id' => $userId,
|
||
'diagnosis_id' => $diagnosisId,
|
||
'patient_id' => $patientId,
|
||
'share_user_id' => $shareUserId,
|
||
'view_count' => 1,
|
||
'first_view_time' => $now,
|
||
'last_view_time' => $now,
|
||
'is_confirmed' => 0,
|
||
'create_time' => $now,
|
||
'update_time' => $now
|
||
]);
|
||
}
|
||
|
||
return true;
|
||
} catch (\Exception $e) {
|
||
\think\facade\Log::error('记录诊单查看失败: ' . $e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 确认诊单
|
||
* @param array $params
|
||
* @return bool
|
||
*/
|
||
public static function confirmDiagnosisRecord(array $params)
|
||
{
|
||
try {
|
||
$userId = $params['user_id'] ?? 0;
|
||
$diagnosisId = $params['id'];
|
||
$shareUserId = $params['share_user_id'] ?? 0;
|
||
if(!$userId){
|
||
throw new \Exception('请重新登录!');
|
||
}
|
||
// 查找查看记录
|
||
$record = \app\common\model\DiagnosisViewRecord::where([
|
||
'user_id' => $userId,
|
||
'diagnosis_id' => $diagnosisId
|
||
])->find();
|
||
|
||
$now = time();
|
||
|
||
if ($record) {
|
||
// 更新确认状态
|
||
$record->is_confirmed = 1;
|
||
$record->confirmed_time = $now;
|
||
$record->update_time = $now;
|
||
$record->save();
|
||
} else {
|
||
// 如果没有查看记录,创建一条已确认的记录
|
||
$diagnosis = Diagnosis::find($diagnosisId);
|
||
if (!$diagnosis) {
|
||
throw new \Exception('诊单不存在');
|
||
}
|
||
|
||
// 再次检查是否存在记录,防止并发创建重复记录
|
||
$existingRecord = \app\common\model\DiagnosisViewRecord::where([
|
||
'user_id' => $userId,
|
||
'diagnosis_id' => $diagnosisId
|
||
])->find();
|
||
|
||
if (!$existingRecord) {
|
||
\app\common\model\DiagnosisViewRecord::create([
|
||
'user_id' => $userId,
|
||
'diagnosis_id' => $diagnosisId,
|
||
'patient_id' => $diagnosis->patient_id ?? 0,
|
||
'share_user_id' => $shareUserId,
|
||
'view_count' => 1,
|
||
'first_view_time' => $now,
|
||
'last_view_time' => $now,
|
||
'is_confirmed' => 1,
|
||
'confirmed_time' => $now,
|
||
'create_time' => $now,
|
||
'update_time' => $now
|
||
]);
|
||
}
|
||
}
|
||
|
||
return true;
|
||
} catch (\Exception $e) {
|
||
self::setError($e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 获取就诊卡列表(供小程序调用)
|
||
* @param int $patientId
|
||
* @return array|false
|
||
*/
|
||
public static function getCardList(int $user_id)
|
||
{
|
||
try {
|
||
if (!$user_id) {
|
||
self::setError('用户ID不能为空');
|
||
return false;
|
||
}
|
||
|
||
// 从诊断查看记录表中获取该用户查看过的诊断ID列表
|
||
$viewRecords = \think\facade\Db::name('diagnosis_view_records')
|
||
->where('user_id', $user_id)
|
||
->where('delete_time', null)
|
||
->column('diagnosis_id');
|
||
|
||
if (empty($viewRecords)) {
|
||
return [];
|
||
}
|
||
|
||
// 根据诊断ID列表查询诊单详情
|
||
$cardList = Diagnosis::whereIn('id', $viewRecords)
|
||
->where('delete_time', null)
|
||
->field([
|
||
'id', 'patient_id', 'patient_name', 'gender', 'age',
|
||
'diagnosis_date', 'diagnosis_type', 'syndrome_type',
|
||
'status', 'create_time', 'update_time'
|
||
])
|
||
->order('create_time', 'desc')
|
||
->select()
|
||
->toArray();
|
||
|
||
// 处理数据格式
|
||
foreach ($cardList as &$card) {
|
||
// 转换诊断日期为时间戳和格式化文本
|
||
if ($card['diagnosis_date']) {
|
||
$card['diagnosis_date_text'] = date('Y-m-d', $card['diagnosis_date']);
|
||
} else {
|
||
$card['diagnosis_date_text'] = '-';
|
||
}
|
||
|
||
// 添加性别描述
|
||
$card['gender_desc'] = $card['gender'] == 1 ? '男' : '女';
|
||
|
||
// 添加状态描述
|
||
$card['status_desc'] = $card['status'] == 1 ? '启用' : '禁用';
|
||
|
||
// 格式化创建时间
|
||
if ($card['create_time']) {
|
||
$card['create_time'] = date('Y-m-d H:i:s', $card['create_time']);
|
||
}
|
||
|
||
// 格式化更新时间
|
||
if ($card['update_time']) {
|
||
$card['update_time'] = date('Y-m-d H:i:s', $card['update_time']);
|
||
}
|
||
}
|
||
|
||
return $cardList;
|
||
} catch (\Exception $e) {
|
||
\think\facade\Log::error('获取就诊卡列表失败 - user_id: ' . $user_id . ', error: ' . $e->getMessage());
|
||
self::setError($e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @notes 新建就诊卡(供小程序调用)
|
||
* patient_id 可选:有则复用(同患者多张卡),无则自动生成(新患者首张卡)
|
||
* @param array $params
|
||
* @return array|bool 成功返回 ['id'=>诊单ID, 'patient_id'=>患者ID],失败返回false
|
||
*/
|
||
public static function addCard(array $params)
|
||
{
|
||
try {
|
||
$userId = $params['user_id'] ?? 0;
|
||
unset($params['user_id']); // 诊单表无此字段,仅用于创建 view_record
|
||
|
||
$patientId = $params['patient_id'] ?? 0;
|
||
if (!$patientId) {
|
||
$params['patient_id'] = self::generatePatientId();
|
||
}
|
||
$params['status'] = 1;
|
||
|
||
// 处理既往史数组
|
||
if (isset($params['past_history']) && is_array($params['past_history'])) {
|
||
$params['past_history'] = implode(',', $params['past_history']);
|
||
}
|
||
$multiSelectFields = [
|
||
'diet_condition', 'body_feeling', 'sleep_condition', 'eye_condition',
|
||
'head_feeling', 'sweat_condition', 'skin_condition', 'urine_condition',
|
||
'stool_condition', 'kidney_condition', 'local_hospital_diagnosis'
|
||
];
|
||
foreach ($multiSelectFields as $field) {
|
||
if (isset($params[$field]) && is_array($params[$field])) {
|
||
$params[$field] = implode(',', $params[$field]);
|
||
}
|
||
}
|
||
// 图片字段迁移至 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<int, array<string, mixed>>
|
||
*/
|
||
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<string,mixed> $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) 未服务天数(与 T3 列同口径)
|
||
$maxBloodTs = BloodRecord::where('diagnosis_id', $diagnosisId)
|
||
->max('record_date');
|
||
$maxBloodTs = (int) $maxBloodTs;
|
||
$unservedDays = $maxBloodTs > 0 ? max(0, (int) floor((time() - $maxBloodTs) / 86400)) : null;
|
||
$lastBloodRecordAt = $maxBloodTs > 0 ? date('Y-m-d', $maxBloodTs) : 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<int,array<string,mixed>>,
|
||
* diet_records: array<int,array<string,mixed>>,
|
||
* exercise_records: array<int,array<string,mixed>>,
|
||
* 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<string,mixed> $diagnosis 已字典翻译过的诊单数据
|
||
* @return array<string,mixed>
|
||
*/
|
||
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<int,array<string,mixed>>
|
||
*/
|
||
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<int,array<string,mixed>>
|
||
*/
|
||
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<int,array<string,mixed>>
|
||
*/
|
||
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;
|
||
}
|
||
}
|