[ ['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"; }