更新
This commit is contained in:
@@ -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