更新
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||
use app\common\service\ImCallbackSignature;
|
||||
use JsonException;
|
||||
use think\facade\Log;
|
||||
use think\response\Json;
|
||||
use Throwable;
|
||||
|
||||
/** 腾讯 IM 单聊消息归档回调;使用腾讯签名认证,不使用患者登录会话。 */
|
||||
class ImController extends BaseApiController
|
||||
{
|
||||
public array $notNeedLogin = ['messageNotify'];
|
||||
|
||||
private const MAX_BODY_BYTES = 1048576;
|
||||
private const AFTER_SEND_COMMAND = 'C2C.CallbackAfterSendMsg';
|
||||
|
||||
public function messageNotify(): Json
|
||||
{
|
||||
if (strtoupper($this->request->method(true)) !== 'POST') {
|
||||
return $this->callbackFailure(405, 'method not allowed')->header(['Allow' => 'POST']);
|
||||
}
|
||||
$declaredLength = $this->request->header('content-length', '');
|
||||
if (is_scalar($declaredLength) && is_numeric($declaredLength) && (float) $declaredLength > self::MAX_BODY_BYTES) {
|
||||
return $this->callbackFailure(413, 'request body too large');
|
||||
}
|
||||
|
||||
$token = (string) config('im.callback_token', '');
|
||||
$sdkAppId = (string) config('project.trtc.sdkAppId', '');
|
||||
if (trim($token) === '' || preg_match('/^[1-9][0-9]*$/D', $sdkAppId) !== 1) {
|
||||
return $this->callbackFailure(503, 'callback authentication unavailable');
|
||||
}
|
||||
$query = $this->request->get();
|
||||
$actualSdkAppId = $query['SdkAppid'] ?? null;
|
||||
if ((!is_string($actualSdkAppId) && !is_int($actualSdkAppId)) || (string) $actualSdkAppId !== $sdkAppId
|
||||
|| !ImCallbackSignature::verify($token, $query['RequestTime'] ?? null, $query['Sign'] ?? null)) {
|
||||
return $this->callbackFailure(403, 'callback authentication failed');
|
||||
}
|
||||
|
||||
$raw = $this->request->getContent();
|
||||
if (strlen($raw) > self::MAX_BODY_BYTES) {
|
||||
return $this->callbackFailure(413, 'request body too large');
|
||||
}
|
||||
try {
|
||||
$decoded = json_decode($raw, false, 64, JSON_THROW_ON_ERROR);
|
||||
if (!$decoded instanceof \stdClass) {
|
||||
return $this->callbackFailure(400, 'invalid callback JSON object');
|
||||
}
|
||||
$payload = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return $this->callbackFailure(400, 'invalid callback JSON object');
|
||||
}
|
||||
|
||||
$command = $payload['CallbackCommand'] ?? null;
|
||||
if (!is_string($command) || $command === '' || strlen($command) > 128
|
||||
|| (array_key_exists('CallbackCommand', $query) && $query['CallbackCommand'] !== $command)) {
|
||||
return $this->callbackFailure(400, 'callback command mismatch');
|
||||
}
|
||||
if ($command !== self::AFTER_SEND_COMMAND) {
|
||||
return $this->callbackSuccess();
|
||||
}
|
||||
|
||||
try {
|
||||
DiagnosisLogic::archiveImCallbackMessage($payload);
|
||||
} catch (Throwable) {
|
||||
// 异常信息可能包含 SQL 或患者消息,日志只保留固定事件标记。
|
||||
try {
|
||||
Log::error('IM callback archive failed');
|
||||
} catch (Throwable) {
|
||||
// 日志存储不可用也必须保留腾讯失败回包。
|
||||
}
|
||||
return $this->callbackFailure(500, 'message archive failed');
|
||||
}
|
||||
|
||||
return $this->callbackSuccess();
|
||||
}
|
||||
|
||||
private function callbackSuccess(): Json
|
||||
{
|
||||
return json(['ActionStatus' => 'OK', 'ErrorCode' => 0, 'ErrorInfo' => '']);
|
||||
}
|
||||
|
||||
private function callbackFailure(int $status, string $message): Json
|
||||
{
|
||||
return json(['ActionStatus' => 'FAIL', 'ErrorCode' => $status, 'ErrorInfo' => $message], $status);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,12 @@ class LoginMiddleware
|
||||
*/
|
||||
public function handle($request, \Closure $next)
|
||||
{
|
||||
// 腾讯 IM 回调由控制器验证应用签名,不在验签前查询用户会话或数据库。
|
||||
if ($request->controllerObject instanceof \app\api\controller\ImController
|
||||
&& $request->action() === 'messageNotify'
|
||||
&& $request->controllerObject->isNotNeedLogin()) {
|
||||
return $next($request);
|
||||
}
|
||||
$token = $request->header('token');
|
||||
//判断接口是否免登录
|
||||
$isNotNeedLogin = $request->controllerObject->isNotNeedLogin();
|
||||
@@ -71,4 +77,4 @@ class LoginMiddleware
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,8 @@ class Crontab extends Command
|
||||
// 记录错误信息
|
||||
CrontabModel::where('id', $item['id'])->update([
|
||||
'error' => $e->getMessage(),
|
||||
'status' => CrontabEnum::ERROR
|
||||
// IM 补偿任务需要下轮继续重试;保留错误原因,避免一次云端抖动永久停掉归档。
|
||||
'status' => $item['command'] === 'sync_im_chat_archive' ? CrontabEnum::START : CrontabEnum::ERROR
|
||||
]);
|
||||
} finally {
|
||||
$endTime = microtime(true);
|
||||
@@ -98,4 +99,4 @@ class Crontab extends Command
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use think\console\Output;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 从腾讯云 IM 拉取诊单单聊漫游消息并写入本地归档表(建议 cron 每几小时执行)
|
||||
* 回调实时归档之外的定时补拉;按患者轮转,覆盖没有更新诊单的旧患者。
|
||||
*/
|
||||
class SyncImChatArchive extends Command
|
||||
{
|
||||
@@ -20,7 +20,7 @@ class SyncImChatArchive extends Command
|
||||
{
|
||||
$this->setName('sync_im_chat_archive')
|
||||
->setDescription('同步诊单腾讯云 IM 聊天记录到数据库归档')
|
||||
->addOption('since-days', null, Option::VALUE_OPTIONAL, '仅处理最近 N 天内更新过的诊单;0 表示不限制', '7')
|
||||
->addOption('since-days', null, Option::VALUE_OPTIONAL, '仅处理最近 N 天内更新过的诊单;默认0,覆盖旧患者聊天', '0')
|
||||
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '本轮最多处理的诊单数量(1-500)', '50')
|
||||
->addOption('diagnosis-id', null, Option::VALUE_OPTIONAL, '只同步指定诊单 ID,设置后忽略 since-days', '0');
|
||||
}
|
||||
@@ -40,12 +40,15 @@ class SyncImChatArchive extends Command
|
||||
}
|
||||
|
||||
$stats = DiagnosisLogic::syncImChatArchiveBatch($sinceDays, $limit, $only);
|
||||
$output->writeln("处理诊单数: {$stats['diagnoses']},新插入行数(INSERT IGNORE 成功数): {$stats['inserted']}");
|
||||
$output->writeln("处理患者诊单数: {$stats['diagnoses']},新归档消息数: {$stats['inserted']}");
|
||||
if (!empty($stats['errors'])) {
|
||||
foreach ($stats['errors'] as $e) {
|
||||
$output->writeln("<error>{$e}</error>");
|
||||
Log::error('sync_im_chat_archive: ' . $e);
|
||||
}
|
||||
// 项目调度器 Console::call 不转发命令退出码,抛错才能写入定时任务失败状态。
|
||||
throw new \RuntimeException('IM 聊天归档未完全同步:' . implode(';', $stats['errors']));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\enum\AppointmentTypeEnum;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use RuntimeException;
|
||||
|
||||
/** 按本次挂号决定通话权限;Appointment.patient_id 存的是诊单 ID。 */
|
||||
class AppointmentCallPolicy
|
||||
{
|
||||
/** @return array{appointment_id:int,appointment_type:?string,appointment_type_desc:string,can_video_call:bool,can_audio_call:bool,call_disabled_reason:string} */
|
||||
public static function resolve(int $diagnosisId, int $appointmentId = 0): array
|
||||
{
|
||||
if ($diagnosisId <= 0) {
|
||||
throw new RuntimeException('诊单 ID 无效');
|
||||
}
|
||||
$appointments = Appointment::where('patient_id', $diagnosisId)
|
||||
->field(['id', 'appointment_type', 'status', 'appointment_date', 'appointment_time'])
|
||||
->select()->toArray();
|
||||
|
||||
return self::resolveFromAppointments($appointments, $appointmentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* $appointments 必须仅包含当前诊单的挂号;未知显式 ID 不可回退到其他挂号。
|
||||
*
|
||||
* @param list<array<string,mixed>> $appointments
|
||||
* @return array{appointment_id:int,appointment_type:?string,appointment_type_desc:string,can_video_call:bool,can_audio_call:bool,call_disabled_reason:string}
|
||||
*/
|
||||
public static function resolveFromAppointments(array $appointments, int $appointmentId = 0): array
|
||||
{
|
||||
if ($appointmentId !== 0) {
|
||||
foreach ($appointments as $appointment) {
|
||||
if ($appointmentId > 0 && (int) ($appointment['id'] ?? 0) === $appointmentId) {
|
||||
return self::policyForAppointment($appointment);
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('指定挂号不存在或不属于当前诊单');
|
||||
}
|
||||
|
||||
$active = array_values(array_filter($appointments, static fn (array $row): bool => (int) ($row['status'] ?? 0) === 1));
|
||||
if (count($active) === 1) {
|
||||
return self::policyForAppointment($active[0]);
|
||||
}
|
||||
if (count($active) > 1) {
|
||||
return array_replace(self::policyForAppointment(null), [
|
||||
'appointment_type_desc' => '待指定挂号',
|
||||
'call_disabled_reason' => '请指定本次挂号',
|
||||
]);
|
||||
}
|
||||
|
||||
$historical = array_values(array_filter($appointments, static fn (array $row): bool => in_array((int) ($row['status'] ?? 0), [3, 4], true)));
|
||||
usort($historical, static function (array $left, array $right): int {
|
||||
return [
|
||||
(string) ($right['appointment_date'] ?? ''),
|
||||
self::sortableTime($right['appointment_time'] ?? ''),
|
||||
(int) ($right['id'] ?? 0),
|
||||
] <=> [
|
||||
(string) ($left['appointment_date'] ?? ''),
|
||||
self::sortableTime($left['appointment_time'] ?? ''),
|
||||
(int) ($left['id'] ?? 0),
|
||||
];
|
||||
});
|
||||
|
||||
return self::policyForAppointment($historical[0] ?? null);
|
||||
}
|
||||
|
||||
/** @return array{appointment_id:int,appointment_type:?string,appointment_type_desc:string,can_video_call:bool,can_audio_call:bool,call_disabled_reason:string} */
|
||||
public static function policyForAppointment(?array $appointment): array
|
||||
{
|
||||
if ($appointment === null) {
|
||||
return [
|
||||
'appointment_id' => 0,
|
||||
'appointment_type' => null,
|
||||
'appointment_type_desc' => '未挂号',
|
||||
'can_video_call' => false,
|
||||
'can_audio_call' => false,
|
||||
'call_disabled_reason' => '未挂号,不能发起通话',
|
||||
];
|
||||
}
|
||||
|
||||
// 空类型仅对确实存在的历史挂号应用旧视频默认值。
|
||||
$type = AppointmentTypeEnum::normalizeStored($appointment['appointment_type'] ?? null);
|
||||
$status = (int) ($appointment['status'] ?? 0);
|
||||
$callableStatus = in_array($status, [1, 3, 4], true);
|
||||
$video = $callableStatus && $type === AppointmentTypeEnum::VIDEO;
|
||||
$audio = $callableStatus && in_array($type, [AppointmentTypeEnum::VIDEO, 'phone'], true);
|
||||
$reason = match (true) {
|
||||
$status === 2 => '本次挂号已取消,不能发起通话',
|
||||
!$callableStatus => '本次挂号状态不支持通话',
|
||||
$type === AppointmentTypeEnum::TEXT => '图文问诊不支持音视频通话',
|
||||
$type === 'phone' => '电话问诊仅支持语音通话',
|
||||
!$video && !$audio => '本次挂号问诊方式不支持音视频通话',
|
||||
default => '',
|
||||
};
|
||||
|
||||
return [
|
||||
'appointment_id' => (int) ($appointment['id'] ?? 0),
|
||||
'appointment_type' => $type,
|
||||
'appointment_type_desc' => AppointmentTypeEnum::description($type),
|
||||
'can_video_call' => $video,
|
||||
'can_audio_call' => $audio,
|
||||
'call_disabled_reason' => $reason,
|
||||
];
|
||||
}
|
||||
|
||||
private static function sortableTime($value): string
|
||||
{
|
||||
$time = (string) $value;
|
||||
if (preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $time, $matches) === 1) {
|
||||
return sprintf('%02d:%02d:%02d', (int) $matches[1], (int) $matches[2], (int) ($matches[3] ?? 0));
|
||||
}
|
||||
|
||||
return $time;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/** 腾讯 IM 回调签名:sha256(Token . RequestTime),只接受一分钟以内的请求。 */
|
||||
class ImCallbackSignature
|
||||
{
|
||||
public static function verify(string $token, mixed $requestTime, mixed $sign, ?int $now = null): bool
|
||||
{
|
||||
if (trim($token) === '' || (!is_string($requestTime) && !is_int($requestTime)) || !is_string($sign)) {
|
||||
return false;
|
||||
}
|
||||
$timestamp = (string) $requestTime;
|
||||
if (preg_match('/^[0-9]{1,12}$/D', $timestamp) !== 1 || preg_match('/^[a-fA-F0-9]{64}$/D', $sign) !== 1) {
|
||||
return false;
|
||||
}
|
||||
if (abs(($now ?? time()) - (int) $timestamp) > 60) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals(hash('sha256', $token . $timestamp), strtolower($sign));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/** 读取历史前批量核验账号,未导入的后台成员不应作为失败会话反复拉取。 */
|
||||
final class ImChatAccountFilter
|
||||
{
|
||||
public static function start(array $accounts, string $patient): array
|
||||
{
|
||||
return [
|
||||
'patient' => $patient,
|
||||
'candidates' => array_values(array_unique(array_merge([$patient], $accounts))),
|
||||
'offset' => 0, 'existing' => [], 'missing' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/** 每步最多一次 account_check;网络/权限/结构错误由调用者明确显示,不作为缺失账号处理。 */
|
||||
public static function step(array $state, callable $check): array
|
||||
{
|
||||
$batch = array_slice($state['candidates'], $state['offset'], 100);
|
||||
if (!$batch) return $state;
|
||||
$result = $check($batch);
|
||||
if (in_array($state['patient'], $result['missing'], true)) {
|
||||
throw new \RuntimeException('当前腾讯 IM 应用中未找到患者聊天账号,请核对应用配置或患者账号;已有归档仍可查看');
|
||||
}
|
||||
$state['existing'] = array_values(array_unique(array_merge($state['existing'], $result['existing'])));
|
||||
$state['missing'] = array_values(array_unique(array_merge($state['missing'], $result['missing'])));
|
||||
$state['offset'] += count($batch);
|
||||
return $state;
|
||||
}
|
||||
|
||||
public static function completed(array $state): bool
|
||||
{
|
||||
return $state['offset'] >= count($state['candidates']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/** 一次 HTTP 请求只拉一页;只有归档成功后才推进游标。 */
|
||||
final class ImChatSyncSession
|
||||
{
|
||||
public static function start(array $accounts): array
|
||||
{
|
||||
return [
|
||||
'accounts' => array_values(array_unique($accounts)),
|
||||
'index' => 0, 'side' => 0, 'cursor' => [], 'inserted' => 0, 'errors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
public static function step(array $state, callable $fetchPage, callable $archive): array
|
||||
{
|
||||
if ($state['index'] >= count($state['accounts'])) {
|
||||
return $state;
|
||||
}
|
||||
$account = $state['accounts'][$state['index']];
|
||||
try {
|
||||
$page = $fetchPage($account, $state['cursor']);
|
||||
} catch (\Throwable $e) {
|
||||
// 失败的会话不妨碍其他医生记录落库,下一轮仍从头补拉该会话。
|
||||
$state['errors'][] = $account . ($state['side'] === 0 ? '(医生侧)' : '(患者侧)') . ':' . $e->getMessage();
|
||||
return self::nextSide($state);
|
||||
}
|
||||
|
||||
// 落库异常交由调用者处理,绝不能把未归档的页标记为已同步。
|
||||
$state['inserted'] += $archive($account, $page['msgList']);
|
||||
if ($page['completed']) {
|
||||
$state = self::nextSide($state);
|
||||
} else {
|
||||
$state['cursor'] = $page['cursor'];
|
||||
}
|
||||
return $state;
|
||||
}
|
||||
|
||||
private static function nextSide(array $state): array
|
||||
{
|
||||
$state['cursor'] = [];
|
||||
if ($state['side'] === 0) {
|
||||
$state['side'] = 1;
|
||||
} else {
|
||||
$state['side'] = 0;
|
||||
$state['index']++;
|
||||
}
|
||||
return $state;
|
||||
}
|
||||
|
||||
public static function progress(array $state): array
|
||||
{
|
||||
$checking = array_key_exists('accounts_verified', $state) && !$state['accounts_verified'];
|
||||
$check = $state['account_check'] ?? null;
|
||||
$completed = !$checking && $state['index'] >= count($state['accounts']);
|
||||
return [
|
||||
'completed' => $completed,
|
||||
'phase' => $checking ? 'checking_accounts' : ($completed ? 'completed' : 'syncing'),
|
||||
'checked_accounts' => $check['offset'] ?? 0,
|
||||
'candidate_accounts' => $check ? count($check['candidates']) : 0,
|
||||
'skipped_accounts' => $check ? count($check['missing']) : 0,
|
||||
'inserted' => $state['inserted'],
|
||||
'processed_peers' => $state['index'],
|
||||
'total_peers' => count($state['accounts']),
|
||||
'errors' => $state['errors'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/** 单次请求一页;调用者只有在消息持久化后才保存返回的游标。 */
|
||||
class ImRoamMessagePager
|
||||
{
|
||||
private const MAX_TIME = 4294967295;
|
||||
|
||||
/**
|
||||
* @return array{msgList:array,completed:bool,cursor:array{max_time:int,last_key:?string,min_time:int,seen_keys:array}}
|
||||
* @throws \RuntimeException 云端错误码通过异常 code 保留。
|
||||
*/
|
||||
public static function nextPage(TencentImService $svc, string $operator, string $peer, array $cursor = []): array
|
||||
{
|
||||
if (trim($operator) === '' || trim($peer) === '' || $operator === $peer) {
|
||||
throw new \RuntimeException('IM会话双方账号无效');
|
||||
}
|
||||
$cursor = self::normalizeCursor($cursor);
|
||||
$response = $svc->adminGetRoamMsg(
|
||||
$operator,
|
||||
$peer,
|
||||
100,
|
||||
$cursor['min_time'],
|
||||
$cursor['max_time'],
|
||||
$cursor['last_key']
|
||||
);
|
||||
if (($response['success'] ?? null) !== true) {
|
||||
$error = is_string($response['error'] ?? null) ? trim($response['error']) : '';
|
||||
$code = is_int($response['rawErrorCode'] ?? null) ? $response['rawErrorCode'] : 0;
|
||||
throw new \RuntimeException($error !== '' ? $error : 'IM漫游消息拉取失败', $code);
|
||||
}
|
||||
if (!in_array($response['complete'] ?? null, [0, 1], true)) {
|
||||
throw new \RuntimeException('IM响应分页状态 Complete 非法');
|
||||
}
|
||||
$messages = $response['msgList'] ?? null;
|
||||
if (!is_array($messages) || array_values($messages) !== $messages) {
|
||||
throw new \RuntimeException('IM响应消息列表 MsgList 非法');
|
||||
}
|
||||
foreach ($messages as $message) {
|
||||
self::validateMessage($message, $operator, $peer, $cursor);
|
||||
}
|
||||
|
||||
$completed = $response['complete'] === 1;
|
||||
$lastTime = $response['lastMsgTime'] ?? null;
|
||||
$lastKey = $response['lastMsgKey'] ?? null;
|
||||
$hasCursor = ($lastTime !== null && $lastTime !== 0) || ($lastKey !== null && $lastKey !== '');
|
||||
if (!$completed || $hasCursor) {
|
||||
if (!is_int($lastTime) || $lastTime <= 0 || !is_string($lastKey) || trim($lastKey) === '') {
|
||||
throw new \RuntimeException('IM响应缺少有效续页游标 LastMsgTime/LastMsgKey');
|
||||
}
|
||||
if ($lastTime < $cursor['min_time'] || $lastTime > $cursor['max_time']) {
|
||||
throw new \RuntimeException('IM响应续页时间超出请求范围');
|
||||
}
|
||||
$seenKeys = $lastTime === $cursor['max_time'] ? $cursor['seen_keys'] : [];
|
||||
if (in_array($lastKey, $seenKeys, true)) {
|
||||
throw new \RuntimeException('IM响应续页游标重复,分页未向前推进');
|
||||
}
|
||||
$seenKeys[] = $lastKey;
|
||||
$cursor['max_time'] = $lastTime;
|
||||
$cursor['last_key'] = $lastKey;
|
||||
$cursor['seen_keys'] = $seenKeys;
|
||||
}
|
||||
|
||||
return ['msgList' => $messages, 'completed' => $completed, 'cursor' => $cursor];
|
||||
}
|
||||
|
||||
/** 不以本地归档最新时间作下界,初次扫描覆盖云端仍保留的全部记录。 */
|
||||
private static function normalizeCursor(array $cursor): array
|
||||
{
|
||||
$maxTime = $cursor['max_time'] ?? self::MAX_TIME;
|
||||
$minTime = $cursor['min_time'] ?? 0;
|
||||
$lastKey = $cursor['last_key'] ?? null;
|
||||
$seenKeys = $cursor['seen_keys'] ?? [];
|
||||
if (!is_int($minTime) || !is_int($maxTime) || $minTime < 0 || $maxTime > self::MAX_TIME || $minTime > $maxTime) {
|
||||
throw new \RuntimeException('IM请求时间游标无效');
|
||||
}
|
||||
if ($lastKey === '') {
|
||||
$lastKey = null;
|
||||
}
|
||||
if ($lastKey !== null && (!is_string($lastKey) || trim($lastKey) === '')) {
|
||||
throw new \RuntimeException('IM请求消息游标无效');
|
||||
}
|
||||
if (!is_array($seenKeys) || array_values($seenKeys) !== $seenKeys) {
|
||||
throw new \RuntimeException('IM请求历史游标无效');
|
||||
}
|
||||
foreach ($seenKeys as $key) {
|
||||
if (!is_string($key) || trim($key) === '') {
|
||||
throw new \RuntimeException('IM请求历史游标无效');
|
||||
}
|
||||
}
|
||||
if ($lastKey !== null && !in_array($lastKey, $seenKeys, true)) {
|
||||
$seenKeys[] = $lastKey;
|
||||
}
|
||||
|
||||
return ['max_time' => $maxTime, 'last_key' => $lastKey, 'min_time' => $minTime, 'seen_keys' => $seenKeys];
|
||||
}
|
||||
|
||||
private static function validateMessage($message, string $operator, string $peer, array $cursor): void
|
||||
{
|
||||
if (!is_array($message)) {
|
||||
throw new \RuntimeException('IM响应包含非法消息');
|
||||
}
|
||||
$from = $message['From_Account'] ?? null;
|
||||
$to = $message['To_Account'] ?? null;
|
||||
if (!(($from === $operator && $to === $peer) || ($from === $peer && $to === $operator))) {
|
||||
throw new \RuntimeException('IM响应消息不属于当前会话,已停止同步');
|
||||
}
|
||||
$key = $message['MsgKey'] ?? null;
|
||||
$time = $message['MsgTimeStamp'] ?? null;
|
||||
if (!is_string($key) || trim($key) === '' || !is_int($time)
|
||||
|| $time < $cursor['min_time'] || $time > $cursor['max_time']
|
||||
|| !is_array($message['MsgBody'] ?? null)) {
|
||||
throw new \RuntimeException('IM响应消息标识、时间或内容格式非法');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,100 @@ class TencentImService
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只读检查一批账号是否已导入 IM;不自动导入,也不在一次调用里拆成多个请求。
|
||||
* @see https://cloud.tencent.com/document/product/269/38417
|
||||
* @return array{existing:array,missing:array}
|
||||
* @throws \RuntimeException 单批最多 100 个不同账号;任何检查失败都保留错误码。
|
||||
*/
|
||||
public function checkAccounts(array $accounts): array
|
||||
{
|
||||
foreach ($accounts as $account) {
|
||||
if (!is_string($account) || trim($account) === '') {
|
||||
throw new \RuntimeException('IM账号查询参数必须是非空账号字符串');
|
||||
}
|
||||
}
|
||||
$accounts = array_values(array_unique($accounts, SORT_STRING));
|
||||
if ($accounts === []) {
|
||||
return ['existing' => [], 'missing' => []];
|
||||
}
|
||||
if (count($accounts) > 100) {
|
||||
throw new \RuntimeException('IM账号查询单批最多支持100个账号');
|
||||
}
|
||||
|
||||
try {
|
||||
$adminUserSig = $this->generateUserSig($this->adminIdentifier);
|
||||
if (!$adminUserSig) {
|
||||
throw new \RuntimeException('生成管理员UserSig失败');
|
||||
}
|
||||
$url = sprintf(
|
||||
'https://console.tim.qq.com/v4/im_open_login_svc/account_check?sdkappid=%s&identifier=%s&usersig=%s&random=%s&contenttype=json',
|
||||
$this->sdkAppId,
|
||||
$this->adminIdentifier,
|
||||
urlencode($adminUserSig),
|
||||
rand(0, 4294967295)
|
||||
);
|
||||
$data = ['CheckItem' => array_map(static function (string $account): array {
|
||||
return ['UserID' => $account];
|
||||
}, $accounts)];
|
||||
$result = $this->httpPost($url, json_encode($data, JSON_THROW_ON_ERROR), 15);
|
||||
if (!is_string($result) || $result === '') {
|
||||
throw new \RuntimeException('IM账号查询接口无响应');
|
||||
}
|
||||
$response = json_decode($result, true);
|
||||
if (!is_array($response) || !is_int($response['ErrorCode'] ?? null)) {
|
||||
throw new \RuntimeException('IM账号查询响应格式非法或缺少ErrorCode');
|
||||
}
|
||||
$code = $response['ErrorCode'];
|
||||
if (($response['ActionStatus'] ?? null) !== 'OK' || $code !== 0) {
|
||||
$info = is_string($response['ErrorInfo'] ?? null) ? trim($response['ErrorInfo']) : '';
|
||||
throw new \RuntimeException($info !== '' ? $info : 'IM账号查询失败:ErrorCode ' . $code, $code);
|
||||
}
|
||||
$items = $response['ResultItem'] ?? null;
|
||||
if (!is_array($items) || array_values($items) !== $items) {
|
||||
throw new \RuntimeException('IM账号查询响应缺少有效ResultItem列表');
|
||||
}
|
||||
$requested = array_fill_keys($accounts, true);
|
||||
$statuses = [];
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item) || !is_string($item['UserID'] ?? null)) {
|
||||
throw new \RuntimeException('IM账号查询结果缺少有效UserID');
|
||||
}
|
||||
$account = $item['UserID'];
|
||||
if (!isset($requested[$account]) || isset($statuses[$account])) {
|
||||
throw new \RuntimeException('IM账号查询结果包含未请求或重复的账号');
|
||||
}
|
||||
if (!is_int($item['ResultCode'] ?? null)) {
|
||||
throw new \RuntimeException('IM账号查询结果缺少有效ResultCode');
|
||||
}
|
||||
if ($item['ResultCode'] !== 0) {
|
||||
$info = is_string($item['ResultInfo'] ?? null) ? trim($item['ResultInfo']) : '';
|
||||
throw new \RuntimeException(
|
||||
$info !== '' ? $info : 'IM单个账号查询失败:ResultCode ' . $item['ResultCode'],
|
||||
$item['ResultCode']
|
||||
);
|
||||
}
|
||||
if (!in_array($item['AccountStatus'] ?? null, ['Imported', 'NotImported'], true)) {
|
||||
throw new \RuntimeException('IM账号查询结果缺少有效AccountStatus');
|
||||
}
|
||||
$statuses[$account] = $item['AccountStatus'];
|
||||
}
|
||||
if (count($statuses) !== count($accounts)) {
|
||||
throw new \RuntimeException('IM账号查询结果不完整,部分账号未返回检查结果');
|
||||
}
|
||||
|
||||
$out = ['existing' => [], 'missing' => []];
|
||||
foreach ($accounts as $account) {
|
||||
$out[$statuses[$account] === 'Imported' ? 'existing' : 'missing'][] = $account;
|
||||
}
|
||||
return $out;
|
||||
} catch (\RuntimeException $exception) {
|
||||
throw $exception;
|
||||
} catch (\Throwable $exception) {
|
||||
throw new \RuntimeException($exception->getMessage(), (int)$exception->getCode(), $exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 删除账号
|
||||
@@ -235,7 +329,7 @@ class TencentImService
|
||||
|
||||
/**
|
||||
* 拉取单聊(C2C)漫游消息
|
||||
* @see https://cloud.tencent.com/document/product/269/2739
|
||||
* @see https://cloud.tencent.cn/document/product/269/42794
|
||||
*
|
||||
* @param string $operatorAccount 会话一方 UserID(如 doctor_1)
|
||||
* @param string $peerAccount 会话另一方 UserID(如 patient_2)
|
||||
@@ -253,7 +347,7 @@ class TencentImService
|
||||
$empty = [
|
||||
'success' => false,
|
||||
'msgList' => [],
|
||||
'complete' => 1,
|
||||
'complete' => 0,
|
||||
'lastMsgKey' => null,
|
||||
'lastMsgTime' => null,
|
||||
'error' => '',
|
||||
@@ -278,45 +372,61 @@ class TencentImService
|
||||
'Peer_Account' => $peerAccount,
|
||||
'MaxCnt' => $maxCnt,
|
||||
'MinTime' => $minTime,
|
||||
'MaxTime' => $maxTime,
|
||||
// 保留旧方法参数;续页时间必须写入 MaxTime,LastMsgTime 仅为响应字段。
|
||||
'MaxTime' => $lastMsgTime ?? $maxTime,
|
||||
];
|
||||
if ($lastMsgKey !== null && $lastMsgKey !== '') {
|
||||
$data['LastMsgKey'] = $lastMsgKey;
|
||||
}
|
||||
if ($lastMsgTime !== null && $lastMsgTime > 0) {
|
||||
$data['LastMsgTime'] = $lastMsgTime;
|
||||
}
|
||||
// 增加超时时间到 60 秒
|
||||
$result = $this->httpPost($url, json_encode($data), 60);
|
||||
// 每个同步步骤只请求一页,超时后交由下一步骤重试。
|
||||
$result = $this->httpPost($url, json_encode($data, JSON_THROW_ON_ERROR), 15);
|
||||
if (!$result) {
|
||||
$empty['error'] = 'IM接口无响应';
|
||||
return $empty;
|
||||
}
|
||||
$response = json_decode($result, true);
|
||||
if (!$response) {
|
||||
if (!is_array($response)) {
|
||||
$empty['error'] = 'IM响应解析失败';
|
||||
return $empty;
|
||||
}
|
||||
$code = (int)($response['ErrorCode'] ?? -1);
|
||||
$code = is_int($response['ErrorCode'] ?? null) ? $response['ErrorCode'] : -1;
|
||||
$empty['rawErrorCode'] = $code;
|
||||
if (($response['ActionStatus'] ?? '') !== 'OK') {
|
||||
$empty['error'] = $response['ErrorInfo'] ?? ('ErrorCode ' . $code);
|
||||
if (($response['ActionStatus'] ?? '') !== 'OK' || $code !== 0) {
|
||||
$errorInfo = is_string($response['ErrorInfo'] ?? null) ? trim($response['ErrorInfo']) : '';
|
||||
$empty['error'] = $errorInfo !== '' ? $errorInfo : ('IM接口返回错误:ErrorCode ' . $code);
|
||||
return $empty;
|
||||
}
|
||||
$msgList = $response['MsgList'] ?? [];
|
||||
if (!is_array($msgList)) {
|
||||
$msgList = [];
|
||||
$msgList = $response['MsgList'] ?? null;
|
||||
if (!is_array($msgList) || array_values($msgList) !== $msgList) {
|
||||
$empty['error'] = 'IM响应消息列表 MsgList 非法';
|
||||
return $empty;
|
||||
}
|
||||
if (!in_array($response['Complete'] ?? null, [0, 1], true)) {
|
||||
$empty['error'] = 'IM响应分页状态 Complete 非法';
|
||||
return $empty;
|
||||
}
|
||||
if (isset($response['MsgCnt']) && (!is_int($response['MsgCnt']) || $response['MsgCnt'] !== count($msgList))) {
|
||||
$empty['error'] = 'IM响应消息条数 MsgCnt 与 MsgList 不一致';
|
||||
return $empty;
|
||||
}
|
||||
if (isset($response['LastMsgTime']) && !is_int($response['LastMsgTime'])) {
|
||||
$empty['error'] = 'IM响应游标 LastMsgTime 非法';
|
||||
return $empty;
|
||||
}
|
||||
if (isset($response['LastMsgKey']) && !is_string($response['LastMsgKey'])) {
|
||||
$empty['error'] = 'IM响应游标 LastMsgKey 非法';
|
||||
return $empty;
|
||||
}
|
||||
return [
|
||||
'success' => true,
|
||||
'msgList' => $msgList,
|
||||
'complete' => (int)($response['Complete'] ?? 1),
|
||||
'complete' => $response['Complete'],
|
||||
'lastMsgKey' => $response['LastMsgKey'] ?? null,
|
||||
'lastMsgTime' => isset($response['LastMsgTime']) ? (int)$response['LastMsgTime'] : null,
|
||||
'lastMsgTime' => $response['LastMsgTime'] ?? null,
|
||||
'error' => '',
|
||||
'rawErrorCode' => $code,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
$empty['error'] = $e->getMessage();
|
||||
return $empty;
|
||||
}
|
||||
@@ -328,7 +438,7 @@ class TencentImService
|
||||
* @param string $data
|
||||
* @return string|false
|
||||
*/
|
||||
private function httpPost(string $url, string $data, int $timeout = 10)
|
||||
protected function httpPost(string $url, string $data, int $timeout = 10)
|
||||
{
|
||||
$ch = curl_init();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user