diff --git a/application/api/controller/Author.php b/application/api/controller/Author.php new file mode 100644 index 00000000..2ea83479 --- /dev/null +++ b/application/api/controller/Author.php @@ -0,0 +1,303 @@ + 0, 'msg' => '请输入作者姓名']); + } + + // 1) 获取 freelookup 页面,用于拿到真实提交地址和隐藏字段。 + $lookupUrl = 'https://www.scopus.com/freelookup/form/author.uri?zone=TopNavBar&origin=NO%20ORIGIN%20DEFINED'; + $lookupRes = $this->httpRequest($lookupUrl, null, true, '', $cookieFile); + if (!$lookupRes['ok']) { + @unlink($cookieFile); + $ret = ['code' => 0, 'msg' => '访问 Scopus 失败:' . $lookupRes['msg']]; + if ($debug === 1) { + $ret['debug'] = $this->buildDebugInfo($lookupRes['url'], $lookupRes['http_code'], $lookupRes['body']); + } + return json($ret); + } + + $formInfo = $this->extractScopusLookupForm($lookupRes['body']); + if (empty($formInfo['action'])) { + @unlink($cookieFile); + $ret = ['code' => 0, 'msg' => 'Scopus 页面结构已变化,未找到查询表单']; + if ($debug === 1) { + $ret['debug'] = $this->buildDebugInfo($lookupRes['url'], $lookupRes['http_code'], $lookupRes['body']); + } + return json($ret); + } + + // 2) 组装查询参数(姓名 + 机构),并携带隐藏字段提交。 + $postData = $formInfo['hidden_fields']; + $postData['authLast'] = $name; + $postData['affil'] = $affil; + + $searchRes = $this->httpRequest($formInfo['action'], $postData, true, $lookupUrl, $cookieFile); + if (!$searchRes['ok']) { + @unlink($cookieFile); + $ret = ['code' => 0, 'msg' => '查询 Scopus 失败:' . $searchRes['msg']]; + if ($debug === 1) { + $ret['debug'] = $this->buildDebugInfo($searchRes['url'], $searchRes['http_code'], $searchRes['body']); + } + return json($ret); + } + + $blockMsg = $this->detectScopusBlocking($searchRes['body']); + if (!empty($blockMsg)) { + @unlink($cookieFile); + $ret = ['code' => 0, 'msg' => $blockMsg]; + $fallback = $this->fallbackByOpenAlex($name, $affil); + if ($fallback !== null) { + $ret = array_merge($fallback, [ + 'msg' => $blockMsg . ',已自动降级 OpenAlex 结果' + ]); + } + if ($debug === 1) { + $ret['debug'] = $this->buildDebugInfo($searchRes['url'], $searchRes['http_code'], $searchRes['body']); + } + return json($ret); + } + + // 3) 从返回页提取 h-index(优先匹配“h-index”关键词附近数字)。 + $hIndex = $this->extractHIndexFromHtml($searchRes['body']); + if ($hIndex === null) { + @unlink($cookieFile); + $ret = [ + 'code' => 0, + 'msg' => '未从 Scopus 结果页解析到 H 指数(可能需要人工登录或页面结构调整)' + ]; + if ($debug === 1) { + $ret['debug'] = $this->buildDebugInfo($searchRes['url'], $searchRes['http_code'], $searchRes['body']); + } + return json($ret); + } + + @unlink($cookieFile); + + $ret = [ + 'code' => 1, + 'name' => $name, + 'affil' => $affil, + 'h_index_scopus' => $hIndex, + 'source' => 'scopus_freelookup', + ]; + if ($debug === 1) { + $ret['debug'] = $this->buildDebugInfo($searchRes['url'], $searchRes['http_code'], $searchRes['body']); + } + return json($ret); + } + + private function httpRequest($url, $postData = null, $followLocation = true, $referer = '', $cookieFile = '') + { + $ch = curl_init(); + $options = [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_FOLLOWLOCATION => $followLocation, + CURLOPT_MAXREDIRS => 8, + CURLOPT_TIMEOUT => 30, + CURLOPT_CONNECTTIMEOUT => 15, + CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + CURLOPT_ENCODING => '', + CURLOPT_HTTPHEADER => [ + 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8', + ], + ]; + + if (!empty($referer)) { + $options[CURLOPT_REFERER] = $referer; + } + + if (!empty($cookieFile)) { + $options[CURLOPT_COOKIEJAR] = $cookieFile; + $options[CURLOPT_COOKIEFILE] = $cookieFile; + } + + if (is_array($postData)) { + $options[CURLOPT_POST] = true; + $options[CURLOPT_POSTFIELDS] = http_build_query($postData); + } + + curl_setopt_array($ch, $options); + $body = curl_exec($ch); + $error = curl_error($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $finalUrl = (string) curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); + curl_close($ch); + + if ($error) { + if (strpos($error, 'Maximum (') !== false && strpos($error, 'redirects followed') !== false) { + return [ + 'ok' => false, + 'msg' => 'Scopus 跳转过多(可能触发登录/验证页面),请稍后重试或先在浏览器登录 Scopus', + 'body' => '', + 'http_code' => $httpCode, + 'url' => $finalUrl + ]; + } + return ['ok' => false, 'msg' => $error, 'body' => '', 'http_code' => $httpCode, 'url' => $finalUrl]; + } + + if ($httpCode >= 400 || $httpCode === 0) { + return ['ok' => false, 'msg' => 'HTTP ' . $httpCode, 'body' => (string) $body, 'http_code' => $httpCode, 'url' => $finalUrl]; + } + + return ['ok' => true, 'msg' => '', 'body' => (string) $body, 'http_code' => $httpCode, 'url' => $finalUrl]; + } + + private function detectScopusBlocking($html) + { + if (empty($html)) { + return ''; + } + + $text = strtolower(strip_tags($html)); + if (strpos($text, 'sign in') !== false || strpos($text, 'institutional sign in') !== false) { + return 'Scopus 返回登录页,当前环境未授权访问作者详情页面'; + } + if (strpos($text, 'captcha') !== false || strpos($text, 'are you a robot') !== false) { + return 'Scopus 触发了人机验证,当前接口无法自动通过'; + } + + return ''; + } + + private function buildDebugInfo($finalUrl, $httpCode, $html) + { + $normalized = html_entity_decode(strip_tags((string) $html), ENT_QUOTES, 'UTF-8'); + $normalized = preg_replace('/\s+/u', ' ', $normalized); + $snippet = mb_substr($normalized, 0, 300, 'UTF-8'); + + return [ + 'final_url' => (string) $finalUrl, + 'http_code' => (int) $httpCode, + 'page_snippet' => $snippet, + 'contains_signin' => stripos($normalized, 'sign in') !== false ? 1 : 0, + 'contains_captcha' => stripos($normalized, 'captcha') !== false ? 1 : 0, + ]; + } + + private function extractScopusLookupForm($html) + { + $ret = [ + 'action' => '', + 'hidden_fields' => [], + ]; + + if (empty($html)) { + return $ret; + } + + // 优先定位包含 author 的 form,减少解析误匹配。 + if (preg_match('/]*action=["\']([^"\']+)["\'][^>]*>.*?<\/form>/is', $html, $formMatch)) { + $action = trim($formMatch[1]); + if (!preg_match('/^https?:\/\//i', $action)) { + $action = 'https://www.scopus.com' . (substr($action, 0, 1) === '/' ? '' : '/') . $action; + } + $ret['action'] = $action; + + if (preg_match_all('/]*type=["\']hidden["\'][^>]*>/is', $formMatch[0], $inputs)) { + foreach ($inputs[0] as $inputTag) { + if (preg_match('/name=["\']([^"\']+)["\']/i', $inputTag, $nameMatch)) { + $fieldName = trim($nameMatch[1]); + $fieldVal = ''; + if (preg_match('/value=["\']([^"\']*)["\']/i', $inputTag, $valMatch)) { + $fieldVal = $valMatch[1]; + } + $ret['hidden_fields'][$fieldName] = $fieldVal; + } + } + } + } + + return $ret; + } + + private function extractHIndexFromHtml($html) + { + if (empty($html)) { + return null; + } + + $text = html_entity_decode(strip_tags($html), ENT_QUOTES, 'UTF-8'); + $text = preg_replace('/\s+/u', ' ', $text); + + $patterns = [ + '/h[\-\s]?index[^0-9]{0,20}([0-9]{1,3})/iu', + '/([0-9]{1,3})[^0-9]{0,20}h[\-\s]?index/iu', + ]; + foreach ($patterns as $pattern) { + if (preg_match($pattern, $text, $m)) { + return (int) $m[1]; + } + } + + return null; + } + + private function fallbackByOpenAlex($name, $affil) + { + $search = urlencode($name); + $url = "https://api.openalex.org/authors?search={$search}&limit=8"; + $res = $this->httpRequest($url, null, true); + if (!$res['ok']) { + return null; + } + + $data = json_decode($res['body'], true); + $list = $data['results'] ?? []; + if (empty($list)) { + return null; + } + + $targetAffil = strtolower((string) $affil); + $match = null; + foreach ($list as $item) { + if (empty($targetAffil)) { + $match = $item; + break; + } + $insts = $item['affiliations'] ?? []; + foreach ($insts as $inst) { + $instName = strtolower($inst['display_name'] ?? ''); + if ($instName !== '' && strpos($instName, $targetAffil) !== false) { + $match = $item; + break 2; + } + } + } + + if ($match === null) { + $match = $list[0]; + } + + return [ + 'code' => 1, + 'name' => $match['display_name'] ?? $name, + 'affil' => !empty($match['affiliations'][0]['display_name']) ? $match['affiliations'][0]['display_name'] : $affil, + 'h_index_scopus' => $match['summary_stats']['h_index_scopus'] ?? null, + 'h_index_openalex' => $match['summary_stats']['h_index'] ?? null, + 'source' => 'openalex_fallback', + ]; + } +} \ No newline at end of file diff --git a/application/api/controller/References.php b/application/api/controller/References.php index fbc6b6be..331edd62 100644 --- a/application/api/controller/References.php +++ b/application/api/controller/References.php @@ -1329,18 +1329,21 @@ class References extends Base return json_encode(array('status' => 3,'msg' => 'No articles found' )); } if($this->checkReferStatus($iPArticleId)==0){ - return jsonError('请修正完文献内容再进行校对。'); + return jsonError('Please correct the reference content before running the check.'); } //已存在校对记录则禁止重复执行第一次校对,提示走重置接口 $iExisting = Db::name('article_reference_check_result') ->where('p_article_id', $iPArticleId) ->count(); if(intval($iExisting) > 0){ - return jsonError('该文章已存在校对记录,请使用"重置校对"接口重新校对。'); + return jsonError('This article already has a reference check record. Please use the "Reset Check" endpoint to run the check again.'); } try { $svc = new ReferenceCheckService(); $result = $svc->enqueueByPArticle($aProductionArticle); + if (empty($result['check_ids'])) { + return jsonError('No reference citations were found in the article.'); + } return jsonSuccess($result); } catch (\Exception $e) { return jsonError($e->getMessage()); @@ -1368,7 +1371,7 @@ class References extends Base return json_encode(array('status' => 3,'msg' => 'No articles found' )); } if($this->checkReferStatus($iPArticleId)==0){ - return jsonError('请修正完文献内容再进行校对。'); + return jsonError('Please correct the reference content before running the check.'); } $iArticleId = empty($aProductionArticle['article_id']) ? 0 : $aProductionArticle['article_id']; if(empty($iArticleId)){ @@ -1533,7 +1536,7 @@ class References extends Base * POST/GET: p_refer_id(必填) * p_article_id(可选) * - * 仅重跑 status=3(校对失败)的记录;不改动 refer_text,只重置结果字段后入 ReferenceCheck 队列。 + * 仅重跑 status=3(校对失败)的记录;不改动 refer_text,只重置结果字段后入 RabbitMQ 批次队列。 * 返回:p_refer_id、p_article_id、reset、queued、check_ids、queue */ public function referenceCheckRecheckFailedAI() diff --git a/application/api/job/ReferenceCheck.php b/application/api/job/ReferenceCheck.php deleted file mode 100644 index 3977e39e..00000000 --- a/application/api/job/ReferenceCheck.php +++ /dev/null @@ -1,114 +0,0 @@ -oQueueJob = new QueueJob(); - $this->QueueRedis = QueueRedis::getInstance(); - } - - public function fire(Job $job, $data) - { - $this->oQueueJob->init($job); - - $rawBody = empty($job->getRawBody()) ? '' : $job->getRawBody(); - $jobData = empty($rawBody) ? [] : json_decode($rawBody, true); - $jobId = empty($jobData['id']) ? 'unknown' : $jobData['id']; - - $sRedisKey = ''; - $sRedisValue = ''; - - $this->oQueueJob->log("-----------队列任务开始-----------"); - $this->oQueueJob->log("当前任务ID: {$jobId}, 尝试次数: {$job->attempts()}"); - - try { - $checkId = intval(isset($data['check_id']) ? $data['check_id'] : 0); - if ($checkId <= 0 && !empty($jobData['data']['check_id'])) { - $checkId = intval($jobData['data']['check_id']); - } - if ($checkId <= 0) { - $job->delete(); - return; - } - - $row = Db::name('article_reference_check_result')->where('id', $checkId)->find(); - if (empty($row)) { - $job->delete(); - return; - } - - if (intval($row['status']) === ReferenceCheckService::RECORD_COMPLETED) { - $job->delete(); - return; - } - - $sClassName = get_class($this); - $sRedisKey = "queue_job:{$sClassName}:{$checkId}"; - $sRedisValue = uniqid() . '_' . getmypid(); - - $svc = new ReferenceCheckService(); - $svc->clearReferenceCheckQueueLock($checkId); - - if (!$this->oQueueJob->acquireLock($sRedisKey, $sRedisValue, $job)) { - return; - } - - try { - $svc->runReferenceCheckOnce($checkId); - - $amId = intval(isset($row['am_id']) ? $row['am_id'] : 0); - if ($amId > 0) { - $svc->syncAmRefCheckStatus($amId); - } - $this->QueueRedis->finishJob($sRedisKey, 'completed', $this->completedExprie, $sRedisValue); - $job->delete(); - $this->oQueueJob->log("任务执行成功 | 日志ID: {$sRedisKey}"); - } catch (\Exception $e) { - $this->oQueueJob->log('ReferenceCheck error: ' . $e->getMessage()); - if ($job->attempts() >= 3) { - $this->markFailed($checkId, $e->getMessage()); - $job->delete(); - return; - } - $job->release(30); - } - } catch (\RuntimeException $e) { - $this->oQueueJob->handleRetryableException($e, $sRedisKey, $sRedisValue, $job); - } catch (\LogicException $e) { - $this->oQueueJob->handleNonRetryableException($e, $sRedisKey, $sRedisValue, $job); - } catch (\Exception $e) { - $this->oQueueJob->handleRetryableException($e, $sRedisKey, $sRedisValue, $job); - } finally { - $this->oQueueJob->finnal(); - } - } - - private function markFailed($checkId, $msg) - { - $row = Db::name('article_reference_check_result')->where('id', $checkId)->find(); - try { - (new ReferenceCheckService())->updateCheckResult($checkId, [ - 'status' => ReferenceCheckService::RECORD_FAILED, - 'error_msg' => $msg, - ]); - } catch (\Exception $e) { - \think\Log::error('ReferenceCheck markFailed: ' . $e->getMessage()); - } - $amId = empty($row) ? 0 : intval(isset($row['am_id']) ? $row['am_id'] : 0); - if ($amId > 0) { - (new ReferenceCheckService())->syncAmRefCheckStatus($amId); - } - } -} diff --git a/application/api/job/ReferenceCheckTwo.php b/application/api/job/ReferenceCheckTwo.php deleted file mode 100644 index 53cb0939..00000000 --- a/application/api/job/ReferenceCheckTwo.php +++ /dev/null @@ -1,162 +0,0 @@ -oQueueJob = new QueueJob(); - $this->QueueRedis = QueueRedis::getInstance(); - } - - public function fire(Job $job, $data) - { - $this->oQueueJob->init($job); - - $rawBody = empty($job->getRawBody()) ? '' : $job->getRawBody(); - $jobData = empty($rawBody) ? [] : json_decode($rawBody, true); - $jobId = empty($jobData['id']) ? 'unknown' : $jobData['id']; - - $sRedisKey = ''; - $sRedisValue = ''; - - $this->oQueueJob->log("-----------队列任务开始-----------"); - $this->oQueueJob->log("当前任务ID: {$jobId}, 尝试次数: {$job->attempts()}"); - - try { - $checkId = intval(isset($data['check_id']) ? $data['check_id'] : 0); - if ($checkId <= 0 && !empty($jobData['data']['check_id'])) { - $checkId = intval($jobData['data']['check_id']); - } - $sClassName = get_class($this); - $sRedisKey = "queue_job_two:{$sClassName}:{$checkId}"; - $sRedisValue = uniqid() . '_' . getmypid(); - - if (!$this->oQueueJob->acquireLock($sRedisKey, $sRedisValue, $job)) { - return; - } - - if ($checkId <= 0) { - $job->delete(); - return; - } - - $row = Db::name('article_reference_check_result')->where('id', $checkId)->find(); - if (empty($row)) { - $job->delete(); - return; - } - -// if (intval($row['status']) === ReferenceCheckService::RECORD_COMPLETED) { -// $job->delete(); -// return; -// } - - try { - $svc = new ReferenceCheckService(); - - $contentA = $svc->resolveMainContentForJob($row); - $referText = trim((string)(isset($row['refer_text']) ? $row['refer_text'] : '')); - $refer = null; - - if (intval($row['p_refer_id']) > 0) { - $refer = Db::name('production_article_refer') - ->where('p_refer_id', intval($row['p_refer_id'])) - ->where('state', 0) - ->find(); - } - - $payload = $svc->prepareRecheckPayload(is_array($refer) ? $refer : [], $referText); - $doiBlock = $payload['doi_block']; - - if ($contentA === '' || $referText === '') { - $this->markFailed($checkId, 'Missing article_main.content or refer_text'); - $job->delete(); - return; - } - $llm = new LLMService(); - $llmResult = $llm->checkReference($contentA, $referText, true, $doiBlock); - - $requestFailed = !empty($llmResult['request_failed']); - $canSupport = $svc->parseLlmCanSupport($llmResult); - $tag = $payload['has_abstract'] - ? ('[Crossref复核' . ($payload['doi_used'] !== '' ? ' ' . $payload['doi_used'] : '') . ']') - : '[Crossref复核-无摘要]'; - $reason = $tag . ' ' . (isset($llmResult['reason']) ? $llmResult['reason'] : ''); - - // LLM 通讯失败:写 status=RECORD_FAILED(3) 并抛异常触发队列重试 - if ($requestFailed) { - $svc->updateCheckResult($checkId, [ - 'confidence' => floatval($llmResult['confidence']), - 'reason' => $reason, - 'status' => ReferenceCheckService::RECORD_FAILED, - 'error_msg' => isset($llmResult['reason']) ? $llmResult['reason'] : 'LLM request failed', - ]); - throw new \RuntimeException(isset($llmResult['reason']) ? $llmResult['reason'] : 'LLM request failed'); - } - - $affected = $svc->updateCheckResult($checkId, [ - 'can_support' => $canSupport ? 1 : 0, - 'is_match' => $canSupport ? 1 : 0, - 'confidence' => floatval($llmResult['confidence']), - 'reason' => $reason, - 'status' => ReferenceCheckService::RECORD_COMPLETED, - 'error_msg' => '', - ]); - $this->oQueueJob->log("Crossref复核写入 id={$checkId} affected={$affected} can_support=" . ($canSupport ? 1 : 0) . " confidence=" . floatval($llmResult['confidence'])); - - $amId = intval(isset($row['am_id']) ? $row['am_id'] : 0); - if ($amId > 0) { - $svc->syncAmRefCheckStatus($amId); - } - $this->QueueRedis->finishJob($sRedisKey, 'completed', $this->completedExprie, $sRedisValue); - $job->delete(); - $this->oQueueJob->log("任务执行成功 | 日志ID: {$sRedisKey}"); - } catch (\Exception $e) { - $this->oQueueJob->log('ReferenceCheckTwo error: ' . $e->getMessage()); - if ($job->attempts() >= 3) { - $this->markFailed($checkId, $e->getMessage()); - $job->delete(); - return; - } - $job->release(30); - } - } catch (\RuntimeException $e) { - $this->oQueueJob->handleRetryableException($e, $sRedisKey, $sRedisValue, $job); - } catch (\LogicException $e) { - $this->oQueueJob->handleNonRetryableException($e, $sRedisKey, $sRedisValue, $job); - } catch (\Exception $e) { - $this->oQueueJob->handleRetryableException($e, $sRedisKey, $sRedisValue, $job); - } finally { - $this->oQueueJob->finnal(); - } - } - - private function markFailed($checkId, $msg) - { - $row = Db::name('article_reference_check_result')->where('id', $checkId)->find(); - try { - (new ReferenceCheckService())->updateCheckResult($checkId, [ - 'status' => ReferenceCheckService::RECORD_FAILED, - 'error_msg' => $msg, - ]); - } catch (\Exception $e) { - \think\Log::error('ReferenceCheckTwo markFailed: ' . $e->getMessage()); - } - $amId = empty($row) ? 0 : intval(isset($row['am_id']) ? $row['am_id'] : 0); - if ($amId > 0) { - (new ReferenceCheckService())->syncAmRefCheckStatus($amId); - } - } -} diff --git a/application/command.php b/application/command.php index 826bb2b2..43892e98 100644 --- a/application/command.php +++ b/application/command.php @@ -9,4 +9,6 @@ // | Author: yunwuxin <448901948@qq.com> // +---------------------------------------------------------------------- -return []; +return [ + 'app\\command\\ReferenceCheckMqConsume', +]; diff --git a/application/command/ReferenceCheckMqConsume.php b/application/command/ReferenceCheckMqConsume.php new file mode 100644 index 00000000..4f2f22f7 --- /dev/null +++ b/application/command/ReferenceCheckMqConsume.php @@ -0,0 +1,77 @@ +setName('reference_check:mq-consume') + ->setDescription('Consume RabbitMQ reference check article queue'); + } + + protected function execute(Input $input, Output $output) + { + if (!class_exists('\\PhpAmqpLib\\Connection\\AMQPStreamConnection')) { + $output->writeln('php-amqplib not installed. Run: php composer.phar require php-amqplib/php-amqplib:^2.12'); + return 1; + } + + $rc = RabbitMqConfig::referenceCheck(); + $exchange = isset($rc['exchange']) ? $rc['exchange'] : 'reference_check'; + $queue = isset($rc['queue']) ? $rc['queue'] : 'ref_check.article'; + $routeKey = isset($rc['route_key']) ? $rc['route_key'] : 'article.start'; + + $conn = new \PhpAmqpLib\Connection\AMQPStreamConnection( + RabbitMqConfig::get('host', '127.0.0.1'), + intval(RabbitMqConfig::get('port', 5672)), + RabbitMqConfig::get('user', 'guest'), + RabbitMqConfig::get('password', 'guest'), + RabbitMqConfig::get('vhost', '/') + ); + $ch = $conn->channel(); + $ch->exchange_declare($exchange, 'direct', false, true, false); + $dlq = isset($rc['dlq']) ? $rc['dlq'] : 'ref_check.article.dlq'; + $ch->queue_declare($dlq, false, true, false, false); + $ch->queue_declare($queue, false, true, false, false, false, new \PhpAmqpLib\Wire\AMQPTable([ + 'x-dead-letter-exchange' => '', + 'x-dead-letter-routing-key' => $dlq, + ])); + $ch->queue_bind($queue, $exchange, $routeKey); + $ch->basic_qos(null, 1, null); + + $output->writeln('Waiting on queue: ' . $queue); + + $worker = new ReferenceCheckArticleWorker(); + $callback = function ($msg) use ($worker, $output) { + $payload = json_decode($msg->body, true); + if (!is_array($payload)) { + $msg->ack(); + return; + } + try { + $worker->handleMessage($payload); + $msg->ack(); + } catch (\Exception $e) { + \think\Log::error('reference_check:mq-consume ' . $e->getMessage()); + $output->writeln('' . $e->getMessage() . ''); + $msg->nack(false, false); + } + }; + + $ch->basic_consume($queue, '', false, false, false, false, $callback); + while ($ch->is_consuming()) { + $ch->wait(); + } + + $ch->close(); + $conn->close(); + return 0; + } +} diff --git a/application/common/ReferenceCheckService.php b/application/common/ReferenceCheckService.php index 89ef6b8a..e551a482 100644 --- a/application/common/ReferenceCheckService.php +++ b/application/common/ReferenceCheckService.php @@ -4,16 +4,17 @@ namespace app\common; use think\Db; use think\Env; -use think\Queue; use app\common\service\LLMService; +use app\common\mq\ReferenceCheckMqPublisher; /** * 正文 <blue>[n]</blue> 引用与 t_production_article_refer(index+1=n)相关性校对。 - * LLM 配置与 PromotionLlmService 相同;单条任务走 ReferenceCheck 队列。 + * LLM 配置与 PromotionLlmService 相同;异步任务走 RabbitMQ(一篇一条消息)。 */ class ReferenceCheckService { - const QUEUE_NAME = 'ReferenceCheck'; + /** API 返回:异步传输方式(RabbitMQ 文章批次) */ + const TRANSPORT_RABBITMQ = 'rabbitmq'; /** t_article_main.type */ const MAIN_TYPE_TEXT = 0; @@ -29,6 +30,9 @@ class ReferenceCheckService /** @var bool|null t_article_main 是否已有 ref_check_status 列 */ private static $amRefCheckStatusColumnExists = null; + /** 单条任务最多重试次数(不含首次执行) */ + const QUEUE_MAX_RETRY = 1; + /** * 引用校对状态(生命周期顺序:0→1→2→3 = 待→进行→完成→失败) * @@ -56,6 +60,12 @@ class ReferenceCheckService const RECORD_COMPLETED = 2; // 校对完成 const RECORD_FAILED = 3; // 校对失败 + /** 队列执行状态(queue_status) */ + const QUEUE_PENDING = 0; // 已入队待执行 + const QUEUE_RUNNING = 1; // worker 正在执行 + const QUEUE_COMPLETED = 2; // 执行完成 + const QUEUE_FAILED = 3; // 最终失败(重试耗尽) + /** LLM 评分(confidence)通过阈值:>= 该值视为"通过" */ const PASS_CONFIDENCE_THRESHOLD = 0.65; @@ -69,6 +79,12 @@ class ReferenceCheckService const BLUE_TAG_REGEX = '/\[([\d,,\-\x{2013}\x{2014}\x{2212}\x{2010}\x{2011}\s]+)\]<\/blue>/u'; const BLUE_TAG_REGEX_BRACKET_OUTSIDE = '/\[([\d,,\-\x{2013}\x{2014}\x{2212}\x{2010}\x{2011}\s]+)<\/blue>\]/u'; + private $logFile; + + public function __construct() + { + $this->logFile = ROOT_PATH . 'runtime' . DS . 'plagiarism_task.log'; + } /** * 兼容无 ?? 的 PHP 版本 */ @@ -77,6 +93,27 @@ class ReferenceCheckService return isset($arr[$key]) ? $arr[$key] : $default; } + /** 新建/重置校对明细时的队列初始字段 */ + private function newCheckRecordFields(array $fields, $queueStatus = self::QUEUE_PENDING, $retryCount = 0) + { + $fields['queue_status'] = intval($queueStatus); + $fields['retry_count'] = max(0, intval($retryCount)); + return $fields; + } + + public function markQueueRuntime($checkId, $queueStatus, $retryCount = null) + { + $checkId = intval($checkId); + if ($checkId <= 0) { + return 0; + } + $fields = ['queue_status' => intval($queueStatus)]; + if ($retryCount !== null) { + $fields['retry_count'] = max(0, intval($retryCount)); + } + return Db::name('article_reference_check_result')->where('id', $checkId)->update($fields); + } + /** * 合并匹配两种 blue 引用排版,按在正文中的起始位置排序。 * @@ -128,7 +165,7 @@ class ReferenceCheckService } $now = date('Y-m-d H:i:s'); - $checkId = Db::name('article_reference_check_result')->insertGetId([ + $checkId = Db::name('article_reference_check_result')->insertGetId($this->newCheckRecordFields([ 'article_id' => intval($this->arrGet($extra, 'article_id', 0)), 'am_id' => intval($this->arrGet($extra, 'am_id', 0)), 'p_article_id' => intval($this->arrGet($extra, 'p_article_id', 0)), @@ -145,14 +182,14 @@ class ReferenceCheckService 'status' => 0, 'created_at' => $now, 'updated_at' => $now, - ]); + ])); $amId = intval($this->arrGet($extra, 'am_id', 0)); if ($amId > 0) { $this->setAmRefCheckStatus($amId, self::AM_STATUS_RUNNING); } - $this->pushJob(intval($checkId), intval($this->arrGet($extra, 'queue_delay', 0))); + $this->startArticleCheckQueue([intval($checkId)], intval($this->arrGet($extra, 'p_article_id', 0)), 'enqueue'); return ['check_id' => $checkId, 'queued' => 1]; } @@ -190,7 +227,8 @@ class ReferenceCheckService } $skipped = 0; - $delay = 0; + $pendingJobs = []; + $now = date('Y-m-d H:i:s'); foreach ($citations as $cite) { foreach ($cite['reference_numbers'] as $refNo) { $referIndex = $refNo - 1; @@ -201,9 +239,7 @@ class ReferenceCheckService $refer = $referMap[$referIndex]; $referText = $this->formatReferForLlm($refer); - $now = date('Y-m-d H:i:s'); - // [70-73] 展开为 reference_no=70,71,72,73 共 4 条记录 - $checkId = Db::name('article_reference_check_result')->insertGetId([ + $checkId = Db::name('article_reference_check_result')->insertGetId($this->newCheckRecordFields([ 'article_id' => $main['article_id'], 'p_article_id' => $pArticleId, 'am_id' => intval($main['am_id']), @@ -211,22 +247,27 @@ class ReferenceCheckService 'refer_index' => $refNo, 'origin_text' => $cite['original_text'], 'refer_text' => $referText, - 'p_refer_id' => $referMap[$referIndex]['p_refer_id'], + 'p_refer_id' => $referMap[$referIndex]['p_refer_id'], 'text_start' => $cite['text_start'], 'text_end' => $cite['text_end'], + 'status' => self::RECORD_PENDING, 'created_at' => $now, 'updated_at' => $now, - ]); - $this->pushJob(intval($checkId), $delay); - $checkIds[] = $checkId; - $delay += 1; + ])); + $pendingJobs[] = [ + 'check_id' => intval($checkId), + 'reference_no' => intval($refNo), + 'am_id' => intval($main['am_id']), + 'text_start' => intval($cite['text_start']), + ]; } } + $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'enqueue'); $this->setAmRefCheckStatus($amId, self::AM_STATUS_RUNNING); } /** - * 手工触发:对已完成且 confidence<=0.65 的记录入队 DOI 第二轮复核 + * 手工触发:对已完成且 confidence<=0.65 的记录同步执行 Crossref 二轮复核 */ public function enqueueSecondPassByArticle($articleId) { @@ -247,7 +288,7 @@ class ReferenceCheckService $delay2 = 0; foreach ($rows as $checkLog) { $rowId = $this->resolveCheckRowId($checkLog); - if ($this->maybeEnqueueSecondPass($rowId, floatval($checkLog['confidence']))) { + if ($this->runSecondPassIfNeeded($rowId, floatval($checkLog['confidence']))) { $checkIds2[] = $rowId; $delay2 += 1; } @@ -299,7 +340,7 @@ class ReferenceCheckService $referText = $this->formatReferForLlm($refer); // [70-73] 展开为 reference_no=70,71,72,73 共 4 条记录;先入队表,再按文献号正序校对 - $checkId = Db::name('article_reference_check_result')->insertGetId([ + $checkId = Db::name('article_reference_check_result')->insertGetId($this->newCheckRecordFields([ 'article_id' => $main['article_id'], 'p_article_id' => $pArticleId, 'am_id' => $amId, @@ -310,9 +351,10 @@ class ReferenceCheckService 'p_refer_id' => $referMap[$referIndex]['p_refer_id'], 'text_start' => $cite['text_start'], 'text_end' => $cite['text_end'], + 'status' => self::RECORD_PENDING, 'created_at' => $now, 'updated_at' => $now, - ]); + ])); $pendingJobs[] = [ 'check_id' => intval($checkId), @@ -325,8 +367,7 @@ class ReferenceCheckService } } } - - $checkIds = $this->pushJobsSortedByReferenceNo($pendingJobs); + $checkIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'enqueue'); foreach (array_keys($amIdsWithJobs) as $amId) { $this->setAmRefCheckStatus($amId, self::AM_STATUS_RUNNING); } @@ -337,7 +378,7 @@ class ReferenceCheckService 'queued' => $queued, 'skipped' => $skipped, 'check_ids' => $checkIds, - 'queue' => self::QUEUE_NAME, + 'queue' => self::TRANSPORT_RABBITMQ, ]; } public function enqueueByArticle($articleId){ @@ -386,7 +427,7 @@ class ReferenceCheckService $referText = $this->formatReferForLlm($refer); // [70-73] 展开为 reference_no=70,71,72,73 共 4 条记录;先入队表,再按文献号正序校对 - $checkId = Db::name('article_reference_check_result')->insertGetId([ + $checkId = Db::name('article_reference_check_result')->insertGetId($this->newCheckRecordFields([ 'article_id' => $main['article_id'], 'p_article_id' => $pArticleId, 'am_id' => $amId, @@ -397,9 +438,10 @@ class ReferenceCheckService 'p_refer_id' => $referMap[$referIndex]['p_refer_id'], 'text_start' => $cite['text_start'], 'text_end' => $cite['text_end'], + 'status' => self::RECORD_PENDING, 'created_at' => $now, 'updated_at' => $now, - ]); + ])); $pendingJobs[] = [ 'check_id' => intval($checkId), @@ -413,7 +455,7 @@ class ReferenceCheckService } } - $checkIds = $this->pushJobsSortedByReferenceNo($pendingJobs); + $checkIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'enqueue'); foreach (array_keys($amIdsWithJobs) as $amId) { $this->setAmRefCheckStatus($amId, self::AM_STATUS_RUNNING); } @@ -424,7 +466,7 @@ class ReferenceCheckService 'queued' => $queued, 'skipped' => $skipped, 'check_ids' => $checkIds, - 'queue' => self::QUEUE_NAME, + 'queue' => self::TRANSPORT_RABBITMQ, ]; } @@ -524,14 +566,6 @@ class ReferenceCheckService ->whereIn('state', [0, 2]) ->value('article_id')); - // 先清掉旧记录对应的队列 Redis 锁,避免在途 worker 写回数据 - $oldIds = Db::name('article_reference_check_result') - ->where('p_article_id', $pArticleId) - ->column('id'); - foreach ($oldIds as $oldId) { - $this->clearReferenceCheckQueueLock(intval($oldId)); - } - $deleted = Db::name('article_reference_check_result') ->where('p_article_id', $pArticleId) ->delete(); @@ -553,14 +587,6 @@ class ReferenceCheckService return 0; } - // 先清掉旧记录对应的队列 Redis 锁,否则同 check_id 在 TTL 内不会再次执行 - $oldIds = Db::name('article_reference_check_result') - ->where('article_id', $articleId) - ->column('id'); - foreach ($oldIds as $oldId) { - $this->clearReferenceCheckQueueLock(intval($oldId)); - } - $deleted = Db::name('article_reference_check_result')->where('article_id', $articleId)->delete(); if ($this->hasAmRefCheckStatusColumn()) { Db::name('article_main') @@ -1518,7 +1544,7 @@ class ReferenceCheckService * 编辑某条文献内容后,按 p_refer_id 异步重新校对该文献对应的全部 check 明细 * * 流程:刷新 refer_text/refer_index → 重置 status/is_match/confidence/reason - * → 设节级 ref_check_status=RUNNING → 投递到 ReferenceCheck 队列 + * → 设节级 ref_check_status=RUNNING → 投递 RabbitMQ 文章批次 * * 与 recheckByRefer 的差异:本方法**不**在请求内同步跑 LLM,仅入队,立即返回。 * 前端可调 getProgressByPArticleId 轮询进度。 @@ -1567,11 +1593,11 @@ class ReferenceCheckService 'reset' => 0, 'queued' => 0, 'check_ids' => [], - 'queue' => self::QUEUE_NAME, + 'queue' => self::TRANSPORT_RABBITMQ, ]; } - $resetFields = [ + $resetFields = $this->newCheckRecordFields([ 'refer_text' => $referText, 'refer_index' => $referenceNo, 'reference_no' => $referenceNo, @@ -1582,14 +1608,13 @@ class ReferenceCheckService 'reason' => '', 'error_msg' => '', 'updated_at' => $now, - ]; + ], self::QUEUE_PENDING, 0); $pendingJobs = []; $amIds = []; foreach ($rows as $row) { $checkId = $this->resolveCheckRowId($row); Db::name('article_reference_check_result')->where('id', $checkId)->update($resetFields); - $this->clearReferenceCheckQueueLock($checkId); $pendingJobs[] = [ 'check_id' => $checkId, 'reference_no' => $referenceNo, @@ -1606,7 +1631,7 @@ class ReferenceCheckService $this->setAmRefCheckStatus($amId, self::AM_STATUS_RUNNING); } - $checkIds = $this->pushJobsSortedByReferenceNo($pendingJobs); + $checkIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'enqueue'); return [ 'p_refer_id' => $pReferId, @@ -1615,7 +1640,7 @@ class ReferenceCheckService 'reset' => count($rows), 'queued' => count($checkIds), 'check_ids' => $checkIds, - 'queue' => self::QUEUE_NAME, + 'queue' => self::TRANSPORT_RABBITMQ, ]; } @@ -1652,7 +1677,7 @@ class ReferenceCheckService 'reset' => 0, 'queued' => 0, 'check_ids' => [], - 'queue' => self::QUEUE_NAME, + 'queue' => self::TRANSPORT_RABBITMQ, ]; } @@ -1661,7 +1686,7 @@ class ReferenceCheckService } $now = date('Y-m-d H:i:s'); - $resetFields = [ + $resetFields = $this->newCheckRecordFields([ 'status' => self::RECORD_PENDING, 'is_match' => 0, 'can_support' => 0, @@ -1669,14 +1694,13 @@ class ReferenceCheckService 'reason' => '', 'error_msg' => '', 'updated_at' => $now, - ]; + ], self::QUEUE_PENDING, 0); $pendingJobs = []; $amIds = []; foreach ($rows as $row) { $checkId = $this->resolveCheckRowId($row); Db::name('article_reference_check_result')->where('id', $checkId)->update($resetFields); - $this->clearReferenceCheckQueueLock($checkId); $pendingJobs[] = [ 'check_id' => $checkId, 'reference_no' => intval($this->arrGet($row, 'reference_no', 0)), @@ -1693,7 +1717,7 @@ class ReferenceCheckService $this->setAmRefCheckStatus($amId, self::AM_STATUS_RUNNING); } - $checkIds = $this->pushJobsSortedByReferenceNo($pendingJobs); + $checkIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'recheck_failed'); return [ 'p_refer_id' => $pReferId, @@ -1701,7 +1725,7 @@ class ReferenceCheckService 'reset' => count($rows), 'queued' => count($checkIds), 'check_ids' => $checkIds, - 'queue' => self::QUEUE_NAME, + 'queue' => self::TRANSPORT_RABBITMQ, ]; } @@ -1735,11 +1759,11 @@ class ReferenceCheckService 'reset' => 0, 'queued' => 0, 'check_ids' => [], - 'queue' => self::QUEUE_NAME, + 'queue' => self::TRANSPORT_RABBITMQ, ]; } - $resetFields = [ + $resetFields = $this->newCheckRecordFields([ 'refer_text' => $referText, 'p_refer_id' => $pReferId, 'p_article_id' => $pArticleId, @@ -1751,7 +1775,7 @@ class ReferenceCheckService 'reason' => '', 'error_msg' => '', 'updated_at' => $now, - ]; + ], self::QUEUE_PENDING, 0); $pendingJobs = []; $amIds = []; @@ -1790,7 +1814,6 @@ class ReferenceCheckService foreach ($pendingJobs as $job) { $checkId = intval($job['check_id']); $checkIds[] = $checkId; - $this->clearReferenceCheckQueueLock($checkId); try { $results[] = $this->runReferenceCheckOnce($checkId); } catch (\Exception $e) { @@ -1819,31 +1842,6 @@ class ReferenceCheckService ]; } - /** - * 清除队列 Redis 完成标记,避免重检任务被 acquireLock 静默丢弃 - */ - public function clearReferenceCheckQueueLock($checkId) - { - $checkId = intval($checkId); - if ($checkId <= 0) { - return; - } - try { - $keys = []; - foreach (['queue_job', 'queue_job_two'] as $prefix) { - $class = $prefix === 'queue_job_two' - ? 'app\\api\\job\\ReferenceCheckTwo' - : 'app\\api\\job\\ReferenceCheck'; - $base = $prefix . ':' . $class . ':' . $checkId; - $keys[] = $base; - $keys[] = $base . ':status'; - } - QueueRedis::getInstance()->deleteRedisKeys($keys); - } catch (\Exception $e) { - \think\Log::warning('clearReferenceCheckQueueLock id=' . $checkId . ' ' . $e->getMessage()); - } - } - /** * 执行一次引用 LLM 校对(同步,写回 article_reference_check_result) */ @@ -1884,8 +1882,7 @@ class ReferenceCheckService $confidence = floatval(isset($llmResult['confidence']) ? $llmResult['confidence'] : 0); $reason = isset($llmResult['reason']) ? $llmResult['reason'] : ''; - // LLM 通讯失败:写 status=RECORD_FAILED(3) + error_msg,抛异常让队列 worker 走 release(30) 重试; - // 重试 3 次后 ReferenceCheck::markFailed 会保持 status=3 收尾 + // LLM 通讯失败:写 status=RECORD_FAILED(3) + error_msg,抛异常由 MQ worker 重试 if ($requestFailed) { $this->updateCheckResult($checkId, [ 'confidence' => $confidence, @@ -1893,7 +1890,6 @@ class ReferenceCheckService 'status' => self::RECORD_FAILED, 'error_msg' => $reason, ]); - $this->clearReferenceCheckQueueLock($checkId); throw new \RuntimeException($reason !== '' ? $reason : 'LLM request failed'); } @@ -1906,8 +1902,9 @@ class ReferenceCheckService 'error_msg' => '', ]); - $this->clearReferenceCheckQueueLock($checkId); - $this->maybeEnqueueSecondPass($checkId, $confidence); + if ($confidence <= self::PASS_CONFIDENCE_THRESHOLD) { + $this->runSecondPassBlocking($checkId, $row, $contentA, $refer, $contentB); + } return [ 'check_id' => $checkId, @@ -1918,6 +1915,82 @@ class ReferenceCheckService ]; } + /** + * 低分结果的二轮 DOI 复核(同步阻塞执行;失败重试一次) + */ + public function runSecondPassBlocking($checkId, array $row, $contentA, $refer, $referText) + { + $checkId = intval($checkId); + if ($checkId <= 0) { + return false; + } + + $payload = $this->prepareRecheckPayload(is_array($refer) ? $refer : [], trim((string)$referText)); + if (empty($payload['has_abstract']) || trim((string)$payload['doi_block']) === '') { + return false; + } + + $lastError = ''; + for ($attempt = 0; $attempt < 2; $attempt++) { + try { + $llmResult = (new LLMService())->checkReference($contentA, trim((string)$referText), true, $payload['doi_block']); + $requestFailed = !empty($llmResult['request_failed']); + $canSupport = $this->parseLlmCanSupport($llmResult); + $confidence = floatval(isset($llmResult['confidence']) ? $llmResult['confidence'] : 0); + $tag = '[Crossref复核' . (trim((string)$payload['doi_used']) !== '' ? (' ' . trim((string)$payload['doi_used'])) : '') . ']'; + $reason = $tag . ' ' . (isset($llmResult['reason']) ? $llmResult['reason'] : ''); + + if ($requestFailed) { + $lastError = isset($llmResult['reason']) ? (string)$llmResult['reason'] : 'LLM request failed'; + if ($attempt < 1) { + continue; + } + $this->updateCheckResult($checkId, [ + 'confidence' => $confidence, + 'reason' => $reason, + 'status' => self::RECORD_FAILED, + 'error_msg' => $lastError, + ]); + $amId = intval(isset($row['am_id']) ? $row['am_id'] : 0); + if ($amId > 0) { + $this->syncAmRefCheckStatus($amId); + } + return false; + } + + $this->updateCheckResult($checkId, [ + 'can_support' => $canSupport ? 1 : 0, + 'is_match' => $canSupport ? 1 : 0, + 'confidence' => $confidence, + 'reason' => $reason, + 'status' => self::RECORD_COMPLETED, + 'error_msg' => '', + ]); + $amId = intval(isset($row['am_id']) ? $row['am_id'] : 0); + if ($amId > 0) { + $this->syncAmRefCheckStatus($amId); + } + return true; + } catch (\Exception $e) { + $lastError = $e->getMessage(); + if ($attempt < 1) { + continue; + } + $this->updateCheckResult($checkId, [ + 'status' => self::RECORD_FAILED, + 'error_msg' => $lastError, + ]); + $amId = intval(isset($row['am_id']) ? $row['am_id'] : 0); + if ($amId > 0) { + $this->syncAmRefCheckStatus($amId); + } + return false; + } + } + + return false; + } + /** * @return array{refer: array, p_article_id: int, p_refer_id: int, reference_no: int} */ @@ -2622,18 +2695,13 @@ class ReferenceCheckService } /** - * 第一轮 confidence<=0.65 且能抓到 DOI 真实内容时,延迟入队第二轮复核 - * - * 跳过条件(避免无意义重跑得到相同结果): - * - check_id 不合法 / 一次置信度高于阈值 - * - refer 行不存在 - * - refer_doi 为空或 Crossref 未返回摘要 + * 对已完成且低分的记录尝试同步 Crossref 二轮(供 enqueueSecondPassByArticle 等手工入口) */ - public function maybeEnqueueSecondPass($checkId, $confidence) + public function runSecondPassIfNeeded($checkId, $confidence) { $checkId = intval($checkId); $confidence = floatval($confidence); - if ($checkId <= 0 || $confidence > 0.65) { + if ($checkId <= 0 || $confidence > self::PASS_CONFIDENCE_THRESHOLD) { return false; } @@ -2658,9 +2726,13 @@ class ReferenceCheckService return false; } - $this->clearReferenceCheckQueueLock($checkId); - $this->pushJob2($checkId, 5); - return true; + $contentA = $this->resolveMainContentForJob($row); + $referText = trim((string)$this->arrGet($row, 'refer_text', '')); + if ($referText === '' && is_array($refer)) { + $referText = $this->formatReferForLlm($refer); + } + + return $this->runSecondPassBlocking($checkId, $row, $contentA, $refer, $referText); } /** @@ -3047,72 +3119,93 @@ class ReferenceCheckService } /** - * 已入库记录按文献编号正序入队(同号按 am_id、正文位置稳定排序) + * 批量记录已入库后创建文章批次并投递 RabbitMQ * - * @param array $rows 元素含 check_id、reference_no,可选 am_id、text_start + * @param array $rows 元素含 check_id + * @param int $pArticleId + * @param string $trigger enqueue|recheck_failed|manual + * @return int[] check_id 列表 */ - private function pushJobsSortedByReferenceNo(array $rows) + private function enqueueChecksSortedByReferenceNo(array $rows, $pArticleId = 0, $trigger = 'enqueue') { - if (empty($rows)) { + $checkIds = []; + foreach ($rows as $row) { + $checkId = intval($row['check_id']); + if ($checkId > 0) { + $checkIds[] = $checkId; + } + } + if (!empty($checkIds)) { + $this->startArticleCheckQueue($checkIds, intval($pArticleId), $trigger); + } + return $checkIds; + } + + /** + * 创建文章批次;队首批次立即发 MQ,其余批次等待前序完成 + * + * @param int[] $checkIds + * @param int $pArticleId + * @param string $trigger + * @return int[] + */ + public function startArticleCheckQueue(array $checkIds, $pArticleId = 0, $trigger = 'enqueue') + { + $checkIds = array_values(array_filter(array_map('intval', $checkIds))); + if (empty($checkIds)) { return []; } - usort($rows, function ($a, $b) { - if ($a['reference_no'] !== $b['reference_no']) { - return $a['reference_no'] - $b['reference_no']; - } - $amA = isset($a['am_id']) ? intval($a['am_id']) : 0; - $amB = isset($b['am_id']) ? intval($b['am_id']) : 0; - if ($amA !== $amB) { - return $amA - $amB; - } - $posA = isset($a['text_start']) ? intval($a['text_start']) : 0; - $posB = isset($b['text_start']) ? intval($b['text_start']) : 0; - return $posA - $posB; - }); + $pArticleId = intval($pArticleId); + if ($pArticleId <= 0) { + $firstRow = Db::name('article_reference_check_result')->where('id', $checkIds[0])->find(); + $pArticleId = empty($firstRow) ? 0 : intval($this->arrGet($firstRow, 'p_article_id', 0)); + } + if ($pArticleId <= 0) { + throw new \RuntimeException('p_article_id is required for reference check queue'); + } - $checkIds = []; - $delay = 0; - foreach ($rows as $row) { - $checkId = intval($row['check_id']); - $checkIds[] = $checkId; - $this->pushJob($checkId, $delay); - $delay++; + $now = date('Y-m-d H:i:s'); + $batchId = Db::name('article_reference_check_batch')->insertGetId([ + 'p_article_id' => $pArticleId, + 'batch_status' => 0, + 'total_count' => count($checkIds), + 'done_count' => 0, + 'failed_count' => 0, + 'trigger' => (string)$trigger, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $shouldPublish = !$this->hasEarlierWaitingBatch($batchId) && !$this->hasRunningReferenceCheckBatch(); + if ($shouldPublish) { + (new ReferenceCheckMqPublisher())->publishArticleStart($pArticleId, intval($batchId), $trigger); + $this->log('startArticleCheckQueue publish p_article_id=' . $pArticleId . ' batch_id=' . $batchId); + } else { + $this->log('startArticleCheckQueue queued batch_id=' . $batchId . ' p_article_id=' . $pArticleId); } return $checkIds; } - private function pushJob($checkId, $delaySeconds = 0) + private function hasRunningReferenceCheckBatch() { - $checkId = intval($checkId); - $this->clearReferenceCheckQueueLock($checkId); - $jobClass = 'app\api\job\ReferenceCheck@fire'; - $data = ['check_id' => $checkId]; - try { - if ($delaySeconds > 0) { - $jobId = Queue::later($delaySeconds, $jobClass, $data, self::QUEUE_NAME); - } else { - $jobId = Queue::push($jobClass, $data, self::QUEUE_NAME); - } - } catch (\Exception $e) { - \think\Log::error('ReferenceCheck pushJob failed check_id=' . $checkId . ' ' . $e->getMessage()); - throw $e; - } + return Db::name('article_reference_check_batch') + ->where('batch_status', 1) + ->count() > 0; } - private function pushJob2($checkId, $delaySeconds = 0) + + private function hasEarlierWaitingBatch($batchId) { - $jobClass = 'app\api\job\ReferenceCheckTwo@fire'; - $data = ['check_id' => $checkId]; - try { - if ($delaySeconds > 0) { - $jobId = Queue::later($delaySeconds, $jobClass, $data, self::QUEUE_NAME); - } else { - $jobId = Queue::push($jobClass, $data, self::QUEUE_NAME); - } - } catch (\Exception $e) { - \think\Log::error('ReferenceCheckTwo pushJob failed check_id=' . $checkId . ' ' . $e->getMessage()); - throw $e; - } + return Db::name('article_reference_check_batch') + ->where('batch_status', 0) + ->where('id', '<', intval($batchId)) + ->count() > 0; + } + + public function log($msg) + { + $line = date('Y-m-d H:i:s') . ' ' . $msg . PHP_EOL; + @file_put_contents($this->logFile, $line, FILE_APPEND); } } diff --git a/application/common/mq/RabbitMqConfig.php b/application/common/mq/RabbitMqConfig.php new file mode 100644 index 00000000..df30aa5e --- /dev/null +++ b/application/common/mq/RabbitMqConfig.php @@ -0,0 +1,24 @@ +svc = new ReferenceCheckService(); + } + + public function handleMessage(array $payload) + { + $pArticleId = intval(isset($payload['p_article_id']) ? $payload['p_article_id'] : 0); + $batchId = intval(isset($payload['batch_id']) ? $payload['batch_id'] : 0); + if ($pArticleId <= 0 || $batchId <= 0) { + $this->svc->log('ReferenceCheckArticleWorker invalid payload'); + return; + } + + if (!$this->canStartArticleWork($batchId)) { + $this->svc->log('ReferenceCheckArticleWorker defer batch_id=' . $batchId . ' other article running'); + (new ReferenceCheckMqPublisher())->publishArticleStart($pArticleId, $batchId, isset($payload['trigger']) ? $payload['trigger'] : 'enqueue'); + sleep(3); + return; + } + + if (!$this->claimBatch($batchId)) { + $batch = $this->getBatch($batchId); + if (empty($batch) || intval($batch['batch_status']) === self::BATCH_DONE) { + return; + } + } + + $this->svc->log('ReferenceCheckArticleWorker start p_article_id=' . $pArticleId . ' batch_id=' . $batchId); + + $done = 0; + $failed = 0; + while (true) { + $row = $this->fetchNextPendingRow($pArticleId); + if (empty($row)) { + break; + } + $checkId = $this->svc->resolveCheckRowId($row); + if ($checkId <= 0) { + continue; + } + $result = $this->processOneRow($checkId, $row); + if ($result === 'ok') { + $done++; + } elseif ($result === 'failed') { + $failed++; + } + } + + $this->finalizeBatch($batchId, $done, $failed); + $this->svc->log('ReferenceCheckArticleWorker done p_article_id=' . $pArticleId . ' batch_id=' . $batchId . ' done=' . $done . ' failed=' . $failed); + + $this->publishNextWaitingBatch(); + } + + private function canStartArticleWork($batchId) + { + $running = Db::name('article_reference_check_batch') + ->where('batch_status', self::BATCH_RUNNING) + ->where('id', '<>', intval($batchId)) + ->count(); + return intval($running) === 0; + } + + private function claimBatch($batchId) + { + $now = date('Y-m-d H:i:s'); + $affected = Db::name('article_reference_check_batch') + ->where('id', intval($batchId)) + ->whereIn('batch_status', [self::BATCH_WAITING, self::BATCH_RUNNING]) + ->update([ + 'batch_status' => self::BATCH_RUNNING, + 'updated_at' => $now, + ]); + return intval($affected) > 0; + } + + private function getBatch($batchId) + { + return Db::name('article_reference_check_batch')->where('id', intval($batchId))->find(); + } + + private function fetchNextPendingRow($pArticleId) + { + return Db::name('article_reference_check_result') + ->where('p_article_id', intval($pArticleId)) + ->where('queue_status', ReferenceCheckService::QUEUE_PENDING) + ->where('status', ReferenceCheckService::RECORD_PENDING) + ->order('reference_no asc,am_id asc,text_start asc,id asc') + ->find(); + } + + /** + * @return string ok|failed|skip + */ + private function processOneRow($checkId, array $row) + { + $claimed = Db::name('article_reference_check_result') + ->where('id', intval($checkId)) + ->where('queue_status', ReferenceCheckService::QUEUE_PENDING) + ->update(['queue_status' => ReferenceCheckService::QUEUE_RUNNING]); + if (intval($claimed) <= 0) { + return 'skip'; + } + + $retryCount = intval(isset($row['retry_count']) ? $row['retry_count'] : 0); + try { + $this->svc->runReferenceCheckOnce($checkId); + $amId = intval(isset($row['am_id']) ? $row['am_id'] : 0); + if ($amId > 0) { + $this->svc->syncAmRefCheckStatus($amId); + } + $this->svc->markQueueRuntime($checkId, ReferenceCheckService::QUEUE_COMPLETED, $retryCount); + return 'ok'; + } catch (\Exception $e) { + $this->svc->log('ReferenceCheckArticleWorker check_id=' . $checkId . ' err=' . $e->getMessage()); + if ($retryCount < ReferenceCheckService::QUEUE_MAX_RETRY) { + $this->svc->markQueueRuntime($checkId, ReferenceCheckService::QUEUE_PENDING, $retryCount + 1); + return $this->processOneRow($checkId, array_merge($row, ['retry_count' => $retryCount + 1])); + } + try { + $this->svc->updateCheckResult($checkId, [ + 'status' => ReferenceCheckService::RECORD_FAILED, + 'error_msg' => $e->getMessage(), + ]); + $this->svc->markQueueRuntime($checkId, ReferenceCheckService::QUEUE_FAILED, $retryCount); + } catch (\Exception $e2) { + \think\Log::error('ReferenceCheckArticleWorker markFailed: ' . $e2->getMessage()); + } + $amId = intval(isset($row['am_id']) ? $row['am_id'] : 0); + if ($amId > 0) { + $this->svc->syncAmRefCheckStatus($amId); + } + return 'failed'; + } + } + + private function finalizeBatch($batchId, $done, $failed) + { + $batch = $this->getBatch($batchId); + if (empty($batch)) { + return; + } + $total = intval($batch['total_count']); + $status = self::BATCH_DONE; + if ($failed > 0) { + $status = self::BATCH_PARTIAL_FAILED; + } + Db::name('article_reference_check_batch')->where('id', intval($batchId))->update([ + 'batch_status' => $status, + 'done_count' => intval($done), + 'failed_count' => intval($failed), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + if ($total > 0 && ($done + $failed) < $total) { + $this->svc->log('ReferenceCheckArticleWorker batch_id=' . $batchId . ' incomplete total=' . $total); + } + } + + private function publishNextWaitingBatch() + { + $next = Db::name('article_reference_check_batch') + ->where('batch_status', self::BATCH_WAITING) + ->order('id asc') + ->find(); + if (empty($next)) { + return; + } + try { + (new ReferenceCheckMqPublisher())->publishArticleStart( + intval($next['p_article_id']), + intval($next['id']), + isset($next['trigger']) ? $next['trigger'] : 'enqueue' + ); + } catch (\Exception $e) { + $this->svc->log('publishNextWaitingBatch failed: ' . $e->getMessage()); + \think\Log::error('publishNextWaitingBatch: ' . $e->getMessage()); + } + } +} diff --git a/application/common/mq/ReferenceCheckMqPublisher.php b/application/common/mq/ReferenceCheckMqPublisher.php new file mode 100644 index 00000000..f3831eec --- /dev/null +++ b/application/common/mq/ReferenceCheckMqPublisher.php @@ -0,0 +1,73 @@ + $pArticleId, + 'batch_id' => $batchId, + 'trigger' => (string)$trigger, + 'ts' => time(), + ], JSON_UNESCAPED_UNICODE); + + $rc = RabbitMqConfig::referenceCheck(); + $exchange = isset($rc['exchange']) ? $rc['exchange'] : 'reference_check'; + $routeKey = isset($rc['route_key']) ? $rc['route_key'] : 'article.start'; + + $conn = $this->connect(); + try { + $ch = $conn->channel(); + $this->declareTopology($ch, $rc); + $msg = new \PhpAmqpLib\Message\AMQPMessage($body, [ + 'content_type' => 'application/json', + 'delivery_mode' => \PhpAmqpLib\Message\AMQPMessage::DELIVERY_MODE_PERSISTENT, + ]); + $ch->basic_publish($msg, $exchange, $routeKey); + $ch->close(); + } finally { + $conn->close(); + } + + return true; + } + + private function connect() + { + return new \PhpAmqpLib\Connection\AMQPStreamConnection( + RabbitMqConfig::get('host', '127.0.0.1'), + intval(RabbitMqConfig::get('port', 5672)), + RabbitMqConfig::get('user', 'guest'), + RabbitMqConfig::get('password', 'guest'), + RabbitMqConfig::get('vhost', '/') + ); + } + + private function declareTopology($ch, array $rc) + { + $exchange = isset($rc['exchange']) ? $rc['exchange'] : 'reference_check'; + $queue = isset($rc['queue']) ? $rc['queue'] : 'ref_check.article'; + $dlq = isset($rc['dlq']) ? $rc['dlq'] : 'ref_check.article.dlq'; + $routeKey = isset($rc['route_key']) ? $rc['route_key'] : 'article.start'; + + $ch->exchange_declare($exchange, 'direct', false, true, false); + $ch->queue_declare($dlq, false, true, false, false); + $ch->queue_declare($queue, false, true, false, false, false, new \PhpAmqpLib\Wire\AMQPTable([ + 'x-dead-letter-exchange' => '', + 'x-dead-letter-routing-key' => $dlq, + ])); + $ch->queue_bind($queue, $exchange, $routeKey); + } +} diff --git a/application/common/service/LLMService.php b/application/common/service/LLMService.php index 69f5e61c..fdd1fb42 100644 --- a/application/common/service/LLMService.php +++ b/application/common/service/LLMService.php @@ -33,7 +33,7 @@ class LLMService public function checkReference($contextText, $referText, $isAgain = false, $doiBlock = null) { // request_failed=true 表示"LLM 通讯/解析层面的失败"(可重试,区别于业务上的"未命中"); - // 上游 runReferenceCheckOnce 会据此把 DB.status 置为 2(失败) 并抛异常触发队列重试 + // 上游 runReferenceCheckOnce 会据此把 DB.status 置为 3(失败) 并抛异常触发 MQ worker 重试 $fallback = [ 'can_support' => false, 'is_match' => false, diff --git a/application/extra/rabbitmq.php b/application/extra/rabbitmq.php new file mode 100644 index 00000000..a98eb526 --- /dev/null +++ b/application/extra/rabbitmq.php @@ -0,0 +1,16 @@ + '127.0.0.1', + 'port' => 5672, + 'user' => 'admin', + 'password' => '751019', + 'vhost' => '/', + + 'reference_check' => [ + 'exchange' => 'reference_check', + 'queue' => 'ref_check.article', + 'dlq' => 'ref_check.article.dlq', + 'route_key' => 'article.start', + ], +]; diff --git a/composer.json b/composer.json index 521fb0a8..cd86d23c 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "phpoffice/phpspreadsheet": "^1.12", "paypal/paypal-server-sdk": "^0.6.1", "guzzlehttp/guzzle": "^7.9", + "php-amqplib/php-amqplib": "^2.12", "tectalic/openai": "^1.6" }, "autoload": { @@ -42,6 +43,14 @@ "allow-plugins": { "topthink/think-installer": true, "php-http/discovery": true - } - } + }, + "secure-http": false + }, + "repositories": [{ + "name": "aliyun", + "type": "composer", + "url": "http://mirrors.aliyun.com/composer/" + },{ + "packagist": false + }] } diff --git a/vendor/autoload.php b/vendor/autoload.php index bfdba2e0..9eaf68c9 100644 --- a/vendor/autoload.php +++ b/vendor/autoload.php @@ -14,9 +14,12 @@ if (PHP_VERSION_ID < 50600) { echo $err; } } - throw new RuntimeException($err); + trigger_error( + $err, + E_USER_ERROR + ); } require_once __DIR__ . '/composer/autoload_real.php'; -return ComposerAutoloaderInit7020b987d316c2076c2a6f439a1140f9::getLoader(); +return ComposerAutoloaderInit2bc4f313dba415539e266f7ac2c87dcd::getLoader(); diff --git a/vendor/composer/autoload_psr4.php b/vendor/composer/autoload_psr4.php index ee3d45d2..6db206ef 100644 --- a/vendor/composer/autoload_psr4.php +++ b/vendor/composer/autoload_psr4.php @@ -9,6 +9,7 @@ return array( 'think\\composer\\' => array($vendorDir . '/topthink/think-installer/src'), 'think\\captcha\\' => array($vendorDir . '/topthink/think-captcha/src'), 'think\\' => array($vendorDir . '/topthink/think-queue/src', $vendorDir . '/topthink/think-image/src', $vendorDir . '/topthink/think-helper/src', $baseDir . '/thinkphp/library/think'), + 'phpseclib3\\' => array($vendorDir . '/phpseclib/phpseclib/phpseclib'), 'app\\' => array($baseDir . '/application'), 'apimatic\\jsonmapper\\' => array($vendorDir . '/apimatic/jsonmapper/src'), 'ZipStream\\' => array($vendorDir . '/maennchen/zipstream-php/src'), @@ -25,7 +26,9 @@ return array( 'PhpOffice\\PhpWord\\' => array($vendorDir . '/phpoffice/phpword/src/PhpWord'), 'PhpOffice\\PhpSpreadsheet\\' => array($vendorDir . '/phpoffice/phpspreadsheet/src/PhpSpreadsheet'), 'PhpOffice\\Math\\' => array($vendorDir . '/phpoffice/math/src/Math'), + 'PhpAmqpLib\\' => array($vendorDir . '/php-amqplib/php-amqplib/PhpAmqpLib'), 'PaypalServerSdkLib\\' => array($vendorDir . '/paypal/paypal-server-sdk/src'), + 'ParagonIE\\ConstantTime\\' => array($vendorDir . '/paragonie/constant_time_encoding/src'), 'PHPMailer\\PHPMailer\\' => array($vendorDir . '/phpmailer/phpmailer/src'), 'Nyholm\\Psr7\\' => array($vendorDir . '/nyholm/psr7/src'), 'MyCLabs\\Enum\\' => array($vendorDir . '/myclabs/php-enum/src'), diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php index 836bb54e..fc6d53f6 100644 --- a/vendor/composer/autoload_static.php +++ b/vendor/composer/autoload_static.php @@ -14,6 +14,7 @@ class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9 '7b11c4dc42b3b3023073cb14e519683c' => __DIR__ . '/..' . '/ralouphie/getallheaders/src/getallheaders.php', '2cffec82183ee1cea088009cef9a6fc3' => __DIR__ . '/..' . '/ezyang/htmlpurifier/library/HTMLPurifier.composer.php', '8cff32064859f4559445b89279f3199c' => __DIR__ . '/..' . '/php-http/message/src/filters.php', + 'decc78cc4436b1292c6c0d151b19445c' => __DIR__ . '/..' . '/phpseclib/phpseclib/phpseclib/bootstrap.php', '9b552a3cc426e3287cc811caefa3cf53' => __DIR__ . '/..' . '/topthink/think-helper/src/helper.php', '37a3dc5111fe8f707ab4c132ef1dbc62' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/functions_include.php', '1cfd2761b63b0a29ed23657ea394cb2d' => __DIR__ . '/..' . '/topthink/think-captcha/src/helper.php', @@ -27,6 +28,10 @@ class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9 'think\\captcha\\' => 14, 'think\\' => 6, ), + 'p' => + array ( + 'phpseclib3\\' => 11, + ), 'a' => array ( 'app\\' => 4, @@ -60,7 +65,9 @@ class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9 'PhpOffice\\PhpWord\\' => 18, 'PhpOffice\\PhpSpreadsheet\\' => 25, 'PhpOffice\\Math\\' => 15, + 'PhpAmqpLib\\' => 11, 'PaypalServerSdkLib\\' => 19, + 'ParagonIE\\ConstantTime\\' => 23, 'PHPMailer\\PHPMailer\\' => 20, ), 'N' => @@ -109,6 +116,10 @@ class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9 2 => __DIR__ . '/..' . '/topthink/think-helper/src', 3 => __DIR__ . '/../..' . '/thinkphp/library/think', ), + 'phpseclib3\\' => + array ( + 0 => __DIR__ . '/..' . '/phpseclib/phpseclib/phpseclib', + ), 'app\\' => array ( 0 => __DIR__ . '/../..' . '/application', @@ -174,10 +185,18 @@ class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9 array ( 0 => __DIR__ . '/..' . '/phpoffice/math/src/Math', ), + 'PhpAmqpLib\\' => + array ( + 0 => __DIR__ . '/..' . '/php-amqplib/php-amqplib/PhpAmqpLib', + ), 'PaypalServerSdkLib\\' => array ( 0 => __DIR__ . '/..' . '/paypal/paypal-server-sdk/src', ), + 'ParagonIE\\ConstantTime\\' => + array ( + 0 => __DIR__ . '/..' . '/paragonie/constant_time_encoding/src', + ), 'PHPMailer\\PHPMailer\\' => array ( 0 => __DIR__ . '/..' . '/phpmailer/phpmailer/src', diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json index 103df237..bcd37877 100644 --- a/vendor/composer/installed.json +++ b/vendor/composer/installed.json @@ -953,6 +953,141 @@ }, "install-path": "../nyholm/psr7" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v2.8.2", + "version_normalized": "2.8.2.0", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "e30811f7bc69e4b5b6d5783e712c06c8eabf0226" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/e30811f7bc69e4b5b6d5783e712c06c8eabf0226", + "reference": "e30811f7bc69e4b5b6d5783e712c06c8eabf0226", + "shasum": "", + "mirrors": [ + { + "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", + "preferred": true + } + ] + }, + "require": { + "php": "^7|^8" + }, + "require-dev": { + "phpunit/phpunit": "^6|^7|^8|^9", + "vimeo/psalm": "^1|^2|^3|^4" + }, + "time": "2025-09-24T15:12:37+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "install-path": "../paragonie/constant_time_encoding" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "version_normalized": "9.99.100.0", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "", + "mirrors": [ + { + "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", + "preferred": true + } + ] + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "time": "2020-10-15T08:29:30+00:00", + "type": "library", + "installation-source": "dist", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "install-path": "../paragonie/random_compat" + }, { "name": "paypal/paypal-server-sdk", "version": "0.6.1", @@ -999,6 +1134,96 @@ }, "install-path": "../paypal/paypal-server-sdk" }, + { + "name": "php-amqplib/php-amqplib", + "version": "v2.12.3", + "version_normalized": "2.12.3.0", + "source": { + "type": "git", + "url": "https://github.com/php-amqplib/php-amqplib.git", + "reference": "f746eb44df6d8f838173729867dd1d20b0265faa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-amqplib/php-amqplib/zipball/f746eb44df6d8f838173729867dd1d20b0265faa", + "reference": "f746eb44df6d8f838173729867dd1d20b0265faa", + "shasum": "", + "mirrors": [ + { + "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", + "preferred": true + } + ] + }, + "require": { + "ext-mbstring": "*", + "ext-sockets": "*", + "php": ">=5.6.3,<8.0", + "phpseclib/phpseclib": "^2.0|^3.0" + }, + "conflict": { + "php": "7.4.0 - 7.4.1" + }, + "replace": { + "videlalvaro/php-amqplib": "self.version" + }, + "require-dev": { + "ext-curl": "*", + "nategood/httpful": "^0.2.20", + "phpunit/phpunit": "^5.7|^6.5|^7.0", + "squizlabs/php_codesniffer": "^3.5" + }, + "time": "2021-03-01T12:21:31+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.12-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "PhpAmqpLib\\": "PhpAmqpLib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Alvaro Videla", + "role": "Original Maintainer" + }, + { + "name": "Raúl Araya", + "email": "nubeiro@gmail.com", + "role": "Maintainer" + }, + { + "name": "Luke Bakken", + "email": "luke@bakken.io", + "role": "Maintainer" + }, + { + "name": "Ramūnas Dronga", + "email": "github@ramuno.lt", + "role": "Maintainer" + } + ], + "description": "Formerly videlalvaro/php-amqplib. This library is a pure PHP implementation of the AMQP protocol. It's been tested against RabbitMQ.", + "homepage": "https://github.com/php-amqplib/php-amqplib/", + "keywords": [ + "message", + "queue", + "rabbitmq" + ], + "support": { + "issues": "https://github.com/php-amqplib/php-amqplib/issues", + "source": "https://github.com/php-amqplib/php-amqplib/tree/v2.12.3" + }, + "install-path": "../php-amqplib/php-amqplib" + }, { "name": "php-http/discovery", "version": "1.20.0", @@ -1693,6 +1918,125 @@ }, "install-path": "../phpoffice/phpword" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.48", + "version_normalized": "3.0.48.0", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/64065a5679c50acb886e82c07aa139b0f757bb89", + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89", + "shasum": "", + "mirrors": [ + { + "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", + "preferred": true + } + ] + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "time": "2025-12-15T11:51:42+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.48" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "install-path": "../phpseclib/phpseclib" + }, { "name": "psr/http-client", "version": "1.0.3", @@ -2712,6 +3056,6 @@ "install-path": "../topthink/think-queue" } ], - "dev": false, + "dev": true, "dev-package-names": [] } diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php index 6ab50d37..91616f00 100644 --- a/vendor/composer/installed.php +++ b/vendor/composer/installed.php @@ -3,11 +3,11 @@ 'name' => 'topthink/think', 'pretty_version' => 'dev-master', 'version' => 'dev-master', - 'reference' => 'fa878334cd151a29627aac8f2e01d8ce27770606', + 'reference' => '94b212fe7c6ec47113eeff7ab2125e0e1636d328', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), - 'dev' => false, + 'dev' => true, ), 'versions' => array( 'apimatic/core' => array( @@ -136,6 +136,24 @@ 'aliases' => array(), 'dev_requirement' => false, ), + 'paragonie/constant_time_encoding' => array( + 'pretty_version' => 'v2.8.2', + 'version' => '2.8.2.0', + 'reference' => 'e30811f7bc69e4b5b6d5783e712c06c8eabf0226', + 'type' => 'library', + 'install_path' => __DIR__ . '/../paragonie/constant_time_encoding', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'paragonie/random_compat' => array( + 'pretty_version' => 'v9.99.100', + 'version' => '9.99.100.0', + 'reference' => '996434e5492cb4c3edcb9168db6fbb1359ef965a', + 'type' => 'library', + 'install_path' => __DIR__ . '/../paragonie/random_compat', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'paypal/paypal-server-sdk' => array( 'pretty_version' => '0.6.1', 'version' => '0.6.1.0', @@ -145,6 +163,15 @@ 'aliases' => array(), 'dev_requirement' => false, ), + 'php-amqplib/php-amqplib' => array( + 'pretty_version' => 'v2.12.3', + 'version' => '2.12.3.0', + 'reference' => 'f746eb44df6d8f838173729867dd1d20b0265faa', + 'type' => 'library', + 'install_path' => __DIR__ . '/../php-amqplib/php-amqplib', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'php-http/async-client-implementation' => array( 'dev_requirement' => false, 'provided' => array( @@ -244,6 +271,15 @@ 'aliases' => array(), 'dev_requirement' => false, ), + 'phpseclib/phpseclib' => array( + 'pretty_version' => '3.0.48', + 'version' => '3.0.48.0', + 'reference' => '64065a5679c50acb886e82c07aa139b0f757bb89', + 'type' => 'library', + 'install_path' => __DIR__ . '/../phpseclib/phpseclib', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'psr/http-client' => array( 'pretty_version' => '1.0.3', 'version' => '1.0.3.0', @@ -385,7 +421,7 @@ 'topthink/think' => array( 'pretty_version' => 'dev-master', 'version' => 'dev-master', - 'reference' => 'fa878334cd151a29627aac8f2e01d8ce27770606', + 'reference' => '94b212fe7c6ec47113eeff7ab2125e0e1636d328', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -436,5 +472,11 @@ 'aliases' => array(), 'dev_requirement' => false, ), + 'videlalvaro/php-amqplib' => array( + 'dev_requirement' => false, + 'replaced' => array( + 0 => 'v2.12.3', + ), + ), ), );