更新bug

This commit is contained in:
Your Name
2026-08-20 17:47:14 +08:00
parent 35f91ee37a
commit 5794f60c5d
67 changed files with 9257 additions and 1287 deletions
@@ -172,7 +172,7 @@ class AppointmentController extends BaseAdminController
public function addDoctorNote()
{
$params = (new AppointmentValidate())->post()->goCheck('addDoctorNote');
if (!DiagnosisLogic::canViewReadonlyDiagnosis(
if (!DiagnosisLogic::canManageDiagnosis(
(int) $params['diagnosis_id'],
$this->adminId,
$this->adminInfo
@@ -330,25 +330,15 @@ class DiagnosisController extends BaseAdminController
* @notes 获取通话签名
* @return \think\response\Json
*/
public function getCallSignature()
{
$params = $this->request->post();
if (empty($params['diagnosis_id'])) {
return $this->fail('诊单ID不能为空');
}
if (empty($params['patient_id'])) {
return $this->fail('患者ID不能为空');
}
// 传递当前管理员ID
$params['admin_id'] = $this->adminId;
$result = DiagnosisLogic::getCallSignature($params);
if ($result) {
return $this->data($result);
}
public function getCallSignature()
{
$params = (new DiagnosisValidate())->post()->goCheck('callIdentity');
$params['admin_id'] = (int) $this->adminId;
$result = DiagnosisLogic::getCallSignature($params, $this->adminInfo);
if ($result) {
return $this->data($result);
}
return $this->fail(DiagnosisLogic::getError());
}
@@ -525,9 +515,9 @@ class DiagnosisController extends BaseAdminController
* @notes 接通后发起腾讯云云端混流录制(需配置 CAM 与云点播)
* @return \think\response\Json
*/
public function startCloudRecording()
{
$params = $this->request->post();
public function startCloudRecording()
{
$params = $this->request->post();
if (empty($params['diagnosis_id'])) {
return $this->fail('诊单ID不能为空');
}
@@ -536,12 +526,32 @@ class DiagnosisController extends BaseAdminController
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/**
* @notes 浏览器本地上传通话录制后,关联到通话记录(合并 recording_urls
return $this->data($result);
}
/**
* @notes 分片上传本机通话录音或手动视频回放
* @return \think\response\Json
*/
public function uploadCallRecording()
{
$params = $this->request->post();
if (empty($params['diagnosis_id'])) {
return $this->fail('诊单ID不能为空');
}
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::uploadCallRecording($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/**
* @notes 浏览器本地上传通话录制后,关联到通话记录(合并 recording_urls
* @return \think\response\Json
*/
public function attachLocalCallRecording()
@@ -293,20 +293,32 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$prescribedDiagnosisIds = $rxQ->column('diagnosis_id');
$prescribedDiagnosisIds = array_flip($prescribedDiagnosisIds ?: []);
}
// 当前页预约关联的处方(按 appointment_id取最新一条):用于「开方/查看」与审核状态
// 当前页预约关联的处方(按 appointment_id优先最新未作废,否则最新一条):
// 用于本次挂号的「开方/编辑/查看」与审核状态。
$appointmentIds = array_filter(array_map('intval', array_column($lists, 'id')));
$rxByAppointmentId = [];
$fallbackRxByAppointmentId = [];
if (!empty($appointmentIds)) {
$rxRows = Prescription::whereIn('appointment_id', $appointmentIds)
->whereNull('delete_time')
->where('void_status', 0)
->order('id', 'desc')
->field(['id', 'appointment_id', 'audit_status', 'void_status', 'is_system_auto'])
->select()
->toArray();
foreach ($rxRows as $rx) {
$aid = (int) ($rx['appointment_id'] ?? 0);
if ($aid > 0 && !isset($rxByAppointmentId[$aid])) {
if ($aid <= 0) {
continue;
}
if (!isset($fallbackRxByAppointmentId[$aid])) {
$fallbackRxByAppointmentId[$aid] = $rx;
}
if ((int) ($rx['void_status'] ?? 0) === 0 && !isset($rxByAppointmentId[$aid])) {
$rxByAppointmentId[$aid] = $rx;
}
}
foreach ($fallbackRxByAppointmentId as $aid => $rx) {
if (!isset($rxByAppointmentId[$aid])) {
$rxByAppointmentId[$aid] = $rx;
}
}
@@ -353,6 +365,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$apptId = (int) ($item['id'] ?? 0);
$apptRx = $rxByAppointmentId[$apptId] ?? null;
$item['current_has_prescription'] = $apptRx !== null ? 1 : 0;
$item['current_prescription_id'] = $apptRx !== null ? (int) ($apptRx['id'] ?? 0) : 0;
$item['prescription_audit_status'] = $apptRx !== null ? (int) ($apptRx['audit_status'] ?? -1) : -1;
$item['prescription_void_status'] = $apptRx !== null ? (int) ($apptRx['void_status'] ?? 0) : 0;
$item['prescription_is_system_auto'] = $apptRx !== null ? (int) ($apptRx['is_system_auto'] ?? 0) : 0;
@@ -282,17 +282,51 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
// 关联是否开方:诊单是否有处方记录
$diagnosisIds = array_column($lists, 'id');
$prescriptionMap = [];
if (!empty($diagnosisIds)) {
$prescriptionTbl = (new Prescription())->getTable();
$prescribedIds = Prescription::whereIn('diagnosis_id', $diagnosisIds)
->whereNull('delete_time')
->column('diagnosis_id');
$prescriptionMap = array_fill_keys($prescribedIds, 1);
}
foreach ($lists as &$item) {
$item['has_prescription'] = isset($prescriptionMap[$item['id']]) ? 1 : 0;
}
$prescriptionMap = [];
if (!empty($diagnosisIds)) {
$prescribedIds = Prescription::whereIn('diagnosis_id', $diagnosisIds)
->whereNull('delete_time')
->column('diagnosis_id');
$prescriptionMap = array_fill_keys($prescribedIds, 1);
}
foreach ($lists as &$item) {
$item['has_prescription'] = isset($prescriptionMap[$item['id']]) ? 1 : 0;
}
// 当前操作必须只认当前挂号的处方。has_prescription 继续表示诊单历史上曾开方,
// 不能再用它驱动本次挂号的“开方/编辑/查看”按钮。
$currentRxByAppointment = [];
$fallbackRxByAppointment = [];
$appointmentIds = array_values(array_filter(array_unique(array_map(
'intval',
array_column($lists, 'appointment_id')
))));
if ($appointmentIds !== []) {
$currentRxRows = Prescription::whereIn('appointment_id', $appointmentIds)
->whereNull('delete_time')
->field(['id', 'appointment_id', 'audit_status', 'void_status'])
->order('id', 'desc')
->select()
->toArray();
foreach ($currentRxRows as $rx) {
$appointmentId = (int) ($rx['appointment_id'] ?? 0);
if ($appointmentId <= 0) {
continue;
}
if (!isset($fallbackRxByAppointment[$appointmentId])) {
$fallbackRxByAppointment[$appointmentId] = $rx;
}
if ((int) ($rx['void_status'] ?? 0) === 0
&& !isset($currentRxByAppointment[$appointmentId])) {
$currentRxByAppointment[$appointmentId] = $rx;
}
}
foreach ($fallbackRxByAppointment as $appointmentId => $rx) {
if (!isset($currentRxByAppointment[$appointmentId])) {
$currentRxByAppointment[$appointmentId] = $rx;
}
}
}
// 复诊展示:业务上「已开方」即算有复诊,取该诊单下最新一条处方的时间与医师(优先未作废)
$followupByDiag = [];
@@ -346,17 +380,21 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
];
}
}
foreach ($lists as &$item) {
$did = (int) $item['id'];
$fu = $followupByDiag[$did] ?? null;
$item['followup_time_text'] = ($item['has_prescription'] && $fu) ? ($fu['time_text'] ?? '') : '';
$item['followup_doctor_name'] = ($item['has_prescription'] && $fu) ? ($fu['doctor_name'] ?? '—') : '';
$item['followup_rx_voided'] = ($item['has_prescription'] && $fu && !empty($fu['voided'])) ? 1 : 0;
$item['prescription_audit_status'] = ($item['has_prescription'] && $fu) ? (int) ($fu['audit_status'] ?? -1) : -1;
$item['prescription_void_status'] = ($item['has_prescription'] && $fu) ? (int) ($fu['void_status'] ?? 0) : 0;
// 开方次数即复诊次数:1 张处方 = 第 1 次复诊,以此类推
$item['followup_prescription_count'] = (int) ($rxCountByDiag[$did] ?? 0);
}
foreach ($lists as &$item) {
$did = (int) $item['id'];
$fu = $followupByDiag[$did] ?? null;
$currentAppointmentId = (int) ($item['appointment_id'] ?? 0);
$currentRx = $currentRxByAppointment[$currentAppointmentId] ?? null;
$item['followup_time_text'] = ($item['has_prescription'] && $fu) ? ($fu['time_text'] ?? '') : '';
$item['followup_doctor_name'] = ($item['has_prescription'] && $fu) ? ($fu['doctor_name'] ?? '—') : '';
$item['followup_rx_voided'] = ($item['has_prescription'] && $fu && !empty($fu['voided'])) ? 1 : 0;
$item['current_has_prescription'] = $currentRx !== null ? 1 : 0;
$item['current_prescription_id'] = $currentRx !== null ? (int) ($currentRx['id'] ?? 0) : 0;
$item['prescription_audit_status'] = $currentRx !== null ? (int) ($currentRx['audit_status'] ?? -1) : -1;
$item['prescription_void_status'] = $currentRx !== null ? (int) ($currentRx['void_status'] ?? 0) : 0;
// 开方次数即复诊次数:1 张处方 = 第 1 次复诊,以此类推
$item['followup_prescription_count'] = (int) ($rxCountByDiag[$did] ?? 0);
}
// 最近一条通话记录状态(列表行展示:通话中 / 已结束等)
$latestCallByDiag = [];
@@ -966,7 +966,8 @@ class ConversionLogic
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重;
* - 同一员工在区间开始前已加过该客户的重加不计(企微「添加时间」仍是首次跟进时间,
* 删后再加会再推 add_external_contact,但不能当当天新客,否则会跨日重复计);
* - 加粉之后、统计结束前须有 msg_audit_approved(排除未完成链路的幽灵事件);
* - add_external_contact 是企微确认客户关系已建立后的权威事件;会话存档同意
* msg_audit_approved 属于独立能力,不能作为加粉前置条件,否则未开通会话存档的员工会被整批清零;
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除)则不计。
* 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
* - 剔除非投放加粉:跟进人 add_way∈{1 扫一扫, 2 搜索手机号, 3 名片分享};
@@ -993,7 +994,7 @@ class ConversionLogic
}
}
$baseKey = self::requestRowsCacheKey('fans-effective-v6', [
$baseKey = self::requestRowsCacheKey('fans-effective-v7', [
$startTimestamp,
$endTimestamp,
self::mediaChannelCacheKey($mediaChannel),
@@ -1035,20 +1036,11 @@ class ConversionLogic
$query = Db::name('qywx_external_contact_event')
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->where('e.user_id', '<>', '')
->where('e.external_userid', '<>', '')
->whereRaw(
'EXISTS (SELECT 1 FROM `' . $eventTable . '` audit_e'
. ' WHERE audit_e.user_id = e.user_id'
. ' AND audit_e.external_userid = e.external_userid'
. ' AND audit_e.change_type = ?'
. ' AND audit_e.event_time >= e.event_time'
. ' AND audit_e.event_time <= ?)',
['msg_audit_approved', $endTimestamp]
)
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` del_e'
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->where('e.user_id', '<>', '')
->where('e.external_userid', '<>', '')
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` del_e'
. ' WHERE del_e.user_id = e.user_id'
. ' AND del_e.external_userid = e.external_userid'
. ' AND del_e.change_type = ?'
+411 -204
View File
@@ -855,68 +855,77 @@ class DiagnosisLogic extends BaseLogic
}
/**
* @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小时
* @notes 获取通话签名
* @param array $params
* @param array $adminInfo
* @return array|bool
*/
public static function getCallSignature(array $params, array $adminInfo = [])
{
try {
$adminId = (int) ($params['admin_id'] ?? 0);
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
$patientId = (int) ($params['patient_id'] ?? 0);
if ($adminId <= 0) {
self::setError('获取管理员信息失败');
return false;
}
if ($diagnosisId <= 0 || $patientId <= 0) {
self::setError('诊单或患者信息不完整');
return false;
}
// Authorize the diagnosis before reading TRTC configuration or importing IM accounts.
if (!self::canManageDiagnosis($diagnosisId, $adminId, $adminInfo)) {
self::setError('诊单不存在、患者不匹配或无权访问');
return false;
}
$diagnosis = Diagnosis::where('id', $diagnosisId)
->where('patient_id', $patientId)
->whereNull('delete_time')
->where('status', 1)
->find();
if (!$diagnosis) {
self::setError('诊单不存在、患者不匹配或无权访问');
return false;
}
$config = self::getTrtcConfig();
if (!$config) {
self::setError('请先配置腾讯云TRTC参数');
return false;
}
$doctorUserId = 'doctor_' . $adminId;
// 患者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' => $diagnosis->assistant_id ? 'doctor_' . $diagnosis->assistant_id : '',
'patientUserId' => $patientUserId, // 患者的userId(用于发起通话)
'diagnosis_id' => $diagnosisId,
'patient_id' => $patientId,
'expireTime' => 86400, // 24小时
// 与 .env [trtc] ISLOCHOSTVOD 一致:true 允许浏览器本地录制并上传
'isLochostVod' => (bool)config('trtc.is_lochost_vod', false),
];
@@ -1645,12 +1654,13 @@ class DiagnosisLogic extends BaseLogic
* @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);
public static function endCall(array $params): bool
{
try {
// 获取当前管理员ID(从参数中获取)
$adminId = (int)($params['admin_id'] ?? 0);
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$callRecordId = (int)($params['call_record_id'] ?? 0);
if ($adminId <= 0) {
self::setError('获取管理员信息失败');
@@ -1661,26 +1671,47 @@ class DiagnosisLogic extends BaseLogic
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,
]);
}
}
$record = null;
if ($callRecordId > 0) {
$record = \app\common\model\tcm\CallRecord::where('id', $callRecordId)
->where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->where('caller_type', 'doctor')
->find();
if (!$record) {
self::setError('通话记录不存在或无权操作');
return false;
}
if ((int)($record['status'] ?? 0) === 2) {
// afterCalling / Store idle may report the same exact call twice.
return true;
}
if ((int)($record['status'] ?? 0) !== 1) {
self::setError('通话记录状态不可结束');
return false;
}
} else {
// Legacy web callers may not yet send call_record_id.
$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: 未传 call_record_id 且未匹配 caller_id,已回退到该诊单最新进行中记录', [
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
'record_caller_id' => $record['caller_id'] ?? null,
'call_record_id' => $record['id'] ?? null,
]);
}
}
}
if (!$record) {
// 前端常重复回调 endCallafterCalling + Store idle),第一条已结束则不再告警
@@ -1861,9 +1892,20 @@ class DiagnosisLogic extends BaseLogic
if (is_array($decoded)) {
$urls = $decoded;
}
}
}
$record['recording_urls_list'] = $urls;
$record['recording_status_text'] = self::recordingStatusText((int)($record['recording_status'] ?? 0));
$localAudioUrls = [];
if (!empty($record['local_audio_urls'])) {
$decodedLocalAudioUrls = json_decode((string)$record['local_audio_urls'], true);
if (is_array($decodedLocalAudioUrls)) {
$localAudioUrls = $decodedLocalAudioUrls;
}
}
$record['local_audio_urls_list'] = $localAudioUrls;
$record['local_audio_status_text'] = self::localAudioStatusText(
(int)($record['local_audio_status'] ?? 0)
);
$record['transcription_status_text'] = self::transcriptionStatusText(
(string)($record['transcription_status'] ?? '')
);
@@ -2184,11 +2226,12 @@ class DiagnosisLogic extends BaseLogic
* @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'] ?? ''));
public static function bindCallRoom(array $params)
{
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$callRecordId = (int)($params['call_record_id'] ?? 0);
$roomId = trim((string)($params['room_id'] ?? ''));
if ($diagnosisId <= 0 || $roomId === '') {
self::setError('诊单ID或房间号不能为空');
return false;
@@ -2196,24 +2239,34 @@ class DiagnosisLogic extends BaseLogic
$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)
$record = null;
if ($callRecordId > 0) {
$record = \app\common\model\tcm\CallRecord::where('id', $callRecordId)
->where('diagnosis_id', $diagnosisId)
->where('status', 1)
->where('caller_id', $adminId)
->where('caller_type', 'doctor')
->find();
if (!$record) {
self::setError('通话记录不存在或无权绑定房间');
return false;
}
} elseif ($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)
if (!$record && $callRecordId <= 0) {
$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', '')
if (!$record && $callRecordId <= 0) {
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('room_id', '')
->order('id', 'desc')
->find();
}
@@ -2233,10 +2286,11 @@ class DiagnosisLogic extends BaseLogic
'message' => '未尝试合流录制(admin_id 为空)',
];
if ($adminId > 0) {
$cloudRec = self::startCloudRecording([
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
], true);
$cloudRec = self::startCloudRecording([
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
'call_record_id' => (int)$record['id'],
], true);
if ($cloudRec === false) {
\think\facade\Log::warning('bindCallRoom: startCloudRecording 未执行或异常', [
'diagnosis_id' => $diagnosisId,
@@ -2286,10 +2340,11 @@ class DiagnosisLogic extends BaseLogic
*/
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) {
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
$callRecordId = (int)($params['call_record_id'] ?? 0);
if ($diagnosisId <= 0 || $adminId <= 0) {
if (!$silent) {
self::setError('参数错误');
}
@@ -2297,24 +2352,40 @@ class DiagnosisLogic extends BaseLogic
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,
]);
}
}
$record = null;
if ($callRecordId > 0) {
$record = \app\common\model\tcm\CallRecord::where('id', $callRecordId)
->where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->where('caller_type', 'doctor')
->where('status', 1)
->find();
if (!$record) {
if (!$silent) {
self::setError('通话记录不存在或无权开启云端录制');
}
return false;
}
} else {
$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: 未传 call_record_id 且未找到 caller_id 匹配的进行中记录,已回退到该诊单最新进行中记录', [
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
'record_caller_id' => $record['caller_id'] ?? null,
]);
}
}
}
if (!$record) {
if (!$silent) {
self::setError('没有进行中的通话记录');
@@ -2389,9 +2460,9 @@ class DiagnosisLogic extends BaseLogic
/**
* @notes 医生端浏览器本地录制上传后,将文件访问地址合并写入当前诊单下该医生的最近一条通话记录
*/
public static function attachLocalCallRecording(array $params): bool
{
try {
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'] ?? ''));
@@ -2428,12 +2499,61 @@ class DiagnosisLogic extends BaseLogic
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 诊单回放视频分片上传,完成后显式关联到指定通话记录
return false;
}
}
/**
* 将医生工作站本机录音独立关联到通话记录
*
* 本机音频与腾讯云混流视频分别持久化,避免录音上传成功后被误计为
* 云端视频已生成。
*/
public static function attachLocalCallAudio(array $params): bool
{
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
$fileUrl = trim((string)($params['file_url'] ?? ''));
$callRecordId = (int)($params['call_record_id'] ?? 0);
if ($diagnosisId <= 0 || $adminId <= 0 || $fileUrl === '') {
self::setError('参数错误');
return false;
}
$record = self::resolveCallRecordForAttachment($diagnosisId, $adminId, $callRecordId);
if (!$record) {
self::setError('未找到通话记录');
return false;
}
$previousUrls = [];
if (!empty($record->local_audio_urls)) {
$decodedUrls = json_decode((string)$record->local_audio_urls, true);
if (is_array($decodedUrls)) {
$previousUrls = $decodedUrls;
}
}
$mergedUrls = array_values(array_unique(array_merge($previousUrls, [$fileUrl])));
$record->save([
'local_audio_urls' => json_encode($mergedUrls, JSON_UNESCAPED_UNICODE),
'local_audio_status' => 2,
'update_time' => time(),
]);
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 诊单回放视频分片上传,完成后显式关联到指定通话记录
* @param array $params
* @return array|false
*/
@@ -2445,11 +2565,12 @@ class DiagnosisLogic extends BaseLogic
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$callRecordId = (int)($params['call_record_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
$uploadId = trim((string)($params['upload_id'] ?? ''));
$fileName = trim((string)($params['file_name'] ?? ''));
$fileSize = (int)($params['file_size'] ?? 0);
$chunkIndex = (int)($params['chunk_index'] ?? -1);
$chunkTotal = (int)($params['chunk_total'] ?? 0);
$uploadId = trim((string)($params['upload_id'] ?? ''));
$fileName = trim((string)($params['file_name'] ?? ''));
$mimeType = strtolower(trim((string)($params['mime_type'] ?? '')));
$fileSize = (int)($params['file_size'] ?? 0);
$chunkIndex = (int)($params['chunk_index'] ?? -1);
$chunkTotal = (int)($params['chunk_total'] ?? 0);
if (
$diagnosisId <= 0 ||
@@ -2462,13 +2583,27 @@ class DiagnosisLogic extends BaseLogic
) {
self::setError('上传参数不完整');
return false;
}
$ext = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
if ($ext === '' || !in_array($ext, config('project.file_video'), true)) {
self::setError('视频格式不支持');
return false;
}
}
$ext = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
$audioExtensions = ['webm', 'ogg', 'opus', 'mp3', 'wav', 'm4a', 'aac', 'amr', 'wma'];
$videoExtensions = (array)config('project.file_video');
$hasAudioMime = str_starts_with($mimeType, 'audio/');
$hasVideoMime = str_starts_with($mimeType, 'video/');
$isAmbiguousWebm = $ext === 'webm';
$isVideo = in_array($ext, $videoExtensions, true)
&& (
$mimeType === ''
|| $hasVideoMime
|| (!$isAmbiguousWebm && $hasAudioMime)
);
$isLocalAudio = !$isVideo
&& $hasAudioMime
&& in_array($ext, $audioExtensions, true);
if (!$isLocalAudio && !$isVideo) {
self::setError('音视频格式不支持');
return false;
}
if ($callRecordId <= 0) {
$record = self::createSyntheticCallRecord($diagnosisId, $adminId, $fileName);
@@ -2497,10 +2632,12 @@ class DiagnosisLogic extends BaseLogic
'diagnosis_id' => $diagnosisId,
'call_record_id' => $callRecordId,
'admin_id' => $adminId,
'file_name' => $fileName,
'file_size' => $fileSize,
'chunk_total' => $chunkTotal,
], JSON_UNESCAPED_UNICODE));
'file_name' => $fileName,
'file_size' => $fileSize,
'mime_type' => $mimeType,
'media_kind' => $isLocalAudio ? 'local_audio' : 'video',
'chunk_total' => $chunkTotal,
], JSON_UNESCAPED_UNICODE));
$chunkPath = $uploadDir . DIRECTORY_SEPARATOR . self::callRecordingChunkName($chunkIndex);
$moved = $chunkFile->move($uploadDir, basename($chunkPath));
@@ -2527,21 +2664,29 @@ class DiagnosisLogic extends BaseLogic
return false;
}
$uploadResult = self::storeMergedCallRecording($mergedPath, $fileName, $adminId);
if (empty($uploadResult['uri'])) {
self::setError('保存视频失败');
return false;
}
$attached = self::attachLocalCallRecording([
'diagnosis_id' => $diagnosisId,
'call_record_id' => $callRecordId,
'admin_id' => $adminId,
'file_url' => (string)$uploadResult['uri'],
]);
if (!$attached) {
return false;
}
$uploadResult = self::storeMergedCallRecording(
$mergedPath,
$fileName,
$adminId,
$isLocalAudio
);
if (empty($uploadResult['uri'])) {
self::setError($isLocalAudio ? '保存本机录音失败' : '保存视频失败');
return false;
}
$attachmentParams = [
'diagnosis_id' => $diagnosisId,
'call_record_id' => $callRecordId,
'admin_id' => $adminId,
'file_url' => (string)$uploadResult['uri'],
];
$attached = $isLocalAudio
? self::attachLocalCallAudio($attachmentParams)
: self::attachLocalCallRecording($attachmentParams);
if (!$attached) {
return false;
}
self::cleanupCallRecordingChunkDir($uploadDir);
@@ -2550,10 +2695,12 @@ class DiagnosisLogic extends BaseLogic
'uploaded_chunks' => $chunkTotal,
'chunk_total' => $chunkTotal,
'call_record_id' => $callRecordId,
'file_id' => $uploadResult['id'] ?? 0,
'file_url' => $uploadResult['uri'] ?? '',
'recording_status' => 2,
];
'file_id' => $uploadResult['id'] ?? 0,
'file_url' => $uploadResult['uri'] ?? '',
'media_kind' => $isLocalAudio ? 'local_audio' : 'video',
'recording_status' => $isLocalAudio ? 0 : 2,
'local_audio_status' => $isLocalAudio ? 2 : 0,
];
} catch (\Throwable $e) {
if ($mergedPath !== '' && is_file($mergedPath)) {
@unlink($mergedPath);
@@ -2656,19 +2803,31 @@ class DiagnosisLogic extends BaseLogic
}
}
private static function recordingStatusText(int $status): string
{
$map = [
private static function recordingStatusText(int $status): string
{
$map = [
0 => '无录制',
1 => '录制中',
2 => '已生成',
3 => '录制失败',
];
return $map[$status] ?? '未知';
}
/**
return $map[$status] ?? '未知';
}
private static function localAudioStatusText(int $status): string
{
$map = [
0 => '无本地录音',
1 => '上传中',
2 => '已保存',
3 => '上传失败',
];
return $map[$status] ?? '未知';
}
/**
* @notes 获取TRTC配置
* @return array|null
*/
@@ -4228,12 +4387,52 @@ class DiagnosisLogic extends BaseLogic
}
/**
* 与 readonlyDetail 共用的诊单行级可见性入口。
* 与诊单列表共用语义的只读行级可见性入口。
*
* 复用“我的患者”统一行权策略:医生按有效接诊关系,医助按归属关系,
* 团队管理角色才使用 DataScope。不存在与越权使用同一错误避免枚举
* 医助仍仅能查看本人归属诊单;其他角色按 DataScope 查看列表范围内的诊单。
* “我的患者”的本人接诊关系只用于管理/写操作,不能限制通用诊单只读页
* 不存在与越权使用同一错误,避免枚举诊单。
*/
public static function canViewReadonlyDiagnosis(int $diagnosisId, int $adminId, array $adminInfo): bool
{
self::$error = '';
if ($diagnosisId <= 0 || $adminId <= 0) {
self::setError('诊单不存在或无权访问');
return false;
}
$query = 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)) {
$query->where('assistant_id', $adminId);
}
if (DataScopeService::isEnabled()) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === []) {
self::setError('诊单不存在或无权访问');
return false;
}
if (is_array($visibleIds)) {
$query->whereIn('assistant_id', $visibleIds);
}
}
if (!$query->find()) {
self::setError('诊单不存在或无权访问');
return false;
}
return true;
}
/**
* 诊单管理/写操作的行级权限入口,保留“我的患者”的本人关系约束。
*/
public static function canManageDiagnosis(int $diagnosisId, int $adminId, array $adminInfo): bool
{
self::$error = '';
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $adminId, $adminInfo)) {
@@ -4526,25 +4725,33 @@ class DiagnosisLogic extends BaseLogic
}
}
private static function storeMergedCallRecording(string $mergedPath, string $fileName, int $adminId): array
{
$config = [
'default' => ConfigService::get('storage', 'default', 'local'),
private static function storeMergedCallRecording(
string $mergedPath,
string $fileName,
int $adminId,
bool $isLocalAudio = false
): array
{
$config = [
'default' => ConfigService::get('storage', 'default', 'local'),
'engine' => ConfigService::get('storage') ?? ['local' => []],
];
$storageDriver = new StorageDriver($config);
$storageDriver->setUploadFileByReal($mergedPath);
$saveDir = 'uploads/video/' . date('Ymd');
if (!$storageDriver->upload($saveDir)) {
throw new \RuntimeException($storageDriver->getError() ?: '上传视频到存储失败');
}
$relativePath = $saveDir . '/' . str_replace('\\', '/', $storageDriver->getFileName());
$storedFile = FileModel::create([
'cid' => 0,
'type' => FileEnum::VIDEO_TYPE,
'name' => mb_substr($fileName, 0, 128),
$storageDriver = new StorageDriver($config);
$storageDriver->setUploadFileByReal($mergedPath);
$saveDir = ($isLocalAudio ? 'uploads/audio/' : 'uploads/video/') . date('Ymd');
if (!$storageDriver->upload($saveDir)) {
throw new \RuntimeException(
$storageDriver->getError()
?: ($isLocalAudio ? '上传本机录音到存储失败' : '上传视频到存储失败')
);
}
$relativePath = $saveDir . '/' . str_replace('\\', '/', $storageDriver->getFileName());
$storedFile = FileModel::create([
'cid' => 0,
'type' => $isLocalAudio ? FileEnum::FILE_TYPE : FileEnum::VIDEO_TYPE,
'name' => mb_substr($fileName, 0, 128),
'uri' => $relativePath,
'source' => FileEnum::SOURCE_ADMIN,
'source_id' => $adminId,
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\common\model\auth\Admin;
use app\common\model\doctor\Appointment;
use app\common\model\doctor\Medicine as DoctorMedicine;
@@ -938,8 +937,8 @@ class PrescriptionLogic
public static function listByDiagnosis(int $diagnosisId, int $viewerAdminId, array $viewerAdminInfo): array
{
self::$error = '';
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $viewerAdminId, $viewerAdminInfo)) {
self::setError('诊单不存在或无权访问');
if (!DiagnosisLogic::canViewReadonlyDiagnosis($diagnosisId, $viewerAdminId, $viewerAdminInfo)) {
self::setError(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
return [];
}
@@ -968,16 +967,29 @@ class PrescriptionLogic
/**
* 根据预约ID获取处方(带权限检查)
*/
public static function getByAppointment(int $appointmentId, int $viewerAdminId, array $viewerAdminInfo): ?array
{
public static function getByAppointment(int $appointmentId, int $viewerAdminId, array $viewerAdminInfo): ?array
{
self::$error = '';
$row = Prescription::where('appointment_id', $appointmentId)
$rows = Prescription::where('appointment_id', $appointmentId)
->whereNull('delete_time')
->order('id', 'desc')
->find();
if (!$row) {
return null;
->select();
$row = null;
$fallback = null;
foreach ($rows as $candidate) {
if ($fallback === null) {
$fallback = $candidate;
}
if ((int) ($candidate->void_status ?? 0) === 0) {
$row = $candidate;
break;
}
}
$row = $row ?? $fallback;
if (!$row) {
return null;
}
// 权限检查:只返回当前用户有权限查看的处方
@@ -133,12 +133,21 @@ class DiagnosisValidate extends BaseValidate
}
/** 拉取跟踪备注列表:诊单ID */
public function sceneTrackingNotes()
{
return $this->only(['diagnosis_id']);
}
public function sceneGenerateQrcode()
public function sceneTrackingNotes()
{
return $this->only(['diagnosis_id']);
}
/** IM / video identity: validate shape here; ownership is checked in the logic layer. */
public function sceneCallIdentity()
{
return $this->only(['diagnosis_id', 'patient_id'])
->remove('diagnosis_id', 'checkDiagnosisId')
->append('diagnosis_id', 'gt:0')
->append('patient_id', 'require|integer|gt:0');
}
public function sceneGenerateQrcode()
{
return $this->only(['diagnosis_id', 'doctor_id', 'patient_id', 'share_user_id', 'mini_program_path'])
// The global diagnosis_id rule is required for diagnosis APIs, but