This commit is contained in:
2026-05-07 18:32:47 +08:00
parent 36847ea70c
commit 4dc9ec1878
4 changed files with 401 additions and 24 deletions
@@ -211,6 +211,7 @@ const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'r
<style scoped lang="scss">
.timeline-actions {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 12px;
padding-bottom: 12px;
@@ -74,9 +74,13 @@ const { pager, getLists, resetPage } = usePaging({
const buildParams = () => {
Object.keys(queryParams).forEach((k) => delete queryParams[k])
if (props.diagnosisId > 0) {
queryParams.context_diagnosis_id = props.diagnosisId
}
if (patientIdAvailable.value) {
queryParams.patient_id = patientIdNum.value
}
queryParams.scene = 'diagnosis_edit'
}
const formatAmount = (value: unknown) => {
@@ -26,6 +26,8 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
{
use HasDataScopeFilter;
private const SCENE_DIAGNOSIS_EDIT = 'diagnosis_edit';
public function setSearch(): array
{
return [
@@ -100,8 +102,10 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$this->applyExpressCompanyFilter($query);
$this->applyExpressKeywordFilter($query);
$this->applySupplyModeFilter($query);
$this->applyCreatorOrOwnPrescriptionVisibility($query);
$this->applyDataScopeForPrescriptionOrder($query);
if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) {
$this->applyCreatorOrOwnPrescriptionVisibility($query);
$this->applyDataScopeForPrescriptionOrder($query);
}
// 业绩看板侧栏等:不展示履约已取消(4),与 stats 业绩统计口径一致
if ((int) ($this->params['exclude_fulfillment_cancelled'] ?? 0) === 1) {
$query->where('fulfillment_status', '<>', 4);
@@ -267,6 +271,29 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
});
}
/**
* 仅诊单编辑页内的患者订单查询可跳过列表页权限,但必须显式带场景且保留患者/诊单范围收敛。
*/
private function shouldBypassListVisibilityForDiagnosisEdit(): bool
{
$scene = trim((string) ($this->params['scene'] ?? ''));
if ($scene !== self::SCENE_DIAGNOSIS_EDIT) {
return false;
}
$patientId = (int) ($this->params['patient_id'] ?? 0);
$contextDiagnosisId = (int) ($this->params['context_diagnosis_id'] ?? 0);
if ($patientId <= 0 || $contextDiagnosisId <= 0) {
return false;
}
$diagnosisPatientId = (int) Diagnosis::where('id', $contextDiagnosisId)
->whereNull('delete_time')
->value('patient_id');
return $diagnosisPatientId > 0 && $diagnosisPatientId === $patientId;
}
/**
* 医助:仅「诊单医助=本人」的订单计业绩
*/
+367 -22
View File
@@ -15,6 +15,8 @@
namespace app\adminapi\logic\tcm;
use app\common\logic\BaseLogic;
use app\common\enum\FileEnum;
use app\common\model\file\File as FileModel;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisGuahaoLog;
use app\common\model\tcm\DiagnosisAssignLog;
@@ -29,8 +31,10 @@ 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\ConfigService;
use app\common\service\FileService;
use app\common\service\DataScope\DataScopeService;
use app\common\service\storage\Driver as StorageDriver;
use think\facade\Db;
/**
* 中医辨房病因诊单逻辑
@@ -963,10 +967,29 @@ class DiagnosisLogic extends BaseLogic
if (empty($doctorAccounts)) {
return [];
}
$diagnosisId = (int)($diag['id'] ?? 0);
// 增量优化:按 doctor_peer_account 维度取归档表最大时间作为 MinTime 起点,
// 避免每次都从头拉取已归档的历史消息(节约腾讯云 admin_getroammsg 调用配额)
$minTimeMap = [];
if ($diagnosisId > 0) {
$rows = ImChatMessage::field('doctor_peer_account, MAX(msg_time) AS max_time')
->where('diagnosis_id', $diagnosisId)
->group('doctor_peer_account')
->select()
->toArray();
foreach ($rows as $r) {
$acct = (string)($r['doctor_peer_account'] ?? '');
if ($acct !== '') {
$minTimeMap[$acct] = (int)($r['max_time'] ?? 0);
}
}
}
$imService = new \app\common\service\TencentImService();
$merged = [];
foreach ($doctorAccounts as $docAccount) {
$batch = self::pullAllRoamMessages($imService, $docAccount, $patientImId);
// 已归档过:从最大已知 msg_time 起拉(含等于以兜底边界,配合 INSERT IGNORE 去重)
$minTime = isset($minTimeMap[$docAccount]) ? max(0, $minTimeMap[$docAccount]) : 0;
$batch = self::pullAllRoamMessages($imService, $docAccount, $patientImId, $minTime);
foreach ($batch as $row) {
$merged[] = $row;
}
@@ -1084,23 +1107,44 @@ class DiagnosisLogic extends BaseLogic
/**
* @return array<int, array<string, mixed>>
*/
private static function pullAllRoamMessages(\app\common\service\TencentImService $svc, string $operator, string $peer): array
private static function pullAllRoamMessages(\app\common\service\TencentImService $svc, string $operator, string $peer, int $minTime = 0): array
{
$out = [];
$lastKey = null;
$lastTime = null;
$guard = 0;
$maxPages = 80;
do {
$res = $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,
]);
// 单页失败重试:避免网络抖动 / 限频导致整段会话被丢弃
$res = null;
$attempt = 0;
while ($attempt < 3) {
$res = $svc->adminGetRoamMsg($operator, $peer, 100, $minTime, 4294967295, $lastKey, $lastTime);
if (!empty($res['success'])) {
break;
}
$attempt++;
\think\facade\Log::warning('IM漫游消息拉取失败(待重试)', [
'operator' => $operator,
'peer' => $peer,
'attempt' => $attempt,
'error' => $res['error'] ?? '',
'code' => $res['rawErrorCode'] ?? 0,
'page' => $guard + 1,
]);
if ($attempt < 3) {
usleep(300000); // 300ms 退避
}
}
if (empty($res['success'])) {
\think\facade\Log::error('IM漫游消息拉取失败(重试耗尽,本轮中断)', [
'operator' => $operator,
'peer' => $peer,
'page' => $guard + 1,
'fetched_so_far' => count($out),
'error' => $res['error'] ?? '',
'code' => $res['rawErrorCode'] ?? 0,
]);
break;
}
foreach ($res['msgList'] as $raw) {
@@ -1121,7 +1165,16 @@ class DiagnosisLogic extends BaseLogic
break;
}
$guard++;
if ($guard > 80) {
if ($guard >= $maxPages) {
// 触顶安全网:增量优化后通常拉不满 80 页,触顶意味着首次全量或会话异常多
\think\facade\Log::warning('IM漫游消息拉取触发分页上限(可能未拉完)', [
'operator' => $operator,
'peer' => $peer,
'min_time' => $minTime,
'pages_fetched' => $guard,
'fetched_so_far' => count($out),
'last_msg_time' => $lastTime,
]);
break;
}
} while (true);
@@ -1720,22 +1773,14 @@ class DiagnosisLogic extends BaseLogic
$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 = \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();
}
$record = self::resolveCallRecordForAttachment($diagnosisId, $adminId, $callRecordId);
if (!$record) {
self::setError('未找到通话记录');
@@ -1765,6 +1810,158 @@ class DiagnosisLogic extends BaseLogic
}
}
/**
* @notes 诊单回放视频分片上传,完成后显式关联到指定通话记录
* @param array $params
* @return array|false
*/
public static function uploadCallRecording(array $params)
{
$uploadDir = '';
$mergedPath = '';
try {
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$callRecordId = (int)($params['call_record_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
$uploadId = trim((string)($params['upload_id'] ?? ''));
$fileName = trim((string)($params['file_name'] ?? ''));
$fileSize = (int)($params['file_size'] ?? 0);
$chunkIndex = (int)($params['chunk_index'] ?? -1);
$chunkTotal = (int)($params['chunk_total'] ?? 0);
if (
$diagnosisId <= 0 ||
$adminId <= 0 ||
$uploadId === '' ||
$fileName === '' ||
$fileSize <= 0 ||
$chunkIndex < 0 ||
$chunkTotal <= 0
) {
self::setError('上传参数不完整');
return false;
}
$ext = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
if ($ext === '' || !in_array($ext, config('project.file_video'), true)) {
self::setError('视频格式不支持');
return false;
}
if ($callRecordId <= 0) {
$record = self::createSyntheticCallRecord($diagnosisId, $adminId, $fileName);
$callRecordId = (int)($record['id'] ?? 0);
} else {
$record = self::resolveCallRecordForAttachment($diagnosisId, $adminId, $callRecordId, false);
}
if (!$record) {
self::setError('通话记录不存在');
return false;
}
$chunkFile = request()->file('file');
if (!$chunkFile) {
self::setError('未找到上传分片');
return false;
}
$uploadDir = self::callRecordingChunkDir($uploadId);
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0777, true) && !is_dir($uploadDir)) {
self::setError('创建上传目录失败');
return false;
}
file_put_contents($uploadDir . DIRECTORY_SEPARATOR . 'meta.json', json_encode([
'diagnosis_id' => $diagnosisId,
'call_record_id' => $callRecordId,
'admin_id' => $adminId,
'file_name' => $fileName,
'file_size' => $fileSize,
'chunk_total' => $chunkTotal,
], JSON_UNESCAPED_UNICODE));
$chunkPath = $uploadDir . DIRECTORY_SEPARATOR . self::callRecordingChunkName($chunkIndex);
$moved = $chunkFile->move($uploadDir, basename($chunkPath));
if (!$moved) {
self::setError($chunkFile->getError() ?: '保存分片失败');
return false;
}
$uploadedCount = self::countUploadedCallRecordingChunks($uploadDir);
if ($uploadedCount < $chunkTotal) {
return [
'completed' => false,
'uploaded_chunks' => $uploadedCount,
'chunk_total' => $chunkTotal,
'call_record_id' => $callRecordId,
];
}
$mergedPath = $uploadDir . DIRECTORY_SEPARATOR . 'merged.' . $ext;
self::mergeCallRecordingChunks($uploadDir, $mergedPath, $chunkTotal);
if (!is_file($mergedPath) || filesize($mergedPath) <= 0) {
self::setError('合并后的文件无效');
return false;
}
$uploadResult = self::storeMergedCallRecording($mergedPath, $fileName, $adminId);
if (empty($uploadResult['uri'])) {
self::setError('保存视频失败');
return false;
}
$attached = self::attachLocalCallRecording([
'diagnosis_id' => $diagnosisId,
'call_record_id' => $callRecordId,
'admin_id' => $adminId,
'file_url' => (string)$uploadResult['uri'],
]);
if (!$attached) {
return false;
}
self::cleanupCallRecordingChunkDir($uploadDir);
return [
'completed' => true,
'uploaded_chunks' => $chunkTotal,
'chunk_total' => $chunkTotal,
'call_record_id' => $callRecordId,
'file_id' => $uploadResult['id'] ?? 0,
'file_url' => $uploadResult['uri'] ?? '',
'recording_status' => 2,
];
} catch (\Throwable $e) {
if ($mergedPath !== '' && is_file($mergedPath)) {
@unlink($mergedPath);
}
self::setError($e->getMessage());
return false;
}
}
public static function createManualCallRecord(array $params)
{
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
if ($diagnosisId <= 0 || $adminId <= 0) {
self::setError('参数错误');
return false;
}
$record = self::createSyntheticCallRecord($diagnosisId, $adminId, 'manual-upload');
if (!$record) {
self::setError(self::getError() ?: '创建通话记录失败');
return false;
}
return [
'id' => (int)($record['id'] ?? 0),
'diagnosis_id' => $diagnosisId,
];
}
/**
* @notes 医助进入当前诊单 TRTC 房间旁观(仅拉流,与医生端 doctor_{id} 账号体系一致)
* @param array $params diagnosis_id、admin_id(当前登录后台用户)
@@ -3599,4 +3796,152 @@ class DiagnosisLogic extends BaseLogic
return $rows;
}
private static function resolveCallRecordForAttachment(
int $diagnosisId,
int $adminId,
int $callRecordId = 0,
bool $allowFallback = true
)
{
if ($callRecordId > 0) {
$record = \app\common\model\tcm\CallRecord::where('id', $callRecordId)
->where('diagnosis_id', $diagnosisId)
->find();
if ($record) {
return $record;
}
if (!$allowFallback) {
return null;
}
}
$record = \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->order('id', 'desc')
->find();
if ($record || !$allowFallback) {
return $record;
}
return \app\common\model\tcm\CallRecord::where('diagnosis_id', $diagnosisId)
->order('id', 'desc')
->find();
}
private static function createSyntheticCallRecord(int $diagnosisId, int $adminId, string $fileName = '')
{
$diagnosis = Diagnosis::where('id', $diagnosisId)->find();
if (!$diagnosis) {
self::setError('诊单不存在');
return null;
}
$now = time();
$roomSeed = substr(md5($diagnosisId . '-' . $adminId . '-' . $fileName . '-' . $now), 0, 8);
return \app\common\model\tcm\CallRecord::create([
'diagnosis_id' => $diagnosisId,
'caller_id' => $adminId,
'caller_type' => 'doctor',
'callee_id' => (int)($diagnosis['patient_id'] ?? 0),
'callee_type' => 'patient',
'call_type' => 2,
'status' => 2,
'start_time' => $now,
'end_time' => $now,
'duration' => 0,
'room_id' => 'manual_upload_' . $roomSeed,
'recording_status' => 0,
'create_time' => $now,
'update_time' => $now,
]);
}
private static function callRecordingChunkDir(string $uploadId): string
{
$safeId = preg_replace('/[^a-zA-Z0-9_-]/', '', $uploadId);
return runtime_path() . 'call_recording_chunks' . DIRECTORY_SEPARATOR . $safeId;
}
private static function callRecordingChunkName(int $chunkIndex): string
{
return sprintf('chunk_%06d.part', $chunkIndex);
}
private static function countUploadedCallRecordingChunks(string $uploadDir): int
{
$files = glob($uploadDir . DIRECTORY_SEPARATOR . 'chunk_*.part');
return is_array($files) ? count($files) : 0;
}
private static function mergeCallRecordingChunks(string $uploadDir, string $mergedPath, int $chunkTotal): void
{
$out = fopen($mergedPath, 'wb');
if ($out === false) {
throw new \RuntimeException('创建合并文件失败');
}
try {
for ($i = 0; $i < $chunkTotal; $i++) {
$chunkPath = $uploadDir . DIRECTORY_SEPARATOR . self::callRecordingChunkName($i);
if (!is_file($chunkPath)) {
throw new \RuntimeException('上传分片缺失,无法合并');
}
$in = fopen($chunkPath, 'rb');
if ($in === false) {
throw new \RuntimeException('读取上传分片失败');
}
stream_copy_to_stream($in, $out);
fclose($in);
}
} finally {
fclose($out);
}
}
private static function storeMergedCallRecording(string $mergedPath, string $fileName, int $adminId): array
{
$config = [
'default' => ConfigService::get('storage', 'default', 'local'),
'engine' => ConfigService::get('storage') ?? ['local' => []],
];
$storageDriver = new StorageDriver($config);
$storageDriver->setUploadFileByReal($mergedPath);
$saveDir = 'uploads/video/' . date('Ymd');
if (!$storageDriver->upload($saveDir)) {
throw new \RuntimeException($storageDriver->getError() ?: '上传视频到存储失败');
}
$relativePath = $saveDir . '/' . str_replace('\\', '/', $storageDriver->getFileName());
$storedFile = FileModel::create([
'cid' => 0,
'type' => FileEnum::VIDEO_TYPE,
'name' => mb_substr($fileName, 0, 128),
'uri' => $relativePath,
'source' => FileEnum::SOURCE_ADMIN,
'source_id' => $adminId,
'create_time' => time(),
]);
return [
'id' => (int)($storedFile['id'] ?? 0),
'uri' => FileService::getFileUrl($relativePath),
'url' => $relativePath,
];
}
private static function cleanupCallRecordingChunkDir(string $uploadDir): void
{
if ($uploadDir === '' || !is_dir($uploadDir)) {
return;
}
foreach ((array)glob($uploadDir . DIRECTORY_SEPARATOR . '*') as $item) {
if (is_file($item)) {
@unlink($item);
}
}
@rmdir($uploadDir);
}
}