This commit is contained in:
Your Name
2026-09-09 15:47:48 +08:00
parent bd5d5c5f08
commit cb10e75ead
98 changed files with 7031 additions and 804 deletions
@@ -466,8 +466,7 @@ class DiagnosisController extends BaseAdminController
}
/**
* @notes 触发后台异步同步当前诊单的腾讯云 IM 聊天记录到本地归档表
* 请求即返回,真正的同步逻辑在 fastcgi_finish_request 之后执行
* @notes 同步一页 IM 历史并返回真实进度;客户端持 token 续拉,归档后即可展示。
*/
public function triggerImChatSync()
{
@@ -486,20 +485,18 @@ class DiagnosisController extends BaseAdminController
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
}
register_shutdown_function(function () use ($diagnosisId) {
try {
@set_time_limit(300);
ignore_user_abort(true);
DiagnosisLogic::syncImChatArchiveForDiagnosis($diagnosisId);
} catch (\Throwable $e) {
\think\facade\Log::warning('triggerImChatSync failed', [
'diagnosis_id' => $diagnosisId,
'err' => $e->getMessage(),
]);
}
});
return $this->success('已发起后台同步,几秒后请重新加载查看', ['queued' => true]);
try {
@set_time_limit(30);
$result = DiagnosisLogic::syncImChatArchiveStep(
$diagnosisId,
(int)$this->adminId,
(string)$this->request->post('sync_token', ''),
(string)$this->request->post('scope', '') === 'current'
);
return $this->data($result);
} catch (\Throwable $e) {
return $this->fail('聊天记录同步失败:' . $e->getMessage());
}
}
/**
@@ -108,6 +108,32 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
);
}
/**
* 挂号类型筛选:空筛选表示全部;未知筛选值返回空结果,不套用旧记录的视频默认值。
* 列表、总数与状态角标共用,历史空值按 normalizeStored 的规则归入视频。
*/
private function applyAppointmentTypeFilter($query): void
{
$type = $this->params['appointment_type'] ?? '';
if ($type === '') {
return;
}
if (!AppointmentTypeEnum::isWritable($type)) {
$query->whereRaw('0 = 1');
return;
}
$query->where(function ($q) use ($type): void {
$q->whereRaw('BINARY a.appointment_type = :appointment_type_filter', ['appointment_type_filter' => $type]);
if ($type === AppointmentTypeEnum::VIDEO) {
// PHP trim 默认空白:空格、NUL、TAB、LF、VT、CR;只把完全为空白的旧值归入视频。
$blankTypeSql = "TRIM(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(a.appointment_type, CHAR(0), ''),"
. " CHAR(9), ''), CHAR(10), ''), CHAR(11), ''), CHAR(13), '')) = ''";
$q->whereOrRaw('a.appointment_type IS NULL')->whereOrRaw($blankTypeSql);
}
});
}
/**
* 渠道筛选:与 AppointmentLogic 一致,兼容仅有 channel_source、仅有 channels、或两者皆有的表结构
*
@@ -228,6 +254,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$this->applyChannelSourceFilter($query, $chFilter);
$this->applyAppointmentTypeFilter($query);
// 是否确认诊单:1=已确认 0=未确认
if (isset($this->params['diagnosis_confirmed']) && $this->params['diagnosis_confirmed'] !== '') {
$confirmed = (int)$this->params['diagnosis_confirmed'];
@@ -422,6 +450,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$this->applyChannelSourceFilter($query, $chFilter);
$this->applyAppointmentTypeFilter($query);
if ((int) ($this->params['exclude_cancelled'] ?? 0) === 1) {
$sf = $this->params['status'] ?? '';
if ($sf === '' || (int) $sf !== 2) {
@@ -6,6 +6,7 @@ namespace app\adminapi\lists\firstvisit;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\common\enum\AppointmentTypeEnum;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
use app\common\model\DiagnosisViewRecord;
@@ -217,7 +218,7 @@ class MyPatientLists extends BaseAdminDataLists implements ListsSearchInterface,
$appointments = Db::table($appointmentTable)
->whereIn('patient_id', $diagnosisIds)
->whereIn('status', self::EFFECTIVE_APPOINTMENT_STATUSES)
->field(['id', 'patient_id', 'doctor_id', 'appointment_date', 'appointment_time', 'status'])
->field(['id', 'patient_id', 'doctor_id', 'appointment_date', 'appointment_time', 'appointment_type', 'status'])
->order('appointment_date', 'asc')
->order('appointment_time', 'asc')
->order('id', 'asc')
@@ -281,21 +282,33 @@ class MyPatientLists extends BaseAdminDataLists implements ListsSearchInterface,
$row['confirmation_text'] = $row['confirmed'] ? '已确认' : '待确认';
$row['visit_count'] = $completedCount;
$row['revisit_count'] = max(0, $completedCount - 1);
$row['appointment_id'] = $primary ? (int) $primary['id'] : 0;
$row['appointment_status'] = $primary ? (int) $primary['status'] : 0;
$row['appointment_status_text'] = $this->appointmentStatusText((int) ($primary['status'] ?? 0));
$row['appointment_doctor_id'] = $primary ? (int) $primary['doctor_id'] : 0;
$row['appointment_doctor_name'] = $primary
? (string) ($adminNames[(int) $primary['doctor_id']] ?? '未知医生')
: '未预约';
$row['appointment_time_text'] = $primary ? $this->appointmentTimeText($primary) : '';
$row['has_appointment'] = $primary !== null ? 1 : 0;
$this->appendPrimaryAppointmentSummary($row, $primary, $adminNames);
}
unset($row);
return $rows;
}
/** 所有挂号展示字段取自同一条主挂号;无挂号时不能补成视频类型。 */
private function appendPrimaryAppointmentSummary(array &$row, ?array $primary, array $adminNames): void
{
$row['appointment_id'] = $primary ? (int) $primary['id'] : 0;
$row['appointment_status'] = $primary ? (int) $primary['status'] : 0;
$row['appointment_status_text'] = $this->appointmentStatusText((int) ($primary['status'] ?? 0));
$row['appointment_doctor_id'] = $primary ? (int) $primary['doctor_id'] : 0;
$row['appointment_doctor_name'] = $primary
? (string) ($adminNames[(int) $primary['doctor_id']] ?? '未知医生')
: '未预约';
$row['appointment_time_text'] = $primary ? $this->appointmentTimeText($primary) : '';
$row['appointment_type'] = $primary !== null
? AppointmentTypeEnum::normalizeStored($primary['appointment_type'] ?? null)
: null;
$row['appointment_type_desc'] = $primary !== null
? AppointmentTypeEnum::description($row['appointment_type'])
: '';
$row['has_appointment'] = $primary !== null ? 1 : 0;
}
/**
* @param array<int, array<string, mixed>> $appointments
* @return array<string, mixed>|null
@@ -6,6 +6,7 @@ namespace app\adminapi\lists\firstvisit;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\common\enum\AppointmentTypeEnum;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
use app\common\model\DiagnosisViewRecord;
@@ -242,7 +243,8 @@ class MyPatientProgressLists extends BaseAdminDataLists implements ListsSearchIn
$row['doctor_name'] = trim((string) ($row['doctor_name'] ?? '')) ?: '未知医生';
$row['appointment_time_text'] = $this->appointmentTimeText($row);
$row['status_text'] = $this->appointmentStatusText($status);
$row['appointment_type_text'] = $this->appointmentTypeText((string) ($row['appointment_type'] ?? ''));
$row['appointment_type'] = AppointmentTypeEnum::normalizeStored($row['appointment_type'] ?? null);
$row['appointment_type_text'] = $this->appointmentTypeText($row['appointment_type']);
$row['registered'] = 1;
$row['diagnosis_confirmed'] = $confirmed ? 1 : 0;
$row['visit_completed'] = $status === 3 ? 1 : 0;
@@ -682,7 +684,7 @@ class MyPatientProgressLists extends BaseAdminDataLists implements ListsSearchIn
private function appointmentTypeText(string $type): string
{
return ['video' => '视频问诊', 'text' => '图文问诊', 'phone' => '电话问诊'][$type] ?? '面诊';
return AppointmentTypeEnum::description($type);
}
private function progressText(bool $confirmed, bool $completed, bool $prescribed, int $status): string
+239 -360
View File
@@ -34,6 +34,7 @@ use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\common\service\ConfigService;
use app\common\service\AppointmentCallPolicy;
use app\common\service\FileService;
use app\common\service\DataScope\DataScopeService;
use app\common\service\storage\Driver as StorageDriver;
@@ -893,6 +894,7 @@ class DiagnosisLogic extends BaseLogic
return false;
}
$callPolicy = AppointmentCallPolicy::resolve($diagnosisId, (int) ($params['appointment_id'] ?? 0));
$config = self::getTrtcConfig();
if (!$config) {
self::setError('请先配置腾讯云TRTC参数');
@@ -928,7 +930,7 @@ class DiagnosisLogic extends BaseLogic
'expireTime' => 86400, // 24小时
// 与 .env [trtc] ISLOCHOSTVOD 一致:true 允许浏览器本地录制并上传
'isLochostVod' => (bool)config('trtc.is_lochost_vod', false),
];
] + $callPolicy;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
@@ -995,336 +997,243 @@ class DiagnosisLogic extends BaseLogic
}
}
/**
* 所有可能以 doctor_{id} 登录 IM 的后台账号(医生 role_id=1、医助 role_id=2),用于合并会话漫游记录
*
* @return array<int, string> 如 ['doctor_1','doctor_2']
*/
private static function collectAllDoctorImPeerAccounts(): array
/** 包含历史/停用医生;先查曾与患者相关的账号,避免先扫描大量无关会话。 */
private static function collectDoctorImPeerAccounts(int $patientId): array
{
try {
$ids = \app\common\model\auth\Admin::alias('a')
->join('admin_role ar', 'a.id = ar.admin_id')
->whereIn('ar.role_id', [1, 2])
->where('a.disable', 0)
->group('a.id')
->column('a.id');
$accounts = [];
foreach ($ids as $id) {
$accounts[] = 'doctor_' . (int)$id;
}
return array_values(array_unique($accounts));
} catch (\Exception $e) {
\think\facade\Log::error('collectAllDoctorImPeerAccounts: ' . $e->getMessage());
return [];
}
$accounts = ImChatMessage::where('patient_id', $patientId)->column('doctor_peer_account');
$diagnosisIds = Diagnosis::where('patient_id', $patientId)->column('id');
$assistantIds = Diagnosis::where('patient_id', $patientId)->column('assistant_id');
$doctorIds = empty($diagnosisIds) ? [] : Appointment::whereIn('patient_id', $diagnosisIds)->column('doctor_id');
// 不按当前账号启用状态排除历史会话。
$roleIds = Db::name('admin_role')->whereIn('role_id', [1, 2])->column('admin_id');
foreach (array_merge($assistantIds, $doctorIds, $roleIds) as $id) {
if ((int)$id > 0) $accounts[] = 'doctor_' . (int)$id;
}
return array_values(array_unique(array_filter($accounts, static function ($account) {
return is_string($account) && preg_match('/^doctor_[1-9][0-9]*$/', $account);
})));
}
/**
* @notes 拉取与本诊单相关的 IM 单聊记录:本地归档 + 腾讯云漫游合并(归档突破云端约 7 天限制)
*
* @param int $diagnosisId
* @param bool $onlyArchived 只读取本地归档,不请求腾讯云(用于首次打开快速展示)
*/
/** 先由控制器校验诊单权限,再以真实 patient_id 合并同患者历次诊单的记录。 */
public static function getImChatMessagesForDiagnosis(int $diagnosisId, bool $onlyArchived = false)
{
try {
$diag = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
if (!$diag) {
self::setError('诊单不存在');
return false;
if (!$diag || (int)$diag['patient_id'] <= 0) {
throw new \RuntimeException('诊单不存在或缺少患者信息');
}
$patientId = (int)$diag['patient_id'];
if ($patientId <= 0) {
self::setError('诊单缺少患者信息');
return false;
}
$patientImId = 'patient_' . $patientId;
$archived = self::loadArchivedImChatRows($diagnosisId);
if ($onlyArchived) {
$lists = self::attachDiagnosisIdToImMessages(
self::enrichImMessagesWithStaffNames($archived),
$diagnosisId
);
return [
'lists' => $lists,
'patient_im_id' => $patientImId,
'patient_name' => $diag['patient_name'] ?? '',
'doctor_accounts_queried' => [],
'only_archived' => true,
];
}
$config = self::getTrtcConfig();
if (!$config) {
if (empty($archived)) {
self::setError('请先配置腾讯云 TRTC / IM 参数');
return false;
}
$lists = self::attachDiagnosisIdToImMessages(
self::enrichImMessagesWithStaffNames($archived),
$diagnosisId
);
return [
'lists' => $lists,
'patient_im_id' => $patientImId,
'patient_name' => $diag['patient_name'] ?? '',
'doctor_accounts_queried' => [],
];
}
$doctorAccounts = self::collectAllDoctorImPeerAccounts();
$assistantId = isset($diag['assistant_id']) ? (int)$diag['assistant_id'] : 0;
if ($assistantId > 0) {
$doctorAccounts[] = 'doctor_' . $assistantId;
}
$doctorAccounts = array_values(array_unique($doctorAccounts));
if (empty($doctorAccounts)) {
self::setError('未找到医生/医助角色账号,无法拉取 IM 记录');
return false;
}
$live = self::pullLiveImChatMessagesForDiagnosis($diag, $doctorAccounts);
$merged = self::mergeImMessagesByMsgId($archived, $live);
$merged = self::attachDiagnosisIdToImMessages(
self::enrichImMessagesWithStaffNames($merged),
$diagnosisId
);
// 首次云端拉取后异步落库,下一次即可直接读归档,无需再全量扫描医生账号
if (!empty($live)) {
try {
self::persistImChatArchiveRows($diagnosisId, $patientId, $live);
} catch (\Throwable $e) {
\think\facade\Log::warning('archive im chat on-the-fly failed: ' . $e->getMessage());
}
}
$sync = $onlyArchived ? null : self::syncImChatArchiveForDiagnosis($diagnosisId);
$rows = self::enrichImMessagesWithStaffNames(self::loadArchivedImChatRows((int)$diag['patient_id']));
return [
'lists' => $merged,
'patient_im_id' => $patientImId,
'lists' => self::attachDiagnosisIdToImMessages($rows, $diagnosisId),
'patient_im_id' => 'patient_' . (int)$diag['patient_id'],
'patient_name' => $diag['patient_name'] ?? '',
'doctor_accounts_queried' => $doctorAccounts,
'only_archived' => $onlyArchived,
'sync_error' => $sync['error'] ?? '',
];
} catch (\Exception $e) {
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* 定时任务:从腾讯云拉取漫游消息写入归档表
*
* @return array{inserted:int, skipped_live_empty:bool, error?:string}
* 有结果的分页同步:token 绑定当前授权诊单及登录人,浏览器无法指定任意患者/医生。
* currentPeer 只由控制器用已登录 adminId 构建,用于聊天窗口即时同步。
*/
public static function syncImChatArchiveStep(int $diagnosisId, int $adminId, string $token = '', bool $currentPeer = false): array
{
$diag = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
if (!$diag || (int)$diag['patient_id'] <= 0) throw new \RuntimeException('诊单不存在或缺少患者信息');
if (!self::getTrtcConfig()) throw new \RuntimeException('请先配置腾讯云 TRTC / IM 参数');
$patientId = (int)$diag['patient_id'];
if ($token === '') {
$accounts = $currentPeer ? ['doctor_' . $adminId] : self::collectDoctorImPeerAccounts($patientId);
if (!$accounts) throw new \RuntimeException('未找到可同步的医生/医助账号');
$token = bin2hex(random_bytes(24));
$state = \app\common\service\ImChatSyncSession::start($accounts);
$state['diagnosis_id'] = $diagnosisId;
$state['patient_id'] = $patientId;
$state['admin_id'] = $adminId;
} else {
if (!preg_match('/^[a-f0-9]{48}$/', $token)) throw new \RuntimeException('同步进度无效,请重新同步');
$state = \think\facade\Cache::get('im_chat_sync:' . $token);
if (!is_array($state) || $state['diagnosis_id'] !== $diagnosisId || $state['patient_id'] !== $patientId || $state['admin_id'] !== $adminId) {
throw new \RuntimeException('同步进度已失效,请重新同步');
}
}
$state = self::advanceImChatSync($state, $diagnosisId, $patientId);
\think\facade\Cache::set('im_chat_sync:' . $token, $state, 3600);
return array_merge(['sync_token' => $token], \app\common\service\ImChatSyncSession::progress($state));
}
private static function advanceImChatSync(array $state, int $diagnosisId, int $patientId): array
{
if (!array_key_exists('accounts_verified', $state)) {
// 兼容发布前仍在浏览器续拉的旧 token:保留已归档数量,重新核验候选账号。
$state = array_merge($state, \app\common\service\ImChatSyncSession::start($state['accounts']), [
'inserted' => $state['inserted'], 'accounts_verified' => false,
'account_check' => \app\common\service\ImChatAccountFilter::start($state['accounts'], 'patient_' . $patientId),
]);
unset($state['active_index'], $state['peer_started_at'], $state['peer_error_count'], $state['peer_min_time']);
}
if (!$state['accounts_verified']) {
$svc = new \app\common\service\TencentImService();
$state['account_check'] = \app\common\service\ImChatAccountFilter::step(
$state['account_check'], static function (array $accounts) use ($svc) { return $svc->checkAccounts($accounts); }
);
if (\app\common\service\ImChatAccountFilter::completed($state['account_check'])) {
$state['accounts'] = array_values(array_intersect($state['accounts'], $state['account_check']['existing']));
$state['accounts_verified'] = true;
}
// 核验与历史读取分开请求,避免一轮多个腾讯请求叠加导致 HTTP 超时。
return $state;
}
if (\app\common\service\ImChatSyncSession::progress($state)['completed']) return $state;
$index = $state['index'];
$checkpointKey = 'im_chat_complete_v1:' . $patientId . ':' . $state['accounts'][$index];
if (($state['active_index'] ?? -1) !== $index) {
$state['active_index'] = $index;
$state['peer_started_at'] = time();
$state['peer_error_count'] = count($state['errors']);
$state['peer_min_time'] = max(0, (int)\think\facade\Cache::get($checkpointKey, 0) - 120);
}
if (empty($state['cursor'])) {
// 检查点只来自完整且已落库的会话,绝不使用 MAX(msg_time)。留两分钟重叠处理边界/时钟偏差。
$state['cursor'] = ['min_time' => $state['peer_min_time']];
}
$side = $state['side'];
$svc = new \app\common\service\TencentImService();
$next = \app\common\service\ImChatSyncSession::step(
$state,
static function (string $account, array $cursor) use ($svc, $patientId, $side) {
$patient = 'patient_' . $patientId;
// 一方删除历史不代表另一方也删除;两侧合并去重后才算同步完成。
return \app\common\service\ImRoamMessagePager::nextPage($svc, $side === 0 ? $account : $patient, $side === 0 ? $patient : $account, $cursor);
},
static function (string $account, array $messages) use ($diagnosisId, $patientId) {
$rows = [];
foreach ($messages as $message) {
$row = self::normalizeTimMessage($message);
$row['doctor_peer_account'] = $account;
$rows[] = $row;
}
return self::persistImChatArchiveRows($diagnosisId, $patientId, $rows);
}
);
if ($next['index'] > $index && count($next['errors']) === $state['peer_error_count']) {
\think\facade\Cache::set($checkpointKey, $state['peer_started_at'], 86400);
}
return $next;
}
/** CLI 补历史,与页面共用分页及落库逻辑;任何会话失败都返回可见错误。 */
public static function syncImChatArchiveForDiagnosis(int $diagnosisId): array
{
$out = ['inserted' => 0, 'skipped_live_empty' => false];
try {
if (!self::getTrtcConfig()) {
$out['error'] = 'TRTC/IM 未配置';
return $out;
}
if (!self::getTrtcConfig()) throw new \RuntimeException('TRTC/IM 未配置');
$diag = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
if (!$diag) {
$out['error'] = '诊单不存在';
return $out;
}
$accounts = self::collectAllDoctorImPeerAccounts();
$assistantId = isset($diag['assistant_id']) ? (int)$diag['assistant_id'] : 0;
if ($assistantId > 0) {
$accounts[] = 'doctor_' . $assistantId;
$accounts = array_values(array_unique($accounts));
}
$live = self::pullLiveImChatMessagesForDiagnosis($diag, $accounts);
if (empty($live)) {
$out['skipped_live_empty'] = true;
return $out;
}
$live = self::enrichImMessagesWithStaffNames($live);
if (!$diag || (int)$diag['patient_id'] <= 0) throw new \RuntimeException('诊单不存在或缺少患者信息');
$patientId = (int)$diag['patient_id'];
$out['inserted'] = self::persistImChatArchiveRows($diagnosisId, $patientId, $live);
return $out;
} catch (\Exception $e) {
$state = \app\common\service\ImChatSyncSession::start(self::collectDoctorImPeerAccounts($patientId));
if (!$state['accounts']) throw new \RuntimeException('未找到可同步的医生/医助账号');
do {
$state = self::advanceImChatSync($state, $diagnosisId, $patientId);
$out['inserted'] = $state['inserted'];
} while (!\app\common\service\ImChatSyncSession::progress($state)['completed']);
if ($state['errors']) $out['error'] = implode('', $state['errors']);
$out['skipped_live_empty'] = !$state['errors'] && $out['inserted'] === 0;
} catch (\Throwable $e) {
$out['error'] = $e->getMessage();
return $out;
}
return $out;
}
/**
* @return array{diagnoses:int, inserted:int, errors:array<int, string>}
*/
/** 定时补拉按患者去重并轮转,避免 limit 每次只扫描最新一批诊单。 */
public static function syncImChatArchiveBatch(int $sinceDays, int $limit, ?int $onlyDiagnosisId): array
{
$stats = ['diagnoses' => 0, 'inserted' => 0, 'errors' => []];
$limit = max(1, min(500, $limit));
$q = Diagnosis::where('delete_time', null);
$cursorKey = 'im_chat_archive_batch_cursor:' . max(0, $sinceDays);
if ($onlyDiagnosisId !== null && $onlyDiagnosisId > 0) {
$q->where('id', $onlyDiagnosisId);
} elseif ($sinceDays > 0) {
$q->where('update_time', '>=', time() - $sinceDays * 86400);
$ids = [$onlyDiagnosisId];
} else {
$query = Diagnosis::where('delete_time', null)->where('patient_id', '>', 0);
if ($sinceDays > 0) $query->where('update_time', '>=', time() - $sinceDays * 86400);
$ids = array_map('intval', $query->group('patient_id')->column('MAX(id)'));
sort($ids);
$cursor = (int)\think\facade\Cache::get($cursorKey, 0);
$pending = array_values(array_filter($ids, static function ($id) use ($cursor) { return $id > $cursor; }));
$ids = array_slice($pending ?: $ids, 0, $limit);
}
$ids = $q->order('id', 'desc')->limit($limit)->column('id');
foreach ($ids as $id) {
$stats['diagnoses']++;
$r = self::syncImChatArchiveForDiagnosis((int)$id);
if (!empty($r['error'])) {
$stats['errors'][] = 'diagnosis ' . $id . ': ' . $r['error'];
continue;
}
$stats['inserted'] += (int)($r['inserted'] ?? 0);
$result = self::syncImChatArchiveForDiagnosis((int)$id);
$stats['inserted'] += (int)$result['inserted'];
if (!empty($result['error'])) $stats['errors'][] = 'diagnosis ' . $id . ': ' . $result['error'];
if (!$onlyDiagnosisId) \think\facade\Cache::set($cursorKey, (int)$id, 0);
}
return $stats;
}
/**
* @return array<int, array<string, mixed>>
*/
private static function loadArchivedImChatRows(int $diagnosisId): array
private static function loadArchivedImChatRows(int $patientId): array
{
if ($diagnosisId <= 0) {
return [];
}
$list = ImChatMessage::where('diagnosis_id', $diagnosisId)
->order('msg_time', 'asc')
->order('id', 'asc')
->select()
->toArray();
if ($patientId <= 0) return [];
$list = ImChatMessage::where('patient_id', $patientId)
->order('msg_time', 'asc')->order('id', 'asc')->select()->toArray();
$out = [];
foreach ($list as $row) {
$out[] = [
'msg_id' => (string)($row['msg_id'] ?? ''),
'from_account' => (string)($row['from_account'] ?? ''),
'to_account' => (string)($row['to_account'] ?? ''),
'time' => (int)($row['msg_time'] ?? 0),
'is_from_doctor' => !empty($row['is_from_doctor']),
'msg_type' => (string)($row['msg_type'] ?? ''),
'text' => (string)($row['text'] ?? ''),
'image_url' => (string)($row['image_url'] ?? ''),
'file_url' => (string)($row['file_url'] ?? ''),
'file_name' => (string)($row['file_name'] ?? ''),
'raw_elem_type' => (string)($row['raw_elem_type'] ?? ''),
'from_staff_name' => (string)($row['from_staff_name'] ?? ''),
'doctor_peer_account' => (string)($row['doctor_peer_account'] ?? ''),
];
// 同时校验账号,防止历史错误的 patient_id 把其他患者消息混进来。
if (!self::isPatientDoctorImPair($row, $patientId)) continue;
$row['time'] = (int)$row['msg_time'];
$row['is_from_doctor'] = strpos((string)$row['from_account'], 'doctor_') === 0;
if (($row['msg_type'] ?? '') === 'composite') {
$row['parts'] = json_decode((string)$row['text'], true) ?: [];
}
$out[] = $row;
}
return $out;
}
/**
* Keep the parent diagnosis on every child row so clients can reject
* accidentally mixed or stale IM payloads before rendering them.
*
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
private static function attachDiagnosisIdToImMessages(array $rows, int $diagnosisId): array
private static function isPatientDoctorImPair(array $row, int $patientId): bool
{
foreach ($rows as &$row) {
$row['diagnosis_id'] = $diagnosisId;
}
unset($row);
return $rows;
$from = (string)($row['from_account'] ?? '');
$to = (string)($row['to_account'] ?? '');
$patient = 'patient_' . $patientId;
return ($from === $patient && preg_match('/^doctor_[1-9][0-9]*$/', $to))
|| ($to === $patient && preg_match('/^doctor_[1-9][0-9]*$/', $from));
}
/**
* @param array<int, array<string, mixed>> $archived
* @param array<int, array<string, mixed>> $live
* @return array<int, array<string, mixed>>
*/
private static function mergeImMessagesByMsgId(array $archived, array $live): array
private static function attachDiagnosisIdToImMessages(array $rows, int $diagnosisId): array
{
$map = [];
$tail = [];
foreach ($archived as $r) {
$k = (string)($r['msg_id'] ?? '');
if ($k !== '') {
$map[$k] = $r;
} else {
$tail[] = $r;
}
}
foreach ($live as $r) {
$k = (string)($r['msg_id'] ?? '');
if ($k !== '') {
$map[$k] = $r;
} else {
$tail[] = $r;
}
}
$merged = array_values($map);
$merged = array_merge($merged, $tail);
usort($merged, function ($a, $b) {
return ($a['time'] ?? 0) <=> ($b['time'] ?? 0);
});
return $merged;
foreach ($rows as &$row) $row['diagnosis_id'] = $diagnosisId;
unset($row);
return $rows;
}
/**
* @param Diagnosis|array<string, mixed> $diag
* @return array<int, array<string, mixed>>
*/
/**
* @param array<int, string> $doctorAccounts 已筛选的医生 IM 账号列表
*/
private static function pullLiveImChatMessagesForDiagnosis($diag, array $doctorAccounts = []): array
/** 仅供已验证腾讯签名的回调使用,不接受浏览器直接上报消息正文。 */
public static function archiveImCallbackMessage(array $payload): int
{
$patientId = (int)$diag['patient_id'];
if ($patientId <= 0) {
return [];
}
$patientImId = 'patient_' . $patientId;
if (empty($doctorAccounts)) {
return [];
}
$diagnosisId = (int)($diag['id'] ?? 0);
// 增量优化:按 doctor_peer_account 维度取归档表最大时间作为 MinTime 起点,
// 避免每次都从头拉取已归档的历史消息(节约腾讯云 admin_getroammsg 调用配额)
$minTimeMap = [];
if ($diagnosisId > 0) {
$rows = ImChatMessage::field('doctor_peer_account, MAX(msg_time) AS max_time')
->where('diagnosis_id', $diagnosisId)
->group('doctor_peer_account')
->select()
->toArray();
foreach ($rows as $r) {
$acct = (string)($r['doctor_peer_account'] ?? '');
if ($acct !== '') {
$minTimeMap[$acct] = (int)($r['max_time'] ?? 0);
}
}
}
$imService = new \app\common\service\TencentImService();
$merged = [];
foreach ($doctorAccounts as $docAccount) {
// 已归档过:从最大已知 msg_time 起拉(含等于以兜底边界,配合 INSERT IGNORE 去重)
$minTime = isset($minTimeMap[$docAccount]) ? max(0, $minTimeMap[$docAccount]) : 0;
$batch = self::pullAllRoamMessages($imService, $docAccount, $patientImId, $minTime);
foreach ($batch as $row) {
$merged[] = $row;
}
}
usort($merged, function ($a, $b) {
return ($a['time'] ?? 0) <=> ($b['time'] ?? 0);
});
$seen = [];
$unique = [];
foreach ($merged as $row) {
$k = $row['msg_id'] ?? '';
if ($k !== '' && isset($seen[$k])) {
continue;
}
if ($k !== '') {
$seen[$k] = true;
}
$unique[] = $row;
}
return $unique;
// 腾讯也会回调发送失败的消息,这类记录不能作为成功发送的聊天历史展示。
if (!array_key_exists('SendMsgResult', $payload) || !is_int($payload['SendMsgResult'])) {
throw new \RuntimeException('IM 回调缺少有效发送结果');
}
if ($payload['SendMsgResult'] !== 0) return 0;
$row = self::normalizeTimMessage($payload);
$from = $row['from_account'];
$to = $row['to_account'];
$patientAccount = strpos($from, 'patient_') === 0 ? $from : $to;
if (!preg_match('/^patient_([1-9][0-9]*)$/', $patientAccount, $match)) return 0;
$patientId = (int)$match[1];
if (!self::isPatientDoctorImPair($row, $patientId)) return 0;
if ($row['time'] <= 0 || empty($payload['MsgBody']) || empty($payload['MsgKey'])) {
throw new \RuntimeException('IM 回调缺少有效消息标识、时间或内容');
}
$diag = Diagnosis::where('patient_id', $patientId)->where('delete_time', null)->order('id', 'desc')->find();
if (!$diag) return 0;
$row['doctor_peer_account'] = $from === $patientAccount ? $to : $from;
return self::persistImChatArchiveRows((int)$diag['id'], $patientId, [$row]);
}
/**
@@ -1334,10 +1243,30 @@ class DiagnosisLogic extends BaseLogic
{
$now = time();
$chunks = [];
// 兼容已入库的 seq_random_from 旧键;必须核对患者、双向账号及时间,不能误复用碰撞键。
$legacyIds = array_values(array_filter(array_column($rows, 'legacy_msg_id')));
$legacyRows = empty($legacyIds) ? [] : ImChatMessage::where('patient_id', $patientId)
->whereIn('msg_id', $legacyIds)->select()->toArray();
$legacyMap = array_column($legacyRows, null, 'msg_id');
foreach ($rows as $r) {
if (!self::isPatientDoctorImPair($r, $patientId)) {
throw new \RuntimeException('IM 消息不属于当前患者会话');
}
$msgId = (string)($r['msg_id'] ?? '');
if ($msgId === '') {
continue;
throw new \RuntimeException('IM 消息缺少标识');
}
$legacy = $legacyMap[$r['legacy_msg_id'] ?? ''] ?? null;
if ($legacy && (int)$legacy['msg_time'] === (int)$r['time']
&& $legacy['from_account'] === $r['from_account'] && $legacy['to_account'] === $r['to_account']) {
$msgId = (string)$legacy['msg_id'];
// 修复旧版本仅保存第一个消息元素的归档,仍保留原始归档诊单及去重键。
if ($r['msg_type'] === 'composite' && (($legacy['msg_type'] ?? '') !== 'composite' || ($legacy['text'] ?? '') !== $r['text'])) {
ImChatMessage::where('id', $legacy['id'])->where('patient_id', $patientId)->update([
'msg_type' => 'composite', 'text' => $r['text'], 'raw_elem_type' => $r['raw_elem_type'],
'image_url' => '', 'file_url' => '', 'file_name' => '',
]);
}
}
$chunks[] = [
'diagnosis_id' => $diagnosisId,
@@ -1384,7 +1313,9 @@ class DiagnosisLogic extends BaseLogic
$flat[] = $row[$c];
}
}
$sql = 'INSERT IGNORE INTO `' . $table . '` (' . $colSql . ') VALUES ' . $allPh;
// 仅重复消息键为幂等成功,表结构/长度/写库错误不能被 INSERT IGNORE 掩盖。
$sql = 'INSERT INTO `' . $table . '` (' . $colSql . ') VALUES ' . $allPh
. ' ON DUPLICATE KEY UPDATE `msg_id` = `msg_id`';
return (int)Db::execute($sql, $flat);
}
@@ -1412,91 +1343,13 @@ class DiagnosisLogic extends BaseLogic
$f = (string)($r['from_account'] ?? '');
if (preg_match('/^doctor_(\d+)$/', $f, $m)) {
$aid = (int)$m[1];
$rows[$k]['from_staff_name'] = $map[$aid] ?? '';
$rows[$k]['from_staff_name'] = $map[$aid] ?? ($r['from_staff_name'] ?? '');
}
}
return $rows;
}
/**
* @return array<int, array<string, mixed>>
*/
private static function pullAllRoamMessages(\app\common\service\TencentImService $svc, string $operator, string $peer, int $minTime = 0): array
{
$out = [];
$lastKey = null;
$lastTime = null;
$guard = 0;
$maxPages = 80;
do {
// 单页失败重试:避免网络抖动 / 限频导致整段会话被丢弃
$res = null;
$attempt = 0;
while ($attempt < 3) {
$res = $svc->adminGetRoamMsg($operator, $peer, 100, $minTime, 4294967295, $lastKey, $lastTime);
if (!empty($res['success'])) {
break;
}
$attempt++;
\think\facade\Log::warning('IM漫游消息拉取失败(待重试)', [
'operator' => $operator,
'peer' => $peer,
'attempt' => $attempt,
'error' => $res['error'] ?? '',
'code' => $res['rawErrorCode'] ?? 0,
'page' => $guard + 1,
]);
if ($attempt < 3) {
usleep(300000); // 300ms 退避
}
}
if (empty($res['success'])) {
\think\facade\Log::error('IM漫游消息拉取失败(重试耗尽,本轮中断)', [
'operator' => $operator,
'peer' => $peer,
'page' => $guard + 1,
'fetched_so_far' => count($out),
'error' => $res['error'] ?? '',
'code' => $res['rawErrorCode'] ?? 0,
]);
break;
}
foreach ($res['msgList'] as $raw) {
if (!is_array($raw)) {
continue;
}
$normalized = self::normalizeTimMessage($raw);
$normalized['doctor_peer_account'] = $operator;
$out[] = $normalized;
}
$complete = (int)$res['complete'];
if ($complete === 1) {
break;
}
$lastKey = $res['lastMsgKey'];
$lastTime = $res['lastMsgTime'];
if ($lastKey === null || $lastKey === '') {
break;
}
$guard++;
if ($guard >= $maxPages) {
// 触顶安全网:增量优化后通常拉不满 80 页,触顶意味着首次全量或会话异常多
\think\facade\Log::warning('IM漫游消息拉取触发分页上限(可能未拉完)', [
'operator' => $operator,
'peer' => $peer,
'min_time' => $minTime,
'pages_fetched' => $guard,
'fetched_so_far' => count($out),
'last_msg_time' => $lastTime,
]);
break;
}
} while (true);
return $out;
}
/**
* @param array<string, mixed> $raw
* @return array<string, mixed>
@@ -1508,13 +1361,33 @@ class DiagnosisLogic extends BaseLogic
$time = (int)($raw['MsgTimeStamp'] ?? $raw['MsgTime'] ?? 0);
$seq = $raw['MsgSeq'] ?? '';
$rand = $raw['MsgRandom'] ?? '';
$msgId = $seq . '_' . $rand . '_' . $from;
$key = (string)($raw['MsgKey'] ?? '');
// 发送后回调不一定带 MsgRandom,MsgKey 与云端漫游消息中的标识相同。
if ($key !== '' && preg_match('/^(\d+)_(\d+)_\d+$/', $key, $keyParts)) {
$seq = $seq === '' ? $keyParts[1] : $seq;
$rand = $rand === '' ? $keyParts[2] : $rand;
}
$identity = $key !== '' ? $key : $seq . '_' . $rand . '_' . $time;
if ($key === '' && ($seq === '' || $rand === '')) {
$identity .= '_' . json_encode($raw['MsgBody'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$msgId = 'im_' . hash('sha256', $from . "\0" . $to . "\0" . $identity);
$isDoctor = strpos($from, 'doctor_') === 0;
$parsed = self::parseTimMsgBody($raw['MsgBody'] ?? []);
$parsed = self::parseTimMsgBody($raw['MsgBody'] ?? []);
if (is_array($raw['MsgBody'] ?? null) && count($raw['MsgBody']) > 1) {
$parts = [];
foreach ($raw['MsgBody'] as $element) $parts[] = self::parseTimMsgBody([$element]);
$parsed = [
'msg_type' => 'composite',
'text' => json_encode($parts, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
'image_url' => '', 'file_url' => '', 'file_name' => '', 'raw_elem_type' => 'TIMMultiElem',
];
}
return array_merge(
[
'msg_id' => $msgId,
'legacy_msg_id' => $seq !== '' && $rand !== '' ? $seq . '_' . $rand . '_' . $from : '',
'from_account' => $from,
'to_account' => $to,
'time' => $time,
@@ -1650,6 +1523,12 @@ class DiagnosisLogic extends BaseLogic
return false;
}
$callPolicy = AppointmentCallPolicy::resolve($diagnosisId, (int) ($params['appointment_id'] ?? 0));
if (!($callType === 1 ? $callPolicy['can_audio_call'] : $callPolicy['can_video_call'])) {
self::setError($callPolicy['call_disabled_reason'] ?: '当前挂号不支持该通话方式');
return false;
}
// 创建通话记录
$record = \app\common\model\tcm\CallRecord::create([
'diagnosis_id' => $diagnosisId,
@@ -141,10 +141,11 @@ class DiagnosisValidate extends BaseValidate
/** IM / video identity: validate shape here; ownership is checked in the logic layer. */
public function sceneCallIdentity()
{
return $this->only(['diagnosis_id', 'patient_id'])
return $this->only(['diagnosis_id', 'patient_id', 'appointment_id'])
->remove('diagnosis_id', 'checkDiagnosisId')
->append('diagnosis_id', 'gt:0')
->append('patient_id', 'require|integer|gt:0');
->append('patient_id', 'require|integer|gt:0')
->append('appointment_id', 'integer|egt:0');
}
public function sceneGenerateQrcode()
@@ -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);
}
}
}
+3 -2
View File
@@ -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响应消息标识、时间或内容格式非法');
}
}
}
+129 -19
View File
@@ -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();
+8
View File
@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
return [
// 与腾讯 IM 控制台「回调配置 → 鉴权 Token」一致;空值拒绝全部回调。
'callback_token' => env('im.callback_token', ''),
];
@@ -0,0 +1,8 @@
-- 现有归档表不需要迁移。把原任务的7天诊单更新时间过滤改为覆盖所有患者,程序会自动轮转。
-- 按实际部署表前缀替换 zyt_。仅改现有此命令的任务,不创建重复定时任务。
UPDATE `zyt_dev_crontab`
SET `params` = '--since-days=0 --limit=200',
`remark` = '按患者轮转补拉IM聊天记录;实时消息由腾讯IM发送后回调归档',
`update_time` = UNIX_TIMESTAMP()
WHERE `command` = 'sync_im_chat_archive'
AND `params` IN ('--since-days=7 --limit=200', '--since-days=7 --limit=50', '');
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
// 只加载纯策略与枚举,模型使用内存替身;不启动框架或读取数据库配置。
require dirname(__DIR__) . '/app/common/enum/AppointmentTypeEnum.php';
require dirname(__DIR__) . '/app/common/service/AppointmentCallPolicy.php';
use app\common\service\AppointmentCallPolicy;
final class AppointmentCallPolicyFixtureModel
{
public static array $rows = [];
public static array $lastWhere = [];
public static function where(string $field, int $value): AppointmentCallPolicyFixtureQuery
{
self::$lastWhere = [$field, $value];
return new AppointmentCallPolicyFixtureQuery(array_values(array_filter(
self::$rows,
static fn (array $row): bool => (int) ($row[$field] ?? 0) === $value
)));
}
}
final class AppointmentCallPolicyFixtureQuery
{
public function __construct(private array $rows) {}
public function field(array $fields): self { return $this; }
public function select(): self { return $this; }
public function toArray(): array { return $this->rows; }
}
class_alias(AppointmentCallPolicyFixtureModel::class, 'app\\common\\model\\doctor\\Appointment');
$assertions = 0;
function callPolicyExpect(bool $condition, string $message): void
{
global $assertions;
$assertions++;
if (!$condition) {
throw new RuntimeException($message);
}
}
function callPolicyRow(int $id, ?string $type, int $status = 1, string $date = '2026-09-09', string $time = '09:00:00'): array
{
return ['id' => $id, 'patient_id' => 101, 'appointment_type' => $type, 'status' => $status, 'appointment_date' => $date, 'appointment_time' => $time];
}
function callPolicyRejects(callable $callback, string $messagePart): void
{
try {
$callback();
} catch (RuntimeException $error) {
callPolicyExpect(str_contains($error->getMessage(), $messagePart), 'rejection explains ' . $messagePart);
return;
}
throw new RuntimeException('Expected rejection: ' . $messagePart);
}
$video = AppointmentCallPolicy::policyForAppointment(callPolicyRow(1, 'video'));
callPolicyExpect($video['appointment_type'] === 'video' && $video['appointment_type_desc'] === '视频问诊', 'video retains accurate type and label');
callPolicyExpect($video['can_video_call'] && $video['can_audio_call'] && $video['call_disabled_reason'] === '', 'video enables both media');
$text = AppointmentCallPolicy::policyForAppointment(callPolicyRow(2, 'text'));
callPolicyExpect(!$text['can_video_call'] && !$text['can_audio_call'] && $text['appointment_type_desc'] === '图文问诊', 'text enables neither video nor audio');
$phone = AppointmentCallPolicy::policyForAppointment(callPolicyRow(3, 'phone'));
callPolicyExpect(!$phone['can_video_call'] && $phone['can_audio_call'] && $phone['appointment_type_desc'] === '电话问诊', 'legacy phone allows audio only');
foreach (['offline', 'unknown', 'Video', ' video '] as $type) {
$policy = AppointmentCallPolicy::policyForAppointment(callPolicyRow(4, $type));
callPolicyExpect($policy['appointment_type'] === $type && !$policy['can_video_call'] && !$policy['can_audio_call'], 'unsupported stored type never becomes video: ' . $type);
}
foreach ([null, '', ' '] as $type) {
$policy = AppointmentCallPolicy::policyForAppointment(callPolicyRow(5, $type));
callPolicyExpect($policy['appointment_type'] === 'video' && $policy['can_video_call'], 'a real historical blank registration follows existing video normalization');
}
foreach (['video', 'text', 'phone', null] as $type) {
$policy = AppointmentCallPolicy::policyForAppointment(callPolicyRow(6, $type, 2));
callPolicyExpect(!$policy['can_video_call'] && !$policy['can_audio_call'] && str_contains($policy['call_disabled_reason'], '已取消'), 'canceled registration never enables calls');
}
$invalidStatus = AppointmentCallPolicy::policyForAppointment(callPolicyRow(7, 'video', 0));
callPolicyExpect(!$invalidStatus['can_video_call'] && !$invalidStatus['can_audio_call'], 'unrecognized status cannot grant calls');
$none = AppointmentCallPolicy::resolveFromAppointments([]);
callPolicyExpect($none === [
'appointment_id' => 0,
'appointment_type' => null,
'appointment_type_desc' => '未挂号',
'can_video_call' => false,
'can_audio_call' => false,
'call_disabled_reason' => '未挂号,不能发起通话',
], 'missing registration never fabricates video eligibility');
$mixed = [callPolicyRow(10, 'text'), callPolicyRow(11, 'video')];
$ambiguous = AppointmentCallPolicy::resolveFromAppointments($mixed);
callPolicyExpect($ambiguous['appointment_id'] === 0 && $ambiguous['appointment_type'] === null, 'multiple active registrations do not guess identity or mode');
callPolicyExpect(!$ambiguous['can_video_call'] && !$ambiguous['can_audio_call'] && $ambiguous['call_disabled_reason'] === '请指定本次挂号', 'multiple active registrations require an explicit choice');
callPolicyExpect(AppointmentCallPolicy::resolveFromAppointments($mixed, 10)['appointment_type'] === 'text', 'explicit text choice is not replaced by video');
callPolicyExpect(AppointmentCallPolicy::resolveFromAppointments($mixed, 11)['can_video_call'], 'explicit video choice is permitted');
$sameMode = AppointmentCallPolicy::resolveFromAppointments([callPolicyRow(10, 'video'), callPolicyRow(11, 'video')]);
callPolicyExpect(!$sameMode['can_video_call'], 'multiple registrations still require a choice when their modes match');
callPolicyRejects(static fn () => AppointmentCallPolicy::resolveFromAppointments($mixed, 99), '不属于当前诊单');
callPolicyRejects(static fn () => AppointmentCallPolicy::resolveFromAppointments($mixed, -1), '不属于当前诊单');
$singleActive = AppointmentCallPolicy::resolveFromAppointments([
callPolicyRow(20, 'text', 1, '2026-08-01'),
callPolicyRow(21, 'video', 3, '2026-09-09'),
callPolicyRow(22, 'video', 2, '2026-09-10'),
]);
callPolicyExpect($singleActive['appointment_id'] === 20 && !$singleActive['can_video_call'], 'single active registration wins over newer history and cancellation');
$history = [
callPolicyRow(101, 'video', 3, '2026-09-08', '23:00'),
callPolicyRow(30, 'phone', 4, '2026-09-09', '09:00'),
callPolicyRow(32, 'text', 3, '2026-09-09', '9:30'),
callPolicyRow(31, 'video', 3, '2026-09-09', '09:30:00'),
callPolicyRow(33, 'video', 2, '2026-09-10', '10:00:00'),
];
$latest = AppointmentCallPolicy::resolveFromAppointments($history);
callPolicyExpect($latest['appointment_id'] === 32 && $latest['appointment_type'] === 'text' && !$latest['can_video_call'], 'history uses date then normalized time then id, ignoring canceled entries');
$explicitCanceled = AppointmentCallPolicy::resolveFromAppointments($history, 33);
callPolicyExpect($explicitCanceled['appointment_id'] === 33 && $explicitCanceled['appointment_type'] === 'video' && !$explicitCanceled['can_video_call'], 'explicit canceled choice preserves identity and accurate type but denies calls');
callPolicyExpect(AppointmentCallPolicy::resolveFromAppointments([callPolicyRow(40, 'video', 2)])['appointment_type'] === null, 'canceled-only history does not invent an eligible registration');
foreach ([3, 4] as $status) {
callPolicyExpect(AppointmentCallPolicy::resolveFromAppointments([callPolicyRow(41, 'video', $status)])['can_video_call'], 'completed/missed history remains usable for legacy chat');
}
AppointmentCallPolicyFixtureModel::$rows = [
callPolicyRow(50, 'text'),
array_replace(callPolicyRow(99, 'video'), ['patient_id' => 202]),
];
$resolved = AppointmentCallPolicy::resolve(101, 50);
callPolicyExpect(AppointmentCallPolicyFixtureModel::$lastWhere === ['patient_id', 101], 'model query scopes appointment.patient_id to diagnosis id');
callPolicyExpect($resolved['appointment_id'] === 50 && !$resolved['can_video_call'], 'scoped resolve uses the requested registration');
callPolicyRejects(static fn () => AppointmentCallPolicy::resolve(101, 99), '不属于当前诊单');
callPolicyRejects(static fn () => AppointmentCallPolicy::resolve(0), '诊单 ID 无效');
echo "Appointment call policy: {$assertions} assertions passed (in-memory model, no database)\n";
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
// Generate SQL through the real list class and ThinkPHP builder without connecting to a database.
require dirname(__DIR__) . '/vendor/autoload.php';
final class AppointmentFilterNoConnection extends think\db\connector\Mysql
{
public function connect(array $config = [], $linkNum = 0, $autoConnection = false): PDO
{
throw new RuntimeException('Appointment filter tests must never open a database connection');
}
public function getFields(string $tableName): array
{
return [];
}
}
final class AppointmentFilterQuery extends think\db\Query
{
public static array $captured = [];
public function select(array $data = []): think\Collection
{
self::$captured[isset($this->getOptions()['group']) ? 'tabs' : 'lists'] = $this->buildSql(false);
return new think\Collection([]);
}
public function count(string $field = '*'): int
{
self::$captured['count'] = $this->fetchSql()->count($field);
return 0;
}
}
final class AppointmentFilterModel
{
public static function alias(string $alias): AppointmentFilterQuery
{
return (new AppointmentFilterQuery(new AppointmentFilterNoConnection(['type' => 'mysql'])))
->table('doctor_appointment')->alias($alias);
}
}
final class AppointmentFilterLookup
{
public static function where(...$args): self { return new self(); }
public function column(...$args): array { return []; }
}
class_alias(AppointmentFilterModel::class, 'app\\common\\model\\doctor\\Appointment');
class_alias(AppointmentFilterLookup::class, 'app\\common\\model\\auth\\AdminRole');
class_alias(AppointmentFilterLookup::class, 'app\\common\\model\\dict\\DictData');
$cases = [
'omitted' => [],
'all' => ['appointment_type' => ''],
'null' => ['appointment_type' => null],
'video' => ['appointment_type' => 'video'],
'text' => ['appointment_type' => 'text'],
'video_status_1' => ['appointment_type' => 'video', 'status' => 1],
'text_doctor' => ['appointment_type' => 'text', 'doctor_id' => 201],
'video_patient' => ['appointment_type' => 'video', 'patient_id' => 101],
'video_page' => ['appointment_type' => 'video', 'fixture_offset' => 1, 'fixture_length' => 2],
'invalid_unknown' => ['appointment_type' => 'unknown'],
'invalid_legacy_phone' => ['appointment_type' => 'phone'],
'invalid_case' => ['appointment_type' => 'Video'],
'invalid_padded' => ['appointment_type' => ' video '],
'invalid_blank' => ['appointment_type' => ' '],
'invalid_number' => ['appointment_type' => 0],
'invalid_boolean' => ['appointment_type' => false],
'invalid_array' => ['appointment_type' => ['video']],
'invalid_injection' => ['appointment_type' => "video' OR 1=1 --"],
];
$reflection = new ReflectionClass(app\adminapi\lists\doctor\AppointmentLists::class);
$result = [];
foreach ($cases as $name => $params) {
$lists = $reflection->newInstanceWithoutConstructor();
$reflection->getProperty('params')->setValue($lists, $params + ['progress_board' => 1, 'include_status_counts' => 1]);
$reflection->getProperty('adminId')->setValue($lists, 7);
$reflection->getProperty('searchWhere')->setValue($lists, []);
$lists->limitOffset = $params['fixture_offset'] ?? 0;
$lists->limitLength = $params['fixture_length'] ?? 100;
AppointmentFilterQuery::$captured = [];
// Count and tabs must work before lists() populates searchWhere.
$lists->count();
$lists->extend();
$lists->lists();
$result[$name] = AppointmentFilterQuery::$captured;
}
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
+54 -2
View File
@@ -5,6 +5,8 @@ declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\lists\firstvisit\MyPatientLists;
use app\adminapi\lists\firstvisit\MyPatientProgressLists;
use app\adminapi\lists\tcm\DiagnosisLists;
use app\adminapi\validate\doctor\AppointmentValidate;
use app\common\enum\AppointmentTypeEnum;
@@ -41,7 +43,7 @@ foreach (['video' => '视频问诊', 'text' => '图文问诊'] as $type => $labe
appointmentTypeExpect(AppointmentTypeEnum::withDefault(['appointment_type' => $type])['appointment_type'] === $type, 'default does not override an explicit choice');
}
$invalidValues = ['', ' ', 'phone', 'Text', ' video ', 'unknown', 0, 1, true, false, null, [], ['text']];
$invalidValues = ['', ' ', 'phone', 'offline', 'Text', ' video ', 'unknown', 0, 1, true, false, null, [], ['text']];
foreach ($invalidValues as $value) {
foreach (['create', 'adminEdit'] as $scene) {
appointmentTypeExpect(!(new AppointmentValidate())->scene($scene)->check($payload + ['appointment_type' => $value]), "$scene rejects " . json_encode($value));
@@ -80,4 +82,54 @@ appointmentTypeExpect($row['latest_appointment_id'] === 8 && $row['latest_appoin
$summary->invokeArgs($lists, [&$row, ['id' => 9, 'appointment_type' => null]]);
appointmentTypeExpect($row['latest_appointment_type'] === 'video', 'next legacy appointment does not inherit previous text type');
echo "Appointment type validation, defaults, legacy labels and summary: OK\n";
$myPatientReflection = new ReflectionClass(MyPatientLists::class);
$myPatientLists = $myPatientReflection->newInstanceWithoutConstructor();
$pickPrimary = $myPatientReflection->getMethod('pickPrimaryAppointment');
$primarySummary = $myPatientReflection->getMethod('appendPrimaryAppointmentSummary');
$appointments = [
['id' => 101, 'doctor_id' => 201, 'appointment_date' => '2026-09-09', 'appointment_time' => '08:30:00', 'status' => 3, 'appointment_type' => 'video'],
['id' => 102, 'doctor_id' => 202, 'appointment_date' => '2026-09-10', 'appointment_time' => '09:30:00', 'status' => 1, 'appointment_type' => 'text'],
['id' => 103, 'doctor_id' => 203, 'appointment_date' => '2026-09-11', 'appointment_time' => '10:30:00', 'status' => 1, 'appointment_type' => 'video'],
];
$adminNames = [201 => '医生甲', 202 => '医生乙', 203 => '医生丙'];
$row = [];
$primary = $pickPrimary->invoke($myPatientLists, $appointments, '', '', '2026-09-10');
$primarySummary->invokeArgs($myPatientLists, [&$row, $primary, $adminNames]);
appointmentTypeExpect(
$row['appointment_id'] === 102 && $row['appointment_doctor_id'] === 202
&& $row['appointment_time_text'] === '2026-09-10 09:30'
&& $row['appointment_type'] === 'text' && $row['appointment_type_desc'] === '图文问诊',
'my patient row uses the upcoming primary appointment type instead of the latest appointment type'
);
$primary = $pickPrimary->invoke($myPatientLists, $appointments, '2026-09-11', '2026-09-11', '2026-09-10');
$primarySummary->invokeArgs($myPatientLists, [&$row, $primary, $adminNames]);
appointmentTypeExpect($row['appointment_id'] === 103 && $row['appointment_type'] === 'video', 'date filter updates both the primary appointment and its type');
$primary = $pickPrimary->invoke($myPatientLists, $appointments, '', '', '2026-09-10', [3]);
$primarySummary->invokeArgs($myPatientLists, [&$row, $primary, $adminNames]);
appointmentTypeExpect($row['appointment_id'] === 101 && $row['appointment_type'] === 'video', 'status filter updates both the primary appointment and its type');
foreach ([null, '', ' ', 'phone', 'unknown'] as $storedType) {
$primary = $appointments[1];
$primary['appointment_type'] = $storedType;
$primarySummary->invokeArgs($myPatientLists, [&$row, $primary, $adminNames]);
appointmentTypeExpect($row['appointment_type'] === AppointmentTypeEnum::normalizeStored($storedType), 'my patient row normalizes only legacy empty types');
appointmentTypeExpect($row['appointment_type_desc'] === AppointmentTypeEnum::description($storedType), 'my patient row preserves historical and unknown labels');
}
$primary = $pickPrimary->invoke($myPatientLists, [], '', '', '2026-09-10');
$primarySummary->invokeArgs($myPatientLists, [&$row, $primary, $adminNames]);
appointmentTypeExpect(
$row['has_appointment'] === 0 && $row['appointment_id'] === 0
&& $row['appointment_type'] === null && $row['appointment_type_desc'] === '',
'a patient without an appointment does not inherit a previous type or gain a default video appointment'
);
$progressReflection = new ReflectionClass(MyPatientProgressLists::class);
$progressLists = $progressReflection->newInstanceWithoutConstructor();
$progressTypeText = $progressReflection->getMethod('appointmentTypeText');
foreach (['video' => '视频问诊', 'text' => '图文问诊', 'phone' => '电话问诊', '' => '视频问诊', ' ' => '视频问诊', 'unknown' => '未知'] as $type => $label) {
appointmentTypeExpect($progressTypeText->invoke($progressLists, $type) === $label, 'progress labels agree with stored appointment types');
}
echo "Appointment type validation, defaults, legacy labels and primary summaries: OK\n";
@@ -66,7 +66,7 @@ callSignatureExpect(
'controller validates both ids and forwards the authenticated row-scope context'
);
callSignatureExpect(
str_contains($validatorSource, "only(['diagnosis_id', 'patient_id'])")
str_contains($validatorSource, "only(['diagnosis_id', 'patient_id', 'appointment_id'])")
&& str_contains($validatorSource, "append('patient_id', 'require|integer|gt:0')"),
'call identity validation requires positive diagnosis and patient ids'
);
@@ -216,11 +216,11 @@ diagnosisWorkspaceAuthExpect(
diagnosisWorkspaceAuthExpect(
str_contains($imChatSyncControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
&& strpos($imChatSyncControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
< strpos($imChatSyncControllerMethod, 'register_shutdown_function('),
'IM archive sync authorizes the diagnosis before queuing background work'
< strpos($imChatSyncControllerMethod, 'DiagnosisLogic::syncImChatArchiveStep('),
'IM archive sync authorizes the diagnosis before syncing any cloud page'
);
diagnosisWorkspaceAuthExpect(
substr_count($imChatLogicMethod, 'attachDiagnosisIdToImMessages(') >= 3
str_contains($imChatLogicMethod, 'attachDiagnosisIdToImMessages(')
&& str_contains($imChatOwnerMethod, "\$row['diagnosis_id'] = \$diagnosisId"),
'every IM response row declares its parent diagnosis for client-side ownership checks'
);
+191
View File
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace app\api\controller {
// 覆盖配置读取以防测试读取真实 .env;控制器本身和 ThinkPHP 请求/响应均为真实类。
function config(string $key, mixed $default = null): mixed
{
return \ImCallbackFixture::$config[$key] ?? $default;
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\api\controller\ImController;
use app\api\http\middleware\LoginMiddleware;
use app\common\service\ImCallbackSignature;
final class ImCallbackFixture
{
public const TOKEN = 'fictional-unit-test-token';
public static array $config = ['im.callback_token' => self::TOKEN, 'project.trtc.sdkAppId' => 1400000000];
public static array $archived = [];
public static array $logs = [];
public static bool $throwArchive = false;
public static bool $throwLog = false;
public static int $inserted = 1;
public static function archiveImCallbackMessage(array $payload): int
{
if (self::$throwArchive) {
throw new \RuntimeException('patient-private-message-and-SQL-must-not-leak');
}
self::$archived[] = $payload;
return self::$inserted;
}
public static function error(string $message): void
{
if (self::$throwLog) throw new \RuntimeException('log storage unavailable');
self::$logs[] = $message;
}
public function getUserInfo(mixed $token): never
{
throw new \RuntimeException('Callback must not query session cache/database');
}
}
class_alias(ImCallbackFixture::class, 'app\\adminapi\\logic\\tcm\\DiagnosisLogic');
class_alias(ImCallbackFixture::class, 'think\\facade\\Log');
class_alias(ImCallbackFixture::class, 'app\\common\\cache\\UserTokenCache');
$assertions = 0;
function imCallbackExpect(bool $condition, string $message): void
{
global $assertions;
$assertions++;
if (!$condition) throw new \RuntimeException($message);
}
function imCallbackQuery(?int $timestamp = null): array
{
$timestamp ??= time();
return [
'SdkAppid' => '1400000000',
'CallbackCommand' => 'C2C.CallbackAfterSendMsg',
'RequestTime' => (string) $timestamp,
'Sign' => hash('sha256', ImCallbackFixture::TOKEN . $timestamp),
];
}
function imCallbackBody(): array
{
return [
'CallbackCommand' => 'C2C.CallbackAfterSendMsg',
'From_Account' => 'patient_1001',
'To_Account' => 'doctor_2001',
'MsgSeq' => 7,
'MsgRandom' => 8,
'MsgTime' => 1700000000,
'MsgKey' => '7_8_1700000000',
'SendMsgResult' => 0,
'MsgBody' => [['MsgType' => 'TIMTextElem', 'MsgContent' => ['Text' => 'synthetic-test-message']]],
];
}
/** @return array{0:ImController,1:\think\Request} */
function imCallbackController(?array $query = null, ?string $body = null, string $method = 'POST', array $headers = []): array
{
$request = (new \think\Request())
->withGet($query ?? imCallbackQuery())
->withInput($body ?? json_encode(imCallbackBody(), JSON_THROW_ON_ERROR))
->withServer(['REQUEST_METHOD' => $method])
->withHeader($headers);
$request->setAction('messageNotify');
$controller = (new \ReflectionClass(ImController::class))->newInstanceWithoutConstructor();
(new \ReflectionProperty(\app\BaseController::class, 'request'))->setValue($controller, $request);
$request->controllerObject = $controller;
return [$controller, $request];
}
function imCallbackResponse(?array $query = null, ?string $body = null, string $method = 'POST', array $headers = []): \think\response\Json
{
[$controller, $request] = imCallbackController($query, $body, $method, $headers);
return (new LoginMiddleware())->handle($request, static fn () => $controller->messageNotify());
}
function imCallbackReject(int $status, ?array $query = null, ?string $body = null, string $method = 'POST', array $headers = []): void
{
$before = count(ImCallbackFixture::$archived);
$response = imCallbackResponse($query, $body, $method, $headers);
imCallbackExpect($response->getCode() === $status && $response->getData()['ActionStatus'] === 'FAIL', 'handler rejects with Tencent FAIL and HTTP ' . $status);
imCallbackExpect(count(ImCallbackFixture::$archived) === $before, 'rejected requests never reach archiving');
}
// 官方文档签名向量与时间窗口边界。
imCallbackExpect(ImCallbackSignature::verify('xxxxyyyy', '1669872112', '17773bc39a671d7b9aa835458704d2a6db81360a5940292b587d6d760d484061', 1669872112), 'official SHA256 concatenation vector matches');
foreach ([-60, -59, 0, 59, 60] as $offset) {
$query = imCallbackQuery(1700000000 + $offset);
imCallbackExpect(ImCallbackSignature::verify(ImCallbackFixture::TOKEN, $query['RequestTime'], $query['Sign'], 1700000000), 'timestamps within the inclusive minute window pass');
}
foreach ([-61, 61] as $offset) {
$query = imCallbackQuery(1700000000 + $offset);
imCallbackExpect(!ImCallbackSignature::verify(ImCallbackFixture::TOKEN, $query['RequestTime'], $query['Sign'], 1700000000), 'expired or future timestamps beyond one minute fail');
}
foreach (['', ' ', [], null, 1.5, '-1', '1e9', '1700000000\n'] as $value) {
imCallbackExpect(!ImCallbackSignature::verify(ImCallbackFixture::TOKEN, $value, str_repeat('a', 64), 1700000000), 'malformed RequestTime fails closed');
}
foreach (['', ' ', [], null, str_repeat('a', 63), str_repeat('g', 64)] as $value) {
imCallbackExpect(!ImCallbackSignature::verify(ImCallbackFixture::TOKEN, '1700000000', $value, 1700000000), 'malformed Sign fails closed');
}
imCallbackExpect(!ImCallbackSignature::verify('', '1700000000', hash('sha256', '1700000000'), 1700000000), 'empty configured token cannot authenticate');
imCallbackExpect(!ImCallbackSignature::verify(' ', '1700000000', hash('sha256', ' 1700000000'), 1700000000), 'blank configured token cannot authenticate');
$success = imCallbackResponse();
imCallbackExpect($success->getCode() === 200 && $success->getData() === ['ActionStatus' => 'OK', 'ErrorCode' => 0, 'ErrorInfo' => ''], 'authenticated actual handler returns the exact Tencent success envelope');
imCallbackExpect(ImCallbackFixture::$archived === [imCallbackBody()], 'entire authenticated payload reaches archiveImCallbackMessage');
$successWithTokenHeader = imCallbackResponse(headers: ['token' => 'fictional-unrelated-user-token']);
imCallbackExpect($successWithTokenHeader->getCode() === 200, 'callback bypasses login session lookup even with an incidental token header');
ImCallbackFixture::$inserted = 0;
imCallbackExpect(imCallbackResponse()->getCode() === 200, 'idempotent duplicate or safely ignored patient pair acknowledges success');
imCallbackReject(405, method: 'GET');
imCallbackReject(405, method: 'GET', headers: ['x-http-method-override' => 'POST']);
imCallbackReject(413, headers: ['content-length' => '1048577']);
imCallbackReject(413, body: str_repeat('x', 1048577));
foreach (['[', '[]', 'null', '"string"', '{}', '{"CallbackCommand":123}'] as $body) imCallbackReject(400, body: $body);
imCallbackReject(400, body: str_repeat('{"nested":', 66) . '0' . str_repeat('}', 66));
imCallbackReject(400, query: array_replace(imCallbackQuery(), ['CallbackCommand' => 'Other.Callback']));
imCallbackReject(400, query: array_replace(imCallbackQuery(), ['CallbackCommand' => []]));
foreach ([null, [], '1400000001', '01400000000'] as $appId) imCallbackReject(403, query: array_replace(imCallbackQuery(), ['SdkAppid' => $appId]));
imCallbackReject(403, query: array_replace(imCallbackQuery(), ['Sign' => str_repeat('0', 64)]));
imCallbackReject(403, query: imCallbackQuery(time() - 120));
imCallbackReject(403, query: imCallbackQuery(time() + 120));
$noSign = imCallbackQuery();
unset($noSign['Sign']);
imCallbackReject(403, query: $noSign);
ImCallbackFixture::$config['im.callback_token'] = '';
imCallbackReject(503);
ImCallbackFixture::$config['im.callback_token'] = ImCallbackFixture::TOKEN;
ImCallbackFixture::$config['project.trtc.sdkAppId'] = 0;
imCallbackReject(503);
ImCallbackFixture::$config['project.trtc.sdkAppId'] = 1400000000;
$before = count(ImCallbackFixture::$archived);
$unknown = imCallbackResponse(array_replace(imCallbackQuery(), ['CallbackCommand' => 'State.StateChange']), '{"CallbackCommand":"State.StateChange"}');
imCallbackExpect($unknown->getCode() === 200 && count(ImCallbackFixture::$archived) === $before, 'other authenticated callbacks are safely ignored');
$noQueryCommand = imCallbackQuery();
unset($noQueryCommand['CallbackCommand']);
imCallbackExpect(imCallbackResponse($noQueryCommand)->getCode() === 200, 'body command is accepted when optional query command is absent');
ImCallbackFixture::$throwArchive = true;
$failedArchive = imCallbackResponse();
imCallbackExpect($failedArchive->getCode() === 500 && $failedArchive->getData()['ActionStatus'] === 'FAIL', 'archive exception returns HTTP 500 and Tencent FAIL rather than OK');
imCallbackExpect(ImCallbackFixture::$logs === ['IM callback archive failed'], 'logging contains neither patient body nor exception SQL nor signature');
imCallbackExpect(!str_contains(json_encode($failedArchive->getData()), 'patient-private'), 'response cannot leak archive exception contents');
ImCallbackFixture::$throwLog = true;
imCallbackExpect(imCallbackResponse()->getCode() === 500, 'logging failure does not replace the Tencent archive failure response');
// 精确免登录分支不扩散到同一控制器的其他 action。
[$otherActionController, $otherActionRequest] = imCallbackController();
$otherActionRequest->setAction('otherAction');
$nextCalled = false;
(new LoginMiddleware())->handle($otherActionRequest, static function () use (&$nextCalled): void { $nextCalled = true; });
imCallbackExpect(!$nextCalled, 'noncallback action remains behind ordinary session middleware');
echo "IM callback signature, actual handler and login middleware: {$assertions} assertions passed (no database/network)\n";
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
// 使用真实命令与定时调度器,内存替代业务逻辑、Console facade、任务模型。
final class ImCommandLogicFixture
{
public static array $result = ['diagnoses' => 1, 'inserted' => 2, 'errors' => []];
public static function syncImChatArchiveBatch($days, $limit, $id): array { return self::$result; }
}
final class ImCommandConsoleFixture
{
public static bool $fail = true;
public static function call($name, $params = []): void
{
if (self::$fail) throw new RuntimeException('云端暂不可用');
}
}
final class ImCommandTaskFixture
{
public static array $writes = [];
public static function where($key, $value): self { return new self(); }
public function update(array $data): void { self::$writes[] = $data; }
}
final class ImCommandLogFixture { public static function error($message): void {} }
class_alias(ImCommandLogicFixture::class, 'app\adminapi\logic\tcm\DiagnosisLogic');
class_alias(ImCommandConsoleFixture::class, 'think\facade\Console');
class_alias(ImCommandTaskFixture::class, 'app\common\model\Crontab');
class_alias(ImCommandLogFixture::class, 'think\facade\Log');
require dirname(__DIR__) . '/vendor/autoload.php';
function imCommandExpect(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
}
$task = ['id' => 3, 'params' => '--since-days=0 --limit=200', 'command' => 'sync_im_chat_archive', 'max_time' => 0];
\app\common\command\Crontab::start($task);
imCommandExpect(ImCommandTaskFixture::$writes[0]['error'] === '云端暂不可用', 'sync failure remains visible in scheduler');
imCommandExpect(ImCommandTaskFixture::$writes[0]['status'] === \app\common\enum\CrontabEnum::START, 'IM sync retries next scheduled round');
ImCommandTaskFixture::$writes = [];
\app\common\command\Crontab::start(array_merge($task, ['command' => 'unrelated_command']));
imCommandExpect(ImCommandTaskFixture::$writes[0]['status'] === \app\common\enum\CrontabEnum::ERROR, 'other command failure behavior is unchanged');
ImCommandTaskFixture::$writes = [];
ImCommandConsoleFixture::$fail = false;
\app\common\command\Crontab::start($task);
imCommandExpect(ImCommandTaskFixture::$writes[0]['error'] === '', 'successful round clears previous error');
$command = new \app\common\command\SyncImChatArchive();
$input = new \think\console\Input([]);
$output = new \think\console\Output('buffer');
ImCommandLogicFixture::$result['errors'] = ['doctor fixture unavailable'];
try {
$command->run($input, $output);
throw new RuntimeException('command silently succeeded');
} catch (RuntimeException $e) {
imCommandExpect(str_contains($e->getMessage(), 'IM 聊天归档未完全同步'), 'command propagates failure even though Console::call discards return codes');
}
ImCommandLogicFixture::$result['errors'] = [];
imCommandExpect($command->run(new \think\console\Input([]), new \think\console\Output('buffer')) === 0, 'successful command returns zero');
echo "IM archive command and scheduler propagation: OK (no database/network)\n";
+548
View File
@@ -0,0 +1,548 @@
<?php
declare(strict_types=1);
namespace ImArchiveTest {
/** Strict in-memory adapters: unexpected persistence operations fail closed. */
final class Store
{
public static array $tables = [];
public static array $cache = [];
public static array $events = [];
public static int $clock = 1000;
public static int $executeCount = 0;
public static int $failOnExecute = 0;
public static bool $failUpdate = false;
public static function reset(): void
{
self::$tables = [
'diagnosis' => [
['id' => 101, 'patient_id' => 501, 'patient_name' => '患者甲', 'assistant_id' => 7, 'delete_time' => null],
['id' => 102, 'patient_id' => 501, 'patient_name' => '患者甲', 'assistant_id' => 7, 'delete_time' => null],
['id' => 201, 'patient_id' => 502, 'patient_name' => '患者乙', 'assistant_id' => 8, 'delete_time' => null],
],
'messages' => [],
'admin' => [['id' => 7, 'name' => '医生甲'], ['id' => 8, 'name' => '医生乙']],
'appointment' => [],
'admin_role' => [['admin_id' => 7, 'role_id' => 1]],
];
self::$cache = self::$events = [];
self::$clock = 1000;
self::$executeCount = self::$failOnExecute = 0;
self::$failUpdate = false;
\app\common\service\TencentImService::$responses = [];
\app\common\service\TencentImService::$requests = [];
\app\common\service\TencentImService::$checkRequests = [];
\app\common\service\TencentImService::$missingAccounts = [];
\app\common\service\TencentImService::$checkFailure = null;
}
}
final class Rows
{
public function __construct(private array $rows) {}
public function toArray(): array { return $this->rows; }
}
final class Query
{
private array $filters = [];
private array $orders = [];
public function __construct(private string $table) {}
public function where(string $field, $operator, $value = null): self
{
if (func_num_args() === 2) {
$value = $operator;
$operator = '=';
}
if ($operator !== '=') throw new \RuntimeException('Unexpected query operator: ' . $operator);
$this->filters[] = static fn (array $row): bool => ($row[$field] ?? null) === $value;
return $this;
}
public function whereIn(string $field, array $values): self
{
$this->filters[] = static fn (array $row): bool => in_array($row[$field] ?? null, $values, true);
return $this;
}
public function order(string $field, string $direction): self
{
$this->orders[] = [$field, $direction];
return $this;
}
private function rows(): array
{
if (!array_key_exists($this->table, Store::$tables)) throw new \RuntimeException('Unexpected table: ' . $this->table);
$rows = array_values(array_filter(Store::$tables[$this->table], function (array $row): bool {
foreach ($this->filters as $filter) if (!$filter($row)) return false;
return true;
}));
if ($this->orders) {
usort($rows, function (array $left, array $right): int {
foreach ($this->orders as [$field, $direction]) {
$comparison = ($left[$field] ?? null) <=> ($right[$field] ?? null);
if ($comparison !== 0) return $direction === 'desc' ? -$comparison : $comparison;
}
return 0;
});
}
return $rows;
}
public function select(): Rows { return new Rows($this->rows()); }
public function find(): ?array { return $this->rows()[0] ?? null; }
public function column(string $field, string $key = ''): array
{
return $key === '' ? array_column($this->rows(), $field) : array_column($this->rows(), $field, $key);
}
public function update(array $data): int
{
if (Store::$failUpdate) throw new \RuntimeException('archive repair failed');
if ($this->table !== 'messages' || array_keys($data) !== ['msg_type', 'text', 'raw_elem_type', 'image_url', 'file_url', 'file_name']) {
throw new \RuntimeException('Unexpected archive update');
}
$ids = array_column($this->rows(), 'id');
foreach (Store::$tables[$this->table] as &$row) {
if (in_array($row['id'], $ids, true)) $row = array_replace($row, $data);
}
unset($row);
Store::$events[] = ['repair', $ids];
return count($ids);
}
}
abstract class Model
{
protected const TABLE = '';
public static function where(...$args): Query { return (new Query(static::TABLE))->where(...$args); }
public static function whereIn(...$args): Query { return (new Query(static::TABLE))->whereIn(...$args); }
public function getTable(): string { return 'archive_test_messages'; }
}
final class Database
{
public function name(string $table): Query { return new Query($table); }
public function execute(string $sql, array $bindings): int
{
Store::$executeCount++;
if (Store::$executeCount === Store::$failOnExecute) throw new \RuntimeException('archive write failed');
if (!preg_match('/^INSERT INTO `archive_test_messages` \(([^)]+)\) VALUES /', $sql, $match)
|| !str_ends_with($sql, ' ON DUPLICATE KEY UPDATE `msg_id` = `msg_id`')) {
throw new \RuntimeException('Unexpected archive SQL; only explicit duplicate-key no-op is supported');
}
$columns = array_map(static fn (string $column): string => trim($column, '`'), explode(',', $match[1]));
if (count($bindings) % count($columns) !== 0) throw new \RuntimeException('Invalid archive SQL bindings');
$inserted = 0;
foreach (array_chunk($bindings, count($columns)) as $values) {
$row = array_combine($columns, $values);
$exists = false;
foreach (Store::$tables['messages'] as $existing) {
if ($existing['msg_id'] === $row['msg_id']) { $exists = true; break; }
}
if ($exists) continue;
$row['id'] = count(Store::$tables['messages']) + 1;
Store::$tables['messages'][] = $row;
$inserted++;
}
Store::$events[] = ['archive', $inserted];
return $inserted;
}
}
final class Cache
{
public function get(string $key, $default = null) { return Store::$cache[$key] ?? $default; }
public function set(string $key, $value, int $ttl = 0): bool
{
Store::$events[] = ['cache', $key];
Store::$cache[$key] = $value;
return true;
}
}
}
namespace app\common\model\tcm {
class Diagnosis extends \ImArchiveTest\Model { protected const TABLE = 'diagnosis'; }
class ImChatMessage extends \ImArchiveTest\Model { protected const TABLE = 'messages'; }
}
namespace app\common\model\auth {
class Admin extends \ImArchiveTest\Model { protected const TABLE = 'admin'; }
}
namespace app\common\model\doctor {
class Appointment extends \ImArchiveTest\Model { protected const TABLE = 'appointment'; }
}
namespace app\common\service {
/** Pager remains real; this adapter cannot make HTTP requests. */
class TencentImService
{
public static array $responses = [];
public static array $requests = [];
public static array $checkRequests = [];
public static array $missingAccounts = [];
public static ?\Throwable $checkFailure = null;
public function checkAccounts(array $accounts): array
{
self::$checkRequests[] = $accounts;
if (count($accounts) > 100) throw new \RuntimeException('account batch exceeds 100');
if (self::$checkFailure) throw self::$checkFailure;
return ['existing' => array_values(array_diff($accounts, self::$missingAccounts)),
'missing' => array_values(array_intersect($accounts, self::$missingAccounts))];
}
public function adminGetRoamMsg(string $operatorAccount, string $peerAccount, int $maxCnt = 100,
int $minTime = 0, int $maxTime = 4294967295, ?string $lastMsgKey = null, ?int $lastMsgTime = null): array
{
self::$requests[] = compact('operatorAccount', 'peerAccount', 'maxCnt', 'minTime', 'maxTime', 'lastMsgKey', 'lastMsgTime');
if (self::$responses === []) throw new \RuntimeException('Unexpected additional IM request');
$response = array_shift(self::$responses);
if ($response instanceof \Throwable) throw $response;
return $response;
}
}
}
namespace app\adminapi\logic\tcm {
// Make checkpoint timing deterministic without replacing any DiagnosisLogic method.
function time(): int { return \ImArchiveTest\Store::$clock; }
}
namespace {
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\common\service\ImChatSyncSession;
use app\common\service\ImRoamMessagePager;
use app\common\service\TencentImService;
use ImArchiveTest\Store;
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
// No initialize(): no environment configuration, network, or business DB connection.
$testApp = new think\App();
$testApp->instance('think\DbManager', new ImArchiveTest\Database());
$testApp->instance('cache', new ImArchiveTest\Cache());
$testApp->instance('log', new Psr\Log\NullLogger());
think\facade\Config::set(['trtc' => ['sdkAppId' => 123, 'secretKey' => 'archive-test-key']], 'project');
function archiveExpect(bool $ok, string $message): void
{
if (!$ok) throw new RuntimeException($message);
}
function archiveFails(callable $action, string $expected): void
{
try { $action(); }
catch (Throwable $exception) {
archiveExpect(str_contains($exception->getMessage(), $expected), 'Expected ' . $expected . '; got ' . $exception->getMessage());
return;
}
throw new RuntimeException('Expected failure: ' . $expected);
}
function archiveInvoke(string $method, ...$args)
{
return (new ReflectionMethod(DiagnosisLogic::class, $method))->invoke(null, ...$args);
}
function archiveRaw(string $key, int $time, int $patientId = 501, bool $reverse = false): array
{
return [
'From_Account' => $reverse ? 'patient_' . $patientId : 'doctor_7',
'To_Account' => $reverse ? 'doctor_7' : 'patient_' . $patientId,
'MsgTimeStamp' => $time, 'MsgSeq' => 11, 'MsgRandom' => 22, 'MsgKey' => $key,
'MsgBody' => [['MsgType' => 'TIMTextElem', 'MsgContent' => ['Text' => 'message ' . $key]]],
];
}
function archivePage(bool $completed, ?int $time, ?string $key, array $messages): array
{
return ['success' => true, 'complete' => $completed ? 1 : 0, 'msgList' => $messages,
'lastMsgTime' => $time, 'lastMsgKey' => $key, 'rawErrorCode' => 0, 'error' => ''];
}
function archiveStored(string $id, int $patientId, int $diagnosisId, string $from, string $to, int $time): array
{
return ['id' => count(Store::$tables['messages']) + 1, 'msg_id' => $id, 'patient_id' => $patientId,
'diagnosis_id' => $diagnosisId, 'from_account' => $from, 'to_account' => $to,
'msg_time' => $time, 'doctor_peer_account' => 'doctor_7', 'text' => $id];
}
// Existing archive/pagination cases advance past account-only setup requests.
function archiveStep(int $diagnosisId, int $adminId, string $token = '', bool $currentPeer = false): array
{
do {
$result = DiagnosisLogic::syncImChatArchiveStep($diagnosisId, $adminId, $token, $currentPeer);
$token = $result['sync_token'];
$state = Store::$cache['im_chat_sync:' . $token];
} while (!$result['completed'] && (!($state['accounts_verified'] ?? false) || !isset($state['active_index'])));
return $result;
}
function archiveFinishPatientSide(string $token): array
{
TencentImService::$responses[] = archivePage(true, null, null, []);
return archiveStep(101, 7, $token, true);
}
archiveExpect(str_ends_with((new ReflectionClass(DiagnosisLogic::class))->getFileName(), 'DiagnosisLogic.php'), 'Use the actual diagnosis logic');
archiveExpect(str_ends_with((new ReflectionClass(ImRoamMessagePager::class))->getFileName(), 'ImRoamMessagePager.php'), 'Use the actual pager');
// Reading a patient's archive spans diagnoses, but never trusts patient_id without checking the accounts.
Store::reset();
Store::$tables['messages'][] = archiveStored('old', 501, 101, 'doctor_7', 'patient_501', 100);
Store::$tables['messages'][] = archiveStored('new', 501, 102, 'patient_501', 'doctor_8', 200);
Store::$tables['messages'][] = archiveStored('corrupt-patient-column', 501, 101, 'doctor_7', 'patient_502', 150);
Store::$tables['messages'][] = archiveStored('other', 502, 201, 'doctor_7', 'patient_502', 100);
$old = DiagnosisLogic::getImChatMessagesForDiagnosis(101, true);
$new = DiagnosisLogic::getImChatMessagesForDiagnosis(102, true);
$other = DiagnosisLogic::getImChatMessagesForDiagnosis(201, true);
archiveExpect(array_column($old['lists'], 'msg_id') === ['old', 'new'] && array_column($new['lists'], 'msg_id') === ['old', 'new'], 'Old and new diagnoses share the same patient archive');
archiveExpect(array_unique(array_column($old['lists'], 'diagnosis_id')) === [101]
&& array_unique(array_column($new['lists'], 'diagnosis_id')) === [102], 'Response rows carry the currently authorized diagnosis');
archiveExpect(array_column($other['lists'], 'msg_id') === ['other'], 'Another patient cannot receive shared or mislabeled rows');
archiveExpect($new['lists'][0]['from_staff_name'] === '医生甲' && TencentImService::$requests === [], 'Archive-only reads enrich staff names without network');
// Callback MsgTime + MsgKey and roam MsgTimeStamp/MsgRandom identify the same message.
Store::reset();
$roam = archiveRaw('11_22_100', 100);
$callback = $roam;
$callback['SendMsgResult'] = 0;
$callback['MsgTime'] = $callback['MsgTimeStamp'];
unset($callback['MsgTimeStamp'], $callback['MsgRandom'], $callback['MsgSeq']);
$normalized = archiveInvoke('normalizeTimMessage', $roam);
archiveExpect($normalized['msg_id'] === archiveInvoke('normalizeTimMessage', $callback)['msg_id'], 'Callback and roam use canonical message identity');
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($callback) === 1, 'Callback archives to the latest patient diagnosis');
$failedCallback = array_replace($callback, ['MsgKey' => 'failed-send', 'SendMsgResult' => 90001]);
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($failedCallback) === 0 && count(Store::$tables['messages']) === 1, 'Failed sending callback is not archived');
foreach ([null, '0', true] as $invalidSendResult) {
archiveFails(static fn () => DiagnosisLogic::archiveImCallbackMessage(array_replace($callback, ['SendMsgResult' => $invalidSendResult])), '发送结果');
}
$missingSendResult = $callback;
unset($missingSendResult['SendMsgResult']);
archiveFails(static fn () => DiagnosisLogic::archiveImCallbackMessage($missingSendResult), '发送结果');
archiveExpect(archiveInvoke('persistImChatArchiveRows', 101, 501, [$normalized]) === 0
&& DiagnosisLogic::archiveImCallbackMessage($callback) === 0, 'Roam and repeated callback are idempotent');
archiveExpect(count(Store::$tables['messages']) === 1 && Store::$tables['messages'][0]['diagnosis_id'] === 102, 'Duplicate writes do not mutate existing archive provenance');
archiveExpect($normalized['msg_id'] !== archiveInvoke('normalizeTimMessage', archiveRaw('11_22_100', 100, 502))['msg_id'], 'Canonical identity includes both accounts to isolate patients');
archiveFails(static fn () => archiveInvoke('persistImChatArchiveRows', 101, 501, [archiveInvoke('normalizeTimMessage', archiveRaw('wrong', 100, 502))]), '不属于当前患者');
// Legacy seq_random_from keys are reused only when patient, endpoints and time all agree.
Store::reset();
Store::$tables['messages'][] = archiveStored('11_22_doctor_7', 501, 101, 'doctor_7', 'patient_501', 100);
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($callback) === 0 && count(Store::$tables['messages']) === 1, 'Legacy key remains deduplicated even when callback omits MsgRandom');
foreach ([[502, 'patient_502', 100], [501, 'patient_502', 100], [501, 'patient_501', 99]] as [$legacyPatient, $legacyTo, $legacyTime]) {
Store::reset();
$legacy = archiveStored('11_22_doctor_7', $legacyPatient, 201, 'doctor_7', $legacyTo, $legacyTime);
Store::$tables['messages'][] = $legacy;
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($callback) === 1, 'Colliding legacy key does not suppress a different patient/time/account message');
archiveExpect(Store::$tables['messages'][0] === $legacy && Store::$tables['messages'][1]['msg_id'] === $normalized['msg_id'], 'Legacy collision preserves the existing row and inserts the canonical key');
}
// Composite messages retain every element and repair truncated legacy rows without changing ownership.
Store::reset();
$composite = $callback;
$composite['MsgBody'][] = ['MsgType' => 'TIMImageElem', 'MsgContent' => ['ImageInfoArray' => [['URL' => 'https://example.invalid/image.jpg']]]];
$composite['MsgBody'][] = ['MsgType' => 'TIMFileElem', 'MsgContent' => ['Url' => 'https://example.invalid/report.pdf', 'FileName' => '报告.pdf']];
$compositeRow = archiveInvoke('normalizeTimMessage', $composite);
archiveExpect($compositeRow['msg_type'] === 'composite' && $compositeRow['raw_elem_type'] === 'TIMMultiElem', 'Multiple message elements use the composite archive representation');
$parts = json_decode($compositeRow['text'], true, 512, JSON_THROW_ON_ERROR);
archiveExpect(array_column($parts, 'msg_type') === ['text', 'image', 'file'] && $parts[2]['file_name'] === '报告.pdf', 'Text, image and file elements retain their order and content');
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($composite) === 1, 'Composite callback archives as one canonical message');
$compositeRead = DiagnosisLogic::getImChatMessagesForDiagnosis(101, true);
archiveExpect($compositeRead['lists'][0]['parts'] === $parts, 'Archive reads restore every composite element for rendering');
Store::reset();
$truncated = archiveStored('11_22_doctor_7', 501, 101, 'doctor_7', 'patient_501', 100);
$truncated['msg_type'] = 'text';
$truncated['text'] = 'first element only';
Store::$tables['messages'][] = $truncated;
$unrelated = archiveStored('other-patient-legacy', 502, 201, 'doctor_7', 'patient_502', 100);
Store::$tables['messages'][] = $unrelated;
archiveExpect(DiagnosisLogic::archiveImCallbackMessage($composite) === 0 && count(Store::$tables['messages']) === 2, 'Repair reuses a matched legacy key without creating a duplicate');
$repaired = Store::$tables['messages'][0];
archiveExpect($repaired['msg_type'] === 'composite' && json_decode($repaired['text'], true) === $parts
&& $repaired['diagnosis_id'] === 101 && $repaired['msg_id'] === $truncated['msg_id']
&& Store::$tables['messages'][1] === $unrelated, 'Legacy content repair preserves archive provenance and cannot modify another patient');
$repairCount = count(array_filter(Store::$events, static fn (array $event): bool => $event[0] === 'repair'));
DiagnosisLogic::archiveImCallbackMessage($composite);
archiveExpect(count(array_filter(Store::$events, static fn (array $event): bool => $event[0] === 'repair')) === $repairCount, 'Already repaired composite content is idempotent');
Store::$tables['messages'][0] = $truncated;
Store::$failUpdate = true;
archiveFails(static fn () => DiagnosisLogic::archiveImCallbackMessage($composite), 'archive repair failed');
archiveExpect(Store::$tables['messages'][0] === $truncated, 'A failed legacy repair is not swallowed as successful archive');
// Current-peer scope is server-selected; token reuse is bound to admin, diagnosis and patient.
Store::reset();
TencentImService::$responses = [archivePage(false, 200, 'first', [archiveRaw('first', 200)])];
$first = archiveStep(101, 7, '', true);
$token = $first['sync_token'];
$sessionKey = 'im_chat_sync:' . $token;
$checkpointKey = 'im_chat_complete_v1:501:doctor_7';
$saved = Store::$cache[$sessionKey];
archiveExpect(!$first['completed'] && $first['inserted'] === 1 && $saved['cursor']['max_time'] === 200, 'A persisted non-final page advances its full cursor');
archiveExpect($saved['accounts'] === ['doctor_7'] && $saved['admin_id'] === 7 && $saved['diagnosis_id'] === 101 && $saved['patient_id'] === 501, 'Current scope records its exact authorized identity');
archiveExpect(!isset(Store::$cache[$checkpointKey]) && TencentImService::$requests[0]['minTime'] === 0, 'Partial scan has no complete checkpoint and starts from the beginning');
$requestCount = count(TencentImService::$requests);
archiveFails(static fn () => archiveStep(101, 8, $token, true), '失效');
archiveFails(static fn () => archiveStep(102, 7, $token, true), '失效');
Store::$tables['diagnosis'][0]['patient_id'] = 502;
archiveFails(static fn () => archiveStep(101, 7, $token, true), '失效');
Store::$tables['diagnosis'][0]['patient_id'] = 501;
archiveFails(static fn () => archiveStep(101, 7, 'bad-token', true), '无效');
archiveFails(static fn () => archiveStep(101, 7, str_repeat('a', 48), true), '失效');
archiveExpect(count(TencentImService::$requests) === $requestCount && Store::$cache[$sessionKey] === $saved, 'Invalid token reuse cannot call IM or change saved progress');
Store::$clock = 2000;
TencentImService::$responses = [archivePage(true, 199, 'last', [archiveRaw('last', 199, 501, true)])];
$doctorSide = archiveStep(101, 7, $token, false);
archiveExpect(!$doctorSide['completed'] && Store::$cache[$sessionKey]['side'] === 1
&& !isset(Store::$cache[$checkpointKey]), 'Finishing doctor-side pages starts the patient-side scan without a complete checkpoint');
TencentImService::$responses = [archivePage(true, 198, 'patient-only', [archiveRaw('first', 200), archiveRaw('patient-only', 198, 501, true)])];
$last = archiveStep(101, 7, $token, true);
archiveExpect($last['completed'] && $last['errors'] === [] && $last['inserted'] === 3, 'Only both persisted perspectives complete a conversation, including patient-only history');
archiveExpect(Store::$cache[$checkpointKey] === 1000, 'Complete checkpoint uses the scan start time, not archive MAX(msg_time) or finish time');
archiveExpect(TencentImService::$requests[1]['maxTime'] === 200 && TencentImService::$requests[1]['lastMsgKey'] === 'first'
&& TencentImService::$requests[1]['operatorAccount'] === 'doctor_7', 'Token continuation preserves cursor and cannot expand current-peer scope');
archiveExpect(TencentImService::$requests[2]['operatorAccount'] === 'patient_501'
&& TencentImService::$requests[2]['peerAccount'] === 'doctor_7' && TencentImService::$requests[2]['minTime'] === 0
&& TencentImService::$requests[2]['maxTime'] === 4294967295, 'Patient-side scan swaps perspective and restarts the same time range');
archiveExpect(count(Store::$tables['messages']) === 3 && Store::$tables['messages'][2]['doctor_peer_account'] === 'doctor_7', 'Both perspectives deduplicate shared messages and preserve doctor attribution');
$eventKinds = array_column(Store::$events, 0);
archiveExpect($eventKinds === ['cache', 'archive', 'cache', 'archive', 'cache', 'archive', 'cache', 'cache'], 'Both-side page persistence precedes checkpoint and session progress writes');
TencentImService::$responses = [archivePage(true, null, null, [])];
$incremental = archiveStep(101, 7, '', true);
archiveFinishPatientSide($incremental['sync_token']);
archiveExpect(TencentImService::$requests[3]['minTime'] === 880 && TencentImService::$requests[4]['minTime'] === 880, 'Only a completed checkpoint can enable the same overlap range for both perspectives');
// A later-page IM error reports failure and leaves the complete checkpoint untouched.
Store::reset();
Store::$cache[$checkpointKey] = 250;
TencentImService::$responses = [archivePage(false, 300, 'a', [archiveRaw('a', 300)]),
['success' => false, 'rawErrorCode' => 91000, 'error' => 'page two unavailable']];
$first = archiveStep(101, 7, '', true);
$failedDoctorSide = archiveStep(101, 7, $first['sync_token'], true);
archiveExpect(!$failedDoctorSide['completed'], 'Doctor-side failure still permits the patient-side attempt');
$failed = archiveFinishPatientSide($first['sync_token']);
archiveExpect($failed['inserted'] === 1 && count($failed['errors']) === 1 && str_contains($failed['errors'][0], 'page two unavailable'), 'Failed page is visible while prior successfully archived pages remain');
archiveExpect(Store::$cache[$checkpointKey] === 250 && count(Store::$tables['messages']) === 1, 'An incomplete conversation preserves the previous complete checkpoint');
TencentImService::$responses = [archivePage(true, 299, 'b', [archiveRaw('a', 300), archiveRaw('b', 299)])];
$recoveredDoctorSide = archiveStep(101, 7, '', true);
$recovered = archiveFinishPatientSide($recoveredDoctorSide['sync_token']);
archiveExpect($recovered['completed'] && $recovered['errors'] === [] && $recovered['inserted'] === 1
&& count(Store::$tables['messages']) === 2 && TencentImService::$requests[3]['minTime'] === 130, 'Next round backfills from the unchanged checkpoint range and deduplicates the already archived page');
Store::reset();
TencentImService::$responses = [archivePage(true, 300, 'foreign', [archiveRaw('foreign', 300, 502)])];
$foreignPage = archiveStep(101, 7, '', true);
archiveExpect(count($foreignPage['errors']) === 1 && Store::$tables['messages'] === []
&& !isset(Store::$cache[$checkpointKey]), 'A cross-patient cloud page cannot be archived or create a complete checkpoint');
Store::reset();
Store::$cache[$checkpointKey] = 250;
TencentImService::$responses = [archivePage(true, 300, 'doctor-only', [archiveRaw('doctor-only', 300)]),
['success' => false, 'rawErrorCode' => 91000, 'error' => 'patient perspective unavailable']];
$doctorOnly = archiveStep(101, 7, '', true);
$patientSideFailure = archiveStep(101, 7, $doctorOnly['sync_token'], true);
archiveExpect($patientSideFailure['completed'] && count($patientSideFailure['errors']) === 1
&& str_contains($patientSideFailure['errors'][0], '患者侧') && Store::$cache[$checkpointKey] === 250,
'A patient-side failure also prevents advancing the complete checkpoint');
// A DB failure must throw before caching the new page cursor or complete checkpoint.
Store::reset();
TencentImService::$responses = [archivePage(false, 400, 'db-first', [archiveRaw('db-first', 400)])];
$first = archiveStep(101, 7, '', true);
$sessionKey = 'im_chat_sync:' . $first['sync_token'];
$beforeWriteFailure = Store::$cache[$sessionKey];
Store::$failOnExecute = Store::$executeCount + 1;
TencentImService::$responses = [archivePage(true, 399, 'db-last', [archiveRaw('db-last', 399)])];
archiveFails(static fn () => archiveStep(101, 7, $first['sync_token'], true), 'archive write failed');
archiveExpect(Store::$cache[$sessionKey] === $beforeWriteFailure && !isset(Store::$cache[$checkpointKey])
&& count(Store::$tables['messages']) === 1, 'Failed persistence leaves resumable session cursor and checkpoint unchanged');
Store::$failOnExecute = 0;
TencentImService::$responses = [archivePage(true, 399, 'db-last', [archiveRaw('db-last', 399)])];
$retried = archiveStep(101, 7, $first['sync_token'], true);
archiveExpect(!$retried['completed'] && $retried['inserted'] === 2 && count(Store::$tables['messages']) === 2
&& !isset(Store::$cache[$checkpointKey]), 'Retry archives the failed doctor-side page before starting the patient side');
archiveExpect(TencentImService::$requests[1] === TencentImService::$requests[2], 'DB-failed page is retried using the exact previous cursor');
archiveExpect(archiveFinishPatientSide($first['sync_token'])['completed'], 'Completion follows successful persistence and both perspective scans');
// A single cloud page can span several SQL batches; retry keeps the successful first batch idempotent.
Store::reset();
$bulkMessages = [];
for ($index = 0; $index < 81; $index++) $bulkMessages[] = archiveRaw('bulk-' . $index, 600);
Store::$failOnExecute = 2;
TencentImService::$responses = [archivePage(true, 600, 'bulk-80', $bulkMessages)];
archiveFails(static fn () => archiveStep(101, 7, '', true), 'archive write failed');
archiveExpect(count(Store::$tables['messages']) === 80 && !isset(Store::$cache[$checkpointKey]) && count(Store::$cache) === 1 && array_values(Store::$cache)[0]['cursor'] === [], 'A failed second SQL batch does not publish a completed page or session cursor');
Store::$failOnExecute = 0;
TencentImService::$responses = [archivePage(true, 600, 'bulk-80', $bulkMessages)];
$bulkDoctorSide = archiveStep(101, 7, '', true);
$bulkRetry = archiveFinishPatientSide($bulkDoctorSide['sync_token']);
archiveExpect($bulkRetry['completed'] && $bulkRetry['errors'] === [] && $bulkRetry['inserted'] === 1
&& count(Store::$tables['messages']) === 81, 'Retry finishes a partially persisted page without duplicating its first batch');
Store::reset();
Store::$failOnExecute = 1;
archiveFails(static fn () => DiagnosisLogic::archiveImCallbackMessage($callback), 'archive write failed');
TencentImService::$responses = [archivePage(true, 100, 'cli', [archiveRaw('cli', 100)])];
Store::$failOnExecute = Store::$executeCount + 1;
$cliFailure = DiagnosisLogic::syncImChatArchiveForDiagnosis(101);
archiveExpect(str_contains($cliFailure['error'] ?? '', 'archive write failed') && !$cliFailure['skipped_live_empty']
&& !isset(Store::$cache[$checkpointKey]), 'CLI sync also reports write failure instead of empty successful synchronization');
// Session policy continues other peers after a fetch failure, never after a swallowed archive failure.
$state = ImChatSyncSession::start(['doctor_7', 'doctor_8', 'doctor_7']);
$archiveCalls = 0;
$next = ImChatSyncSession::step($state, static function () { throw new RuntimeException('peer unavailable'); },
static function () use (&$archiveCalls): int { $archiveCalls++; return 1; });
archiveExpect($next['index'] === 0 && $next['side'] === 1 && $next['cursor'] === [] && count($next['errors']) === 1 && $archiveCalls === 0, 'Fetch failure starts the other side without archiving or marking that peer as successful');
$nextPeer = ImChatSyncSession::step($next, static fn (): array => ['msgList' => [], 'completed' => true, 'cursor' => []], static fn (): int => 0);
archiveExpect($nextPeer['index'] === 1 && $nextPeer['side'] === 0, 'Only finishing both perspectives moves to the next peer');
$lastSide = ImChatSyncSession::step($nextPeer, static fn (): array => ['msgList' => [], 'completed' => true, 'cursor' => []], static fn (): int => 0);
$finished = ImChatSyncSession::step($lastSide, static fn (): array => ['msgList' => [], 'completed' => true, 'cursor' => []], static fn (): int => 0);
archiveExpect(ImChatSyncSession::progress($finished)['completed'] && count($finished['errors']) === 1, 'Other peers can finish while the prior failure remains visible');
archiveFails(static fn () => ImChatSyncSession::step($state,
static fn (): array => ['msgList' => [], 'completed' => true, 'cursor' => []],
static function (): int { throw new RuntimeException('archive failed'); }), 'archive failed');
archiveExpect($state['index'] === 0 && $state['cursor'] === [], 'Archive failure does not mutate the caller state');
// Check candidates in bounded batches before querying any cloud history.
Store::reset();
Store::$tables['admin_role'] = array_map(static fn (int $id): array => ['admin_id' => $id, 'role_id' => 1], range(1, 184));
TencentImService::$missingAccounts = array_values(array_filter(array_map(static fn (int $id): string => 'doctor_' . $id, range(1, 184)), static fn (string $account): bool => $account !== 'doctor_7'));
$checking = DiagnosisLogic::syncImChatArchiveStep(101, 7);
archiveExpect($checking['phase'] === 'checking_accounts' && !$checking['completed'] && $checking['checked_accounts'] === 100
&& $checking['candidate_accounts'] === 185 && TencentImService::$requests === [], 'First request only validates one batch of 100 accounts');
$beforeCheckFailure = Store::$cache['im_chat_sync:' . $checking['sync_token']];
TencentImService::$checkFailure = new RuntimeException('account service permission denied', 70001);
archiveFails(static fn () => DiagnosisLogic::syncImChatArchiveStep(101, 7, $checking['sync_token']), 'permission denied');
archiveExpect(Store::$cache['im_chat_sync:' . $checking['sync_token']] === $beforeCheckFailure && TencentImService::$requests === [], 'Account check failure does not discard unknown accounts or advance progress');
TencentImService::$checkFailure = null;
$checked = DiagnosisLogic::syncImChatArchiveStep(101, 7, $checking['sync_token']);
archiveExpect($checked['phase'] === 'syncing' && $checked['total_peers'] === 1 && $checked['skipped_accounts'] === 183
&& $checked['errors'] === [] && TencentImService::$requests === [], 'Only imported doctor accounts become history peers; missing accounts are an informational count');
archiveExpect(count(TencentImService::$checkRequests[0]) === 100 && count(TencentImService::$checkRequests[2]) === 85, 'Continuation reuses the uncompleted second batch');
TencentImService::$responses = [archivePage(true, 900, 'valid', [archiveRaw('valid', 900)]), archivePage(true, null, null, [])];
DiagnosisLogic::syncImChatArchiveStep(101, 7, $checking['sync_token']);
$validFinished = DiagnosisLogic::syncImChatArchiveStep(101, 7, $checking['sync_token']);
archiveExpect($validFinished['completed'] && $validFinished['errors'] === [] && $validFinished['inserted'] === 1, 'Valid messages continue syncing despite 183 unregistered staff accounts');
archiveExpect(array_column(TencentImService::$requests, 'operatorAccount') === ['doctor_7', 'patient_501']
&& array_column(TencentImService::$requests, 'peerAccount') === ['patient_501', 'doctor_7'], 'Missing accounts are never sent to admin_getroammsg');
Store::reset();
Store::$tables['messages'][] = archiveStored('keep-archive', 501, 101, 'doctor_7', 'patient_501', 100);
TencentImService::$missingAccounts = ['doctor_7'];
$emptyPeers = DiagnosisLogic::syncImChatArchiveStep(101, 7);
archiveExpect($emptyPeers['completed'] && $emptyPeers['total_peers'] === 0 && $emptyPeers['skipped_accounts'] === 1 && $emptyPeers['errors'] === [], 'All staff missing completes without flooding errors or fabricating a failed conversation');
archiveExpect(count(DiagnosisLogic::getImChatMessagesForDiagnosis(101, true)['lists']) === 1 && TencentImService::$requests === [], 'Missing/deleted cloud accounts do not remove existing archives');
TencentImService::$missingAccounts = ['patient_501'];
archiveFails(static fn () => DiagnosisLogic::syncImChatArchiveStep(101, 7), '未找到患者聊天账号');
archiveExpect(TencentImService::$requests === [] && count(Store::$tables['messages']) === 1, 'Missing patient yields one actionable configuration error and keeps archived records');
// In-flight tokens from the previous release also go through validation, instead of repeating stale invalid-account errors.
Store::reset();
$oldToken = str_repeat('b', 48);
Store::$cache['im_chat_sync:' . $oldToken] = array_merge(ImChatSyncSession::start(['doctor_7', 'doctor_8']), [
'diagnosis_id' => 101, 'patient_id' => 501, 'admin_id' => 7, 'index' => 1,
'inserted' => 3, 'errors' => ['old invalid Operator_Account or Peer_Account'], 'active_index' => 1,
]);
TencentImService::$missingAccounts = ['doctor_8'];
$migrated = DiagnosisLogic::syncImChatArchiveStep(101, 7, $oldToken);
archiveExpect($migrated['total_peers'] === 1 && $migrated['inserted'] === 3 && $migrated['errors'] === []
&& $migrated['skipped_accounts'] === 1 && TencentImService::$requests === [], 'Old tokens retain archived counts and restart verified peer selection without stale errors');
echo "IM_CHAT_ARCHIVE_TEST_OK\n";
}
+161
View File
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
use app\common\service\ImRoamMessagePager;
use app\common\service\TencentImService;
use think\facade\Config;
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
// 不 initialize,不读取环境数据库;只有虚构配置,HTTP 始终由替身拦截。
$testApp = new think\App();
$testApp->instance('log', new Psr\Log\NullLogger());
Config::set(['trtc' => ['sdkAppId' => 123, 'secretKey' => 'im-pager-test-key']], 'project');
class RoamHttpFixture extends TencentImService
{
public array $responses = [];
public array $requests = [];
protected function httpPost(string $url, string $data, int $timeout = 10)
{
$this->requests[] = ['data' => json_decode($data, true), 'timeout' => $timeout];
if ($this->responses === []) {
throw new RuntimeException('Unexpected extra HTTP request');
}
$response = array_shift($this->responses);
if ($response instanceof Throwable) {
throw $response;
}
return is_array($response) ? json_encode($response, JSON_THROW_ON_ERROR) : $response;
}
}
function roamExpect(bool $ok, string $message): void
{
if (!$ok) {
throw new RuntimeException($message);
}
}
function roamFails(callable $action, string $message, int $code = 0): void
{
try {
$action();
} catch (RuntimeException $exception) {
roamExpect(str_contains($exception->getMessage(), $message), 'Expected error: ' . $message . '; got: ' . $exception->getMessage());
roamExpect($exception->getCode() === $code, 'Cloud error code must survive pager failure');
return;
}
throw new RuntimeException('Expected page failure: ' . $message);
}
$message = static fn (string $key, int $time, bool $reverse = false): array => [
'From_Account' => $reverse ? 'patient_2' : 'doctor_1',
'To_Account' => $reverse ? 'doctor_1' : 'patient_2',
'MsgKey' => $key,
'MsgTimeStamp' => $time,
'MsgBody' => [['MsgType' => 'TIMTextElem', 'MsgContent' => ['Text' => $key]]],
];
$page = static fn (int $complete, ?int $time, ?string $key, array $messages): array => [
'ActionStatus' => 'OK', 'ErrorCode' => 0, 'Complete' => $complete,
'LastMsgTime' => $time, 'LastMsgKey' => $key, 'MsgCnt' => count($messages), 'MsgList' => $messages,
];
$service = new RoamHttpFixture();
$service->responses = [
$page(0, 200, 'a', [$message('a', 200)]),
$page(0, 200, 'b', [$message('b', 200, true)]),
$page(1, 199, 'c', [$message('c', 199)]),
];
$first = ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2');
roamExpect(!$first['completed'] && count($service->requests) === 1, 'One step pulls exactly one page');
$second = ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $first['cursor']);
roamExpect(!$second['completed'] && $second['cursor']['seen_keys'] === ['a', 'b'], 'Different keys allow multiple pages in the same second');
$third = ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $second['cursor']);
roamExpect($third['completed'] && $third['cursor']['seen_keys'] === ['c'], 'History of keys resets only when time moves into an earlier second');
roamExpect(array_column(array_merge($first['msgList'], $second['msgList'], $third['msgList']), 'MsgKey') === ['a', 'b', 'c'], 'All pages preserve raw messages in both directions');
foreach ($service->requests as $request) {
roamExpect($request['timeout'] === 15, 'Each page uses a bounded 15 second timeout');
roamExpect($request['data']['MinTime'] === 0, 'Initial full scan does not use the latest archived message as its lower bound');
roamExpect(!array_key_exists('LastMsgTime', $request['data']), 'LastMsgTime is never a request field');
}
roamExpect($service->requests[0]['data']['MaxTime'] === 4294967295 && !isset($service->requests[0]['data']['LastMsgKey']), 'Initial request has the full range and no last key');
roamExpect($service->requests[1]['data']['MaxTime'] === 200 && $service->requests[1]['data']['LastMsgKey'] === 'a', 'Second request uses response LastMsgTime as MaxTime');
roamExpect($service->requests[2]['data']['MaxTime'] === 200 && $service->requests[2]['data']['LastMsgKey'] === 'b', 'Same-second continuation advances by key');
$service->responses = [$page(1, null, null, [])];
$legacy = $service->adminGetRoamMsg('doctor_1', 'patient_2', 100, 0, 4294967295, 'old-key', 123);
$legacyRequest = $service->requests[count($service->requests) - 1]['data'];
roamExpect($legacy['success'] && $legacyRequest['MaxTime'] === 123 && !isset($legacyRequest['LastMsgTime']), 'Legacy seven argument calls also use the correct continuation field');
foreach ([
['ActionStatus' => 'FAIL', 'ErrorCode' => 91000, 'ErrorInfo' => 'Cloud retry later'],
['ActionStatus' => 'OK', 'ErrorCode' => 90009, 'ErrorInfo' => 'Cloud permission denied'],
] as $error) {
$service->responses = [$error];
$before = count($service->requests);
roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2'), $error['ErrorInfo'], $error['ErrorCode']);
roamExpect(count($service->requests) === $before + 1, 'Cloud errors are reported without an inline retry');
}
$service->responses = [['ActionStatus' => 'FAIL', 'ErrorCode' => 91000, 'ErrorInfo' => 'Cloud retry later']];
$failedPage = $service->adminGetRoamMsg('doctor_1', 'patient_2');
roamExpect(!$failedPage['success'] && $failedPage['complete'] === 0
&& $failedPage['error'] === 'Cloud retry later' && $failedPage['rawErrorCode'] === 91000,
'Legacy service errors retain the error and code without marking the conversation complete');
foreach ([
['not-json', 'IM响应解析失败'],
[false, 'IM接口无响应'],
[new RuntimeException('transport timeout'), 'transport timeout'],
[['ActionStatus' => 'OK', 'ErrorCode' => 0, 'MsgList' => []], 'Complete'],
[array_replace($page(1, null, null, []), ['MsgList' => 'invalid']), 'MsgList'],
[array_replace($page(1, null, null, []), ['MsgCnt' => 1]), 'MsgCnt'],
[array_replace($page(1, null, null, []), ['LastMsgTime' => '200']), 'LastMsgTime'],
[array_replace($page(1, null, null, []), ['LastMsgKey' => ['bad']]), 'LastMsgKey'],
[$page(0, null, null, []), 'LastMsgTime/LastMsgKey'],
[$page(0, 200, null, []), 'LastMsgTime/LastMsgKey'],
[$page(0, null, 'a', []), 'LastMsgTime/LastMsgKey'],
] as [$invalid, $error]) {
$service->responses = [$invalid];
roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2'), $error);
}
foreach ([0, 1] as $complete) {
$service->responses = [$page($complete, 200, 'a', [$message('a', 200)])];
roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $first['cursor']), '游标重复');
$service->responses = [$page($complete, 200, 'a', [$message('a', 200)])];
roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $second['cursor']), '游标重复');
}
$service->responses = [$page(0, 201, 'future', [])];
roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $first['cursor']), '时间超出');
$otherPatientMessage = $message('other-patient', 200);
$otherPatientMessage['To_Account'] = 'patient_999';
$service->responses = [$page(1, 200, 'other-patient', [$otherPatientMessage])];
roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2'), '不属于当前会话');
foreach ([
'invalid-message',
array_replace($message('bad', 200), ['MsgKey' => '']),
array_replace($message('bad', 200), ['MsgTimeStamp' => '200']),
array_replace($message('bad', 200), ['MsgBody' => null]),
] as $invalidMessage) {
$service->responses = [$page(1, 200, 'bad', [$invalidMessage])];
roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2'), '非法');
}
$before = count($service->requests);
foreach ([['max_time' => '200'], ['max_time' => -1], ['min_time' => 201, 'max_time' => 200], ['last_key' => []], ['seen_keys' => [null]]] as $invalidCursor) {
roamFails(static fn () => ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2', $invalidCursor), '游标无效');
}
roamFails(static fn () => ImRoamMessagePager::nextPage($service, '', 'patient_2'), '账号无效');
roamExpect(count($service->requests) === $before, 'Invalid requests are rejected before HTTP');
$service->responses = [$page(1, null, null, [])];
$empty = ImRoamMessagePager::nextPage($service, 'doctor_1', 'patient_2');
roamExpect($empty['completed'] && $empty['msgList'] === [], 'Explicit successful empty response can complete a conversation');
echo "IM_ROAM_MESSAGE_PAGER_TEST_OK\n";
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
use app\common\service\TencentImService;
use think\facade\Config;
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
// Do not initialize the app or load environment/DB settings; HTTP is always replaced.
$testApp = new think\App();
$testApp->instance('log', new Psr\Log\NullLogger());
Config::set(['trtc' => ['sdkAppId' => 123, 'secretKey' => 'account-check-test-key']], 'project');
final class AccountCheckHttpFixture extends TencentImService
{
public array $responses = [];
public array $requests = [];
protected function httpPost(string $url, string $data, int $timeout = 10)
{
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
$this->requests[] = [
'path' => parse_url($url, PHP_URL_PATH), 'query' => $query,
'data' => json_decode($data, true, 512, JSON_THROW_ON_ERROR), 'timeout' => $timeout,
];
if ($this->responses === []) throw new RuntimeException('Unexpected additional HTTP request');
$response = array_shift($this->responses);
if ($response instanceof Throwable) throw $response;
return is_array($response) ? json_encode($response, JSON_THROW_ON_ERROR) : $response;
}
public function importAccount(string $userId, string $nick = '', string $faceUrl = '')
{
throw new RuntimeException('Read-only account checks must not import accounts');
}
public function batchImportAccounts(array $accounts): array
{
throw new RuntimeException('Read-only account checks must not import accounts');
}
}
function accountCheckExpect(bool $ok, string $message): void
{
if (!$ok) throw new RuntimeException($message);
}
function accountCheckFails(callable $action, string $expected, int $code = 0): void
{
try { $action(); }
catch (RuntimeException $exception) {
accountCheckExpect(str_contains($exception->getMessage(), $expected), 'Expected ' . $expected . '; got ' . $exception->getMessage());
accountCheckExpect($exception->getCode() === $code, 'Account-check errors must retain the original code');
return;
}
throw new RuntimeException('Expected account check to fail: ' . $expected);
}
$item = static fn (string $account, string $status): array => [
'UserID' => $account, 'ResultCode' => 0, 'ResultInfo' => '', 'AccountStatus' => $status,
];
$success = static fn (array $items): array => [
'ActionStatus' => 'OK', 'ErrorCode' => 0, 'ErrorInfo' => '', 'ResultItem' => $items,
];
$service = new AccountCheckHttpFixture();
accountCheckExpect($service->checkAccounts([]) === ['existing' => [], 'missing' => []] && $service->requests === [], 'Empty input makes no network request');
$service->responses = [$success([
$item('doctor_3', 'Imported'), $item('patient_501', 'NotImported'), $item('doctor_1', 'Imported'),
])];
$result = $service->checkAccounts(['doctor_1', 'patient_501', 'doctor_3', 'doctor_1']);
accountCheckExpect($result === ['existing' => ['doctor_1', 'doctor_3'], 'missing' => ['patient_501']], 'Only explicit NotImported is missing; results preserve requested order and deduplicate input');
accountCheckExpect(count($service->requests) === 1 && $service->requests[0]['timeout'] === 15, 'A check performs exactly one bounded request');
accountCheckExpect($service->requests[0]['data'] === ['CheckItem' => [
['UserID' => 'doctor_1'], ['UserID' => 'patient_501'], ['UserID' => 'doctor_3'],
]], 'Use the official CheckItem/UserID request shape');
accountCheckExpect($service->requests[0]['query']['sdkappid'] === '123'
&& $service->requests[0]['query']['identifier'] === 'administrator'
&& $service->requests[0]['query']['contenttype'] === 'json'
&& $service->requests[0]['query']['usersig'] !== '', 'Use configured IM app and administrator signature');
$hundred = array_map(static fn (int $id): string => 'doctor_' . $id, range(1, 100));
$service->responses = [$success(array_map(static fn (string $account): array => $item($account, 'Imported'), $hundred))];
$before = count($service->requests);
accountCheckExpect($service->checkAccounts($hundred) === ['existing' => $hundred, 'missing' => []]
&& count($service->requests) === $before + 1 && count($service->requests[$before]['data']['CheckItem']) === 100, 'One hundred accounts fit one official batch');
accountCheckFails(static fn () => $service->checkAccounts(array_merge($hundred, ['doctor_101'])), '最多支持100');
accountCheckExpect(count($service->requests) === $before + 1, 'Oversized batch is rejected rather than silently making several requests');
foreach ([[''], [' '], [null], [1], [false], [[]]] as $invalidAccounts) {
$before = count($service->requests);
accountCheckFails(static fn () => $service->checkAccounts($invalidAccounts), '非空账号字符串');
accountCheckExpect(count($service->requests) === $before, 'Invalid account input is rejected before HTTP');
}
foreach ([
['ActionStatus' => 'FAIL', 'ErrorCode' => 70403, 'ErrorInfo' => 'Administrator permission required'],
['ActionStatus' => 'OK', 'ErrorCode' => 70500, 'ErrorInfo' => 'Server internal error'],
] as $failure) {
$service->responses = [$failure];
$before = count($service->requests);
accountCheckFails(static fn () => $service->checkAccounts(['doctor_1']), $failure['ErrorInfo'], $failure['ErrorCode']);
accountCheckExpect(count($service->requests) === $before + 1, 'API failure is visible without inline retries');
}
$service->responses = [$success([
$item('doctor_1', 'Imported'),
['UserID' => 'doctor_2', 'ResultCode' => 70169, 'ResultInfo' => 'Per-account timeout', 'AccountStatus' => 'NotImported'],
])];
accountCheckFails(static fn () => $service->checkAccounts(['doctor_1', 'doctor_2']), 'Per-account timeout', 70169);
$service->responses = [$success([
['UserID' => 'doctor_1', 'ResultCode' => 70202, 'AccountStatus' => 'NotImported'],
])];
accountCheckFails(static fn () => $service->checkAccounts(['doctor_1']), 'ResultCode 70202', 70202);
foreach ([
[false, '无响应', 0],
['', '无响应', 0],
[new RuntimeException('transport timed out', 28), 'transport timed out', 28],
[new LogicException('transport refused', 7), 'transport refused', 7],
['not-json', '响应格式非法', 0],
['null', '响应格式非法', 0],
[['ActionStatus' => 'OK', 'ResultItem' => []], 'ErrorCode', 0],
[['ActionStatus' => 'OK', 'ErrorCode' => '0', 'ResultItem' => []], 'ErrorCode', 0],
[['ErrorCode' => 0, 'ResultItem' => []], '查询失败', 0],
[['ActionStatus' => 'OK', 'ErrorCode' => 0], 'ResultItem', 0],
[$success(['named' => $item('doctor_1', 'Imported')]), 'ResultItem', 0],
[$success([]), '不完整', 0],
[$success([null]), 'UserID', 0],
[$success([['ResultCode' => 0, 'AccountStatus' => 'Imported']]), 'UserID', 0],
[$success([$item('unrequested', 'Imported')]), '未请求或重复', 0],
[$success([$item('doctor_1', 'Imported'), $item('doctor_1', 'NotImported')]), '未请求或重复', 0],
[$success([['UserID' => 'doctor_1', 'CheckResult' => 0, 'AccountStatus' => 'Imported']]), 'ResultCode', 0],
[$success([['UserID' => 'doctor_1', 'ResultCode' => '0', 'AccountStatus' => 'Imported']]), 'ResultCode', 0],
[$success([['UserID' => 'doctor_1', 'ResultCode' => 0]]), 'AccountStatus', 0],
[$success([$item('doctor_1', 'Unknown')]), 'AccountStatus', 0],
] as [$failure, $error, $code]) {
$service->responses = [$failure];
accountCheckFails(static fn () => $service->checkAccounts(['doctor_1']), $error, $code);
}
$service->responses = [$success([$item('doctor_1', 'Imported')])];
accountCheckFails(static fn () => $service->checkAccounts(['doctor_1', 'doctor_2']), '不完整');
foreach ($service->requests as $request) {
accountCheckExpect($request['path'] === '/v4/im_open_login_svc/account_check', 'All requests use the read-only account_check endpoint, never account_import');
accountCheckExpect($request['timeout'] === 15, 'Every account check has a 15 second timeout');
}
echo "TENCENT_IM_ACCOUNT_CHECK_TEST_OK\n";