Compare commits

...

61 Commits

Author SHA1 Message Date
wangjinlei
978c81ea10 升级 2026-06-23 09:55:38 +08:00
wangjinlei
6b9d119b27 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	application/api/controller/Preaccept.php
#	application/api/controller/Production.php
#	application/api/controller/References.php
#	application/api/controller/Reviewer.php
#	application/api/controller/Workbench.php
#	application/common/ArticleParserService.php
#	application/common/ProductionArticleRefer.php
#	application/common/Reviewer.php
2026-06-15 16:44:38 +08:00
wangjinlei
cd7b148ad4 升级 2026-06-15 16:36:03 +08:00
wangjinlei
4b20ffba85 推广频率更改,半个月发一封是可行的 2026-06-11 15:12:48 +08:00
wangjinlei
60c8b7d532 推广频率更改,半个月发一封是可行的 2026-06-11 14:51:13 +08:00
wangjinlei
249a04c109 参考文献bug 2026-06-10 15:31:10 +08:00
wangjinlei
0a2b053718 Merge remote-tracking branch 'origin/master' 2026-06-08 17:19:04 +08:00
wangjinlei
738ffa847f 总结expert领域的功能 2026-06-08 17:18:55 +08:00
chengxl
f64ca74b66 修改 2026-01-14 09:46:20 +08:00
chengxl
66f914bd35 修改 2026-01-14 09:43:56 +08:00
chengxl
7a629434b8 验证步骤状态方法修改 2026-01-07 09:25:04 +08:00
chengxl
06e8c3e69b 新增验证标题重复方法 2026-01-06 13:12:55 +08:00
chengxl
972331c24e 稿件验证标题是否存在 2026-01-06 10:58:33 +08:00
chengxl
ab69b9d99d 稿件提交给作者邮件内容修改 2026-01-05 11:23:06 +08:00
chengxl
537026636d 稿件提交给作者发送邮件 2026-01-05 10:57:13 +08:00
chengxl
e09e407f58 新增字段收费页面地址及收费说明 2026-01-04 17:17:55 +08:00
chengxl
e5b72e8a28 邮件内容期刊价格调用数据库里的 2026-01-04 15:03:40 +08:00
chengxl
799da4910b 邮件内容期刊价格调用数据库里的 2026-01-04 14:19:53 +08:00
chengxl
f28719058d 新增方法 2025-12-31 15:07:49 +08:00
chengxl
e710ecaf05 推送提醒 2025-12-29 16:37:36 +08:00
chengxl
47594a9042 定时任务 2025-12-29 14:57:25 +08:00
chengxl
a951558f9b 时间调整 2025-12-29 14:00:44 +08:00
chengxl
4d46b66d02 新增任务-邮件发送 2025-12-29 13:56:31 +08:00
chengxl
038d28633b 测试问题修改 2025-12-25 17:19:49 +08:00
chengxl
bbe66504e4 测试问题修改 2025-12-25 17:05:40 +08:00
chengxl
6b1ec4d1d2 审稿人推荐作者修改 2025-12-25 15:35:57 +08:00
chengxl
602842e72c 参考文献页码处理 2025-12-23 14:51:30 +08:00
chengxl
f35e6ea0b9 参考文献页码处理 2025-12-23 14:27:44 +08:00
chengxl
a614206eaf 问题修改 2025-12-16 09:41:38 +08:00
chengxl
6d07002cf1 接口调整 2025-12-10 16:05:19 +08:00
chengxl
78651c21f8 接口新增 2025-12-05 16:03:17 +08:00
chengxl
2958024f69 接口新增 2025-12-05 14:41:36 +08:00
chengxl
09d61b3785 接口新增 2025-12-05 13:14:01 +08:00
chengxl
fac1d1d4d9 接口新增 2025-12-05 11:06:38 +08:00
chengxl
d60b59dae4 接口修改 2025-12-04 17:41:21 +08:00
chengxl
d061759561 接口新增字段 2025-12-04 16:11:28 +08:00
chengxl
5121dc127b 新增接口 2025-12-04 15:17:58 +08:00
chengxl
402cb82841 新增接口 2025-12-04 14:46:47 +08:00
chengxl
b1c23c9599 新增接口 2025-12-04 10:48:06 +08:00
chengxl
bf8b4ecf74 接口调整 2025-12-03 17:01:54 +08:00
chengxl
488a312006 新增接口 2025-12-03 16:59:18 +08:00
chengxl
f15d072b2e 代码修改 2025-12-02 14:26:53 +08:00
chengxl
705dce5e94 代码修改 2025-12-02 13:17:23 +08:00
chengxl
93f9e705cb 测试问题修改 2025-12-01 11:53:41 +08:00
chengxl
c15b784cf8 测试问题修改 2025-11-28 16:35:11 +08:00
chengxl
55aa94adbe 参考文献相关上传 2025-11-28 15:46:00 +08:00
chengxl
7cdf825418 接口调整 2025-11-28 11:53:12 +08:00
chengxl
74f47346d5 新增期卷号处理 2025-11-28 09:25:27 +08:00
chengxl
97e30ab80c 新增期卷号处理 2025-11-28 09:22:34 +08:00
chengxl
632fede3cb 测试问题修改 2025-11-27 13:42:15 +08:00
chengxl
b904a0d3df 测试问题修改 2025-11-27 13:40:46 +08:00
chengxl
30995b2194 测试问题修改 2025-11-27 13:38:52 +08:00
chengxl
f5c59d222f 接口调整 2025-11-27 10:25:33 +08:00
chengxl
27c7f88c0b 接口调整 2025-11-27 10:24:29 +08:00
chengxl
f9ba4c0d6f 标题修改 2025-11-27 09:33:09 +08:00
chengxl
bd2305d83d 调整 2025-11-26 17:34:47 +08:00
chengxl
99ada38114 测试问题修改 2025-11-24 17:43:41 +08:00
chengxl
eabde1d138 上传参考文献相关调整 2025-11-20 15:27:42 +08:00
chengxl
0876328264 上传参考文献相关调整 2025-11-20 15:25:03 +08:00
chengxl
4d111fd9e9 新增方法 2025-11-19 14:40:49 +08:00
chengxl
2be05942bd 新增需求 2025-11-19 14:39:58 +08:00
21 changed files with 1389 additions and 139 deletions

View File

@@ -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;

View 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";
}
}

View File

@@ -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;
}
/**

View File

@@ -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));

View File

@@ -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([

View File

@@ -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)),

View File

@@ -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'];

View 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();
}
}
}

View 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();
}
}
}

View 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();
}
}
}

View 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

View 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

View File

@@ -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;
}
/**
* 提取发表年份
*/

View File

@@ -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 判断。'
. '要求精确、简洁13 个中文领域词或短短语,用顿号分隔;不要解释、不要英文。'
. '只输出 JSON{"field_ai":"..."}。',
],
[
'role' => 'user',
'content' => "请根据以下 JSON 资料总结该专家的主要研究领域:\n" . $payloadJson,
],
];
$systemPrompt = '你是学术领域分类助手。根据专家的单位、论文标题与 PubMed 检索上下文,用简体中文总结该专家最主要的研究领域。'
. '注意search_keywords 只是检索词,不可直接当作领域结论,应结合 paper 标题与 affiliation 判断。'
. '要求精确、简洁13 个中文领域词或短短语,用顿号分隔;不要解释、不要英文。'
. '只输出 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 = [

View File

@@ -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_offsetlast_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,
];
}

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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');
// 期刊规范缩写ISOAbbreviationJournal 下)与 MedlineTAMedlineJournalInfo 下)
$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,
];
}

View 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';
}
}

View 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`;

View 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";