pullChatData(seq) * 3) 遍历解密后的消息,按 msgtype 归一化字段后 UPSERT qywx_msg_archive * 4) 同步刷新 qywx_msg_session 列表摘要 * 5) 媒体消息登记 qywx_msg_archive_media(待下载) * 6) 更新 cursor * * 媒体下载由 downloadPendingMedia() 独立跑,避免拉取慢。 */ class QywxMsgArchiveService { private const MEDIA_TYPES = ['image', 'voice', 'video', 'file', 'emotion']; /** * 拉一次,直到本批为空或达到 maxBatches * * @return array{ * enabled: bool, * batches: int, * pulled: int, * inserted: int, * updated: int, * media_pending: int, * last_seq: int, * errors: string[] * } */ public static function pullLoop(int $maxBatches = 20, string $cursorKey = QywxMsgArchiveCursor::DEFAULT_KEY): array { $result = [ 'enabled' => true, 'batches' => 0, 'pulled' => 0, 'inserted' => 0, 'updated' => 0, 'media_pending' => 0, 'last_seq' => 0, 'errors' => [], ]; $sdk = new WeComFinanceSdkClient(); if (!$sdk->isAvailable()) { $result['enabled'] = false; $result['errors'][] = $sdk->getLastError(); return $result; } $seq = self::getCursorSeq($cursorKey); $result['last_seq'] = $seq; for ($i = 0; $i < $maxBatches; $i++) { $batch = $sdk->pullChatData($seq); $result['batches']++; if (!$batch['ok']) { $result['errors'][] = $batch['error']; self::touchCursor($cursorKey, $seq, 0, $batch['error']); break; } $messages = $batch['messages']; $count = count($messages); if ($count === 0) { self::touchCursor($cursorKey, $seq, 0, ''); break; } [$inserted, $updated, $mediaPending] = self::persistBatch($messages); $result['pulled'] += $count; $result['inserted'] += $inserted; $result['updated'] += $updated; $result['media_pending'] += $mediaPending; $seq = (int) $batch['next_seq']; $result['last_seq'] = $seq; self::touchCursor($cursorKey, $seq, $count, ''); if ($count < $sdk->getLimit()) { // 本批不足 limit,说明暂无更多,结束本轮 break; } } return $result; } /** * 下载 pending 的媒体文件 * * @return array{enabled: bool, ok: int, failed: int, skipped: int, errors: string[]} */ public static function downloadPendingMedia(int $max = 200): array { $result = ['enabled' => true, 'ok' => 0, 'failed' => 0, 'skipped' => 0, 'errors' => []]; $sdk = new WeComFinanceSdkClient(); if (!$sdk->isAvailable()) { $result['enabled'] = false; $result['errors'][] = $sdk->getLastError(); return $result; } $rows = QywxMsgArchiveMedia::where('status', QywxMsgArchiveMedia::STATUS_PENDING) ->where('retry_count', '<', 5) ->order('id', 'asc') ->limit($max) ->select() ->toArray(); foreach ($rows as $row) { $sdkFileid = (string) $row['sdkfileid']; if ($sdkFileid === '') { $result['skipped']++; continue; } $relPath = self::buildMediaRelPath((string) $row['msgid'], $sdkFileid); $absPath = self::absoluteMediaPath($relPath); $ret = $sdk->downloadMedia($sdkFileid, $absPath); $now = time(); if ($ret['ok']) { QywxMsgArchiveMedia::where('id', $row['id'])->update([ 'status' => QywxMsgArchiveMedia::STATUS_SUCCESS, 'file_path' => $relPath, 'file_size' => $ret['size'], 'error' => '', 'update_time' => $now, ]); $result['ok']++; } else { QywxMsgArchiveMedia::where('id', $row['id'])->update([ 'retry_count' => (int) $row['retry_count'] + 1, 'status' => ((int) $row['retry_count'] + 1) >= 5 ? QywxMsgArchiveMedia::STATUS_FAILED : QywxMsgArchiveMedia::STATUS_PENDING, 'error' => mb_substr($ret['error'], 0, 500), 'update_time' => $now, ]); $result['failed']++; $result['errors'][] = sprintf('msgid=%s err=%s', (string) $row['msgid'], $ret['error']); } } return $result; } /** * @param array> $messages * * @return array{0:int,1:int,2:int} [inserted, updated, mediaPending] */ private static function persistBatch(array $messages): array { $inserted = 0; $updated = 0; $mediaPending = 0; $now = time(); foreach ($messages as $msg) { $norm = self::normalize($msg); if ($norm === null) { continue; } try { $isUpdate = false; Db::transaction(function () use ($norm, $now, &$inserted, &$updated, &$mediaPending, &$isUpdate) { $exists = QywxMsgArchive::where('msgid', $norm['msgid'])->findOrEmpty(); if ($exists->isEmpty()) { QywxMsgArchive::create(array_merge($norm, [ 'create_time' => $now, 'update_time' => $now, ])); $inserted++; } else { // recall/switch 会重发相同 msgid,更新 action / content QywxMsgArchive::where('msgid', $norm['msgid'])->update(array_merge($norm, [ 'update_time' => $now, ])); $updated++; $isUpdate = true; } if ($norm['media_id'] !== '' && in_array($norm['msgtype'], self::MEDIA_TYPES, true)) { $existsMedia = QywxMsgArchiveMedia::where('msgid', $norm['msgid']) ->where('sdkfileid', $norm['media_id']) ->findOrEmpty(); if ($existsMedia->isEmpty()) { QywxMsgArchiveMedia::create([ 'msgid' => $norm['msgid'], 'sdkfileid' => $norm['media_id'], 'status' => QywxMsgArchiveMedia::STATUS_PENDING, 'md5sum' => $norm['md5sum'], 'create_time' => $now, 'update_time' => $now, ]); $mediaPending++; } } self::upsertSession($norm, $now); }); } catch (\Throwable $e) { Log::error(sprintf( '会话存档-消息落库异常 msgid=%s err=%s', (string) ($norm['msgid'] ?? '-'), $e->getMessage() )); } } return [$inserted, $updated, $mediaPending]; } /** * 把解密后的原始消息归一化为数据库字段 * * @param array $msg * * @return array|null */ public static function normalize(array $msg): ?array { $msgid = (string) ($msg['msgid'] ?? $msg['__msgid_sdk'] ?? ''); if ($msgid === '') { return null; } $action = (string) ($msg['action'] ?? 'send'); // send / recall / switch $msgtype = (string) ($msg['msgtype'] ?? ''); $from = (string) ($msg['from'] ?? ''); $roomid = (string) ($msg['roomid'] ?? ''); $tolist = isset($msg['tolist']) && is_array($msg['tolist']) ? $msg['tolist'] : []; $sendTime = isset($msg['msgtime']) ? (int) (((int) $msg['msgtime']) / 1000) : 0; if ($sendTime === 0 && isset($msg['msgtime'])) { // 兼容已经是秒的情况 $sendTime = (int) $msg['msgtime']; } $content = ''; $mediaId = ''; $md5sum = ''; $fileName = ''; $fileSize = 0; $fileExt = ''; $playLength = 0; switch ($msgtype) { case 'text': $content = (string) ($msg['text']['content'] ?? ''); break; case 'markdown': $content = (string) ($msg['markdown']['content'] ?? ''); break; case 'image': $mediaId = (string) ($msg['image']['sdkfileid'] ?? ''); $md5sum = (string) ($msg['image']['md5sum'] ?? ''); $fileSize = (int) ($msg['image']['filesize'] ?? 0); $content = '[图片]'; break; case 'voice': $mediaId = (string) ($msg['voice']['sdkfileid'] ?? ''); $md5sum = (string) ($msg['voice']['md5sum'] ?? ''); $fileSize = (int) ($msg['voice']['voice_size'] ?? 0); $playLength = (int) ($msg['voice']['play_length'] ?? 0); $content = sprintf('[语音 %ds]', $playLength); break; case 'video': $mediaId = (string) ($msg['video']['sdkfileid'] ?? ''); $md5sum = (string) ($msg['video']['md5sum'] ?? ''); $fileSize = (int) ($msg['video']['filesize'] ?? 0); $playLength = (int) ($msg['video']['play_length'] ?? 0); $content = '[视频]'; break; case 'emotion': $mediaId = (string) ($msg['emotion']['sdkfileid'] ?? ''); $md5sum = (string) ($msg['emotion']['md5sum'] ?? ''); $fileSize = (int) ($msg['emotion']['imagesize'] ?? 0); $content = '[表情]'; break; case 'file': $mediaId = (string) ($msg['file']['sdkfileid'] ?? ''); $md5sum = (string) ($msg['file']['md5sum'] ?? ''); $fileName = (string) ($msg['file']['filename'] ?? ''); $fileExt = (string) ($msg['file']['fileext'] ?? ''); $fileSize = (int) ($msg['file']['filesize'] ?? 0); $content = '[文件] ' . $fileName; break; case 'link': $content = (string) ($msg['link']['title'] ?? '[链接]'); break; case 'weapp': $content = '[小程序] ' . (string) ($msg['weapp']['title'] ?? ''); break; case 'chatrecord': $content = '[聊天记录]'; break; case 'location': $content = '[位置] ' . (string) ($msg['location']['address'] ?? ''); break; case 'card': $content = '[名片]'; break; case 'revoke': $action = 'recall'; $content = '[撤回] ' . (string) ($msg['revoke']['pre_msgid'] ?? ''); break; case 'mixed': $content = self::summarizeMixed($msg['mixed'] ?? null); break; case 'meeting': $content = '[会议] ' . (string) ($msg['meeting']['topic'] ?? ''); break; case 'docmsg': $content = '[文档] ' . (string) ($msg['docmsg']['title'] ?? ''); break; default: $content = '[' . $msgtype . ']'; } return [ 'msgid' => $msgid, 'seq' => (int) ($msg['__seq'] ?? 0), 'action' => $action, 'from_user' => $from, 'from_type' => 0, // 外层 session upsert 时再判定 'to_list' => $tolist, 'roomid' => $roomid, 'msgtype' => $msgtype, 'content' => mb_substr($content, 0, 16000), 'media_id' => $mediaId, 'md5sum' => $md5sum, 'file_name' => mb_substr($fileName, 0, 500), 'file_size' => $fileSize, 'file_ext' => $fileExt, 'play_length' => $playLength, 'raw' => json_encode($msg, JSON_UNESCAPED_UNICODE), 'send_time' => $sendTime, ]; } /** * @param mixed $mixed */ private static function summarizeMixed($mixed): string { if (!is_array($mixed) || !isset($mixed['item']) || !is_array($mixed['item'])) { return '[混合消息]'; } $parts = []; foreach ($mixed['item'] as $el) { if (!is_array($el)) { continue; } $t = (string) ($el['type'] ?? ''); if ($t === 'text') { $parts[] = (string) ($el['content'] ?? ''); } else { $parts[] = '[' . $t . ']'; } if (count($parts) >= 3) { break; } } return implode(' ', $parts); } private static function upsertSession(array $norm, int $now): void { // 外部联系人 id 的企微前缀通常是 "wm" 或 "wo";员工 userid 无此前缀。 // 单聊时 tolist 有 1 个对端;群聊走 roomid。 $tolist = $norm['to_list'] ?? []; if ($norm['roomid'] !== '') { $sessionType = 2; $staff = self::isStaffUserid($norm['from_user']) ? $norm['from_user'] : ''; if ($staff === '') { foreach ($tolist as $to) { if (self::isStaffUserid($to)) { $staff = (string) $to; break; } } } $external = ''; } else { $sessionType = 1; if (self::isStaffUserid($norm['from_user'])) { $staff = $norm['from_user']; $external = (string) ($tolist[0] ?? ''); } else { $external = $norm['from_user']; $staff = (string) ($tolist[0] ?? ''); } } if ($staff === '' && $norm['roomid'] === '') { // 兜底:两端都辨不出,跳过 session 更新 return; } $where = [ 'staff_userid' => $staff, 'external_userid' => $external, 'roomid' => $norm['roomid'], ]; $summary = mb_substr((string) $norm['content'], 0, 500); $data = [ 'session_type' => $sessionType, 'last_msg_id' => $norm['msgid'], 'last_msg_seq' => $norm['seq'], 'last_msg_time' => $norm['send_time'], 'last_msg_type' => $norm['msgtype'], 'last_msg_summary' => $summary, 'update_time' => $now, ]; $existing = QywxMsgSession::where($where)->findOrEmpty(); if ($existing->isEmpty()) { QywxMsgSession::create(array_merge($where, $data, [ 'unread_staff' => self::isStaffUserid($norm['from_user']) ? 0 : 1, 'create_time' => $now, ])); } else { // 仅当本条更新,才覆盖最近消息字段 if ((int) $existing['last_msg_time'] <= (int) $norm['send_time']) { if (!self::isStaffUserid($norm['from_user'])) { $data['unread_staff'] = (int) $existing['unread_staff'] + 1; } QywxMsgSession::where('id', $existing['id'])->update($data); } } } /** * 粗判 userid 是员工还是外部联系人:企微外部联系人 id 以 "wm"/"wo" 开头且长度较长; * 员工 userid 是管理员配置的,通常是字母/数字无这两个前缀。 */ private static function isStaffUserid(string $id): bool { if ($id === '') { return false; } $lower = strtolower($id); if (strpos($lower, 'wm') === 0 || strpos($lower, 'wo') === 0) { // 仍需长度判断:员工 userid 以 wm 开头的极少见;外部联系人通常 20+ 字符 return strlen($id) < 20; } return true; } private static function buildMediaRelPath(string $msgid, string $sdkFileid): string { $hash = substr(md5($msgid . ':' . $sdkFileid), 0, 16); return 'qywx_archive/' . date('Ymd') . '/' . $hash; } private static function absoluteMediaPath(string $relPath): string { $base = rtrim((string) config('filesystem.disks.public.root', public_path() . 'storage'), DIRECTORY_SEPARATOR); return $base . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relPath); } private static function getCursorSeq(string $cursorKey): int { $row = QywxMsgArchiveCursor::where('cursor_key', $cursorKey)->findOrEmpty(); return $row->isEmpty() ? 0 : (int) $row['seq']; } private static function touchCursor(string $cursorKey, int $seq, int $pullCount, string $error): void { $now = time(); $row = QywxMsgArchiveCursor::where('cursor_key', $cursorKey)->findOrEmpty(); if ($row->isEmpty()) { QywxMsgArchiveCursor::create([ 'cursor_key' => $cursorKey, 'seq' => $seq, 'last_pull_time' => $now, 'last_pull_count' => $pullCount, 'last_error' => mb_substr($error, 0, 500), 'create_time' => $now, 'update_time' => $now, ]); } else { QywxMsgArchiveCursor::where('id', $row['id'])->update([ 'seq' => $seq, 'last_pull_time' => $now, 'last_pull_count' => $pullCount, 'last_error' => mb_substr($error, 0, 500), 'update_time' => $now, ]); } } }