Files
transmit/app/alibaba/service/InquiryScraperService.php
2025-12-22 05:57:11 +00:00

423 lines
17 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\alibaba\service;
use support\Db;
use support\Request;
use support\Response;
use app\alibaba\model\InquiryTask;
use app\alibaba\model\InquiryListPage;
use app\alibaba\model\InquiryListItem;
use app\alibaba\model\InquiryDetailSession;
use app\alibaba\model\InquiryDetailMessage;
use app\alibaba\model\InquiryDetailMedia;
class InquiryScraperService
{
/** 列表上报:写 task + list_page + list_item(幂等) */
public function handleListReport(array $p): array
{
$now = time();
$taskId = (string)($p['taskId'] ?? '');
if ($taskId === '') return ['ok' => false, 'err' => 'taskId required'];
$page = (int)($p['page'] ?? 1);
$serverUrl = (string)($p['serverUrl'] ?? '');
// 兼容:payload.data.list 或 payload.list
$list = [];
if (isset($p['data']['list']) && is_array($p['data']['list'])) $list = $p['data']['list'];
if (!$list && isset($p['list']) && is_array($p['list'])) $list = $p['list'];
$totalOnPage = (int)($p['totalOnPage'] ?? count($list));
return Db::transaction(function () use ($taskId, $page, $serverUrl, $list, $totalOnPage, $p, $now) {
// 1) task
$task = InquiryTask::query()->firstOrCreate(
['task_id' => $taskId],
[
'mode' => 'list',
'start_page' => $page,
'max_pages' => 0,
'status' => 0,
'server_url' => $serverUrl,
'meta_json' => null,
'created_at' => $now,
'updated_at' => $now,
]
);
$task->mode = 'list';
if ($serverUrl) $task->server_url = $serverUrl;
$task->updated_at = $now;
$task->save();
// 2) list_page(唯一:task_id+page)
$pageRow = InquiryListPage::query()->updateOrCreate(
['task_id' => $taskId, 'page' => $page],
[
'total_on_page' => $totalOnPage,
'is_done' => 1,
'raw_json' => $this->trimListPageRaw($p),
'created_at' => $now,
'updated_at' => $now,
]
);
// 3) list_item(唯一:page_id+idx)
$n = 0;
foreach ($list as $i => $row) {
if (!is_array($row)) continue;
$idx = (int)($row['idx'] ?? $i);
$detailUrl = (string)($row['detailUrl'] ?? $row['url'] ?? '');
// $inquiryId = (string)($row['inquiryId'] ?? $row['imInquiryId'] ?? '');
// ✅ 只信 imInquiryId,避免把 encrypt/hash 长串塞进 inquiry_id
$inquiryId = (string)($row['imInquiryId'] ?? '');
if (strlen($inquiryId) > 255) $inquiryId = ''; // 兜底
InquiryListItem::query()->updateOrCreate(
['page_id' => $pageRow->id, 'idx' => $idx],
[
'task_id' => $taskId,
'page' => $page,
'inquiry_id' => $inquiryId,
'detail_url' => $detailUrl,
'buyer_name' => (string)($row['buyerName'] ?? ''),
'country' => (string)($row['country'] ?? ''),
'subject' => (string)($row['subject'] ?? ''),
'snippet' => (string)($row['snippet'] ?? ''),
'raw_json' => $row,
'created_at' => $now,
'updated_at' => $now,
]
);
$n++;
}
return ['ok' => true, 'page' => $page, 'items' => $n, 'listPageId' => (int)$pageRow->id];
});
}
/** 详情上报:写 task + detail_session + detail_message(分块幂等)+(可选)media 元信息绑定 */
public function handleDetailReport(array $p): array
{
$now = time();
$taskId = (string)($p['taskId'] ?? '');
if ($taskId === '') return ['ok' => false, 'err' => 'taskId required'];
$serverUrl = (string)($p['serverUrl'] ?? '');
$detail = $p['detail'] ?? null;
if (!is_array($detail)) return ['ok' => false, 'err' => 'detail required'];
$detailUrl = (string)($detail['url'] ?? '');
if ($detailUrl === '') return ['ok' => false, 'err' => 'detail.url required'];
// $inquiryId = (string)($detail['inquiryId'] ?? $this->extractImInquiryId($detailUrl));
// ✅ 只从 detailUrl 取 imInquiryId,取不到就空(不要 fallback 到其它字段)
$inquiryId = (string)($this->extractImInquiryId($detailUrl) ?: '');
if (strlen($inquiryId) > 255) $inquiryId = ''; // 兜底
$customer = $detail['customer'] ?? null;
$messages = $detail['messages'] ?? [];
if (!is_array($messages)) $messages = [];
// chunk info
$chunk = $detail['chunk'] ?? [];
$chunkIndex = (int)($chunk['index'] ?? $chunk['seq'] ?? 0);
$isFinal = !empty($chunk['isFinal']);
$messagesCount = (int)($detail['messagesCount'] ?? 0);
// (可选)如果插件传 listItemId 或者 listRow 的定位信息
$listItemId = (int)($detail['listItemId'] ?? $p['listItemId'] ?? 0);
return Db::transaction(function () use (
$taskId, $serverUrl, $detailUrl, $inquiryId, $customer,
$messages, $chunkIndex, $isFinal, $messagesCount, $listItemId, $detail, $now
) {
// 1) task
$task = InquiryTask::query()->firstOrCreate(
['task_id' => $taskId],
[
'mode' => 'detail',
'start_page' => 1,
'max_pages' => 0,
'status' => 0,
'server_url' => $serverUrl,
'meta_json' => null,
'created_at' => $now,
'updated_at' => $now,
]
);
$task->mode = 'detail';
if ($serverUrl) $task->server_url = $serverUrl;
$task->updated_at = $now;
$task->save();
// 2) session(唯一:task_id+detail_url)
$session = InquiryDetailSession::query()->firstOrCreate(
['task_id' => $taskId, 'detail_url' => $detailUrl],
[
'list_item_id' => $listItemId,
'inquiry_id' => $inquiryId,
'customer_json' => $customer,
'messages_count' => 0,
'status' => 0,
'raw_json' => null,
'created_at' => $now,
'updated_at' => $now,
]
);
// 更新 session 头信息
if ($listItemId && (int)$session->list_item_id === 0) $session->list_item_id = $listItemId;
if ($inquiryId) $session->inquiry_id = $inquiryId;
$session->customer_json = $customer;
$session->raw_json = $this->trimDetailRaw($detail);
$session->updated_at = $now;
$session->save();
// 3) messages upsert(唯一:session_id+msg_id)
$upserted = 0;
$mediaBound = 0;
foreach ($messages as $m) {
if (!is_array($m)) continue;
$msgId = (string)($m['msgId'] ?? '');
if ($msgId === '') continue;
$msgRow = InquiryDetailMessage::query()->updateOrCreate(
['session_id' => $session->id, 'msg_id' => $msgId],
[
'direction' => (string)($m['direction'] ?? ''),
'time_text' => (string)($m['timeText'] ?? ''),
'original_raw' => (string)($m['originalRaw'] ?? ''),
'text_shown' => (string)($m['textShown'] ?? ''),
'translated' => (string)($m['translated'] ?? ''),
'is_card' => (int)(!empty($m['isCard']) ? 1 : 0),
'hrefs_json' => $m['hrefs'] ?? [],
'read_status' => (string)($m['readStatus'] ?? ''),
'chunk_index' => $chunkIndex,
'created_at' => $now,
'updated_at' => $now,
]
);
$upserted++;
// 如果插件在 message 上携带 medias 元信息:先绑定 media 行(文件真正上传由 /media/upload)
if (!empty($m['medias']) && is_array($m['medias'])) {
foreach ($m['medias'] as $media) {
if (!is_array($media)) continue;
$sourceUrl = (string)($media['url'] ?? $media['sourceUrl'] ?? '');
if ($sourceUrl === '') continue;
InquiryDetailMedia::query()->updateOrCreate(
[
'session_id' => $session->id,
'message_id' => $msgRow->id,
'source_url' => $sourceUrl,
],
[
'media_type' => (string)($media['type'] ?? 'file'),
'file_name' => (string)($media['name'] ?? ''),
'file_ext' => (string)($media['ext'] ?? ''),
'size' => (int)($media['size'] ?? 0),
'md5' => (string)($media['md5'] ?? ''),
'storage_path' => (string)($media['storagePath'] ?? ''),
'public_url' => (string)($media['publicUrl'] ?? ''),
'created_at' => $now,
'updated_at' => $now,
]
);
$mediaBound++;
}
}
}
// 4) final:统计 messages_count + 标记 session 完成
if ($isFinal) {
$cnt = InquiryDetailMessage::query()->where('session_id', $session->id)->count();
$session->messages_count = max($messagesCount, (int)$cnt);
$session->status = 2; // 完成
$session->updated_at = $now;
$session->save();
}
return [
'ok' => true,
'sessionId' => (int)$session->id,
'chunk' => ['index' => $chunkIndex, 'isFinal' => $isFinal],
'messagesUpserted' => $upserted,
'mediasBound' => $mediaBound,
];
});
}
/** 媒体上传:multipart,保存文件并回填 inquiry_detail_media.storage_path/public_url */
public function handleUploadMedia(Request $request): array
{
$now = time();
$taskId = (string)$request->post('taskId', '');
$detailUrl = (string)$request->post('detailUrl', '');
$msgId = (string)$request->post('msgId', '');
$mediaType = (string)$request->post('mediaType', 'file');
$sourceUrl = (string)$request->post('sourceUrl', '');
$md5 = (string)$request->post('md5', '');
$size = (int)$request->post('size', 0);
$fileName = (string)$request->post('fileName', '');
if ($taskId === '' || $detailUrl === '' || $msgId === '') {
return ['ok' => false, 'err' => 'taskId/detailUrl/msgId required'];
}
$file = $request->file('file');
if (!$file) return ['ok' => false, 'err' => 'file missing'];
// 保存目录:runtime/uploads/inquiry_media/YYYYMM/
$ym = date('Ym');
$dir = base_path() . "/runtime/uploads/inquiry_media/{$ym}";
if (!is_dir($dir)) @mkdir($dir, 0777, true);
$originalName = $fileName ?: ($file->getUploadName() ?: ('media_' . $now));
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
$safeExt = $ext ? preg_replace('/[^a-z0-9]/i', '', $ext) : '';
$saveName = 't' . $taskId . '_' . md5($originalName . $now . random_int(1, 999999)) . ($safeExt ? ".{$safeExt}" : '');
$file->move($dir . '/' . $saveName);
// 注意:serveMedia 是从 runtime/uploads/ 下面读,所以这里用相对 uploads 的路径
// full: runtime/uploads/inquiry_media/YYYYMM/xxx.ext
$storageRel = "inquiry_media/{$ym}/{$saveName}";
$storagePath = "/runtime/uploads/{$storageRel}";
$publicUrl = "/media/{$storageRel}";
return Db::transaction(function () use (
$taskId, $detailUrl, $msgId, $mediaType, $sourceUrl, $md5, $size, $originalName, $safeExt,
$storagePath, $publicUrl, $now
) {
// session:确保存在
$session = InquiryDetailSession::query()->firstOrCreate(
['task_id' => $taskId, 'detail_url' => $detailUrl],
[
'list_item_id' => 0,
'inquiry_id' => $this->extractImInquiryId($detailUrl),
'customer_json' => null,
'messages_count' => 0,
'status' => 0,
'raw_json' => null,
'created_at' => $now,
'updated_at' => $now,
]
);
// message:确保存在(某些媒体消息可能先传媒体再传 message)
$msgRow = InquiryDetailMessage::query()->firstOrCreate(
['session_id' => $session->id, 'msg_id' => $msgId],
[
'direction' => '',
'time_text' => '',
'original_raw' => '',
'text_shown' => '',
'translated' => '',
'is_card' => 1,
'hrefs_json' => [],
'read_status' => '',
'chunk_index' => 0,
'created_at' => $now,
'updated_at' => $now,
]
);
// media:幂等(唯一:session_id+message_id+source_url)
$uniqSource = $sourceUrl ?: $storagePath;
$media = InquiryDetailMedia::query()->updateOrCreate(
['session_id' => $session->id, 'message_id' => $msgRow->id, 'source_url' => $uniqSource],
[
'media_type' => $mediaType,
'source_url' => $uniqSource,
'file_name' => $originalName,
'file_ext' => $safeExt,
'size' => $size,
'md5' => $md5,
'storage_path' => $storagePath,
'public_url' => $publicUrl,
'created_at' => $now,
'updated_at' => $now,
]
);
return [
'ok' => true,
'mediaId' => (int)$media->id,
'storage_path' => $storagePath,
'public_url' => $publicUrl,
'file_name' => $originalName,
];
});
}
/** 本地媒体访问:GET /media/{path} -> runtime/uploads/{path} */
public function serveMediaFile(string $path): Response
{
// 防路径穿越
$path = str_replace(['..', '\\'], ['', '/'], $path);
$full = base_path() . '/runtime/uploads/' . $path;
if (!is_file($full)) {
return response('Not Found', 404);
}
$mime = $this->guessMime($full);
return response(file_get_contents($full), 200, ['Content-Type' => $mime]);
}
private function guessMime(string $file): string
{
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
return match ($ext) {
'jpg','jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'mp4' => 'video/mp4',
'pdf' => 'application/pdf',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'ppt' => 'application/vnd.ms-powerpoint',
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'zip' => 'application/zip',
default => 'application/octet-stream'
};
}
private function extractImInquiryId(string $url): string
{
if ($url === '') return '';
$m = [];
if (preg_match('/[?&]imInquiryId=([^&]+)/i', $url, $m)) return (string)$m[1];
return '';
}
/** 裁剪 list_page raw_json(避免存超大) */
private function trimListPageRaw(array $p): array
{
$keep = $p;
if (isset($keep['list'])) unset($keep['list']);
if (isset($keep['data']['list'])) unset($keep['data']['list']);
return $keep;
}
/** 裁剪 detail raw_json(避免存超大 messages) */
private function trimDetailRaw(array $detail): array
{
$keep = $detail;
if (isset($keep['messages'])) unset($keep['messages']);
return $keep;
}
}