diff --git a/.env b/.env index 258571d4..b3ad4df2 100644 --- a/.env +++ b/.env @@ -46,6 +46,8 @@ APPLY_URL="https://submission.tmrjournals.com/youthBoardRegister" [turnitin] viewer_permission_set=ADMINISTRATOR viewer_locale=en-US +; viewer-url 必填 viewer_user_id;默认用查重记录的 triggered_by → editor_{id},也可写死: +;viewer_user_id=editor_1 ; 与 Crossref 网页手动查重对齐:三项默认 0(不排除引用/参考文献/引文)。若只要正文相似度可改为 1 exclude_quotes=0 exclude_bibliography=0 diff --git a/application/api/controller/Plagiarism.php b/application/api/controller/Plagiarism.php index 9400c92b..9d0020b7 100644 --- a/application/api/controller/Plagiarism.php +++ b/application/api/controller/Plagiarism.php @@ -183,11 +183,18 @@ class Plagiarism extends Base } /** - * 取在线查看 URL;过期则自动刷新 + * 取在线查看 URL(Turnitin 一次性会话链接,关闭报告页后勿复用旧 URL) + * + * 入参: + * check_id 必填 + * editor_id 选填,当前打开报告的编辑 user_id(与 viewer_user_id 对应,避免 session 认证失败) + * reuse 选填,1=在未过期时复用库内缓存;默认 0,每次调用重新向 Turnitin 申请 */ public function getReportUrl() { $checkId = intval($this->request->param('check_id', 0)); + $editorId = intval($this->request->param('editor_id', 0)); + $reuse = intval($this->request->param('reuse', 0)) === 1; if ($checkId <= 0) { return jsonError('check_id required'); } @@ -199,25 +206,37 @@ class Plagiarism extends Base if ($row['state'] != 3) { return jsonError('check not completed yet, state=' . $row['state']); } - $needRefresh = empty($row['view_only_url']) + $viewerContext = []; + if ($editorId > 0) { + $viewerContext['editor_id'] = $editorId; + } + $needRefresh = !$reuse + || empty($row['view_only_url']) || intval($row['view_only_url_expire']) < time() + 60; + $usageHint = '每次打开请先调用本接口获取新链接;勿收藏或再次打开旧链接。请在新标签页打开,并允许 Turnitin 域名 Cookie。'; + if ($needRefresh) { $svc = new PlagiarismService(); - $info = $svc->refreshViewerUrlFor($checkId); + $info = $svc->refreshViewerUrlFor($checkId, $viewerContext); if ($info['url'] === '') { return jsonError('Turnitin returned empty viewer_url'); } return jsonSuccess([ - 'view_only_url' => $info['url'], - 'expire' => $info['expire'], - 'has_pdf' => !empty($info['local_pdf']), + 'view_only_url' => $info['url'], + 'expire' => $info['expire'], + 'has_pdf' => !empty($info['local_pdf']), + 'viewer_user_id' => $info['viewer_user_id'], + 'refreshed' => true, + 'usage_hint' => $usageHint, ]); } return jsonSuccess([ - 'view_only_url' => $row['view_only_url'], - 'expire' => intval($row['view_only_url_expire']), - 'has_pdf' => !empty($row['pdf_local_path']), + 'view_only_url' => $row['view_only_url'], + 'expire' => intval($row['view_only_url_expire']), + 'has_pdf' => !empty($row['pdf_local_path']), + 'refreshed' => false, + 'usage_hint' => $usageHint, ]); } catch (\Throwable $e) { if (!empty($row['pdf_local_path'])) { diff --git a/application/api/job/AiCheckReferByDoi.php b/application/api/job/AiCheckReferByDoi.php new file mode 100644 index 00000000..750b6374 --- /dev/null +++ b/application/api/job/AiCheckReferByDoi.php @@ -0,0 +1,85 @@ +oQueueJob = new QueueJob; + $this->QueueRedis = QueueRedis::getInstance(); + } + + public function fire(Job $job, $data) + { + //任务开始判断 + $this->oQueueJob->init($job); + + // 获取 Redis 任务的原始数据 + $rawBody = empty($job->getRawBody()) ? '' : $job->getRawBody(); + $jobData = empty($rawBody) ? [] : json_decode($rawBody, true); + $jobId = empty($jobData['id']) ? 'unknown' : $jobData['id']; + + $this->oQueueJob->log("-----------队列任务开始-----------"); + $this->oQueueJob->log("当前任务ID: {$jobId}, 尝试次数: {$job->attempts()}"); + + + // 获取生产文章ID + $iPArticleId = empty($data['p_article_id']) ? 0 : $data['p_article_id']; + if (empty($iPArticleId)) { + $this->oQueueJob->log("无效的p_article_id,删除任务"); + $job->delete(); + return; + } + // 获取参考文献ID + $iPReferId = empty($data['p_refer_id']) ? 0 : $data['p_refer_id']; + if (empty($iPArticleId)) { + $this->oQueueJob->log("无效的p_article_id,删除任务"); + $job->delete(); + return; + } + try { + + // 生成Redis键并尝试获取锁 + $sClassName = get_class($this); + $sRedisKey = "queue_job:{$sClassName}:{$iPArticleId}:{$iPReferId}"; + $sRedisValue = uniqid() . '_' . getmypid(); + if (!$this->oQueueJob->acquireLock($sRedisKey, $sRedisValue, $job)) { + return; // 未获取到锁,已处理 + } + + //生成内容 + $oProductionArticleRefer = new \app\api\controller\References; + $response = $oProductionArticleRefer->getCheckByAiResult($data); + // 验证API响应 + if (empty($response)) { + throw new \RuntimeException("OpenAI API返回空结果"); + } + // 检查JSON解析错误 + $aResult = json_decode($response, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException("解析OpenAI响应失败: " . json_last_error_msg() . " | 原始响应: {$response}"); + } + $sMsg = empty($aResult['msg']) ? 'success' : $aResult['msg']; + //更新完成标识 + $this->QueueRedis->finishJob($sRedisKey, 'completed', $this->completedExprie,$sRedisValue); + $job->delete(); + $this->oQueueJob->log("任务执行成功 | 日志ID: {$sRedisKey} | 执行日志:{$sMsg}"); + + } 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(); + } + } +} \ No newline at end of file diff --git a/application/api/job/ArticleReferDetailQueue.php b/application/api/job/ArticleReferDetailQueue.php new file mode 100644 index 00000000..12190846 --- /dev/null +++ b/application/api/job/ArticleReferDetailQueue.php @@ -0,0 +1,92 @@ +oQueueJob = new QueueJob; + $this->QueueRedis = QueueRedis::getInstance(); + } + + public function fire(Job $job, $data) + { + //任务开始判断 + $this->oQueueJob->init($job); + + // 获取 Redis 任务的原始数据 + $rawBody = empty($job->getRawBody()) ? '' : $job->getRawBody(); + $jobData = empty($rawBody) ? [] : json_decode($rawBody, true); + $jobId = empty($jobData['id']) ? 'unknown' : $jobData['id']; + + $this->oQueueJob->log("-----------队列任务开始-----------"); + $this->oQueueJob->log("当前任务ID: {$jobId}, 尝试次数: {$job->attempts()}"); + + // // 获取文章ID + // $iArticleId = empty($data['article_id']) ? 0 : $data['article_id']; + // if (empty($iArticleId)) { + // $this->oQueueJob->log("无效的article_id,删除任务"); + // $job->delete(); + // return; + // } + // 获取生产文章ID + $iPArticleId = empty($data['p_article_id']) ? 0 : $data['p_article_id']; + if (empty($iPArticleId)) { + $this->oQueueJob->log("无效的p_article_id,删除任务"); + $job->delete(); + return; + } + // 获取生产文章ID + $iPReferId = empty($data['p_refer_id']) ? 0 : $data['p_refer_id']; + if (empty($iPReferId)) { + $this->oQueueJob->log("无效的p_refer_id,删除任务"); + $job->delete(); + return; + } + try { + + // 生成Redis键并尝试获取锁 + $sClassName = get_class($this); + $sRedisKey = "queue_job:{$sClassName}:{$iPArticleId}:{$iPReferId}"; + $sRedisValue = uniqid() . '_' . getmypid(); + if (!$this->oQueueJob->acquireLock($sRedisKey, $sRedisValue, $job)) { + return; // 未获取到锁,已处理 + } + + //生成内容 + $oProductionArticleRefer = new ProductionArticleRefer; + $response = $oProductionArticleRefer->get($data); + // 验证API响应 + if (empty($response)) { + throw new \RuntimeException("返回空结果"); + } + // 检查JSON解析错误 + $aResult = json_decode($response, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException("解析响应失败: " . json_last_error_msg() . " | 原始响应: {$response}"); + } + $sMsg = empty($aResult['msg']) ? 'success' : $aResult['msg']; + //更新完成标识 + $this->QueueRedis->finishJob($sRedisKey, 'completed', $this->completedExprie,$sRedisValue); + $job->delete(); + $this->oQueueJob->log("任务执行成功 | 日志ID: {$sRedisKey} | 执行日志:{$sMsg}"); + + } 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(); + } + } +} \ No newline at end of file diff --git a/application/api/job/ArticleReferQueue.php b/application/api/job/ArticleReferQueue.php new file mode 100644 index 00000000..e35ecc5a --- /dev/null +++ b/application/api/job/ArticleReferQueue.php @@ -0,0 +1,85 @@ +oQueueJob = new QueueJob; + $this->QueueRedis = QueueRedis::getInstance(); + } + + public function fire(Job $job, $data) + { + //任务开始判断 + $this->oQueueJob->init($job); + + // 获取 Redis 任务的原始数据 + $rawBody = empty($job->getRawBody()) ? '' : $job->getRawBody(); + $jobData = empty($rawBody) ? [] : json_decode($rawBody, true); + $jobId = empty($jobData['id']) ? 'unknown' : $jobData['id']; + + $this->oQueueJob->log("-----------队列任务开始-----------"); + $this->oQueueJob->log("当前任务ID: {$jobId}, 尝试次数: {$job->attempts()}"); + + // 获取文章ID + $iArticleId = empty($data['article_id']) ? 0 : $data['article_id']; + if (empty($iArticleId)) { + $this->oQueueJob->log("无效的article_id,删除任务"); + $job->delete(); + return; + } + // 获取生产文章ID + $iPArticleId = empty($data['p_article_id']) ? 0 : $data['p_article_id']; + if (empty($iPArticleId)) { + $this->oQueueJob->log("无效的p_article_id,删除任务"); + $job->delete(); + return; + } + try { + + // 生成Redis键并尝试获取锁 + $sClassName = get_class($this); + $sRedisKey = "queue_job:{$sClassName}:{$iArticleId}:{$iPArticleId}"; + $sRedisValue = uniqid() . '_' . getmypid(); + if (!$this->oQueueJob->acquireLock($sRedisKey, $sRedisValue, $job)) { + return; // 未获取到锁,已处理 + } + + //生成内容 + $oProductionArticleRefer = new ProductionArticleRefer; + $response = $oProductionArticleRefer->top($data); + // 验证API响应 + if (empty($response)) { + throw new \RuntimeException("OpenAI API返回空结果"); + } + // 检查JSON解析错误 + $aResult = json_decode($response, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException("解析OpenAI响应失败: " . json_last_error_msg() . " | 原始响应: {$response}"); + } + $sMsg = empty($aResult['msg']) ? 'success' : $aResult['msg']; + //更新完成标识 + $this->QueueRedis->finishJob($sRedisKey, 'completed', $this->completedExprie,$sRedisValue); + $job->delete(); + $this->oQueueJob->log("任务执行成功 | 日志ID: {$sRedisKey} | 执行日志:{$sMsg}"); + + } 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(); + } + } +} \ No newline at end of file diff --git a/application/api/job/ReminderEmailToReviewer.php b/application/api/job/ReminderEmailToReviewer.php new file mode 100644 index 00000000..312bc8ac --- /dev/null +++ b/application/api/job/ReminderEmailToReviewer.php @@ -0,0 +1,101 @@ +oQueueJob = new QueueJob; + $this->QueueRedis = QueueRedis::getInstance(); + } + + public function fire(Job $job, $data) + { + //任务开始判断 + $this->oQueueJob->init($job); + + // 获取 Redis 任务的原始数据 + $rawBody = empty($job->getRawBody()) ? '' : $job->getRawBody(); + $jobData = empty($rawBody) ? [] : json_decode($rawBody, true); + $jobId = empty($jobData['id']) ? 'unknown' : $jobData['id']; + + $this->oQueueJob->log("-----------队列任务开始-----------"); + $this->oQueueJob->log("当前任务ID: {$jobId}, 尝试次数: {$job->attempts()}"); + + try { + + // 验证任务数据完整性 + // 获取文章ID + $iArticleId = empty($data['article_id']) ? 0 : $data['article_id']; + //审稿记录表主键ID + $art_rev_id = empty($data['art_rev_id']) ? 0 : $data['art_rev_id']; + //审稿人ID + $reviewer_id = empty($data['reviewer_id']) ? 0 : $data['reviewer_id']; + //邮件类型 + $email_type = empty($data['email_type']) ? 0 : $data['email_type']; + if (empty($iArticleId)) { + $this->oQueueJob->log("无效的article_id,删除任务"); + $job->delete(); + return; + } + if (empty($art_rev_id)) { + $this->oQueueJob->log("无效的art_rev_id,删除任务"); + $job->delete(); + return; + } + if (empty($reviewer_id)) { + $this->oQueueJob->log("无效的reviewer_id,删除任务"); + $job->delete(); + return; + } + if (empty($email_type)) { + $this->oQueueJob->log("无效的email_type,删除任务"); + $job->delete(); + return; + } + // 生成唯一任务标识 + $sClassName = get_class($this); + $sRedisKey = "queue_job:{$sClassName}:{$iArticleId}:{$reviewer_id}:{$art_rev_id}:{$email_type}"; + $sRedisValue = uniqid() . '_' . getmypid(); + if (!$this->oQueueJob->acquireLock($sRedisKey, $sRedisValue, $job)) { + return; // 未获取到锁,已处理 + } + + // 执行核心任务 + //查询是否发送过邮件 + $oCronreview = new Cronreview; + $response = $oCronreview->reminder($data); + // 验证API响应 + if (empty($response)) { + throw new \RuntimeException("OpenAI API返回空结果"); + } + // 检查JSON解析错误 + $aResult = json_decode($response, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException("解析OpenAI响应失败: " . json_last_error_msg() . " | 原始响应: {$response}"); + } + $sMsg = empty($aResult['msg']) ? 'success' : $aResult['msg']; + //更新完成标识 + $this->QueueRedis->finishJob($sRedisKey, 'completed', $this->completedExprie,$sRedisValue); + $job->delete(); + $this->oQueueJob->log("任务执行成功 | 日志ID: {$sRedisKey} | 执行日志:{$sMsg}"); + + } 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(); + } + } +} \ No newline at end of file diff --git a/application/common/PlagiarismService.php b/application/common/PlagiarismService.php index 0bdcb060..1927c124 100644 --- a/application/common/PlagiarismService.php +++ b/application/common/PlagiarismService.php @@ -373,24 +373,26 @@ class PlagiarismService /** * 按需获取/刷新 Turnitin 在线报告 URL(与 poll 解耦,避免 viewer-url 失败拖死查重完成)。 * - * @return array{url:string, expire:int, local_pdf:string} + * @param array $viewerContext editor_id=当前打开报告的编辑 user_id;viewer_user_id 可显式指定 + * @return array{url:string, expire:int, local_pdf:string, viewer_user_id:string} */ - public function refreshViewerUrlFor($checkId) + public function refreshViewerUrlFor($checkId, array $viewerContext = []) { $check = $this->mustGetCheck($checkId); if (empty($check['tii_submission_id'])) { throw new Exception('check has no tii_submission_id'); } $tii = new TurnitinService(); - $info = $this->refreshViewerUrl($tii, $check['tii_submission_id']); + $info = $this->refreshViewerUrl($tii, $check['tii_submission_id'], $check, $viewerContext); $this->updateCheck($checkId, [ 'view_only_url' => $info['url'], 'view_only_url_expire' => $info['expire'], ]); return [ - 'url' => $info['url'], - 'expire' => $info['expire'], - 'local_pdf' => $check['pdf_local_path'], + 'url' => $info['url'], + 'expire' => $info['expire'], + 'local_pdf' => $check['pdf_local_path'], + 'viewer_user_id' => $info['viewer_user_id'], ]; } @@ -399,9 +401,14 @@ class PlagiarismService /** * 调用 Turnitin POST viewer-url;仅由 refreshViewerUrlFor / getReportUrl 触发。 */ - private function refreshViewerUrl($tii, $submissionId) + private function refreshViewerUrl($tii, $submissionId, array $check = [], array $viewerContext = []) { - $resp = $tii->getViewerUrl($submissionId); + $viewerOpts = $viewerContext; + if (!isset($viewerOpts['editor_id']) && !empty($check['triggered_by'])) { + $viewerOpts['triggered_by'] = intval($check['triggered_by']); + } + $viewerUserId = $tii->resolveViewerUserId($viewerOpts); + $resp = $tii->getViewerUrl($submissionId, $viewerOpts); $url = ''; if (isset($resp['viewer_url'])) { $url = (string) $resp['viewer_url']; @@ -413,8 +420,22 @@ class PlagiarismService if ($url === '') { throw new Exception('viewer-url response has no url: ' . json_encode($resp, JSON_UNESCAPED_UNICODE)); } - // 默认 2 小时过期,保守起见 - return ['url' => $url, 'expire' => time() + 7200]; + $expire = time() + 7200; + foreach (['viewer_url_expires', 'expires_at', 'expiration_time', 'expire_time'] as $k) { + if (empty($resp[$k])) { + continue; + } + $ts = is_numeric($resp[$k]) ? intval($resp[$k]) : strtotime((string) $resp[$k]); + if ($ts > time()) { + $expire = $ts; + break; + } + } + return [ + 'url' => $url, + 'expire' => $expire, + 'viewer_user_id' => $viewerUserId, + ]; } /** diff --git a/application/common/TurnitinService.php b/application/common/TurnitinService.php index cf2be2dd..0e1dc9aa 100644 --- a/application/common/TurnitinService.php +++ b/application/common/TurnitinService.php @@ -488,7 +488,7 @@ class TurnitinService * Crossref 通道常用 ADMINISTRATOR/USER,非 INSTRUCTOR。可在 .env 配置: * turnitin.viewer_permission_set=ADMINISTRATOR * - * @param array $viewer 可选,覆盖默认 viewer 请求体字段 + * @param array $viewer 可选:viewer_user_id、triggered_by(映射为 editor_{id})、或完整请求体覆盖 */ public function getViewerUrl($submissionId, $viewer = []) { @@ -497,6 +497,12 @@ class TurnitinService throw new Exception('submissionId required for viewer-url'); } + $statusResp = $this->getSimilarityStatus($submissionId); + $st = strtoupper(trim((string) ($statusResp['status'] ?? ''))); + if ($st !== '' && $st !== 'COMPLETE') { + throw new Exception('similarity report not ready for viewer-url, status=' . $st); + } + $path = '/submissions/' . rawurlencode($submissionId) . '/viewer-url'; $lastError = null; @@ -521,8 +527,12 @@ class TurnitinService */ private function buildViewerUrlBodies(array $viewerOverrides) { - if (!empty($viewerOverrides)) { - return [$viewerOverrides]; + if (!empty($viewerOverrides) && isset($viewerOverrides['viewer_default_permission_set'])) { + $body = $viewerOverrides; + if (empty($body['viewer_user_id'])) { + $body['viewer_user_id'] = $this->resolveViewerUserId($viewerOverrides); + } + return [$body]; } $locale = trim((string) Env::get('turnitin.viewer_locale', 'en-US')) ?: 'en-US'; @@ -530,27 +540,67 @@ class TurnitinService $permissionSets = $configured !== '' ? array_map('trim', explode(',', $configured)) : $this->defaultViewerPermissionSets(); + $viewerUserId = $this->resolveViewerUserId($viewerOverrides); + $saveChanges = $this->envBool('turnitin.viewer_save_changes', false); + $simModes = $this->defaultViewerSimilarityBlock(); $bodies = []; foreach ($permissionSets as $perm) { if ($perm === '') { continue; } + // TCA 认证要求:必须带 viewer_user_id(此前缺失会导致 400 Bad request) $bodies[] = [ - 'viewer_default_permission_set' => $perm, + 'viewer_user_id' => $viewerUserId, 'locale' => $locale, - 'similarity' => $this->defaultViewerSimilarityBlock(), + 'viewer_default_permission_set' => $perm, + 'similarity' => [ + 'view_settings' => ['save_changes' => $saveChanges], + ], ]; - // 最简请求体(部分 Crossref 租户只接受 permission + locale) $bodies[] = [ - 'viewer_default_permission_set' => $perm, + 'viewer_user_id' => $viewerUserId, 'locale' => $locale, + 'viewer_default_permission_set' => $perm, + 'similarity' => array_merge($simModes, [ + 'view_settings' => ['save_changes' => $saveChanges], + ]), + ]; + $bodies[] = [ + 'viewer_user_id' => $viewerUserId, + 'locale' => $locale, + 'viewer_default_permission_set' => $perm, ]; } return $bodies; } + /** + * viewer-url 必填:与 createSubmission 的 owner/submitter 同一命名空间(editor_{user_id})。 + */ + public function resolveViewerUserId(array $opts = []) + { + if (!empty($opts['viewer_user_id'])) { + return trim((string) $opts['viewer_user_id']); + } + // 打开报告的人(当前编辑)须与申请 viewer-url 时一致,否则易出现 session 认证失败 + $editorId = isset($opts['editor_id']) ? intval($opts['editor_id']) : 0; + if ($editorId > 0) { + return 'editor_' . $editorId; + } + $triggeredBy = isset($opts['triggered_by']) ? intval($opts['triggered_by']) : 0; + if ($triggeredBy > 0) { + return 'editor_' . $triggeredBy; + } + $custom = trim((string) Env::get('turnitin.viewer_user_id', '')); + if ($custom !== '') { + return $custom; + } + $name = trim((string) $this->integrationName); + return ($name !== '' ? $name : 'tmr') . '_viewer'; + } + /** * Crossref Similarity Check 通常不用 INSTRUCTOR;按常见可用角色排序尝试。 *