Compare commits
61 Commits
83a8b6272c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
978c81ea10 | ||
|
|
6b9d119b27 | ||
|
|
cd7b148ad4 | ||
|
|
4b20ffba85 | ||
|
|
60c8b7d532 | ||
|
|
249a04c109 | ||
|
|
0a2b053718 | ||
|
|
738ffa847f | ||
|
|
f64ca74b66 | ||
|
|
66f914bd35 | ||
|
|
7a629434b8 | ||
|
|
06e8c3e69b | ||
|
|
972331c24e | ||
|
|
ab69b9d99d | ||
|
|
537026636d | ||
|
|
e09e407f58 | ||
|
|
e5b72e8a28 | ||
|
|
799da4910b | ||
|
|
f28719058d | ||
|
|
e710ecaf05 | ||
|
|
47594a9042 | ||
|
|
a951558f9b | ||
|
|
4d46b66d02 | ||
|
|
038d28633b | ||
|
|
bbe66504e4 | ||
|
|
6b1ec4d1d2 | ||
|
|
602842e72c | ||
|
|
f35e6ea0b9 | ||
|
|
a614206eaf | ||
|
|
6d07002cf1 | ||
|
|
78651c21f8 | ||
|
|
2958024f69 | ||
|
|
09d61b3785 | ||
|
|
fac1d1d4d9 | ||
|
|
d60b59dae4 | ||
|
|
d061759561 | ||
|
|
5121dc127b | ||
|
|
402cb82841 | ||
|
|
b1c23c9599 | ||
|
|
bf8b4ecf74 | ||
|
|
488a312006 | ||
|
|
f15d072b2e | ||
|
|
705dce5e94 | ||
|
|
93f9e705cb | ||
|
|
c15b784cf8 | ||
|
|
55aa94adbe | ||
|
|
7cdf825418 | ||
|
|
74f47346d5 | ||
|
|
97e30ab80c | ||
|
|
632fede3cb | ||
|
|
b904a0d3df | ||
|
|
30995b2194 | ||
|
|
f5c59d222f | ||
|
|
27c7f88c0b | ||
|
|
f9ba4c0d6f | ||
|
|
bd2305d83d | ||
|
|
99ada38114 | ||
|
|
eabde1d138 | ||
|
|
0876328264 | ||
|
|
4d111fd9e9 | ||
|
|
2be05942bd |
@@ -3476,6 +3476,17 @@ class Article extends Base
|
||||
return jsonError("Submission failed");
|
||||
}
|
||||
$article_id = $data['article_id'];
|
||||
|
||||
//查询标题是否存在
|
||||
$oArticle = new \app\common\Article;
|
||||
$aCheckTitle = $oArticle->checkTitle(['title' => $data['title'],'article_id' => $article_id,'user_id' => $data['user_id']]);
|
||||
$iStatus = empty($aCheckTitle['status']) ? -1 : $aCheckTitle['status'];
|
||||
$sMsg = empty($aCheckTitle['msg']) ? '': $aCheckTitle['msg'];
|
||||
$iDraftId = empty($aCheckTitle['draft_id']) ? 0 : $aCheckTitle['draft_id'];
|
||||
if($iStatus != 1){
|
||||
return json(['code' => $iStatus, 'msg' => $sMsg,'draft_id' => $iDraftId]);
|
||||
}
|
||||
|
||||
//添加文章基础信息
|
||||
if ($data['article_id'] == 0) {
|
||||
$checkArticle = $this->article_obj->where("title", trim($data['title']))->select();
|
||||
@@ -5489,6 +5500,17 @@ class Article extends Base
|
||||
}
|
||||
}
|
||||
|
||||
//查询标题是否存在
|
||||
$oArticle = new \app\common\Article;
|
||||
$aCheckTitle = $oArticle->checkTitle(['title' => $aArticle['title'],'article_id' => $iArticleId,'user_id' => $iUserId]);
|
||||
$iStatus = empty($aCheckTitle['status']) ? -1 : $aCheckTitle['status'];
|
||||
$sMsg = empty($aCheckTitle['msg']) ? '': $aCheckTitle['msg'];
|
||||
$iDraftId = empty($aCheckTitle['draft_id']) ? 0 : $aCheckTitle['draft_id'];
|
||||
if($iStatus != 1){
|
||||
$iFirstStatus = 2;
|
||||
$sFirstMsg = 'Step 1: '.$sMsg;
|
||||
}
|
||||
|
||||
//判断伦理
|
||||
if(!empty($aArticle['approval']) && $aArticle['approval'] == 1 && empty($aArticle['approval_file'])){
|
||||
$iFirstStatus = 2;
|
||||
|
||||
407
application/api/controller/Cronreview.php
Normal file
407
application/api/controller/Cronreview.php
Normal file
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
namespace app\api\controller;
|
||||
use think\Controller;
|
||||
use think\Db;
|
||||
class Cronreview extends Controller
|
||||
{
|
||||
|
||||
//定义邮件模版
|
||||
protected $aEmailConfig = [
|
||||
'three' => [
|
||||
'email_subject' => 'Invitation to Review Manuscript for [{accept_sn}]-Reminder',
|
||||
'email_content' => '
|
||||
Dear Dr. {realname},<br><br>
|
||||
I hope this email finds you well.<br><br>
|
||||
On {invite_time}, we sent you the following review request for <i>{journal_title}</i>, <br>
|
||||
Manuscript ID:{accept_sn}<br>
|
||||
Title:{article_title}<br><br>
|
||||
We have not yet received a response from you, and we understand that the original invitation may not have reached you. We would greatly appreciate it if you could kindly inform us whether you are available to undertake this review.<br><br>
|
||||
For your convenience, please find the relevant links below:<br>
|
||||
<a href="{creatLoginUrlForreviewer}">Accept the review invitation</a><br>
|
||||
<a href="{creatRejectUrlForReviewer}">Reject the review invitation</a><br>
|
||||
Your username: {account}<br>
|
||||
Your original password:123456qwe, if you have reset the password, please sign in with the new one or click the "<a href="https://submission.tmrjournals.com/retrieve">forgot password</a>".<br><br>
|
||||
Thank you once again for considering our invitation. Your input is invaluable to us, and we truly appreciate your time and effort.<br><br>
|
||||
Please feel free to reply to this email or contact me directly with any questions.<br><br>
|
||||
Sincerely,<br>
|
||||
Editorial Office<br>
|
||||
<a href="https://www.tmrjournals.com/draw_up.html?issn={journal_issn}">Subscribe to this journal</a><br><i>{journal_title}</i><br>
|
||||
Email: {journal_email}<br>
|
||||
Website: {website}'
|
||||
],
|
||||
'five' => [
|
||||
'email_subject' => 'Gentle Reminder: Review Invitation for Manuscript {accept_sn}',
|
||||
'email_content' => '
|
||||
Dear Dr. {realname},<br><br>
|
||||
This is a brief follow-up regarding our review invitation sent on {invite_time} for the following manuscript submitted to <i>{journal_title}</i>:<br><br>
|
||||
Manuscript ID:{accept_sn}<br>
|
||||
Title:{article_title}<br><br>
|
||||
We would appreciate it if you could inform us whether you are able to undertake this review. If you are unavailable or require additional time, please feel free to let us know so that we may make appropriate arrangements.<br><br>
|
||||
For your convenience, please find the relevant links below:<br>
|
||||
<a href="{creatLoginUrlForreviewer}">Accept the review invitation</a><br>
|
||||
<a href="{creatRejectUrlForReviewer}">Reject the review invitation</a><br>
|
||||
Your username: {account}<br>
|
||||
Your original password:123456qwe, if you have reset the password, please sign in with the new one or click the "<a href="https://submission.tmrjournals.com/retrieve">forgot password</a>".<br><br>
|
||||
Thank you very much for your time and consideration.<br><br>
|
||||
Sincerely,<br>
|
||||
Editorial Office<br>
|
||||
<a href="https://www.tmrjournals.com/draw_up.html?issn={journal_issn}">Subscribe to this journal</a><br><i>{journal_title}</i><br>
|
||||
Email: {journal_email}<br>
|
||||
Website: {website}'
|
||||
],
|
||||
|
||||
'ten' => [
|
||||
'email_subject' => 'Reminder: Review Report for Manuscript [{accept_sn}]',
|
||||
'email_content' => '
|
||||
Dear Dr. {realname},<br><br>
|
||||
I hope this message finds you well.<br><br>
|
||||
I am writing to kindly follow up regarding the review report for the following manuscript, for which you were so kind to agree to serve as a reviewer for <i>{journal_title}</i>.<br>
|
||||
Manuscript ID:{accept_sn}<br>
|
||||
Title:{article_title}<br><br>
|
||||
We sincerely appreciate the time and expertise you are dedicating to this review. We fully understand that academic and professional commitments can be demanding, and should you require additional time or experience any difficulty in accessing the manuscript, please do not hesitate to let us know. We would be more than happy to accommodate your schedule or provide any assistance needed.<br><br>
|
||||
We would greatly appreciate your expert feedback at your earliest convenience, as it will help us proceed smoothly with the editorial process. For your convenience, please find the relevant link below:<br>
|
||||
<a href="{creatLoginUrlForreviewer}">Click here to submit the review report</a><br>
|
||||
Your username: {account}<br>
|
||||
Your original password:123456qwe, if you have reset the password, please sign in with the new one or click the "<a href="https://submission.tmrjournals.com/retrieve">forgot password</a>".<br><br>
|
||||
Thank you once again for your valued contribution to <i>{journal_title}</i> and for your continued support of our peer-review process.<br><br>
|
||||
Sincerely,<br>
|
||||
Editorial Office<br>
|
||||
<a href="https://www.tmrjournals.com/draw_up.html?issn={journal_issn}">Subscribe to this journal</a><br><i>{journal_title}</i><br>
|
||||
Email: {journal_email}<br>
|
||||
Website: {website}'
|
||||
],
|
||||
'twelve' => [
|
||||
'email_subject' => 'Gentle Reminder: Review Report for Manuscript [{accept_sn}]',
|
||||
'email_content' => '
|
||||
Dear Dr. {realname},<br><br>
|
||||
This is a gentle reminder regarding the review report for the manuscript listed below, for which you kindly agreed to serve as a reviewer:<br><br>
|
||||
Manuscript ID:{accept_sn}<br>
|
||||
Title:{article_title}<br><br>
|
||||
For your convenience, please find the relevant links below:<br>
|
||||
<a href="{creatLoginUrlForreviewer}">Click here to submit review report</a><br>
|
||||
Your username: {account}<br>
|
||||
Your original password:123456qwe, if you have reset the password, please login with the new one or click the "<a href="https://submission.tmrjournals.com/retrieve">forgot password</a>".<br><br>
|
||||
We would greatly appreciate it if you could submit your review at your convenience before <b>{agree_deadline}</b>. If you require additional time or encounter any difficulties, please feel free to let us know.<br><br>
|
||||
Thank you very much for your valuable time and contribution to the peer-review process.<br><br>
|
||||
Sincerely,<br>
|
||||
Editorial Office<br>
|
||||
<a href="https://www.tmrjournals.com/draw_up.html?issn={journal_issn}">Subscribe to this journal</a><br><i>{journal_title}</i><br>
|
||||
Email: {journal_email}<br>
|
||||
Website: {website}'
|
||||
],
|
||||
];
|
||||
private $iDayTime = 86400;//一天秒数
|
||||
/**
|
||||
* 文章审稿阶段-邀请审稿超过三天/五天发送提醒邮件
|
||||
* @return void
|
||||
*/
|
||||
public function inviteReminder(){
|
||||
|
||||
//获取当前时间
|
||||
$sCurrentDate = date('Y-m-d', time());
|
||||
$iTime = $this->iDayTime;
|
||||
//获取文章信息
|
||||
$aResult = $this->getArticle();
|
||||
$iStatus = empty($aResult['status']) ? 0 : $aResult['status'];
|
||||
$sMsg = empty($aResult['msg']) ? 'No data obtained' : $aResult['msg'];
|
||||
if($iStatus != 1){
|
||||
$this->showMessage($sMsg,2);
|
||||
exit;
|
||||
}
|
||||
|
||||
//数据处理
|
||||
$aArticle = empty($aResult['data']) ? [] : $aResult['data'];
|
||||
if(empty($aArticle)){
|
||||
$this->showMessage('Article or journal information is empty',2);
|
||||
exit;
|
||||
}
|
||||
|
||||
//查询文章邀请的审稿人
|
||||
$aArticleId = array_column($aArticle, 'article_id');
|
||||
$aWhere = ['article_id' => ['in',$aArticleId],'state' => 5];
|
||||
$aReviewer = Db::name('article_reviewer')->field('art_rev_id,reviewer_id,article_id,ctime')->where($aWhere)->order('article_id desc')->select();
|
||||
if(empty($aReviewer)){
|
||||
$this->showMessage('No qualified reviewers were found',2);
|
||||
exit;
|
||||
}
|
||||
|
||||
//数据处理
|
||||
foreach ($aReviewer as $key => $value) {
|
||||
if(empty($value['ctime'])){
|
||||
continue;
|
||||
}
|
||||
//时间处理
|
||||
$sTargetDate = date('Y-m-d', $value['ctime']);
|
||||
//日期转时间戳
|
||||
$iTargetTime = strtotime($sTargetDate);//邀请时间戳
|
||||
$iCurrentTime = strtotime($sCurrentDate);//当前时间戳
|
||||
$iThreeCtime = intval($iTargetTime + (3 * $iTime));//三天
|
||||
$iFiveCtime = intval($iTargetTime + (5 * $iTime));//五天
|
||||
//对比
|
||||
if($iCurrentTime != $iThreeCtime && $iCurrentTime != $iFiveCtime){
|
||||
continue;
|
||||
}
|
||||
if($iThreeCtime == $iCurrentTime){ //超过三天
|
||||
$value['email_type'] = 'three';
|
||||
}
|
||||
if($iFiveCtime == $iCurrentTime){ //超过五天
|
||||
$value['email_type'] = 'five';
|
||||
}
|
||||
\think\Queue::push('app\api\job\ReminderEmailToReviewer@fire',$value, 'ReminderEmailToReviewer');
|
||||
}
|
||||
$this->showMessage('邀请审稿超过三天/五天发送提醒邮件处理完成',1);
|
||||
}
|
||||
/**
|
||||
* 文章审稿阶段-同意审稿超过十天/十二天发送提醒邮件
|
||||
* @return void
|
||||
*/
|
||||
public function agreeReminder(){
|
||||
|
||||
//获取当前时间
|
||||
$sCurrentDate = date('Y-m-d', time());
|
||||
$iTime = $this->iDayTime;
|
||||
//获取文章信息
|
||||
$aResult = $this->getArticle();
|
||||
$iStatus = empty($aResult['status']) ? 0 : $aResult['status'];
|
||||
$sMsg = empty($aResult['msg']) ? 'No data obtained' : $aResult['msg'];
|
||||
if($iStatus != 1){
|
||||
$this->showMessage($sMsg,2);
|
||||
exit;
|
||||
}
|
||||
|
||||
//数据处理
|
||||
$aArticle = empty($aResult['data']) ? [] : $aResult['data'];
|
||||
if(empty($aArticle)){
|
||||
$this->showMessage('Article or journal information is empty',2);
|
||||
exit;
|
||||
}
|
||||
|
||||
//查询文章邀请的审稿人
|
||||
$aArticleId = array_column($aArticle, 'article_id');
|
||||
$aWhere = ['article_id' => ['in',$aArticleId],'state' => 0];
|
||||
$aReviewer = Db::name('article_reviewer')->field('art_rev_id,reviewer_id,article_id,agree_review_time')->where($aWhere)->order('article_id desc')->select();
|
||||
if(empty($aReviewer)){
|
||||
$this->showMessage('No qualified reviewers were found',2);
|
||||
exit;
|
||||
}
|
||||
|
||||
//数据处理
|
||||
foreach ($aReviewer as $key => $value) {
|
||||
if(empty($value['agree_review_time'])){
|
||||
continue;
|
||||
}
|
||||
//时间处理
|
||||
$sTargetDate = date('Y-m-d', $value['agree_review_time']);
|
||||
//日期转时间戳
|
||||
$iTargetTime = strtotime($sTargetDate);//邀请时间戳
|
||||
$iCurrentTime = strtotime($sCurrentDate);//当前时间戳
|
||||
$iTenCtime = intval($iTargetTime + (10 * $iTime));//十天
|
||||
$iTwelveCtime = intval($iTargetTime + (12 * $iTime));//十二天
|
||||
//对比
|
||||
if($iCurrentTime != $iTenCtime && $iCurrentTime != $iTwelveCtime){
|
||||
continue;
|
||||
}
|
||||
if($iTenCtime == $iCurrentTime){ //超过十天
|
||||
$value['email_type'] = 'ten';
|
||||
}
|
||||
if($iTwelveCtime == $iCurrentTime){ //超过十二天
|
||||
$value['email_type'] = 'twelve';
|
||||
}
|
||||
\think\Queue::push('app\api\job\ReminderEmailToReviewer@fire',$value, 'ReminderEmailToReviewer');
|
||||
}
|
||||
$this->showMessage('同意审稿超过十天/十二天发送提醒邮件处理完成',1);
|
||||
}
|
||||
/**
|
||||
* 发送邮件提醒
|
||||
* @return void
|
||||
*/
|
||||
public function reminder($aParam = []){
|
||||
//文章ID
|
||||
$iArticleId = empty($aParam['article_id']) ? 0 : $aParam['article_id'];
|
||||
if(empty($iArticleId)){
|
||||
return json_encode(['status' => 2,'msg' => 'Please select the article to query']);
|
||||
}
|
||||
//审稿人ID
|
||||
$iReviewerId = empty($aParam['reviewer_id']) ? 0 : $aParam['reviewer_id'];
|
||||
if(empty($iReviewerId)){
|
||||
return json_encode(['status' => 2,'msg' => 'Reviewers who meet the criteria of the article were not selected']);
|
||||
}
|
||||
//邮件类型
|
||||
$sEmailType = empty($aParam['email_type']) ? '' : $aParam['email_type'];
|
||||
if(empty($sEmailType)){
|
||||
return json_encode(['status' => 2,'msg' => 'Email type cannot be empty']);
|
||||
}
|
||||
//判断文章是否送审
|
||||
$aWhere = ['state' => 2,'article_id' => $iArticleId];
|
||||
$aArticle = Db::name('article')->field('article_id,user_id,journal_id,accept_sn,title,abstrart')->where($aWhere)->find();
|
||||
if(empty($aArticle)){
|
||||
return json_encode(array('status' => 3,'msg' => 'No articles requiring review were found' ));
|
||||
}
|
||||
//查询是否存在审稿记录
|
||||
$aState = ['three' => 5,'five' => 5,'ten' => 0,'twelve' => 0];
|
||||
$iState = isset($aState[$sEmailType]) ? $aState[$sEmailType] : -1 ;
|
||||
$aWhere = ['reviewer_id' => $iReviewerId,'article_id' => $iArticleId,'state' => $iState];
|
||||
$aReviewer = Db::name('article_reviewer')->field('art_rev_id,reviewer_id,ctime,agree_review_time,state')->where($aWhere)->find();
|
||||
if(empty($aReviewer)){
|
||||
return json_encode(['status' => 4,'msg' => 'No qualified reviewers were found']);
|
||||
}
|
||||
|
||||
//查询期刊信息
|
||||
if(empty($aArticle['journal_id'])){
|
||||
return json_encode(array('status' => 5,'msg' => 'The article is not associated with a journal' ));
|
||||
}
|
||||
$aWhere = ['state' => 0,'journal_id' => $aArticle['journal_id']];
|
||||
$aJournal = Db::name('journal')->where($aWhere)->find();
|
||||
if(empty($aJournal)){
|
||||
return json_encode(array('status' => 6,'msg' => 'No journal information found' ));
|
||||
}
|
||||
|
||||
//查询审稿人邮箱
|
||||
$aWhere = ['user_id' => $iReviewerId,'state' => 0,'email' => ['<>','']];
|
||||
$aUser = Db::name('user')->field('user_id,email,realname,account')->where($aWhere)->find();
|
||||
//收件人
|
||||
$sEmail = empty($aUser['email']) ? '' : $aUser['email'];//'1172937051@qq.com';//'publisher@tmrjournals.com';//'1172937051@qq.com';//
|
||||
if(empty($sEmail)){
|
||||
return json_encode(['status' => 7,'msg' => "Reviewer's email information not found"]);
|
||||
}
|
||||
|
||||
//处理发邮件
|
||||
//获取邮件模版
|
||||
$aEmailConfig= empty($this->aEmailConfig[$sEmailType]) ? [] : $this->aEmailConfig[$sEmailType];
|
||||
if(empty($aEmailConfig)){
|
||||
return json_encode(['status' => 8,'msg' => "Email template not obtained"]);
|
||||
}
|
||||
//邮件内容
|
||||
$aSearch = [
|
||||
'{accept_sn}' => empty($aArticle['accept_sn']) ? '' : $aArticle['accept_sn'],//accept_sn
|
||||
'{article_title}' => empty($aArticle['title']) ? '' : $aArticle['title'],//文章标题
|
||||
'{abstrart}' => empty($aArticle['abstrart']) ? '' : $aArticle['abstrart'],//文章摘要
|
||||
'{journal_title}' => empty($aJournal['title']) ? '' : $aJournal['title'],//期刊名
|
||||
'{journal_issn}' => empty($aJournal['issn']) ? '' : $aJournal['issn'],
|
||||
'{journal_email}' => empty($aJournal['email']) ? '' : $aJournal['email'],
|
||||
'{website}' => empty($aJournal['website']) ? '' : $aJournal['website'],
|
||||
];
|
||||
//用户名
|
||||
$realname = empty($aUser['account']) ? '' : $aUser['account'];
|
||||
$realname = empty($aUser['realname']) ? $realname : $aUser['realname'];
|
||||
$aSearch['{realname}'] = $realname;
|
||||
//用户账号
|
||||
$aSearch['{account}'] = empty($aUser['account']) ? '' : $aUser['account'];
|
||||
//审稿链接
|
||||
$oArticle = new \app\api\controller\Article;
|
||||
$aSearch['{creatLoginUrlForreviewer}'] = $oArticle->creatLoginUrlForreviewer(['user_id' => $iReviewerId],$aReviewer['art_rev_id']);
|
||||
if($aReviewer['state'] == 5){
|
||||
$aSearch['{creatRejectUrlForReviewer}'] = $oArticle->creatRejectUrlForReviewer(['user_id' => $iReviewerId],$aReviewer['art_rev_id']);
|
||||
|
||||
}
|
||||
//邀请时间
|
||||
$aSearch['{invite_time}'] = empty($aReviewer['ctime']) ? '' : $this->timestampToEnglishDate($aReviewer['ctime']);
|
||||
//同意审稿截止时间
|
||||
$iAgreeTime = empty($aReviewer['agree_review_time']) ? 0 : $aReviewer['agree_review_time'];
|
||||
$iAgreeTime = empty($iAgreeTime) ? '' : intval($iAgreeTime + (14 * $this->iDayTime));//十四天
|
||||
$aSearch['{agree_deadline}'] = empty($iAgreeTime) ? '' : $this->timestampToEnglishDate($iAgreeTime);
|
||||
//邮件标题
|
||||
$title = str_replace(array_keys($aSearch), array_values($aSearch),$aEmailConfig['email_subject']);
|
||||
//邮件内容变量替换
|
||||
$content = str_replace(array_keys($aSearch), array_values($aSearch), $aEmailConfig['email_content']);
|
||||
//判断标题和内容是否为空
|
||||
if(empty($title) || empty($content)){
|
||||
return json_encode(['status' => 9,'msg' => "The email content and title are empty"]);
|
||||
}
|
||||
//拼接样式
|
||||
$pre = \think\Env::get('emailtemplete.pre');
|
||||
$net = \think\Env::get('emailtemplete.net');
|
||||
$net1 = str_replace("{{email}}",trim($sEmail),$net);
|
||||
$content=$pre.$content.$net1;
|
||||
|
||||
//发送邮件
|
||||
$memail = empty($aJournal['email']) ? '' : $aJournal['email'];
|
||||
$mpassword = empty($aJournal['epassword']) ? '' : $aJournal['epassword'];
|
||||
//期刊标题
|
||||
$from_name = empty($aJournal['title']) ? '' : $aJournal['title'];
|
||||
|
||||
//查询是否发送过邮件
|
||||
$aStateType = ['three' => 10,'five' => 11,'ten' => 12,'twelve' => 13];
|
||||
//邮件日志类型
|
||||
$iLogType = empty($aStateType[$sEmailType]) ? 0 : $aStateType[$sEmailType];
|
||||
$oReviewer = new \app\common\Reviewer;
|
||||
$aEmailLog = ['article_id' => $iArticleId,'art_rev_id' => $aReviewer['art_rev_id'],'reviewer_id' => $iReviewerId,'type' => $iLogType,'is_success' => 1];
|
||||
$aLog = DB::name('email_reviewer')->field('id')->where($aEmailLog)->find();
|
||||
$sMsg = '邮件已发送';
|
||||
if(empty($aLog)){
|
||||
$aResult = sendEmail($sEmail,$title,$from_name,$content,$memail,$mpassword);
|
||||
$iStatus = empty($aResult['status']) ? 1 : $aResult['status'];
|
||||
$iIsSuccess = 2;
|
||||
$sMsg = empty($aResult['data']) ? '失败' : $aResult['data'];
|
||||
if($iStatus == 1){
|
||||
$iIsSuccess = 1;
|
||||
$sMsg = '成功';
|
||||
}
|
||||
//记录邮件发送日志
|
||||
$aEmailLog['email'] = $sEmail;
|
||||
$aEmailLog['content'] = $content;
|
||||
$aEmailLog['create_time'] = time();
|
||||
$aEmailLog['is_success'] = $iIsSuccess;
|
||||
$aEmailLog['msg'] = $sMsg;
|
||||
//添加邮件发送日志
|
||||
$iId = $oReviewer->addLog($aEmailLog);
|
||||
}
|
||||
return json_encode(['status' => 1,'msg' => $sMsg]);
|
||||
}
|
||||
/**
|
||||
* 获取审稿状态的文章
|
||||
* @return void
|
||||
*/
|
||||
private function getArticle(){
|
||||
|
||||
//查询送审中的文章
|
||||
$aWhere = ['state' => 2];
|
||||
$aArticle = Db::name('article')->field('article_id')->where($aWhere)->select();
|
||||
if(empty($aArticle)){
|
||||
return ['status' => 2,'msg' => 'No articles requiring review were found'];
|
||||
}
|
||||
|
||||
//数据返回
|
||||
return ['status' => 1,'msg' => 'Successfully obtained information','data' => $aArticle];
|
||||
}
|
||||
private function timestampToEnglishDate($timestamp) {
|
||||
//验证时间戳有效性
|
||||
if (!is_numeric($timestamp) || $timestamp < 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
//设置本地化(确保月份为英文全称,兼容Linux/Windows)
|
||||
$locale = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'
|
||||
? 'English_United States.1252'
|
||||
: 'en_US.UTF-8';
|
||||
setlocale(LC_TIME, $locale);
|
||||
|
||||
//格式化:d=日(无前导零)、F=英文月份全称、Y=四位年 strftime的%e在Linux下是无前导零的日,Windows用%#d
|
||||
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
|
||||
$dateStr = strftime('%#d %B %Y', $timestamp); // Windows
|
||||
} else {
|
||||
$dateStr = strftime('%e %B %Y', $timestamp); // Linux/Mac
|
||||
}
|
||||
return trim($dateStr);
|
||||
}
|
||||
/**
|
||||
*
|
||||
* 格式化信息输出
|
||||
*
|
||||
* @access public
|
||||
* @return void
|
||||
* @date 2018.09.28
|
||||
* @param $[message] [<显示信息>]
|
||||
* @param $[status] [<输出信息1成功,2失败>]
|
||||
*/
|
||||
private function showMessage($message, $status = 1) {
|
||||
if ($status == 1) {
|
||||
echo "[SUCCESS]";
|
||||
} else {
|
||||
echo "[ERROR]";
|
||||
}
|
||||
echo date("Y-m-d H:i:s") . " " . $message . "\n";
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1385,7 +1385,7 @@ class EmailClient extends Base
|
||||
$factoryId = intval($this->request->param('promotion_factory_id', 0));
|
||||
$sendDate = trim($this->request->param('send_date', date('Y-m-d', strtotime('+1 day'))));
|
||||
$taskName = trim($this->request->param('task_name', ''));
|
||||
$noRepeatDays = intval($this->request->param('no_repeat_days', 30));
|
||||
$noRepeatDays = intval($this->request->param('no_repeat_days', 15));
|
||||
$minInterval = intval($this->request->param('min_interval', 30));
|
||||
$maxInterval = intval($this->request->param('max_interval', 60));
|
||||
$maxBounceRate = intval($this->request->param('max_bounce_rate', 5));
|
||||
@@ -2282,7 +2282,7 @@ class EmailClient extends Base
|
||||
set_time_limit(120);
|
||||
|
||||
$sendDate = date('Y-m-d', strtotime('+1 day'));
|
||||
$noRepeatDaysDefault = 30;
|
||||
$noRepeatDaysDefault = 15;
|
||||
|
||||
$factories = Db::name('promotion_factory')
|
||||
->alias('f')
|
||||
@@ -2354,7 +2354,7 @@ class EmailClient extends Base
|
||||
$expertType = intval($factory['expert_type']);
|
||||
|
||||
// 内部受众(type∈{1..4}):默认 60 天频次(约稿场景);外部 expert 库(type=5)沿用 30 天
|
||||
$noRepeatDays = $expertType === 5 ? $noRepeatDaysDefault : 60;
|
||||
$noRepeatDays = $expertType === 5 ? $noRepeatDaysDefault : 20;
|
||||
|
||||
if ($expertType === 5) {
|
||||
$fields = $this->resolveFieldsByFetchIds($factory['fetch_ids']);
|
||||
@@ -2624,8 +2624,8 @@ class EmailClient extends Base
|
||||
$expertType = intval($factory['expert_type']);
|
||||
$dailyLimit = max(1, intval($factory['send_count']));
|
||||
|
||||
// 默认频次:expert=30天,内部=60天
|
||||
$noRepeatDaysDefault = $expertType === 5 ? 30 : 60;
|
||||
// 默认频次:expert=15天,内部=20天
|
||||
$noRepeatDaysDefault = $expertType === 5 ? 15 : 20;
|
||||
$noRepeatDays = intval($this->request->param('no_repeat_days', $noRepeatDaysDefault));
|
||||
|
||||
if ($expertType === 5) {
|
||||
@@ -2734,7 +2734,7 @@ class EmailClient extends Base
|
||||
|
||||
$query = Db::name('expert')->alias('e')
|
||||
->join('t_expert_field ef', 'e.expert_id = ef.expert_id', 'inner')
|
||||
->where('e.state', 0)
|
||||
// ->where('e.state', 0)
|
||||
->where('e.unsubscribed', 0)
|
||||
->where('ef.state', 0);
|
||||
|
||||
@@ -2776,11 +2776,16 @@ class EmailClient extends Base
|
||||
$query->where('e.country_id', 'in', $countryIds);
|
||||
}
|
||||
|
||||
return $query
|
||||
$res1 = $query
|
||||
->field('e.*')
|
||||
->group('e.expert_id')
|
||||
->limit($limit)
|
||||
->select();
|
||||
|
||||
|
||||
// echo $query->getLastSql();
|
||||
|
||||
return $res1;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -298,7 +298,7 @@ class ExpertFinder extends Base
|
||||
*/
|
||||
public function dailyFetchAll()
|
||||
{
|
||||
$perPage = max(10, intval($this->request->param('per_page', 10)));
|
||||
$perPage = max(10, intval($this->request->param('per_page', 50)));
|
||||
$source = $this->request->param('source', 'pubmed');
|
||||
$minYear = intval($this->request->param('min_year', date('Y') - 3));
|
||||
|
||||
|
||||
@@ -126,6 +126,24 @@ class Journal extends Base {
|
||||
return jsonSuccess($program);
|
||||
}
|
||||
|
||||
public function citeMate(){
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
"journal_id"=>"require",
|
||||
"year"=>"require"
|
||||
]);
|
||||
if(!$rule->check($data)){
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
$journal_info = $this->journal_obj->where("journal_id",$data['journal_id'])->find();
|
||||
$url = "http://journalapi.tmrjournals.com/public/index.php/api/Main/citeMate";
|
||||
$program['journal_issn'] = $journal_info['issn'];
|
||||
$program['year'] = $data['year'];
|
||||
$res = object_to_array(json_decode(myPost($url,$program)));
|
||||
|
||||
return json($res);
|
||||
}
|
||||
|
||||
public function delJournalStage(){
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
|
||||
@@ -782,7 +782,8 @@ class Preaccept extends Base
|
||||
|
||||
$title = trim((string)($summary['title'] ?? ''));
|
||||
$jouraRaw = trim((string)($summary['joura'] ?? ''));
|
||||
$authorStr = trim((string)($summary['author_str'] ?? ''));
|
||||
// 姓全写 + 名首字母,超过 3 个作者取前 3 个 + et al
|
||||
$authorCitation = $svc->getAuthorsCitation($summary['raw'] ?? [], 3);
|
||||
$dateno = trim((string)($summary['dateno'] ?? ''));
|
||||
$doilink = trim((string)($summary['doilink'] ?? ''));
|
||||
if ($doilink === '') {
|
||||
@@ -790,7 +791,7 @@ class Preaccept extends Base
|
||||
}
|
||||
|
||||
$f = [
|
||||
'author' => $authorStr !== '' ? prgeAuthor($authorStr) : '',
|
||||
'author' => $authorCitation !== '' ? $authorCitation . '.' : '',
|
||||
'title' => $title,
|
||||
'joura' => $jouraRaw !== '' ? formateJournal($jouraRaw) : '',
|
||||
'dateno' => str_replace(' ', '', str_replace('-', '–', $dateno)),
|
||||
|
||||
@@ -3208,7 +3208,7 @@ class Production extends Base
|
||||
$z = count($list);
|
||||
$m = 0;
|
||||
foreach ($list as $v) {
|
||||
if ($v['refer_frag'] != '' || $v['author'] != '') {
|
||||
if ($v['refer_frag'] != '' || $v['author'] != ''|| $v['title'] != '') {
|
||||
$m++;
|
||||
}
|
||||
}
|
||||
@@ -3361,7 +3361,7 @@ class Production extends Base
|
||||
$tt .= "Please carefully check the proof, including the text, figures, tables, references, author information, affiliations, spelling, and formatting. If any corrections are needed, please mark them clearly on the proof or submit comments through the system.<br/>";
|
||||
$tt .= "If we do not receive your confirmation by ".date("Y-m-d", strtotime("+3 days")).", the proof will be considered approved in its current form. Please note that no further revisions will be accepted after online confirmation.<br/><br/>";
|
||||
$tt .= "Thank you for your time and cooperation. Should you have any questions, please feel free to contact us.<br/><br/>";
|
||||
$tt .= "Best regards,<br/>Biomedical Engineering Communications<br/>Email: bmec@tmrjournals.com<br/>Website: https://www.tmrjournals.com/bmec/";
|
||||
$tt .= "Best regards,<br/>".$journal_info['title']."<br/>Email: ".$journal_info['email']."<br/>Website: ".$journal_info['website'];
|
||||
|
||||
// $maidata['email'] = '751475802@qq.com';
|
||||
$maidata['email'] = $user_info['email'];
|
||||
|
||||
78
application/api/job/AiCheckRefer.php
Normal file
78
application/api/job/AiCheckRefer.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
namespace app\api\job;
|
||||
use think\queue\Job;
|
||||
use app\common\QueueJob;
|
||||
use app\common\QueueRedis;
|
||||
use think\Db;
|
||||
class AiCheckRefer
|
||||
{
|
||||
private $oQueueJob;
|
||||
private $QueueRedis;
|
||||
private $completedExprie = 3600;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
try {
|
||||
|
||||
// 生成Redis键并尝试获取锁
|
||||
$sClassName = get_class($this);
|
||||
$sRedisKey = "queue_job:{$sClassName}:{$iPArticleId}";
|
||||
$sRedisValue = uniqid() . '_' . getmypid();
|
||||
if (!$this->oQueueJob->acquireLock($sRedisKey, $sRedisValue, $job)) {
|
||||
return; // 未获取到锁,已处理
|
||||
}
|
||||
|
||||
//生成内容
|
||||
$oProductionArticleRefer = new \app\api\controller\References;
|
||||
$response = $oProductionArticleRefer->checkByAi($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();
|
||||
}
|
||||
}
|
||||
}
|
||||
82
application/api/job/ProofReadQueue.php
Normal file
82
application/api/job/ProofReadQueue.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
namespace app\api\job;
|
||||
|
||||
use think\queue\Job;
|
||||
use app\common\QueueJob;
|
||||
use app\common\QueueRedis;
|
||||
use app\common\ProofRead;
|
||||
class ProofReadQueue
|
||||
{
|
||||
private $oQueueJob;
|
||||
private $QueueRedis;
|
||||
private $completedExprie = 3600;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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'];
|
||||
$sChunkIndex = empty($data['chunkIndex']) ? 0 : $data['chunkIndex'];
|
||||
$sPrompt = empty($data['prompt']) ? '' : $data['prompt'];
|
||||
if (empty($iArticleId)) {
|
||||
$this->oQueueJob->log("无效的article_id,删除任务");
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
||||
// 生成Redis键并尝试获取锁
|
||||
$sClassName = get_class($this);
|
||||
$sRedisKey = "queue_job:{$sClassName}:{$iArticleId}:{$sPrompt}_{$sChunkIndex}";
|
||||
$sRedisValue = uniqid() . '_' . getmypid();
|
||||
if (!$this->oQueueJob->acquireLock($sRedisKey, $sRedisValue, $job)) {
|
||||
return; // 未获取到锁,已处理
|
||||
}
|
||||
|
||||
//生成内容
|
||||
$oAireview = new ProofRead;
|
||||
$response = $oAireview->proofReadQueue($data);
|
||||
|
||||
// 验证API响应
|
||||
if (empty($response)) {
|
||||
throw new \RuntimeException("OpenAI API返回空结果");
|
||||
}
|
||||
// 检查JSON解析错误
|
||||
$aResult = json_decode($response, true);
|
||||
echo '<pre>';var_dump($aResult);
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
89
application/api/job/SendAuthorEmail.php
Normal file
89
application/api/job/SendAuthorEmail.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
namespace app\api\job;
|
||||
|
||||
use think\queue\Job;
|
||||
use app\common\QueueJob;
|
||||
use app\common\QueueRedis;
|
||||
class SendAuthorEmail
|
||||
{
|
||||
private $oQueueJob;
|
||||
private $QueueRedis;
|
||||
private $completedExprie = 3600; // 完成状态过期时间
|
||||
public function __construct()
|
||||
{
|
||||
$this->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'];
|
||||
//作者邮箱
|
||||
$email = empty($data['email']) ? '' : $data['email'];
|
||||
//邮件主题
|
||||
$subject = empty($data['subject']) ? '' : $data['subject'];
|
||||
//发送来源
|
||||
$title = empty($data['title']) ? '' : $data['title'];
|
||||
$subject = empty($subject) ? $title : $subject;
|
||||
//邮件内容
|
||||
$content = empty($data['content']) ? '' : $data['content'];
|
||||
//邮箱
|
||||
$temail = empty($data['temail']) ? '' : $data['temail'];
|
||||
//密码
|
||||
$tpassword = empty($data['tpassword']) ? '' : $data['tpassword'];
|
||||
//邮件类型
|
||||
$type = empty($data['type']) ? 1 : $data['type'];
|
||||
if (empty($iArticleId) || empty($email)) {
|
||||
$this->oQueueJob->log("无效的article_id/email,删除任务");
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成唯一任务标识
|
||||
$sClassName = get_class($this);
|
||||
$sRedisKey = "queue_job:{$sClassName}:{$iArticleId}:{$email}";
|
||||
$sRedisValue = uniqid() . '_' . getmypid();
|
||||
if (!$this->oQueueJob->acquireLock($sRedisKey, $sRedisValue, $job)) {
|
||||
return; // 未获取到锁,已处理
|
||||
}
|
||||
|
||||
// 执行核心任务-发送邮件
|
||||
$aResult = sendEmail($email,$subject,$title,$content,$temail,$tpassword);
|
||||
$iStatus = empty($aResult['status']) ? 1 : $aResult['status'];
|
||||
$iIsSuccess = 2;
|
||||
$sMsg = empty($aResult['data']) ? '失败' : $aResult['data'];
|
||||
if($iStatus == 1){
|
||||
$iIsSuccess = 1;
|
||||
$sMsg = '成功';
|
||||
}
|
||||
// 更新完成标识
|
||||
$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();
|
||||
}
|
||||
}
|
||||
}
|
||||
23
application/command/agreeReviewReminder.sh
Executable file
23
application/command/agreeReviewReminder.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
# agreeReviewReminder.sh
|
||||
# 批量处理文章审稿阶段-同意审稿超过十天/十二天发送提醒邮件
|
||||
# 调用接口获取需要提醒的审稿人记录
|
||||
# 此文件需要在crontab中配置每天【凌晨0:30】运行一次
|
||||
# @author chengxiaoling
|
||||
# @date 2025-12-29
|
||||
|
||||
# 基础配置
|
||||
DOMAIN="http://api.tmrjournals.com/public/index.php/" # 项目域名
|
||||
# DOMAIN="http://zmzm.tougao.dev.com" # 项目域名
|
||||
ROUTE="/api/Cronreview/agreeReminder" # 控制器路由
|
||||
BASE_PATH=$(cd `dirname $0`; pwd)
|
||||
# 如果日志目录不存在则创建
|
||||
logDir=${BASE_PATH}/log/$(date "+%Y")/$(date "+%m")
|
||||
if [ ! -d $logDir ];then
|
||||
mkdir -p $logDir
|
||||
fi
|
||||
|
||||
# 执行请求并记录日志
|
||||
curl "${DOMAIN}${ROUTE}" >> ${logDir}/agreeReminder_$(date "+%Y%m%d").log 2>&1
|
||||
# 添加时间戳
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 定时任务已执行" >> ${logDir}/agreeReminder_$(date "+%Y%m%d").log
|
||||
23
application/command/inviteReviewReminder.sh
Executable file
23
application/command/inviteReviewReminder.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
# inviteReviewReminder.sh
|
||||
# 批量处理文章审稿阶段-邀请审稿超过三天/五天发送提醒邮件
|
||||
# 调用接口获取需要提醒的审稿人记录
|
||||
# 此文件需要在crontab中配置每天【凌晨0:30】运行一次
|
||||
# @author chengxiaoling
|
||||
# @date 2025-12-29
|
||||
|
||||
# 基础配置
|
||||
DOMAIN="http://api.tmrjournals.com/public/index.php/" # 项目域名
|
||||
# DOMAIN="http://zmzm.tougao.dev.com" # 项目域名
|
||||
ROUTE="/api/Cronreview/inviteReminder" # 控制器路由
|
||||
BASE_PATH=$(cd `dirname $0`; pwd)
|
||||
# 如果日志目录不存在则创建
|
||||
logDir=${BASE_PATH}/log/$(date "+%Y")/$(date "+%m")
|
||||
if [ ! -d $logDir ];then
|
||||
mkdir -p $logDir
|
||||
fi
|
||||
|
||||
# 执行请求并记录日志
|
||||
curl "${DOMAIN}${ROUTE}" >> ${logDir}/inviteReminder_$(date "+%Y%m%d").log 2>&1
|
||||
# 添加时间戳
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 定时任务已执行" >> ${logDir}/inviteReminder_$(date "+%Y%m%d").log
|
||||
@@ -16,6 +16,10 @@ class CrossrefService
|
||||
private $timeout = 15; // 请求超时(秒)
|
||||
private $maxRetry = 2; // 单个DOI最大重试次数
|
||||
private $crossrefUrl = "https://api.crossref.org/works/"; // 接口地址
|
||||
private $pubmedAbbr = true; // CrossRef 无期刊缩写时,是否回退到 PubMed/NLM 规范缩写
|
||||
|
||||
/** @var PubmedService|null 懒加载 */
|
||||
private $pubmedService = null;
|
||||
|
||||
public function __construct($config = [])
|
||||
{
|
||||
@@ -24,6 +28,7 @@ class CrossrefService
|
||||
if (isset($config['timeout'])) $this->timeout = intval($config['timeout']);
|
||||
if (isset($config['maxRetry'])) $this->maxRetry = intval($config['maxRetry']);
|
||||
if (isset($config['crossrefUrl'])) $this->crossrefUrl = (string)$config['crossrefUrl'];
|
||||
if (isset($config['pubmed_abbr'])) $this->pubmedAbbr = (bool)$config['pubmed_abbr'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +196,15 @@ class CrossrefService
|
||||
|
||||
$title = $this->getTitle($msg);
|
||||
$publisher = $this->getPublisher($msg);
|
||||
$joura = !empty($publisher['title']) ? $publisher['title'] : ($publisher['short_title'] ?? '');
|
||||
$validDoi = $this->filterValidDoi($doi);
|
||||
// 期刊缩写优先级:CrossRef short-container-title → PubMed/NLM 规范缩写 → CrossRef 全称
|
||||
$shortTitle = trim((string)($publisher['short_title'] ?? ''));
|
||||
$fullTitle = trim((string)($publisher['title'] ?? ''));
|
||||
$joura = $shortTitle;
|
||||
if ($joura === '') {
|
||||
$pubmedAbbr = $this->lookupPubmedJournalAbbr($validDoi);
|
||||
$joura = $pubmedAbbr !== '' ? $pubmedAbbr : $fullTitle;
|
||||
}
|
||||
$authors = $this->getAuthors($msg);
|
||||
$dateno = $this->getVolumeIssuePages($msg);
|
||||
$retractInfo = $this->checkRetracted($msg);
|
||||
@@ -280,6 +293,34 @@ class CrossrefService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 PubMed/NLM 反查期刊规范缩写(CrossRef 无缩写时的兜底)。
|
||||
* 任何异常都吞掉并返回空串,保证不影响主流程。
|
||||
*
|
||||
* @param string $doi 已规整的裸 DOI
|
||||
* @return string 缩写或空串
|
||||
*/
|
||||
private function lookupPubmedJournalAbbr($doi)
|
||||
{
|
||||
$doi = trim((string)$doi);
|
||||
if (!$this->pubmedAbbr || $doi === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->pubmedService === null) {
|
||||
$this->pubmedService = new PubmedService([
|
||||
'email' => $this->mailto,
|
||||
'timeout' => $this->timeout,
|
||||
]);
|
||||
}
|
||||
$abbr = $this->pubmedService->journalAbbrByDoi($doi);
|
||||
return is_string($abbr) ? trim($abbr) : '';
|
||||
} catch (\Throwable $e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取作者列表
|
||||
*/
|
||||
@@ -300,6 +341,73 @@ class CrossrefService
|
||||
return $authors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 引用格式作者串:姓全写 + 名首字母,超过 $maxAuthors 个取前 N 个 + et al
|
||||
* 例:Smith JA, Jones B, Lee C, et al
|
||||
*
|
||||
* @param array $aDoiInfo Crossref message
|
||||
* @param int $maxAuthors 最多展示作者数,超过则截断加 et al
|
||||
* @return string
|
||||
*/
|
||||
public function getAuthorsCitation($aDoiInfo = [], $maxAuthors = 3)
|
||||
{
|
||||
$list = [];
|
||||
if (!empty($aDoiInfo['author'])) {
|
||||
foreach ($aDoiInfo['author'] as $author) {
|
||||
$family = trim((string)($author['family'] ?? ''));
|
||||
$given = trim((string)($author['given'] ?? ''));
|
||||
|
||||
if ($family === '' && $given === '') {
|
||||
// 机构作者等无姓名结构的情况
|
||||
$orgName = trim((string)($author['name'] ?? ''));
|
||||
if ($orgName !== '') {
|
||||
$list[] = $orgName;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$initials = $this->givenToInitials($given);
|
||||
$name = $initials !== '' ? trim($family . ' ' . $initials) : $family;
|
||||
if ($name !== '') {
|
||||
$list[] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($list)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$maxAuthors = max(1, (int)$maxAuthors);
|
||||
if (count($list) > $maxAuthors) {
|
||||
$list = array_slice($list, 0, $maxAuthors);
|
||||
return implode(', ', $list) . ', et al';
|
||||
}
|
||||
|
||||
return implode(', ', $list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 名转首字母:取每个组成部分(空格/连字符/点分隔)首字母大写并拼接。
|
||||
* 例:"John A." -> "JA","Mary-Jane" -> "MJ"
|
||||
*/
|
||||
private function givenToInitials($given)
|
||||
{
|
||||
$given = trim((string)$given);
|
||||
if ($given === '') {
|
||||
return '';
|
||||
}
|
||||
$parts = preg_split('/[\s\-\.]+/u', $given, -1, PREG_SPLIT_NO_EMPTY);
|
||||
$initials = '';
|
||||
foreach ($parts as $p) {
|
||||
$first = mb_substr($p, 0, 1);
|
||||
if ($first !== '') {
|
||||
$initials .= mb_strtoupper($first);
|
||||
}
|
||||
}
|
||||
return $initials;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取发表年份
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\common;
|
||||
|
||||
use app\common\service\LocalModelService;
|
||||
use think\Db;
|
||||
use think\Env;
|
||||
use think\Exception;
|
||||
@@ -27,9 +28,17 @@ class ExpertFieldAiService
|
||||
|
||||
private $logFile;
|
||||
|
||||
/** @var bool|null */
|
||||
private static $schemaReady = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->logFile = ROOT_PATH . 'runtime' . DS . 'expert_field_ai.log';
|
||||
try {
|
||||
$this->ensureSchema();
|
||||
} catch (\Throwable $e) {
|
||||
$this->log('[ExpertFieldAi] ensureSchema fail: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 链式队列 =====================
|
||||
@@ -366,10 +375,16 @@ class ExpertFieldAiService
|
||||
$papers = array_slice($papers, 0, $maxPapers);
|
||||
$searchKeywords = array_values(array_unique(array_filter($searchKeywords)));
|
||||
|
||||
$countryName = '';
|
||||
$countryId = intval($expert['country_id'] ?? 0);
|
||||
if ($countryId > 0) {
|
||||
$countryName = (string)Db::name('country')->where('country_id', $countryId)->value('title');
|
||||
// t_expert.country 已存国家英文名,无需再查 country 表
|
||||
$countryName = trim((string)($expert['country'] ?? ''));
|
||||
if ($countryName === '') {
|
||||
$countryId = intval($expert['country_id'] ?? 0);
|
||||
if ($countryId > 0) {
|
||||
$row = Db::name('country')->where('country_id', $countryId)->find();
|
||||
if ($row) {
|
||||
$countryName = (string)($row['en_name'] ?? ($row['zh_name'] ?? ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -453,69 +468,27 @@ class ExpertFieldAiService
|
||||
|
||||
private function summarizeWithLlm(array $context)
|
||||
{
|
||||
$url = $this->resolveLlmChatUrl();
|
||||
$model = $this->resolveLlmModel();
|
||||
$apiKey = trim((string)Env::get(
|
||||
'expert_field_ai.chat_api_key',
|
||||
Env::get('user_field_ai.chat_api_key', Env::get('expert_country_chat_api_key', Env::get('citation_chat_api_key', '')))
|
||||
));
|
||||
|
||||
if ($url === '' || $model === '') {
|
||||
throw new Exception('LLM not configured (set base.model_url / expert_field_ai.chat_model)');
|
||||
}
|
||||
|
||||
$payloadJson = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$messages = [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => '你是学术领域分类助手。根据专家的单位、论文标题与 PubMed 检索上下文,用简体中文总结该专家最主要的研究领域。'
|
||||
. '注意:search_keywords 只是检索词,不可直接当作领域结论,应结合 paper 标题与 affiliation 判断。'
|
||||
. '要求:精确、简洁,1~3 个中文领域词或短短语,用顿号分隔;不要解释、不要英文。'
|
||||
. '只输出 JSON:{"field_ai":"..."}。',
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => "请根据以下 JSON 资料总结该专家的主要研究领域:\n" . $payloadJson,
|
||||
],
|
||||
];
|
||||
$systemPrompt = '你是学术领域分类助手。根据专家的单位、论文标题与 PubMed 检索上下文,用简体中文总结该专家最主要的研究领域。'
|
||||
. '注意:search_keywords 只是检索词,不可直接当作领域结论,应结合 paper 标题与 affiliation 判断。'
|
||||
. '要求:精确、简洁,1~3 个中文领域词或短短语,用顿号分隔;不要解释、不要英文。'
|
||||
. '只输出 JSON:{"field_ai":"..."}。';
|
||||
$userPrompt = "请根据以下 JSON 资料总结该专家的主要研究领域:\n" . $payloadJson;
|
||||
|
||||
$body = [
|
||||
'model' => $model,
|
||||
'temperature' => 0.2,
|
||||
'messages' => $messages,
|
||||
];
|
||||
// 按上下文长度动态选模型(小: base.model_url1 / 大: base.model_url)
|
||||
$svc = new LocalModelService();
|
||||
$res = $svc->chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
], ['temperature' => 0.2]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 15,
|
||||
CURLOPT_TIMEOUT => max(30, (int)Env::get('expert_field_ai.timeout', Env::get('user_field_ai.timeout', 90))),
|
||||
CURLOPT_HTTPHEADER => array_filter([
|
||||
'Content-Type: application/json',
|
||||
$apiKey !== '' ? 'Authorization: Bearer ' . $apiKey : null,
|
||||
]),
|
||||
]);
|
||||
$raw = curl_exec($ch);
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($raw === false) {
|
||||
throw new Exception('LLM curl error: ' . $err);
|
||||
}
|
||||
if ($code < 200 || $code >= 300) {
|
||||
throw new Exception('LLM HTTP ' . $code . ': ' . mb_substr((string)$raw, 0, 400));
|
||||
if (empty($res['ok'])) {
|
||||
throw new Exception('LLM error: ' . (string)($res['error'] ?? 'unknown'));
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
$content = '';
|
||||
if (is_array($data) && isset($data['choices'][0]['message']['content'])) {
|
||||
$content = trim((string)$data['choices'][0]['message']['content']);
|
||||
}
|
||||
$this->log('[ExpertFieldAi] llm tier=' . ($res['tier'] ?? '') . ' ctx_len=' . ($res['context_len'] ?? 0) . ' url=' . ($res['url'] ?? ''));
|
||||
|
||||
$content = trim((string)($res['content'] ?? ''));
|
||||
$fieldAi = $this->parseFieldAiFromContent($content);
|
||||
if ($fieldAi === '' && $content !== '') {
|
||||
$fieldAi = $this->cleanFieldAiText($content);
|
||||
@@ -523,44 +496,6 @@ class ExpertFieldAiService
|
||||
return $fieldAi;
|
||||
}
|
||||
|
||||
private function resolveLlmChatUrl()
|
||||
{
|
||||
$candidates = [
|
||||
// Env::get('expert_field_ai.chat_url', ''),
|
||||
// Env::get('user_field_ai.chat_url', ''),
|
||||
Env::get('base.model_url1', ''),
|
||||
];
|
||||
foreach ($candidates as $u) {
|
||||
$u = trim((string)$u);
|
||||
if ($u === '') {
|
||||
continue;
|
||||
}
|
||||
if (stripos($u, 'chat/completions') !== false) {
|
||||
return $u;
|
||||
}
|
||||
return rtrim($u, '/') . '/v1/chat/completions';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function resolveLlmModel()
|
||||
{
|
||||
$candidates = [
|
||||
Env::get('expert_field_ai.chat_model', ''),
|
||||
Env::get('user_field_ai.chat_model', ''),
|
||||
Env::get('base.model', ''),
|
||||
Env::get('expert_country_chat_model', ''),
|
||||
'gpt-4.1',
|
||||
];
|
||||
foreach ($candidates as $m) {
|
||||
$m = trim((string)$m);
|
||||
if ($m !== '' && strtolower($m) !== 'your-model-name') {
|
||||
return $m;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function parseFieldAiFromContent($content)
|
||||
{
|
||||
$content = trim((string)$content);
|
||||
@@ -637,18 +572,73 @@ class ExpertFieldAiService
|
||||
|
||||
private function updateFieldAi($expertId, $fieldAi, $status, $source, $note)
|
||||
{
|
||||
$this->ensureSchema();
|
||||
|
||||
$data = [
|
||||
'field_ai' => mb_substr(trim((string)$fieldAi), 0, 512),
|
||||
'field_ai_status' => intval($status),
|
||||
'field_ai_utime' => time(),
|
||||
'field_ai_source' => mb_substr(trim((string)$source), 0, 32),
|
||||
];
|
||||
if ($this->hasColumn('field_ai_source')) {
|
||||
$data['field_ai_source'] = mb_substr(trim((string)$source), 0, 32);
|
||||
}
|
||||
|
||||
Db::name('expert')->where('expert_id', intval($expertId))->update($data);
|
||||
if ($note !== '') {
|
||||
$this->log('[ExpertFieldAi] expert_id=' . $expertId . ' status=' . $status . ' note=' . $note);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动补全 t_expert 上缺失的 field_ai 字段(可重复执行)。
|
||||
*/
|
||||
public function ensureSchema()
|
||||
{
|
||||
if (self::$schemaReady === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table = config('database.prefix') . 'expert';
|
||||
$columns = Db::query('SHOW COLUMNS FROM `' . $table . '`');
|
||||
$existing = [];
|
||||
foreach ($columns as $col) {
|
||||
$existing[$col['Field']] = true;
|
||||
}
|
||||
|
||||
$alters = [];
|
||||
if (!isset($existing['field_ai'])) {
|
||||
$alters[] = "ADD COLUMN `field_ai` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'AI总结的主要研究领域(中文)' AFTER `affiliation`";
|
||||
$existing['field_ai'] = true;
|
||||
}
|
||||
if (!isset($existing['field_ai_status'])) {
|
||||
$alters[] = "ADD COLUMN `field_ai_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待处理 1已生成 2资料不足 3失败 4无user待AI' AFTER `field_ai`";
|
||||
$existing['field_ai_status'] = true;
|
||||
}
|
||||
if (!isset($existing['field_ai_utime'])) {
|
||||
$alters[] = "ADD COLUMN `field_ai_utime` INT NOT NULL DEFAULT 0 COMMENT 'field_ai更新时间' AFTER `field_ai_status`";
|
||||
$existing['field_ai_utime'] = true;
|
||||
}
|
||||
if (!isset($existing['field_ai_source'])) {
|
||||
$alters[] = "ADD COLUMN `field_ai_source` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源: user_link / ai' AFTER `field_ai_utime`";
|
||||
$existing['field_ai_source'] = true;
|
||||
}
|
||||
|
||||
if (!empty($alters)) {
|
||||
Db::execute('ALTER TABLE `' . $table . '` ' . implode(', ', $alters));
|
||||
$this->log('[ExpertFieldAi] schema patched: ' . implode('; ', $alters));
|
||||
}
|
||||
|
||||
self::$schemaReady = true;
|
||||
}
|
||||
|
||||
private function hasColumn($column)
|
||||
{
|
||||
$this->ensureSchema();
|
||||
$table = config('database.prefix') . 'expert';
|
||||
$columns = Db::query('SHOW COLUMNS FROM `' . $table . '` LIKE \'' . addslashes($column) . '\'');
|
||||
return !empty($columns);
|
||||
}
|
||||
|
||||
public function statusLabel($status)
|
||||
{
|
||||
$map = [
|
||||
|
||||
@@ -13,6 +13,9 @@ class ExpertFinderService
|
||||
private $ncbiBaseUrl = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/';
|
||||
private $logFile;
|
||||
|
||||
/** @var bool|null */
|
||||
private static $schemaReady = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->httpClient = new Client([
|
||||
@@ -21,6 +24,54 @@ class ExpertFinderService
|
||||
'verify' => false,
|
||||
]);
|
||||
$this->logFile = ROOT_PATH . 'runtime' . DS . 'expert_finder.log';
|
||||
|
||||
try {
|
||||
$this->ensureSchema();
|
||||
} catch (\Throwable $e) {
|
||||
$this->log('[ExpertFinder] ensureSchema fail: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史遗留数据迁移用:旧版每天按页抓取时使用的 per_page。
|
||||
* 用于把旧的 last_page 换算成新的 last_offset(last_offset = last_page × 此值)。
|
||||
*/
|
||||
const MIGRATE_LEGACY_PER_PAGE = 10;
|
||||
|
||||
/**
|
||||
* 自动补全 expert_fetch 上缺失的 last_offset 列,并一次性回填历史进度(可重复执行)。
|
||||
* last_offset 为累计抓取偏移量(已扫到第几篇),与 per_page 解耦,
|
||||
* 改 per_page 不会再导致翻页错位。
|
||||
*/
|
||||
public function ensureSchema()
|
||||
{
|
||||
if (self::$schemaReady === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table = config('database.prefix') . 'expert_fetch';
|
||||
$columns = Db::query('SHOW COLUMNS FROM `' . $table . '`');
|
||||
$existing = [];
|
||||
foreach ($columns as $col) {
|
||||
$existing[$col['Field']] = true;
|
||||
}
|
||||
|
||||
if (!isset($existing['last_offset'])) {
|
||||
Db::execute('ALTER TABLE `' . $table . '` ADD COLUMN `last_offset` INT NOT NULL DEFAULT 0 COMMENT \'累计抓取偏移量(与per_page解耦)\' AFTER `last_page`');
|
||||
$this->log('[ExpertFinder] schema patched: add last_offset');
|
||||
}
|
||||
|
||||
// 一次性迁移:把旧 last_page 按历史 per_page 换算成 last_offset。
|
||||
// 只命中"未迁移"的遗留行(last_offset=0 且 last_page>0),幂等,不会重复执行。
|
||||
$affected = Db::execute(
|
||||
'UPDATE `' . $table . '` SET `last_offset` = `last_page` * ' . intval(self::MIGRATE_LEGACY_PER_PAGE)
|
||||
. ' WHERE `last_offset` = 0 AND `last_page` > 0'
|
||||
);
|
||||
if ($affected > 0) {
|
||||
$this->log('[ExpertFinder] migrated last_offset from last_page for ' . $affected . ' rows (×' . self::MIGRATE_LEGACY_PER_PAGE . ')');
|
||||
}
|
||||
|
||||
self::$schemaReady = true;
|
||||
}
|
||||
|
||||
public function doFetchForField($field, $source = 'pubmed', $perPage = 100, $minYear = null)
|
||||
@@ -30,12 +81,13 @@ class ExpertFinderService
|
||||
}
|
||||
|
||||
$fetchLog = $this->getFetchLog($field, $source);
|
||||
$page = $fetchLog['last_page'] + 1;
|
||||
// 基于累计偏移量(offset)的游标:改 per_page 也不会错位
|
||||
$offset = intval($fetchLog['last_offset'] ?? 0);
|
||||
|
||||
if ($source === 'pmc') {
|
||||
$result = $this->searchViaPMC($field, $perPage, $minYear, $page);
|
||||
$result = $this->searchViaPMC($field, $perPage, $minYear, $offset);
|
||||
} else {
|
||||
$result = $this->searchViaPubMed($field, $perPage, $minYear, $page);
|
||||
$result = $this->searchViaPubMed($field, $perPage, $minYear, $offset);
|
||||
}
|
||||
|
||||
if(!isset($result['total'])){
|
||||
@@ -45,13 +97,15 @@ class ExpertFinderService
|
||||
}
|
||||
$saveResult = $this->saveExperts($result['experts'], $field, $source);
|
||||
|
||||
$nextPage = $result['has_more'] ? $page : $fetchLog['last_page'];
|
||||
$totalPages = $result['total_pages'] ?? $fetchLog['total_pages'];
|
||||
$this->updateFetchLog($field, $source, $nextPage, $totalPages);
|
||||
// 抓到下一篇则前移一个窗口;抓完则保持当前 offset
|
||||
$nextOffset = $result['has_more'] ? ($offset + $perPage) : $offset;
|
||||
$totalPages = $result['total_pages'] ?? ($fetchLog['total_pages'] ?? 0);
|
||||
$this->updateFetchLog($field, $source, $nextOffset, $totalPages, $perPage);
|
||||
|
||||
return [
|
||||
'keyword' => $field,
|
||||
'page' => $page,
|
||||
'page' => $result['page'] ?? 1,
|
||||
'offset' => $offset,
|
||||
'experts_found' => $result['total'],
|
||||
'saved_new' => $saveResult['inserted'],
|
||||
'saved_exist' => $saveResult['existing'],
|
||||
@@ -63,10 +117,12 @@ class ExpertFinderService
|
||||
|
||||
public function searchExperts($keyword, $perPage, $minYear, $page, $source)
|
||||
{
|
||||
// 交互式按页搜索:把页码换算成偏移量后走统一的 offset 逻辑
|
||||
$retstart = max(0, (intval($page) - 1) * intval($perPage));
|
||||
if ($source === 'pmc') {
|
||||
return $this->searchViaPMC($keyword, $perPage, $minYear, $page);
|
||||
return $this->searchViaPMC($keyword, $perPage, $minYear, $retstart);
|
||||
}
|
||||
return $this->searchViaPubMed($keyword, $perPage, $minYear, $page);
|
||||
return $this->searchViaPubMed($keyword, $perPage, $minYear, $retstart);
|
||||
}
|
||||
|
||||
public function saveExperts($experts, $field, $source)
|
||||
@@ -184,14 +240,25 @@ class ExpertFinderService
|
||||
->find();
|
||||
|
||||
if (!$log) {
|
||||
return ['last_page' => 0, 'total_pages' => 0, 'last_time' => 0];
|
||||
return ['last_page' => 0, 'last_offset' => 0, 'total_pages' => 0, 'last_time' => 0];
|
||||
}
|
||||
|
||||
return $log;
|
||||
}
|
||||
|
||||
public function updateFetchLog($field, $source, $lastPage, $totalPages)
|
||||
/**
|
||||
* 回写抓取进度。
|
||||
* @param int $lastOffset 累计偏移量(权威游标)
|
||||
* @param int $totalPages 总页数(仅展示)
|
||||
* @param int $perPage 本次窗口大小,用于换算展示用 last_page
|
||||
*/
|
||||
public function updateFetchLog($field, $source, $lastOffset, $totalPages, $perPage = 0)
|
||||
{
|
||||
$lastOffset = max(0, intval($lastOffset));
|
||||
$perPage = intval($perPage);
|
||||
// last_page 仅作展示:由偏移量换算(per_page 未知时退化为偏移量本身)
|
||||
$lastPage = $perPage > 0 ? intval(floor($lastOffset / $perPage)) : $lastOffset;
|
||||
|
||||
$exists = Db::name('expert_fetch')
|
||||
->where('field', $field)
|
||||
->where('source', $source)
|
||||
@@ -201,6 +268,7 @@ class ExpertFinderService
|
||||
Db::name('expert_fetch')
|
||||
->where('expert_fetch_id', $exists['expert_fetch_id'])
|
||||
->update([
|
||||
'last_offset' => $lastOffset,
|
||||
'last_page' => $lastPage,
|
||||
'total_pages' => $totalPages,
|
||||
'last_time' => time(),
|
||||
@@ -209,6 +277,7 @@ class ExpertFinderService
|
||||
Db::name('expert_fetch')->insert([
|
||||
'field' => mb_substr($field, 0, 128),
|
||||
'source' => mb_substr($source, 0, 128),
|
||||
'last_offset' => $lastOffset,
|
||||
'last_page' => $lastPage,
|
||||
'total_pages' => $totalPages,
|
||||
'last_time' => time(),
|
||||
@@ -218,16 +287,16 @@ class ExpertFinderService
|
||||
|
||||
// ==================== PubMed Search ====================
|
||||
|
||||
private function searchViaPubMed($keyword, $perPage, $minYear, $page = 1)
|
||||
private function searchViaPubMed($keyword, $perPage, $minYear, $retstart = 0)
|
||||
{
|
||||
set_time_limit(600);
|
||||
|
||||
$searchResult = $this->esearch('pubmed', $keyword, $perPage, $minYear, $page);
|
||||
$searchResult = $this->esearch('pubmed', $keyword, $perPage, $minYear, $retstart);
|
||||
$ids = $searchResult['ids'];
|
||||
$totalArticles = $searchResult['total'];
|
||||
|
||||
if (empty($ids)) {
|
||||
return $this->buildPagedResult([], 0, 0, $totalArticles, $page, $perPage, 'pubmed');
|
||||
return $this->buildPagedResult([], 0, 0, $totalArticles, $retstart, $perPage, 'pubmed');
|
||||
}
|
||||
|
||||
$allAuthors = [];
|
||||
@@ -243,21 +312,21 @@ class ExpertFinderService
|
||||
|
||||
$experts = $this->aggregateExperts($allAuthors);
|
||||
|
||||
return $this->buildPagedResult($experts, count($experts), count($ids), $totalArticles, $page, $perPage, 'pubmed');
|
||||
return $this->buildPagedResult($experts, count($experts), count($ids), $totalArticles, $retstart, $perPage, 'pubmed');
|
||||
}
|
||||
|
||||
// ==================== PMC Search ====================
|
||||
|
||||
private function searchViaPMC($keyword, $perPage, $minYear, $page = 1)
|
||||
private function searchViaPMC($keyword, $perPage, $minYear, $retstart = 0)
|
||||
{
|
||||
set_time_limit(600);
|
||||
|
||||
$searchResult = $this->esearch('pmc', $keyword, $perPage, $minYear, $page);
|
||||
$searchResult = $this->esearch('pmc', $keyword, $perPage, $minYear, $retstart);
|
||||
$ids = $searchResult['ids'];
|
||||
$totalArticles = $searchResult['total'];
|
||||
|
||||
if (empty($ids)) {
|
||||
return $this->buildPagedResult([], 0, 0, $totalArticles, $page, $perPage, 'pmc');
|
||||
return $this->buildPagedResult([], 0, 0, $totalArticles, $retstart, $perPage, 'pmc');
|
||||
}
|
||||
|
||||
$allAuthors = [];
|
||||
@@ -273,15 +342,15 @@ class ExpertFinderService
|
||||
|
||||
$experts = $this->aggregateExperts($allAuthors);
|
||||
|
||||
return $this->buildPagedResult($experts, count($experts), count($ids), $totalArticles, $page, $perPage, 'pmc');
|
||||
return $this->buildPagedResult($experts, count($experts), count($ids), $totalArticles, $retstart, $perPage, 'pmc');
|
||||
}
|
||||
|
||||
// ==================== NCBI API ====================
|
||||
|
||||
private function esearch($db, $keyword, $perPage, $minYear, $page = 1)
|
||||
private function esearch($db, $keyword, $perPage, $minYear, $retstart = 0)
|
||||
{
|
||||
$term = $keyword . ' AND ' . $minYear . ':' . date('Y') . '[pdat]';
|
||||
$retstart = ($page - 1) * $perPage;
|
||||
$retstart = max(0, intval($retstart));
|
||||
|
||||
$response = $this->httpClient->get($this->ncbiBaseUrl . 'esearch.fcgi', [
|
||||
'query' => [
|
||||
@@ -563,18 +632,23 @@ class ExpertFinderService
|
||||
return $experts;
|
||||
}
|
||||
|
||||
private function buildPagedResult($experts, $expertCount, $articlesScanned, $totalArticles, $page, $perPage, $source)
|
||||
private function buildPagedResult($experts, $expertCount, $articlesScanned, $totalArticles, $retstart, $perPage, $source)
|
||||
{
|
||||
$perPage = max(1, intval($perPage));
|
||||
$retstart = max(0, intval($retstart));
|
||||
$totalPages = $totalArticles > 0 ? ceil($totalArticles / $perPage) : 0;
|
||||
$page = intval(floor($retstart / $perPage)) + 1;
|
||||
return [
|
||||
'experts' => $experts,
|
||||
'total' => $expertCount,
|
||||
'articles_scanned' => $articlesScanned,
|
||||
'total_articles' => $totalArticles,
|
||||
'page' => $page,
|
||||
'offset' => $retstart,
|
||||
'per_page' => $perPage,
|
||||
'total_pages' => $totalPages,
|
||||
'has_more' => $page < $totalPages,
|
||||
// 偏移量驱动:下一个窗口还在范围内才有更多
|
||||
'has_more' => ($retstart + $perPage) < $totalArticles,
|
||||
'source' => $source,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
namespace app\common;
|
||||
use think\Db;
|
||||
use think\Env;
|
||||
use app\common\CrossrefService;
|
||||
class ProductionArticleRefer
|
||||
{
|
||||
|
||||
@@ -78,6 +79,41 @@ class ProductionArticleRefer
|
||||
return json_encode(['status' => 4,'msg' => 'Reference DOI is empty'.json_encode($aParam)]);
|
||||
}
|
||||
|
||||
|
||||
//开始用crossref接口的方式处理数据
|
||||
$doiNorm = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', $aRefer['refer_doi']);
|
||||
$doiNorm = trim($doiNorm, " \t\n\r\0\x0B/");
|
||||
|
||||
$svc = new CrossrefService([
|
||||
'mailto' => trim((string)Env::get('crossref_mailto', '')),
|
||||
]);
|
||||
$summary = $svc->fetchWorkSummary($doiNorm);
|
||||
if ($summary !== null && !empty($summary['doi'])) {
|
||||
$update_a = [];
|
||||
$title = trim((string)($summary['title'] ?? ''));
|
||||
$jouraRaw = trim((string)($summary['joura'] ?? ''));
|
||||
// 姓全写 + 名首字母,超过 3 个作者取前 3 个 + et al
|
||||
$authorCitation = $svc->getAuthorsCitation($summary['raw'] ?? [], 3);
|
||||
$dateno = trim((string)($summary['dateno'] ?? ''));
|
||||
$doilink = trim((string)($summary['doilink'] ?? ''));
|
||||
$update_a['title'] = $title;
|
||||
$update_a['author'] = $authorCitation !== '' ? $authorCitation . '.' : '';
|
||||
$update_a['joura'] = $jouraRaw;
|
||||
$update_a['dateno'] = $dateno;
|
||||
$update_a['refer_type'] = "journal";
|
||||
$update_a['is_ja'] = 1;
|
||||
$update_a['doilink'] = $doilink;
|
||||
$update_a['cs'] = 1;
|
||||
$update_a['update_time'] = time();
|
||||
$update_a['is_deal'] = 1;
|
||||
Db::name('production_article_refer')->where(['p_refer_id' => $iPReferId])->limit(1)->update($update_a);
|
||||
return json_encode(['status' => 1,'msg' => 'Update successful']);
|
||||
}
|
||||
|
||||
//结束---用crossref接口的方式处理数据
|
||||
|
||||
|
||||
|
||||
//数据处理
|
||||
$doi = str_replace('/', '%2F', $aRefer['refer_doi']);
|
||||
$url = "https://citation.doi.org/format?doi=$doi&style=cancer-translational-medicine&lang=en-US";
|
||||
|
||||
@@ -253,9 +253,13 @@ class PromotionService
|
||||
'send_time' => $now,
|
||||
]);
|
||||
Db::name('journal_email')->where('j_email_id', $account['j_email_id'])->setInc('today_sent');
|
||||
// 仅外部 expert 库回写最近一次推广时间;内部 user 用 promotion_email_log.send_time 计频次
|
||||
// 仅外部 expert 库回写最近一次推广时间与累计推广次数;内部 user 用 promotion_email_log.send_time 计频次
|
||||
if ($audienceKind === 'expert' && intval($expert['expert_id']) > 0) {
|
||||
Db::name('expert')->where('expert_id', $expert['expert_id'])->update(['state' => 1, 'ltime' => $now]);
|
||||
Db::name('expert')->where('expert_id', $expert['expert_id'])->update([
|
||||
'state' => 1,
|
||||
'ltime' => $now,
|
||||
'times' => Db::raw('times+1'),
|
||||
]);
|
||||
}
|
||||
Db::name('promotion_task')->where('task_id', $taskId)->setInc('sent_count');
|
||||
} else {
|
||||
|
||||
@@ -60,7 +60,8 @@ class PubmedService
|
||||
$pmid = trim($pmid);
|
||||
if ($pmid === '') return null;
|
||||
|
||||
$cacheKey = 'pmid_' . $pmid;
|
||||
// v2:解析结果新增 journal_iso_abbr / journal_medline_ta,换 key 避免命中旧缓存
|
||||
$cacheKey = 'pmid_v2_' . $pmid;
|
||||
$cached = $this->cacheGet($cacheKey, 30 * 86400);
|
||||
if (is_array($cached)) return $cached;
|
||||
|
||||
@@ -96,6 +97,22 @@ class PubmedService
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* DOI -> 期刊规范缩写(NLM/ISO 形式,如 "J Clin Oncol")
|
||||
* 优先 ISOAbbreviation,回退 MedlineTA;查不到返回 null。
|
||||
*/
|
||||
public function journalAbbrByDoi(string $doi): ?string
|
||||
{
|
||||
$info = $this->fetchByDoi($doi);
|
||||
if (!is_array($info)) return null;
|
||||
|
||||
$abbr = trim((string)($info['journal_iso_abbr'] ?? ''));
|
||||
if ($abbr === '') {
|
||||
$abbr = trim((string)($info['journal_medline_ta'] ?? ''));
|
||||
}
|
||||
return $abbr !== '' ? $abbr : null;
|
||||
}
|
||||
|
||||
// ----------------- Internals -----------------
|
||||
|
||||
private function esearch(string $term): ?string
|
||||
@@ -162,6 +179,9 @@ class PubmedService
|
||||
$pubTypes = array_values(array_unique($pubTypes));
|
||||
|
||||
$journal = $this->xpText($xp, '//PubmedArticle//Journal//Title');
|
||||
// 期刊规范缩写:ISOAbbreviation(Journal 下)与 MedlineTA(MedlineJournalInfo 下)
|
||||
$journalIsoAbbr = $this->xpText($xp, '//PubmedArticle//Journal//ISOAbbreviation');
|
||||
$journalMedlineTa = $this->xpText($xp, '//PubmedArticle//MedlineJournalInfo//MedlineTA');
|
||||
|
||||
$year = '';
|
||||
$year = $this->xpText($xp, '//PubmedArticle//JournalIssue//PubDate//Year');
|
||||
@@ -182,6 +202,8 @@ class PubmedService
|
||||
'mesh_terms' => $mesh,
|
||||
'publication_types' => $pubTypes,
|
||||
'journal' => $journal,
|
||||
'journal_iso_abbr' => $journalIsoAbbr,
|
||||
'journal_medline_ta' => $journalMedlineTa,
|
||||
'year' => $year,
|
||||
];
|
||||
}
|
||||
|
||||
219
application/common/service/LocalModelService.php
Normal file
219
application/common/service/LocalModelService.php
Normal file
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\Env;
|
||||
|
||||
/**
|
||||
* 本地模型服务:按上下文长度自动选择模型
|
||||
*
|
||||
* - 短上下文 -> 小模型(显存为大模型一半),对应 base.model_url1
|
||||
* - 长上下文 -> 大模型,对应 base.model_url
|
||||
*
|
||||
* 选择规则:上下文字符数 <= 阈值 用小模型;超过阈值 用大模型。
|
||||
* 两个端点模型名相同(base.model)。
|
||||
*
|
||||
* 用法:
|
||||
* $svc = new LocalModelService();
|
||||
* $res = $svc->chat([
|
||||
* ['role' => 'system', 'content' => '...'],
|
||||
* ['role' => 'user', 'content' => '...'],
|
||||
* ]);
|
||||
* // $res['ok'], $res['content'], $res['tier'](small|large), $res['context_len']
|
||||
*
|
||||
* // 只要文本结果:
|
||||
* $text = $svc->complete($systemPrompt, $userPrompt);
|
||||
*/
|
||||
class LocalModelService
|
||||
{
|
||||
/** 上下文长度阈值(字符数):<= 用小模型,> 用大模型 */
|
||||
const CONTEXT_THRESHOLD = 1000;
|
||||
|
||||
/** 请求超时(秒) */
|
||||
const TIMEOUT = 120;
|
||||
|
||||
/** 小模型端点(短上下文,显存一半) */
|
||||
private $smallUrl;
|
||||
|
||||
/** 大模型端点(长上下文) */
|
||||
private $largeUrl;
|
||||
|
||||
/** 模型名(两端点相同) */
|
||||
private $model;
|
||||
|
||||
/** 上下文长度阈值(字符数) */
|
||||
private $threshold;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// 小模型 -> base.model_url1,大模型 -> base.model_url,模型名同为 base.model
|
||||
$this->smallUrl = $this->normalizeChatUrl((string)Env::get('base.model_url1', ''));
|
||||
$this->largeUrl = $this->normalizeChatUrl((string)Env::get('base.model_url', ''));
|
||||
$this->model = trim((string)Env::get('base.model', ''));
|
||||
$this->threshold = self::CONTEXT_THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起一次对话,按上下文长度自动选模型。
|
||||
*
|
||||
* @param array $messages OpenAI 格式 messages
|
||||
* @param array $options 可选:
|
||||
* - temperature (float, 默认 0.2)
|
||||
* - max_tokens (int, 可选)
|
||||
* - force_tier ('small'|'large') 强制指定模型,跳过长度判断
|
||||
* - extra (array) 透传到请求体的额外字段
|
||||
* @return array{ok:bool, content:string, tier:string, model:string, url:string, context_len:int, error:string}
|
||||
*/
|
||||
public function chat(array $messages, array $options = [])
|
||||
{
|
||||
$contextLen = $this->measureMessages($messages);
|
||||
|
||||
$tier = isset($options['force_tier']) && in_array($options['force_tier'], ['small', 'large'], true)
|
||||
? $options['force_tier']
|
||||
: $this->pickTier($contextLen);
|
||||
|
||||
$endpoint = $this->resolveEndpoint($tier);
|
||||
|
||||
$result = [
|
||||
'ok' => false,
|
||||
'content' => '',
|
||||
'tier' => $tier,
|
||||
'model' => $endpoint['model'],
|
||||
'url' => $endpoint['url'],
|
||||
'context_len' => $contextLen,
|
||||
'error' => '',
|
||||
];
|
||||
|
||||
if ($endpoint['url'] === '' || $endpoint['model'] === '') {
|
||||
$result['error'] = $tier . ' 模型未配置(检查 .env [base] model_url / model_url1 / model)';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'model' => $endpoint['model'],
|
||||
'temperature' => isset($options['temperature']) ? (float)$options['temperature'] : 0.2,
|
||||
'messages' => $messages,
|
||||
];
|
||||
if (isset($options['max_tokens']) && intval($options['max_tokens']) > 0) {
|
||||
$payload['max_tokens'] = intval($options['max_tokens']);
|
||||
}
|
||||
if (isset($options['extra']) && is_array($options['extra'])) {
|
||||
$payload = array_merge($payload, $options['extra']);
|
||||
}
|
||||
|
||||
$content = $this->postChat($endpoint['url'], $payload, $err);
|
||||
if ($content === null) {
|
||||
$result['error'] = $err !== '' ? $err : 'LLM 请求失败';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['ok'] = true;
|
||||
$result['content'] = $content;
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 便捷方法:传 system + user,返回纯文本内容(失败返回空字符串)。
|
||||
*/
|
||||
public function complete($systemPrompt, $userPrompt, array $options = [])
|
||||
{
|
||||
$messages = [];
|
||||
if (trim((string)$systemPrompt) !== '') {
|
||||
$messages[] = ['role' => 'system', 'content' => (string)$systemPrompt];
|
||||
}
|
||||
$messages[] = ['role' => 'user', 'content' => (string)$userPrompt];
|
||||
|
||||
$res = $this->chat($messages, $options);
|
||||
return $res['ok'] ? $res['content'] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据上下文长度选择 tier。
|
||||
*/
|
||||
public function pickTier($contextLen)
|
||||
{
|
||||
return $contextLen > $this->threshold ? 'large' : 'small';
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计 messages 的上下文长度(所有 content 字符数之和)。
|
||||
*/
|
||||
public function measureMessages(array $messages)
|
||||
{
|
||||
$len = 0;
|
||||
foreach ($messages as $m) {
|
||||
if (isset($m['content']) && is_string($m['content'])) {
|
||||
$len += mb_strlen($m['content']);
|
||||
}
|
||||
}
|
||||
return $len;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回某 tier 的端点配置(模型名两端点相同)。
|
||||
*/
|
||||
private function resolveEndpoint($tier)
|
||||
{
|
||||
$url = $tier === 'large' ? $this->largeUrl : $this->smallUrl;
|
||||
return ['url' => $url, 'model' => $this->model];
|
||||
}
|
||||
|
||||
private function postChat($url, array $payload, &$err = '')
|
||||
{
|
||||
$err = '';
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT);
|
||||
|
||||
$headers = ['Content-Type: application/json'];
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
|
||||
$raw = curl_exec($ch);
|
||||
if ($raw === false) {
|
||||
$err = 'curl error: ' . curl_error($ch);
|
||||
curl_close($ch);
|
||||
return null;
|
||||
}
|
||||
$httpCode = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE));
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$err = 'http ' . $httpCode . ': ' . mb_substr((string)$raw, 0, 300);
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
$err = 'invalid json response';
|
||||
return null;
|
||||
}
|
||||
if (isset($data['choices'][0]['message']['content'])) {
|
||||
return (string)$data['choices'][0]['message']['content'];
|
||||
}
|
||||
if (isset($data['content'])) {
|
||||
return (string)$data['content'];
|
||||
}
|
||||
$err = 'no content in response: ' . mb_substr((string)$raw, 0, 300);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根地址自动补 /v1/chat/completions。
|
||||
*/
|
||||
private function normalizeChatUrl($url)
|
||||
{
|
||||
$url = trim((string)$url);
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
if (stripos($url, 'chat/completions') !== false) {
|
||||
return $url;
|
||||
}
|
||||
return rtrim($url, '/') . '/v1/chat/completions';
|
||||
}
|
||||
}
|
||||
3
sql/add_field_ai_source_to_expert.sql
Normal file
3
sql/add_field_ai_source_to_expert.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- 若已执行过 add_field_ai_to_expert.sql 但缺少 field_ai_source,单独补这一列
|
||||
ALTER TABLE `t_expert`
|
||||
ADD COLUMN `field_ai_source` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源: user_link / ai' AFTER `field_ai_utime`;
|
||||
46
sql/patch_expert_field_ai_columns.php
Normal file
46
sql/patch_expert_field_ai_columns.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
/**
|
||||
* 补全 t_expert 缺失的 field_ai 相关字段(可重复执行)
|
||||
* 用法: php sql/patch_expert_field_ai_columns.php
|
||||
*/
|
||||
$config = require __DIR__ . '/../application/database.php';
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
|
||||
$config['hostname'],
|
||||
$config['hostport'],
|
||||
$config['database'],
|
||||
$config['charset']
|
||||
);
|
||||
$pdo = new PDO($dsn, $config['username'], $config['password'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
]);
|
||||
|
||||
$table = $config['prefix'] . 'expert';
|
||||
$cols = $pdo->query("SHOW COLUMNS FROM `{$table}`")->fetchAll(PDO::FETCH_COLUMN, 0);
|
||||
$colSet = array_flip($cols);
|
||||
|
||||
$alters = [];
|
||||
if (!isset($colSet['field_ai'])) {
|
||||
$alters[] = "ADD COLUMN `field_ai` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'AI总结的主要研究领域(中文)' AFTER `affiliation`";
|
||||
}
|
||||
if (!isset($colSet['field_ai_status'])) {
|
||||
$after = isset($colSet['field_ai']) || !empty($alters) ? 'field_ai' : 'affiliation';
|
||||
$alters[] = "ADD COLUMN `field_ai_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待处理 1已生成 2资料不足 3失败 4无user待AI' AFTER `{$after}`";
|
||||
}
|
||||
if (!isset($colSet['field_ai_utime'])) {
|
||||
$alters[] = "ADD COLUMN `field_ai_utime` INT NOT NULL DEFAULT 0 COMMENT 'field_ai更新时间' AFTER `field_ai_status`";
|
||||
}
|
||||
if (!isset($colSet['field_ai_source'])) {
|
||||
$alters[] = "ADD COLUMN `field_ai_source` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源: user_link / ai' AFTER `field_ai_utime`";
|
||||
}
|
||||
|
||||
if (empty($alters)) {
|
||||
echo "OK: all field_ai columns exist on {$table}\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$sql = "ALTER TABLE `{$table}` " . implode(', ', $alters);
|
||||
echo "Running: {$sql}\n";
|
||||
$pdo->exec($sql);
|
||||
echo "Done.\n";
|
||||
Reference in New Issue
Block a user