This commit is contained in:
Your Name
2026-09-09 15:47:48 +08:00
parent bd5d5c5f08
commit cb10e75ead
98 changed files with 7031 additions and 804 deletions
@@ -466,8 +466,7 @@ class DiagnosisController extends BaseAdminController
}
/**
* @notes 触发后台异步同步当前诊单的腾讯云 IM 聊天记录到本地归档表
* 请求即返回,真正的同步逻辑在 fastcgi_finish_request 之后执行
* @notes 同步一页 IM 历史并返回真实进度;客户端持 token 续拉,归档后即可展示。
*/
public function triggerImChatSync()
{
@@ -486,20 +485,18 @@ class DiagnosisController extends BaseAdminController
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
}
register_shutdown_function(function () use ($diagnosisId) {
try {
@set_time_limit(300);
ignore_user_abort(true);
DiagnosisLogic::syncImChatArchiveForDiagnosis($diagnosisId);
} catch (\Throwable $e) {
\think\facade\Log::warning('triggerImChatSync failed', [
'diagnosis_id' => $diagnosisId,
'err' => $e->getMessage(),
]);
}
});
return $this->success('已发起后台同步,几秒后请重新加载查看', ['queued' => true]);
try {
@set_time_limit(30);
$result = DiagnosisLogic::syncImChatArchiveStep(
$diagnosisId,
(int)$this->adminId,
(string)$this->request->post('sync_token', ''),
(string)$this->request->post('scope', '') === 'current'
);
return $this->data($result);
} catch (\Throwable $e) {
return $this->fail('聊天记录同步失败:' . $e->getMessage());
}
}
/**
@@ -108,6 +108,32 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
);
}
/**
* 挂号类型筛选:空筛选表示全部;未知筛选值返回空结果,不套用旧记录的视频默认值。
* 列表、总数与状态角标共用,历史空值按 normalizeStored 的规则归入视频。
*/
private function applyAppointmentTypeFilter($query): void
{
$type = $this->params['appointment_type'] ?? '';
if ($type === '') {
return;
}
if (!AppointmentTypeEnum::isWritable($type)) {
$query->whereRaw('0 = 1');
return;
}
$query->where(function ($q) use ($type): void {
$q->whereRaw('BINARY a.appointment_type = :appointment_type_filter', ['appointment_type_filter' => $type]);
if ($type === AppointmentTypeEnum::VIDEO) {
// PHP trim 默认空白:空格、NUL、TAB、LF、VT、CR;只把完全为空白的旧值归入视频。
$blankTypeSql = "TRIM(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(a.appointment_type, CHAR(0), ''),"
. " CHAR(9), ''), CHAR(10), ''), CHAR(11), ''), CHAR(13), '')) = ''";
$q->whereOrRaw('a.appointment_type IS NULL')->whereOrRaw($blankTypeSql);
}
});
}
/**
* 渠道筛选:与 AppointmentLogic 一致,兼容仅有 channel_source、仅有 channels、或两者皆有的表结构
*
@@ -228,6 +254,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$this->applyChannelSourceFilter($query, $chFilter);
$this->applyAppointmentTypeFilter($query);
// 是否确认诊单:1=已确认 0=未确认
if (isset($this->params['diagnosis_confirmed']) && $this->params['diagnosis_confirmed'] !== '') {
$confirmed = (int)$this->params['diagnosis_confirmed'];
@@ -422,6 +450,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$this->applyChannelSourceFilter($query, $chFilter);
$this->applyAppointmentTypeFilter($query);
if ((int) ($this->params['exclude_cancelled'] ?? 0) === 1) {
$sf = $this->params['status'] ?? '';
if ($sf === '' || (int) $sf !== 2) {
@@ -6,6 +6,7 @@ namespace app\adminapi\lists\firstvisit;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\common\enum\AppointmentTypeEnum;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
use app\common\model\DiagnosisViewRecord;
@@ -217,7 +218,7 @@ class MyPatientLists extends BaseAdminDataLists implements ListsSearchInterface,
$appointments = Db::table($appointmentTable)
->whereIn('patient_id', $diagnosisIds)
->whereIn('status', self::EFFECTIVE_APPOINTMENT_STATUSES)
->field(['id', 'patient_id', 'doctor_id', 'appointment_date', 'appointment_time', 'status'])
->field(['id', 'patient_id', 'doctor_id', 'appointment_date', 'appointment_time', 'appointment_type', 'status'])
->order('appointment_date', 'asc')
->order('appointment_time', 'asc')
->order('id', 'asc')
@@ -281,21 +282,33 @@ class MyPatientLists extends BaseAdminDataLists implements ListsSearchInterface,
$row['confirmation_text'] = $row['confirmed'] ? '已确认' : '待确认';
$row['visit_count'] = $completedCount;
$row['revisit_count'] = max(0, $completedCount - 1);
$row['appointment_id'] = $primary ? (int) $primary['id'] : 0;
$row['appointment_status'] = $primary ? (int) $primary['status'] : 0;
$row['appointment_status_text'] = $this->appointmentStatusText((int) ($primary['status'] ?? 0));
$row['appointment_doctor_id'] = $primary ? (int) $primary['doctor_id'] : 0;
$row['appointment_doctor_name'] = $primary
? (string) ($adminNames[(int) $primary['doctor_id']] ?? '未知医生')
: '未预约';
$row['appointment_time_text'] = $primary ? $this->appointmentTimeText($primary) : '';
$row['has_appointment'] = $primary !== null ? 1 : 0;
$this->appendPrimaryAppointmentSummary($row, $primary, $adminNames);
}
unset($row);
return $rows;
}
/** 所有挂号展示字段取自同一条主挂号;无挂号时不能补成视频类型。 */
private function appendPrimaryAppointmentSummary(array &$row, ?array $primary, array $adminNames): void
{
$row['appointment_id'] = $primary ? (int) $primary['id'] : 0;
$row['appointment_status'] = $primary ? (int) $primary['status'] : 0;
$row['appointment_status_text'] = $this->appointmentStatusText((int) ($primary['status'] ?? 0));
$row['appointment_doctor_id'] = $primary ? (int) $primary['doctor_id'] : 0;
$row['appointment_doctor_name'] = $primary
? (string) ($adminNames[(int) $primary['doctor_id']] ?? '未知医生')
: '未预约';
$row['appointment_time_text'] = $primary ? $this->appointmentTimeText($primary) : '';
$row['appointment_type'] = $primary !== null
? AppointmentTypeEnum::normalizeStored($primary['appointment_type'] ?? null)
: null;
$row['appointment_type_desc'] = $primary !== null
? AppointmentTypeEnum::description($row['appointment_type'])
: '';
$row['has_appointment'] = $primary !== null ? 1 : 0;
}
/**
* @param array<int, array<string, mixed>> $appointments
* @return array<string, mixed>|null
@@ -6,6 +6,7 @@ namespace app\adminapi\lists\firstvisit;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\common\enum\AppointmentTypeEnum;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
use app\common\model\DiagnosisViewRecord;
@@ -242,7 +243,8 @@ class MyPatientProgressLists extends BaseAdminDataLists implements ListsSearchIn
$row['doctor_name'] = trim((string) ($row['doctor_name'] ?? '')) ?: '未知医生';
$row['appointment_time_text'] = $this->appointmentTimeText($row);
$row['status_text'] = $this->appointmentStatusText($status);
$row['appointment_type_text'] = $this->appointmentTypeText((string) ($row['appointment_type'] ?? ''));
$row['appointment_type'] = AppointmentTypeEnum::normalizeStored($row['appointment_type'] ?? null);
$row['appointment_type_text'] = $this->appointmentTypeText($row['appointment_type']);
$row['registered'] = 1;
$row['diagnosis_confirmed'] = $confirmed ? 1 : 0;
$row['visit_completed'] = $status === 3 ? 1 : 0;
@@ -682,7 +684,7 @@ class MyPatientProgressLists extends BaseAdminDataLists implements ListsSearchIn
private function appointmentTypeText(string $type): string
{
return ['video' => '视频问诊', 'text' => '图文问诊', 'phone' => '电话问诊'][$type] ?? '面诊';
return AppointmentTypeEnum::description($type);
}
private function progressText(bool $confirmed, bool $completed, bool $prescribed, int $status): string
+239 -360
View File
@@ -34,6 +34,7 @@ use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\common\service\ConfigService;
use app\common\service\AppointmentCallPolicy;
use app\common\service\FileService;
use app\common\service\DataScope\DataScopeService;
use app\common\service\storage\Driver as StorageDriver;
@@ -893,6 +894,7 @@ class DiagnosisLogic extends BaseLogic
return false;
}
$callPolicy = AppointmentCallPolicy::resolve($diagnosisId, (int) ($params['appointment_id'] ?? 0));
$config = self::getTrtcConfig();
if (!$config) {
self::setError('请先配置腾讯云TRTC参数');
@@ -928,7 +930,7 @@ class DiagnosisLogic extends BaseLogic
'expireTime' => 86400, // 24小时
// 与 .env [trtc] ISLOCHOSTVOD 一致:true 允许浏览器本地录制并上传
'isLochostVod' => (bool)config('trtc.is_lochost_vod', false),
];
] + $callPolicy;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
@@ -995,336 +997,243 @@ class DiagnosisLogic extends BaseLogic
}
}
/**
* 所有可能以 doctor_{id} 登录 IM 的后台账号(医生 role_id=1、医助 role_id=2),用于合并会话漫游记录
*
* @return array<int, string> 如 ['doctor_1','doctor_2']
*/
private static function collectAllDoctorImPeerAccounts(): array
/** 包含历史/停用医生;先查曾与患者相关的账号,避免先扫描大量无关会话。 */
private static function collectDoctorImPeerAccounts(int $patientId): 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 [];
}
$accounts = ImChatMessage::where('patient_id', $patientId)->column('doctor_peer_account');
$diagnosisIds = Diagnosis::where('patient_id', $patientId)->column('id');
$assistantIds = Diagnosis::where('patient_id', $patientId)->column('assistant_id');
$doctorIds = empty($diagnosisIds) ? [] : Appointment::whereIn('patient_id', $diagnosisIds)->column('doctor_id');
// 不按当前账号启用状态排除历史会话。
$roleIds = Db::name('admin_role')->whereIn('role_id', [1, 2])->column('admin_id');
foreach (array_merge($assistantIds, $doctorIds, $roleIds) as $id) {
if ((int)$id > 0) $accounts[] = 'doctor_' . (int)$id;
}
return array_values(array_unique(array_filter($accounts, static function ($account) {
return is_string($account) && preg_match('/^doctor_[1-9][0-9]*$/', $account);
})));
}
/**
* @notes 拉取与本诊单相关的 IM 单聊记录:本地归档 + 腾讯云漫游合并(归档突破云端约 7 天限制)
*
* @param int $diagnosisId
* @param bool $onlyArchived 只读取本地归档,不请求腾讯云(用于首次打开快速展示)
*/
/** 先由控制器校验诊单权限,再以真实 patient_id 合并同患者历次诊单的记录。 */
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;
if (!$diag || (int)$diag['patient_id'] <= 0) {
throw new \RuntimeException('诊单不存在或缺少患者信息');
}
$patientId = (int)$diag['patient_id'];
if ($patientId <= 0) {
self::setError('诊单缺少患者信息');
return false;
}
$patientImId = 'patient_' . $patientId;
$archived = self::loadArchivedImChatRows($diagnosisId);
if ($onlyArchived) {
$lists = self::attachDiagnosisIdToImMessages(
self::enrichImMessagesWithStaffNames($archived),
$diagnosisId
);
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::attachDiagnosisIdToImMessages(
self::enrichImMessagesWithStaffNames($archived),
$diagnosisId
);
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::attachDiagnosisIdToImMessages(
self::enrichImMessagesWithStaffNames($merged),
$diagnosisId
);
// 首次云端拉取后异步落库,下一次即可直接读归档,无需再全量扫描医生账号
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());
}
}
$sync = $onlyArchived ? null : self::syncImChatArchiveForDiagnosis($diagnosisId);
$rows = self::enrichImMessagesWithStaffNames(self::loadArchivedImChatRows((int)$diag['patient_id']));
return [
'lists' => $merged,
'patient_im_id' => $patientImId,
'lists' => self::attachDiagnosisIdToImMessages($rows, $diagnosisId),
'patient_im_id' => 'patient_' . (int)$diag['patient_id'],
'patient_name' => $diag['patient_name'] ?? '',
'doctor_accounts_queried' => $doctorAccounts,
'only_archived' => $onlyArchived,
'sync_error' => $sync['error'] ?? '',
];
} catch (\Exception $e) {
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* 定时任务:从腾讯云拉取漫游消息写入归档表
*
* @return array{inserted:int, skipped_live_empty:bool, error?:string}
* 有结果的分页同步:token 绑定当前授权诊单及登录人,浏览器无法指定任意患者/医生。
* currentPeer 只由控制器用已登录 adminId 构建,用于聊天窗口即时同步。
*/
public static function syncImChatArchiveStep(int $diagnosisId, int $adminId, string $token = '', bool $currentPeer = false): array
{
$diag = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
if (!$diag || (int)$diag['patient_id'] <= 0) throw new \RuntimeException('诊单不存在或缺少患者信息');
if (!self::getTrtcConfig()) throw new \RuntimeException('请先配置腾讯云 TRTC / IM 参数');
$patientId = (int)$diag['patient_id'];
if ($token === '') {
$accounts = $currentPeer ? ['doctor_' . $adminId] : self::collectDoctorImPeerAccounts($patientId);
if (!$accounts) throw new \RuntimeException('未找到可同步的医生/医助账号');
$token = bin2hex(random_bytes(24));
$state = \app\common\service\ImChatSyncSession::start($accounts);
$state['diagnosis_id'] = $diagnosisId;
$state['patient_id'] = $patientId;
$state['admin_id'] = $adminId;
} else {
if (!preg_match('/^[a-f0-9]{48}$/', $token)) throw new \RuntimeException('同步进度无效,请重新同步');
$state = \think\facade\Cache::get('im_chat_sync:' . $token);
if (!is_array($state) || $state['diagnosis_id'] !== $diagnosisId || $state['patient_id'] !== $patientId || $state['admin_id'] !== $adminId) {
throw new \RuntimeException('同步进度已失效,请重新同步');
}
}
$state = self::advanceImChatSync($state, $diagnosisId, $patientId);
\think\facade\Cache::set('im_chat_sync:' . $token, $state, 3600);
return array_merge(['sync_token' => $token], \app\common\service\ImChatSyncSession::progress($state));
}
private static function advanceImChatSync(array $state, int $diagnosisId, int $patientId): array
{
if (!array_key_exists('accounts_verified', $state)) {
// 兼容发布前仍在浏览器续拉的旧 token:保留已归档数量,重新核验候选账号。
$state = array_merge($state, \app\common\service\ImChatSyncSession::start($state['accounts']), [
'inserted' => $state['inserted'], 'accounts_verified' => false,
'account_check' => \app\common\service\ImChatAccountFilter::start($state['accounts'], 'patient_' . $patientId),
]);
unset($state['active_index'], $state['peer_started_at'], $state['peer_error_count'], $state['peer_min_time']);
}
if (!$state['accounts_verified']) {
$svc = new \app\common\service\TencentImService();
$state['account_check'] = \app\common\service\ImChatAccountFilter::step(
$state['account_check'], static function (array $accounts) use ($svc) { return $svc->checkAccounts($accounts); }
);
if (\app\common\service\ImChatAccountFilter::completed($state['account_check'])) {
$state['accounts'] = array_values(array_intersect($state['accounts'], $state['account_check']['existing']));
$state['accounts_verified'] = true;
}
// 核验与历史读取分开请求,避免一轮多个腾讯请求叠加导致 HTTP 超时。
return $state;
}
if (\app\common\service\ImChatSyncSession::progress($state)['completed']) return $state;
$index = $state['index'];
$checkpointKey = 'im_chat_complete_v1:' . $patientId . ':' . $state['accounts'][$index];
if (($state['active_index'] ?? -1) !== $index) {
$state['active_index'] = $index;
$state['peer_started_at'] = time();
$state['peer_error_count'] = count($state['errors']);
$state['peer_min_time'] = max(0, (int)\think\facade\Cache::get($checkpointKey, 0) - 120);
}
if (empty($state['cursor'])) {
// 检查点只来自完整且已落库的会话,绝不使用 MAX(msg_time)。留两分钟重叠处理边界/时钟偏差。
$state['cursor'] = ['min_time' => $state['peer_min_time']];
}
$side = $state['side'];
$svc = new \app\common\service\TencentImService();
$next = \app\common\service\ImChatSyncSession::step(
$state,
static function (string $account, array $cursor) use ($svc, $patientId, $side) {
$patient = 'patient_' . $patientId;
// 一方删除历史不代表另一方也删除;两侧合并去重后才算同步完成。
return \app\common\service\ImRoamMessagePager::nextPage($svc, $side === 0 ? $account : $patient, $side === 0 ? $patient : $account, $cursor);
},
static function (string $account, array $messages) use ($diagnosisId, $patientId) {
$rows = [];
foreach ($messages as $message) {
$row = self::normalizeTimMessage($message);
$row['doctor_peer_account'] = $account;
$rows[] = $row;
}
return self::persistImChatArchiveRows($diagnosisId, $patientId, $rows);
}
);
if ($next['index'] > $index && count($next['errors']) === $state['peer_error_count']) {
\think\facade\Cache::set($checkpointKey, $state['peer_started_at'], 86400);
}
return $next;
}
/** CLI 补历史,与页面共用分页及落库逻辑;任何会话失败都返回可见错误。 */
public static function syncImChatArchiveForDiagnosis(int $diagnosisId): array
{
$out = ['inserted' => 0, 'skipped_live_empty' => false];
try {
if (!self::getTrtcConfig()) {
$out['error'] = 'TRTC/IM 未配置';
return $out;
}
if (!self::getTrtcConfig()) throw new \RuntimeException('TRTC/IM 未配置');
$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);
if (!$diag || (int)$diag['patient_id'] <= 0) throw new \RuntimeException('诊单不存在或缺少患者信息');
$patientId = (int)$diag['patient_id'];
$out['inserted'] = self::persistImChatArchiveRows($diagnosisId, $patientId, $live);
return $out;
} catch (\Exception $e) {
$state = \app\common\service\ImChatSyncSession::start(self::collectDoctorImPeerAccounts($patientId));
if (!$state['accounts']) throw new \RuntimeException('未找到可同步的医生/医助账号');
do {
$state = self::advanceImChatSync($state, $diagnosisId, $patientId);
$out['inserted'] = $state['inserted'];
} while (!\app\common\service\ImChatSyncSession::progress($state)['completed']);
if ($state['errors']) $out['error'] = implode('', $state['errors']);
$out['skipped_live_empty'] = !$state['errors'] && $out['inserted'] === 0;
} catch (\Throwable $e) {
$out['error'] = $e->getMessage();
return $out;
}
return $out;
}
/**
* @return array{diagnoses:int, inserted:int, errors:array<int, string>}
*/
/** 定时补拉按患者去重并轮转,避免 limit 每次只扫描最新一批诊单。 */
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);
$cursorKey = 'im_chat_archive_batch_cursor:' . max(0, $sinceDays);
if ($onlyDiagnosisId !== null && $onlyDiagnosisId > 0) {
$q->where('id', $onlyDiagnosisId);
} elseif ($sinceDays > 0) {
$q->where('update_time', '>=', time() - $sinceDays * 86400);
$ids = [$onlyDiagnosisId];
} else {
$query = Diagnosis::where('delete_time', null)->where('patient_id', '>', 0);
if ($sinceDays > 0) $query->where('update_time', '>=', time() - $sinceDays * 86400);
$ids = array_map('intval', $query->group('patient_id')->column('MAX(id)'));
sort($ids);
$cursor = (int)\think\facade\Cache::get($cursorKey, 0);
$pending = array_values(array_filter($ids, static function ($id) use ($cursor) { return $id > $cursor; }));
$ids = array_slice($pending ?: $ids, 0, $limit);
}
$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);
$result = self::syncImChatArchiveForDiagnosis((int)$id);
$stats['inserted'] += (int)$result['inserted'];
if (!empty($result['error'])) $stats['errors'][] = 'diagnosis ' . $id . ': ' . $result['error'];
if (!$onlyDiagnosisId) \think\facade\Cache::set($cursorKey, (int)$id, 0);
}
return $stats;
}
/**
* @return array<int, array<string, mixed>>
*/
private static function loadArchivedImChatRows(int $diagnosisId): array
private static function loadArchivedImChatRows(int $patientId): array
{
if ($diagnosisId <= 0) {
return [];
}
$list = ImChatMessage::where('diagnosis_id', $diagnosisId)
->order('msg_time', 'asc')
->order('id', 'asc')
->select()
->toArray();
if ($patientId <= 0) return [];
$list = ImChatMessage::where('patient_id', $patientId)
->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'] ?? ''),
];
// 同时校验账号,防止历史错误的 patient_id 把其他患者消息混进来。
if (!self::isPatientDoctorImPair($row, $patientId)) continue;
$row['time'] = (int)$row['msg_time'];
$row['is_from_doctor'] = strpos((string)$row['from_account'], 'doctor_') === 0;
if (($row['msg_type'] ?? '') === 'composite') {
$row['parts'] = json_decode((string)$row['text'], true) ?: [];
}
$out[] = $row;
}
return $out;
}
/**
* Keep the parent diagnosis on every child row so clients can reject
* accidentally mixed or stale IM payloads before rendering them.
*
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
private static function attachDiagnosisIdToImMessages(array $rows, int $diagnosisId): array
private static function isPatientDoctorImPair(array $row, int $patientId): bool
{
foreach ($rows as &$row) {
$row['diagnosis_id'] = $diagnosisId;
}
unset($row);
return $rows;
$from = (string)($row['from_account'] ?? '');
$to = (string)($row['to_account'] ?? '');
$patient = 'patient_' . $patientId;
return ($from === $patient && preg_match('/^doctor_[1-9][0-9]*$/', $to))
|| ($to === $patient && preg_match('/^doctor_[1-9][0-9]*$/', $from));
}
/**
* @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
private static function attachDiagnosisIdToImMessages(array $rows, int $diagnosisId): 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;
foreach ($rows as &$row) $row['diagnosis_id'] = $diagnosisId;
unset($row);
return $rows;
}
/**
* @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
/** 仅供已验证腾讯签名的回调使用,不接受浏览器直接上报消息正文。 */
public static function archiveImCallbackMessage(array $payload): int
{
$patientId = (int)$diag['patient_id'];
if ($patientId <= 0) {
return [];
}
$patientImId = 'patient_' . $patientId;
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) {
// 已归档过:从最大已知 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;
}
}
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;
// 腾讯也会回调发送失败的消息,这类记录不能作为成功发送的聊天历史展示。
if (!array_key_exists('SendMsgResult', $payload) || !is_int($payload['SendMsgResult'])) {
throw new \RuntimeException('IM 回调缺少有效发送结果');
}
if ($payload['SendMsgResult'] !== 0) return 0;
$row = self::normalizeTimMessage($payload);
$from = $row['from_account'];
$to = $row['to_account'];
$patientAccount = strpos($from, 'patient_') === 0 ? $from : $to;
if (!preg_match('/^patient_([1-9][0-9]*)$/', $patientAccount, $match)) return 0;
$patientId = (int)$match[1];
if (!self::isPatientDoctorImPair($row, $patientId)) return 0;
if ($row['time'] <= 0 || empty($payload['MsgBody']) || empty($payload['MsgKey'])) {
throw new \RuntimeException('IM 回调缺少有效消息标识、时间或内容');
}
$diag = Diagnosis::where('patient_id', $patientId)->where('delete_time', null)->order('id', 'desc')->find();
if (!$diag) return 0;
$row['doctor_peer_account'] = $from === $patientAccount ? $to : $from;
return self::persistImChatArchiveRows((int)$diag['id'], $patientId, [$row]);
}
/**
@@ -1334,10 +1243,30 @@ class DiagnosisLogic extends BaseLogic
{
$now = time();
$chunks = [];
// 兼容已入库的 seq_random_from 旧键;必须核对患者、双向账号及时间,不能误复用碰撞键。
$legacyIds = array_values(array_filter(array_column($rows, 'legacy_msg_id')));
$legacyRows = empty($legacyIds) ? [] : ImChatMessage::where('patient_id', $patientId)
->whereIn('msg_id', $legacyIds)->select()->toArray();
$legacyMap = array_column($legacyRows, null, 'msg_id');
foreach ($rows as $r) {
if (!self::isPatientDoctorImPair($r, $patientId)) {
throw new \RuntimeException('IM 消息不属于当前患者会话');
}
$msgId = (string)($r['msg_id'] ?? '');
if ($msgId === '') {
continue;
throw new \RuntimeException('IM 消息缺少标识');
}
$legacy = $legacyMap[$r['legacy_msg_id'] ?? ''] ?? null;
if ($legacy && (int)$legacy['msg_time'] === (int)$r['time']
&& $legacy['from_account'] === $r['from_account'] && $legacy['to_account'] === $r['to_account']) {
$msgId = (string)$legacy['msg_id'];
// 修复旧版本仅保存第一个消息元素的归档,仍保留原始归档诊单及去重键。
if ($r['msg_type'] === 'composite' && (($legacy['msg_type'] ?? '') !== 'composite' || ($legacy['text'] ?? '') !== $r['text'])) {
ImChatMessage::where('id', $legacy['id'])->where('patient_id', $patientId)->update([
'msg_type' => 'composite', 'text' => $r['text'], 'raw_elem_type' => $r['raw_elem_type'],
'image_url' => '', 'file_url' => '', 'file_name' => '',
]);
}
}
$chunks[] = [
'diagnosis_id' => $diagnosisId,
@@ -1384,7 +1313,9 @@ class DiagnosisLogic extends BaseLogic
$flat[] = $row[$c];
}
}
$sql = 'INSERT IGNORE INTO `' . $table . '` (' . $colSql . ') VALUES ' . $allPh;
// 仅重复消息键为幂等成功,表结构/长度/写库错误不能被 INSERT IGNORE 掩盖。
$sql = 'INSERT INTO `' . $table . '` (' . $colSql . ') VALUES ' . $allPh
. ' ON DUPLICATE KEY UPDATE `msg_id` = `msg_id`';
return (int)Db::execute($sql, $flat);
}
@@ -1412,91 +1343,13 @@ class DiagnosisLogic extends BaseLogic
$f = (string)($r['from_account'] ?? '');
if (preg_match('/^doctor_(\d+)$/', $f, $m)) {
$aid = (int)$m[1];
$rows[$k]['from_staff_name'] = $map[$aid] ?? '';
$rows[$k]['from_staff_name'] = $map[$aid] ?? ($r['from_staff_name'] ?? '');
}
}
return $rows;
}
/**
* @return array<int, array<string, mixed>>
*/
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 = 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) {
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 >= $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);
return $out;
}
/**
* @param array<string, mixed> $raw
* @return array<string, mixed>
@@ -1508,13 +1361,33 @@ class DiagnosisLogic extends BaseLogic
$time = (int)($raw['MsgTimeStamp'] ?? $raw['MsgTime'] ?? 0);
$seq = $raw['MsgSeq'] ?? '';
$rand = $raw['MsgRandom'] ?? '';
$msgId = $seq . '_' . $rand . '_' . $from;
$key = (string)($raw['MsgKey'] ?? '');
// 发送后回调不一定带 MsgRandom,MsgKey 与云端漫游消息中的标识相同。
if ($key !== '' && preg_match('/^(\d+)_(\d+)_\d+$/', $key, $keyParts)) {
$seq = $seq === '' ? $keyParts[1] : $seq;
$rand = $rand === '' ? $keyParts[2] : $rand;
}
$identity = $key !== '' ? $key : $seq . '_' . $rand . '_' . $time;
if ($key === '' && ($seq === '' || $rand === '')) {
$identity .= '_' . json_encode($raw['MsgBody'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$msgId = 'im_' . hash('sha256', $from . "\0" . $to . "\0" . $identity);
$isDoctor = strpos($from, 'doctor_') === 0;
$parsed = self::parseTimMsgBody($raw['MsgBody'] ?? []);
$parsed = self::parseTimMsgBody($raw['MsgBody'] ?? []);
if (is_array($raw['MsgBody'] ?? null) && count($raw['MsgBody']) > 1) {
$parts = [];
foreach ($raw['MsgBody'] as $element) $parts[] = self::parseTimMsgBody([$element]);
$parsed = [
'msg_type' => 'composite',
'text' => json_encode($parts, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
'image_url' => '', 'file_url' => '', 'file_name' => '', 'raw_elem_type' => 'TIMMultiElem',
];
}
return array_merge(
[
'msg_id' => $msgId,
'legacy_msg_id' => $seq !== '' && $rand !== '' ? $seq . '_' . $rand . '_' . $from : '',
'from_account' => $from,
'to_account' => $to,
'time' => $time,
@@ -1650,6 +1523,12 @@ class DiagnosisLogic extends BaseLogic
return false;
}
$callPolicy = AppointmentCallPolicy::resolve($diagnosisId, (int) ($params['appointment_id'] ?? 0));
if (!($callType === 1 ? $callPolicy['can_audio_call'] : $callPolicy['can_video_call'])) {
self::setError($callPolicy['call_disabled_reason'] ?: '当前挂号不支持该通话方式');
return false;
}
// 创建通话记录
$record = \app\common\model\tcm\CallRecord::create([
'diagnosis_id' => $diagnosisId,
@@ -141,10 +141,11 @@ class DiagnosisValidate extends BaseValidate
/** IM / video identity: validate shape here; ownership is checked in the logic layer. */
public function sceneCallIdentity()
{
return $this->only(['diagnosis_id', 'patient_id'])
return $this->only(['diagnosis_id', 'patient_id', 'appointment_id'])
->remove('diagnosis_id', 'checkDiagnosisId')
->append('diagnosis_id', 'gt:0')
->append('patient_id', 'require|integer|gt:0');
->append('patient_id', 'require|integer|gt:0')
->append('appointment_id', 'integer|egt:0');
}
public function sceneGenerateQrcode()