新增功能

This commit is contained in:
Your Name
2026-03-24 16:32:56 +08:00
parent 250d173c2f
commit 9160c36735
248 changed files with 3063 additions and 250 deletions
@@ -314,6 +314,7 @@ class DiagnosisController extends BaseAdminController
return $this->fail('房间号不能为空');
}
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::bindCallRoom($params);
if ($result) {
return $this->success('绑定成功', [], 1, 1);
@@ -321,6 +322,69 @@ class DiagnosisController extends BaseAdminController
return $this->fail(DiagnosisLogic::getError());
}
/**
* @notes 接通后发起腾讯云云端混流录制(需配置 CAM 与云点播)
* @return \think\response\Json
*/
public function startCloudRecording()
{
$params = $this->request->post();
if (empty($params['diagnosis_id'])) {
return $this->fail('诊单ID不能为空');
}
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::startCloudRecording($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/**
* @notes 浏览器本地上传通话录制后,关联到通话记录(合并 recording_urls
* @return \think\response\Json
*/
public function attachLocalCallRecording()
{
$params = $this->request->post();
if (empty($params['diagnosis_id'])) {
return $this->fail('诊单ID不能为空');
}
if (empty($params['file_url'])) {
return $this->fail('文件地址不能为空');
}
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::attachLocalCallRecording($params);
if ($result) {
return $this->success('已关联录制', [], 1, 1);
}
return $this->fail(DiagnosisLogic::getError());
}
/**
* @notes 医助旁观诊单当前视频通话(TRTC 进房参数,仅拉流)
* @return \think\response\Json
*/
public function watchCall()
{
$diagnosisId = (int)$this->request->get('diagnosis_id', 0);
if ($diagnosisId <= 0) {
return $this->fail('诊单ID不能为空');
}
$result = DiagnosisLogic::getAssistantWatchRoomParams([
'diagnosis_id' => $diagnosisId,
'admin_id' => (int)$this->adminId,
]);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/**
* @notes 获取患者通话签名(用于测试)
* @return \think\response\Json
@@ -19,6 +19,7 @@ use app\common\model\tcm\Diagnosis;
use app\common\model\DiagnosisViewRecord;
use app\common\model\doctor\Appointment;
use app\common\model\tcm\Prescription;
use app\common\model\tcm\CallRecord;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminRole;
use app\common\lists\ListsSearchInterface;
@@ -171,9 +172,76 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
$item['has_prescription'] = isset($prescriptionMap[$item['id']]) ? 1 : 0;
}
// 最近一条通话记录状态(列表行展示:通话中 / 已结束等)
$latestCallByDiag = [];
if (!empty($diagnosisIds)) {
$agg = CallRecord::whereIn('diagnosis_id', $diagnosisIds)
->field('diagnosis_id, MAX(id) AS max_id')
->group('diagnosis_id')
->select()
->toArray();
$maxIds = array_filter(array_column($agg, 'max_id'));
if (!empty($maxIds)) {
$recList = CallRecord::whereIn('id', $maxIds)->select()->toArray();
foreach ($recList as $r) {
$latestCallByDiag[(int)$r['diagnosis_id']] = $r;
}
}
}
foreach ($lists as &$item) {
$item['video_call_hint'] = $this->buildVideoCallHint($latestCallByDiag[$item['id']] ?? null);
}
return $lists;
}
/**
* @param array<string,mixed>|null $rec tcm_call_record 一行
* @return array{state:string,label:string,end_time?:int,start_time?:int}
*/
private function buildVideoCallHint(?array $rec): array
{
if (!$rec) {
return ['state' => 'none', 'label' => ''];
}
$st = (int)($rec['status'] ?? 0);
$roomOk = trim((string)($rec['room_id'] ?? '')) !== '';
if ($st === 1 && $roomOk) {
$t = (int)($rec['start_time'] ?? 0);
return [
'state' => 'live',
'label' => '视频通话进行中',
'start_time' => $t,
];
}
if ($st === 1 && !$roomOk) {
return [
'state' => 'pending_room',
'label' => '通话发起中,待同步房间',
];
}
if ($st === 2) {
$end = (int)($rec['end_time'] ?? 0);
$timePart = $end > 0 ? date('m-d H:i', $end) : '';
return [
'state' => 'ended',
'label' => $timePart !== '' ? ('视频已结束 · ' . $timePart) : '视频已结束',
'end_time' => $end,
];
}
if ($st === 3) {
return ['state' => 'missed', 'label' => '上次通话未接听'];
}
if ($st === 4) {
return ['state' => 'cancelled', 'label' => '上次通话已取消'];
}
return ['state' => 'none', 'label' => ''];
}
/**
* @notes 获取数量
* @return int
@@ -934,13 +934,22 @@ class DiagnosisLogic extends BaseLogic
->find();
if ($record) {
$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 - $record->start_time;
$record->save([
'status' => 2, // 2-已结束
'end_time' => $endTime,
'duration' => $duration
'duration' => $duration,
'cloud_recording_task_id' => '',
'update_time' => time(),
]);
}
@@ -1019,6 +1028,14 @@ class DiagnosisLogic extends BaseLogic
'update_time' => time(),
]);
$adminId = (int)($params['admin_id'] ?? 0);
if ($adminId > 0) {
self::startCloudRecording([
'diagnosis_id' => $diagnosisId,
'admin_id' => $adminId,
], true);
}
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
@@ -1026,6 +1043,227 @@ class DiagnosisLogic extends BaseLogic
}
}
/**
* @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) {
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();
$r = \app\common\service\TrtcCloudRecordingService::startMixRecording(
$sdkAppId,
$roomId,
$botUserId,
$botSig
);
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 = [
@@ -8,6 +8,7 @@ declare(strict_types=1);
namespace app\api\controller;
use app\common\model\tcm\CallRecord;
use app\common\service\FileService;
use think\facade\Log;
class TrtcController extends BaseApiController
@@ -69,6 +70,7 @@ class TrtcController extends BaseApiController
}
}
$merged = array_values(array_unique(array_merge($prev, $urls)));
$merged = $this->mirrorRecordingUrlsIfEnabled($merged);
$record->save([
'recording_urls' => json_encode($merged, JSON_UNESCAPED_UNICODE),
@@ -126,4 +128,73 @@ class TrtcController extends BaseApiController
}
}
}
/**
* 将腾讯云录制地址拉取到本服务器 public/uploads(需在 .env 开启 trtc.recording_mirror_to_storage=1
* @param array<int, string> $urls
* @return array<int, string>
*/
private function mirrorRecordingUrlsIfEnabled(array $urls): array
{
if (!(int)config('trtc.recording_mirror_to_storage', 0)) {
return $urls;
}
$out = [];
foreach ($urls as $u) {
$u = trim((string)$u);
if ($u === '' || strncmp($u, 'http', 4) !== 0) {
$out[] = $u;
continue;
}
$local = $this->downloadRecordingToPublic($u);
$out[] = $local !== null ? FileService::getFileUrl($local) : $u;
}
return $out;
}
private function downloadRecordingToPublic(string $url): ?string
{
$dirRel = 'uploads/video/trtc_recording/' . date('Ymd');
$dirAbs = public_path() . $dirRel;
if (!is_dir($dirAbs) && !mkdir($dirAbs, 0755, true) && !is_dir($dirAbs)) {
Log::warning('TRTC mirror: mkdir failed', ['dir' => $dirAbs]);
return null;
}
$ext = '.mp4';
if (preg_match('/\.([a-z0-9]+)(\?|#|$)/i', $url, $m)) {
$ext = '.' . strtolower($m[1]);
}
$name = 'trtc_' . date('His') . '_' . bin2hex(random_bytes(4)) . $ext;
$target = $dirAbs . DIRECTORY_SEPARATOR . $name;
$ctx = stream_context_create([
'http' => ['timeout' => 600],
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
]);
try {
$src = @fopen($url, 'rb', false, $ctx);
if ($src === false) {
return null;
}
$dst = @fopen($target, 'wb');
if ($dst === false) {
fclose($src);
return null;
}
stream_copy_to_stream($src, $dst);
fclose($src);
fclose($dst);
} catch (\Throwable $e) {
Log::warning('TRTC mirror download failed: ' . $e->getMessage());
return null;
}
if (!is_file($target) || filesize($target) < 1) {
return null;
}
return str_replace('\\', '/', $dirRel . '/' . $name);
}
}
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use think\facade\Log;
/**
* 腾讯云 TRTC 云端录制(CreateCloudRecording / DeleteCloudRecording
* 依赖:composer require tencentcloud/trtc
*/
class TrtcCloudRecordingService
{
public static function sdkAvailable(): bool
{
return class_exists(\TencentCloud\Trtc\V20190722\TrtcClient::class);
}
/**
* @return array{ok:bool,task_id?:string,message?:string}
*/
public static function startMixRecording(
int $sdkAppId,
string $roomId,
string $botUserId,
string $botUserSig
): array {
if (!self::sdkAvailable()) {
return ['ok' => false, 'message' => '未安装 tencentcloud/trtc,请在 server 目录执行 composer update'];
}
$secretId = trim((string)config('trtc.api_secret_id', ''));
$secretKey = trim((string)config('trtc.api_secret_key', ''));
if ($secretId === '' || $secretKey === '') {
return ['ok' => false, 'message' => '未配置 trtc.api_secret_id / trtc.api_secret_keyCAM 密钥)'];
}
if ($sdkAppId <= 0) {
return ['ok' => false, 'message' => 'SdkAppId 无效'];
}
if (self::trtcUserSigSecretKey() === '') {
return ['ok' => false, 'message' => '未配置 TRTC UserSig 密钥(project.trtc.secretKey'];
}
$roomIdType = ctype_digit($roomId) ? 1 : 0;
$region = (string)config('trtc.recording_api_region', 'ap-guangzhou');
try {
$cred = new \TencentCloud\Common\Credential($secretId, $secretKey);
$httpProfile = new \TencentCloud\Common\Profile\HttpProfile();
$httpProfile->setEndpoint('trtc.tencentcloudapi.com');
$clientProfile = new \TencentCloud\Common\Profile\ClientProfile();
$clientProfile->setHttpProfile($httpProfile);
$client = new \TencentCloud\Trtc\V20190722\TrtcClient($cred, $region, $clientProfile);
$req = new \TencentCloud\Trtc\V20190722\Models\CreateCloudRecordingRequest();
$req->SdkAppId = $sdkAppId;
$req->RoomId = (string)$roomId;
$req->RoomIdType = $roomIdType;
$req->UserId = $botUserId;
$req->UserSig = $botUserSig;
$recordParams = new \TencentCloud\Trtc\V20190722\Models\RecordParams();
$recordParams->RecordMode = 2;
$recordParams->StreamType = 0;
$recordParams->MaxIdleTime = 300;
$req->RecordParams = $recordParams;
$tencentVod = new \TencentCloud\Trtc\V20190722\Models\TencentVod();
$tencentVod->ExpireTime = 0;
$cloudVod = new \TencentCloud\Trtc\V20190722\Models\CloudVod();
$cloudVod->TencentVod = $tencentVod;
$storage = new \TencentCloud\Trtc\V20190722\Models\StorageParams();
$storage->CloudVod = $cloudVod;
$req->StorageParams = $storage;
$videoParams = new \TencentCloud\Trtc\V20190722\Models\VideoParams();
$videoParams->Width = 720;
$videoParams->Height = 1280;
$videoParams->Fps = 15;
$videoParams->BitRate = 1000000;
$videoParams->Gop = 2;
$mixTc = new \TencentCloud\Trtc\V20190722\Models\MixTranscodeParams();
$mixTc->VideoParams = $videoParams;
$req->MixTranscodeParams = $mixTc;
$mixLayout = new \TencentCloud\Trtc\V20190722\Models\MixLayoutParams();
$mixLayout->MixLayoutMode = 3;
$req->MixLayoutParams = $mixLayout;
$resp = $client->CreateCloudRecording($req);
$taskId = $resp->TaskId ?? '';
if ($taskId === '') {
return ['ok' => false, 'message' => 'CreateCloudRecording 未返回 TaskId'];
}
return ['ok' => true, 'task_id' => $taskId];
} catch (\Throwable $e) {
Log::error('CreateCloudRecording failed: ' . $e->getMessage());
return ['ok' => false, 'message' => $e->getMessage()];
}
}
/**
* @return array{ok:bool,message?:string}
*/
public static function stopRecording(int $sdkAppId, string $taskId): array
{
if ($taskId === '') {
return ['ok' => true];
}
if (!self::sdkAvailable()) {
return ['ok' => false, 'message' => '未安装 tencentcloud/trtc'];
}
$secretId = trim((string)config('trtc.api_secret_id', ''));
$secretKey = trim((string)config('trtc.api_secret_key', ''));
if ($secretId === '' || $secretKey === '') {
return ['ok' => false, 'message' => '未配置云 API 密钥'];
}
$region = (string)config('trtc.recording_api_region', 'ap-guangzhou');
try {
$cred = new \TencentCloud\Common\Credential($secretId, $secretKey);
$httpProfile = new \TencentCloud\Common\Profile\HttpProfile();
$httpProfile->setEndpoint('trtc.tencentcloudapi.com');
$clientProfile = new \TencentCloud\Common\Profile\ClientProfile();
$clientProfile->setHttpProfile($httpProfile);
$client = new \TencentCloud\Trtc\V20190722\TrtcClient($cred, $region, $clientProfile);
$req = new \TencentCloud\Trtc\V20190722\Models\DeleteCloudRecordingRequest();
$req->SdkAppId = $sdkAppId;
$req->TaskId = $taskId;
$client->DeleteCloudRecording($req);
return ['ok' => true];
} catch (\Throwable $e) {
Log::warning('DeleteCloudRecording: ' . $e->getMessage());
return ['ok' => false, 'message' => $e->getMessage()];
}
}
public static function trtcSdkAppId(): int
{
$p = config('project.trtc');
return (int)(is_array($p) ? ($p['sdkAppId'] ?? 0) : 0) ?: (int)config('trtc.sdkAppId', 0);
}
public static function trtcUserSigSecretKey(): string
{
$p = config('project.trtc');
$fromProject = is_array($p) ? (string)($p['secretKey'] ?? '') : '';
return $fromProject !== '' ? $fromProject : (string)config('trtc.secretKey', '');
}
public static function makeBotUserSig(string $botUserId): string
{
$sdkAppId = self::trtcSdkAppId();
$key = self::trtcUserSigSecretKey();
$api = new TLSSigAPIv2($sdkAppId, $key);
$expire = (int)(config('project.trtc.expireTime') ?? config('trtc.expireTime', 86400));
return $api->genUserSig($botUserId, $expire > 0 ? $expire : 86400);
}
}