Compare commits

...

67 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
wyn
83a8b6272c Merge branch 'master' of https://git.nuttyreading.com/zm/tougao 2026-06-08 11:01:17 +08:00
wyn
aee9b00c6f api/expert_manage/getList 加国家筛选
解析简历信息
2026-06-08 11:00:54 +08:00
wangjinlei
9cfa2fccc3 Merge remote-tracking branch 'origin/master' 2026-06-05 15:50:53 +08:00
wangjinlei
633ec028b0 总结expert领域的功能 2026-06-05 15:50:48 +08:00
wyn
1d54946fef 背调优化
发邮件记录添加姓名和邮箱模糊搜索
2026-06-05 13:52:35 +08:00
wyn
752494dbdb 去掉背调多余代码 2026-06-05 11:27:11 +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
33 changed files with 2178 additions and 312 deletions

5
.env
View File

@@ -29,6 +29,11 @@ model=DeepSeek-Coder-V2-Instruct
;chat_url=http://chat.taimed.cn/v1/chat/completions
;chat_model=DeepSeek-Coder-V2-Instruct
[expert_field_ai]
; Expert 库 field_ai AI 总结(留空则复用 user_field_ai / base.model_url
;max_papers=8
;timeout=90
[promotion]
PROMOTION_LLM_URL=http://chat.taimed.cn/v1/chat/completions
PROMOTION_LLM_MODEL=DeepSeek-Coder-V2-Instruct

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

@@ -4,33 +4,33 @@ namespace app\api\controller;
use app\common\service\AuthorBackgroundService;
use think\Controller;
use think\Db;
/**
* 作者背调 API前后端分离返回 JSON
* 作者背调HTML 报告页 + JSON API
*
* 主接口background_report / due_diligenceORCID 必填)
* Scopus 相关接口委托 AuthorInfo 实现
* 主接口:index / background_report / due_diligence
*/
class Author extends Controller
{
/** @var AuthorBackgroundService */
private $bgService;
/** @var AuthorInfo */
private $authorInfo;
/** @var string */
private $articleLookupError = '';
public function __construct(\think\Request $request = null)
{
parent::__construct($request);
$this->bgService = new AuthorBackgroundService();
$this->authorInfo = new AuthorInfo();
$this->bgService = new AuthorBackgroundService();
}
/**
* 作者背调 HTML 页面入口
*
* 1. 传了 ORCID → 直接生成报告
* 2. 未传 ORCID + 姓氏(机构选填)→ 仅按姓名搜 ORCID1 条直接报告,多条显示选择列表
* 2. 传了 articleId稿件作者 ID即 art_aut_id→ 从 t_article_author 补全 ORCID / 姓名 / 机构
* 3. 未传 ORCID + 姓氏(机构选填)→ 仅按姓名搜 ORCID1 条直接报告,多条显示选择列表
*/
public function index()
{
@@ -38,6 +38,18 @@ class Author extends Controller
$formAction = $this->resolveFormAction();
$params = $this->resolveBackgroundParams();
if ($this->articleLookupError !== '') {
$this->assign([
'form_action' => $formAction,
'error_msg' => $this->articleLookupError,
'last_name' => $params['last_name'],
'first_name' => $params['first_name'],
'institution' => $params['institution'],
]);
return $this->fetch('author/index');
}
$orcidNorm = $this->bgService->normalizeOrcid($params['orcid']);
if ($orcidNorm === ''
@@ -123,104 +135,19 @@ class Author extends Controller
]);
}
/** camelCase 别名 */
public function backgroundReport()
{
return $this->background_report();
}
/** 与 background_report 相同(路由兼容) */
public function due_diligence()
{
return $this->background_report();
}
public function dueDiligence()
{
return $this->due_diligence();
}
/**
* OpenAlex + Crossref 诚信扫描(不依赖 ORCID 必填)
*/
public function background_check()
{
return $this->authorInfo->background_check();
}
public function backgroundCheck()
{
return $this->background_check();
}
public function get_hindex()
{
return $this->authorInfo->get_hindex();
}
public function getHindex()
{
return $this->get_hindex();
}
public function get_scopus_id()
{
return $this->authorInfo->get_scopus_id();
}
public function getScopusId()
{
return $this->get_scopus_id();
}
public function check_scopus_cookie()
{
return $this->authorInfo->check_scopus_cookie();
}
public function checkScopusCookie()
{
return $this->check_scopus_cookie();
}
public function save_scopus_cookie()
{
return $this->authorInfo->save_scopus_cookie();
}
public function saveScopusCookie()
{
return $this->save_scopus_cookie();
}
public function login_scopus()
{
return $this->authorInfo->login_scopus();
}
public function loginScopus()
{
return $this->login_scopus();
}
public function check_elsevier_api()
{
if (method_exists($this->authorInfo, 'check_elsevier_api')) {
return $this->authorInfo->check_elsevier_api();
}
return json(['code' => 0, 'msg' => 'check_elsevier_api not implemented']);
}
public function checkElsevierApi()
{
return $this->check_elsevier_api();
}
/**
* 解析背调查询参数(兼容多种命名)
*/
private function resolveBackgroundParams()
{
$this->articleLookupError = '';
$pick = function (...$keys) {
foreach ($keys as $k) {
$v = trim((string) input('param.' . $k, ''));
@@ -237,17 +164,146 @@ class Author extends Controller
return '';
};
$orcid = $pick('orcid', 'orcid_id');
$lastName = $pick('lastName', 'last_name', 'lastname', 'surname');
$firstName = $pick('firstName', 'first_name', 'firstname', 'given_name');
$institution = $pick('institution', 'affiliation', 'affil', 'org');
$realname = $pick('realname', 'real_name');
$artAutId = $pick( 'art_aut_id', 'artAutId');
if ($artAutId !== '') {
$fromAuthor = $this->loadAuthorByArtAutId($artAutId);
if ($fromAuthor === null) {
$this->articleLookupError = '未找到该作者信息';
} else {
if ($orcid === '') {
$orcid = $fromAuthor['orcid'];
}
if ($lastName === '') {
$lastName = $fromAuthor['last_name'];
}
if ($firstName === '') {
$firstName = $fromAuthor['first_name'];
}
if ($institution === '') {
$institution = $fromAuthor['institution'];
}
}
}
if ($realname !== '' && ($lastName === '' || $firstName === '')) {
$parsed = $this->parseRealname($realname);
if ($lastName === '') {
$lastName = $parsed['last_name'];
}
if ($firstName === '') {
$firstName = $parsed['first_name'];
}
}
return [
'orcid' => $pick('orcid', 'orcid_id'),
'last_name' => $pick('lastName', 'last_name', 'lastname', 'surname'),
'first_name' => $pick('firstName', 'first_name', 'firstname', 'given_name'),
'institution' => $pick('institution', 'affiliation', 'affil', 'org'),
'orcid' => $orcid,
'last_name' => $lastName,
'first_name' => $firstName,
'institution' => $institution,
];
}
/**
* 按 art_aut_id 从 t_article_author 读取作者信息
*/
private function loadAuthorByArtAutId($artAutId)
{
$artAutId = (int) $artAutId;
if ($artAutId <= 0) {
return null;
}
$row = Db::name('article_author')
->field('orcid,firstname,lastname,company')
->where(['art_aut_id' => $artAutId, 'state' => 0])
->find();
if (empty($row)) {
return null;
}
return [
'orcid' => trim((string) ($row['orcid'] ?? '')),
'first_name' => trim((string) ($row['firstname'] ?? '')),
'last_name' => trim((string) ($row['lastname'] ?? '')),
'institution' => $this->extractInstitutionFromCompany($row['company'] ?? ''),
];
}
/**
* 从 company 字段提取机构名(去掉序号前缀,支持 ; 和 , 分隔)
*/
private function extractInstitutionFromCompany($company)
{
$company = trim((string) $company);
if ($company === '') {
return '';
}
$company = str_replace('', ',', $company);
$parts = preg_split('/[;,]/u', $company);
$institutions = [];
foreach ($parts as $part) {
$part = trim($part);
if ($part === '') {
continue;
}
$part = preg_replace('/^\d+\s*/', '', $part);
$part = trim($part);
if ($part !== '' && !in_array($part, $institutions, true)) {
$institutions[] = $part;
}
}
return implode('', $institutions);
}
/**
* 将整段姓名拆成名+姓(如 Chuanying ZHANG → first=Chuanying, last=ZHANG
*/
private function parseRealname($realname)
{
$realname = trim((string) $realname);
if ($realname === '') {
return ['first_name' => '', 'last_name' => ''];
}
if (strpos($realname, ',') !== false) {
$parts = array_map('trim', explode(',', $realname, 2));
$family = $parts[0] ?? '';
$given = $parts[1] ?? '';
if ($family !== '' && $given !== '') {
return ['first_name' => $given, 'last_name' => $family];
}
}
$tokens = preg_split('/\s+/u', $realname);
$tokens = array_values(array_filter($tokens, function ($t) {
return $t !== '';
}));
if (count($tokens) === 0) {
return ['first_name' => '', 'last_name' => ''];
}
if (count($tokens) === 1) {
return ['first_name' => '', 'last_name' => $tokens[0]];
}
$lastName = array_pop($tokens);
$firstName = implode(' ', $tokens);
return ['first_name' => $firstName, 'last_name' => $lastName];
}
private function resolveFormAction()
{
return rtrim($this->request->root(), '/') . '/api/author/index';
// 生产环境未配置伪静态时需带 index.php如 /public/index.php/api/author/index
return rtrim($this->request->baseFile(), '/') . '/api/author/index';
}
private function renderReportPage(array $params, $formAction)

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));
@@ -2130,6 +2130,7 @@ class EmailClient extends Base
$state = $this->request->param('state', '-1');
$page = max(1, intval($this->request->param('page', 1)));
$perPage = max(1, min(intval($this->request->param('per_page', 50)), 200));
$keyword = trim($this->request->param('keyword', ''));
if (!$taskId) {
return jsonError('task_id is required');
@@ -2139,8 +2140,13 @@ class EmailClient extends Base
if ($state !== '-1' && $state !== '') {
$where['l.state'] = intval($state);
}
$total = Db::name('promotion_email_log')->alias('l')->where($where)->count();
if ($keyword !== '') {
$where['e.email|e.name'] = ['like', '%' . $keyword . '%'];
}
$total = Db::name('promotion_email_log')->alias('l')
->join('t_expert e', 'l.expert_id = e.expert_id', 'LEFT')
->where($where)
->count('l.log_id');
$list = Db::name('promotion_email_log')->alias('l')
->join('t_expert e', 'l.expert_id = e.expert_id', 'LEFT')
->where($where)
@@ -2276,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')
@@ -2348,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']);
@@ -2618,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) {
@@ -2728,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);
@@ -2770,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

@@ -6,39 +6,81 @@ use think\Db;
use app\common\ExpertFieldAiService;
/**
* Expert 领域总结(方案 C - 阶段1邮箱关联 user.field_ai
* Expert 领域 AI 总结(方案 C:少量 user 关联 + 主流程 AI
*
* POST startLinkChain 启动链式队列,批量关联
* POST linkOne 同步关联单个 expert_id
* POST linkBatch 同步批量关联 expert_ids
* POST syncByUser user 有 field_ai 后,同步到同邮箱 expert
* GET preview 预览是否可关联
* GET statistics 统计 field_ai 覆盖情况
* POST startChain 启动链式队列(关联 + AI主入口
* POST processOne 同步处理单个 expert_id
* POST processBatch 同步批量处理
* POST linkOne user 关联(调试)
* POST syncByUser user 有 field_ai 后同步到 expert
* GET preview 预览可关联 / 可 AI 总结 / 上下文
* GET statistics 覆盖统计
*/
class ExpertFieldAi extends Base
{
/**
* 启动链式关联队列
* 启动链式处理(主入口)
* Worker: php think queue:work --queue ExpertFieldAi
*/
public function startLinkChain()
public function startChain()
{
$force = intval($this->request->param('force', 0)) === 1;
$delay = max(0, intval($this->request->param('delay', 1)));
$svc = new ExpertFieldAiService();
$started = $svc->startLinkChain($force, $delay);
$started = $svc->startChain($force, $delay);
return jsonSuccess([
'started' => $started,
'queue' => ExpertFieldAiService::QUEUE_NAME,
'force' => $force,
'msg' => $started ? 'link chain enqueued' : 'no pending experts',
'msg' => $started ? 'chain enqueued' : 'no pending experts',
]);
}
/** 兼容旧接口名 */
public function startLinkChain()
{
return $this->startChain();
}
/**
* 同步关联单个 expert
* 同步处理单个 expert(关联 + AI
*/
public function processOne()
{
$expertId = intval($this->request->param('expert_id', 0));
$force = intval($this->request->param('force', 0)) === 1;
if ($expertId <= 0) {
return jsonError('expert_id required');
}
$svc = new ExpertFieldAiService();
$result = $svc->processExpert($expertId, $force);
if (empty($result['ok'])) {
return jsonError(isset($result['error']) ? $result['error'] : 'failed');
}
return jsonSuccess($result);
}
/**
* 同步批量处理
* expert_ids 逗号分隔,或 limit 扫描待处理前 N 条
*/
public function processBatch()
{
$force = intval($this->request->param('force', 0)) === 1;
$ids = $this->resolveExpertIds();
if (empty($ids)) {
return jsonError('expert_ids 或 limit 必填');
}
$svc = new ExpertFieldAiService();
return jsonSuccess($svc->batchProcess($ids, $force));
}
/**
* 仅 user 关联(不 AI
*/
public function linkOne()
{
@@ -56,43 +98,18 @@ class ExpertFieldAi extends Base
return jsonSuccess($result);
}
/**
* 同步批量关联
* expert_ids: 逗号分隔,或传 limit 扫描待处理前 N 条
*/
public function linkBatch()
{
$force = intval($this->request->param('force', 0)) === 1;
$idsRaw = trim((string)$this->request->param('expert_ids', ''));
$limit = min(max(intval($this->request->param('limit', 0)), 0), 200);
$ids = [];
if ($idsRaw !== '') {
$ids = array_filter(array_map('intval', explode(',', $idsRaw)));
} elseif ($limit > 0) {
$ids = Db::name('expert')
->where('state', '<>', 5)
->where(function ($q) {
$q->where('field_ai_status', ExpertFieldAiService::STATUS_PENDING)
->whereOr('field_ai_status', ExpertFieldAiService::STATUS_FAILED);
})
->order('expert_id asc')
->limit($limit)
->column('expert_id');
}
$ids = $this->resolveExpertIds(true);
if (empty($ids)) {
return jsonError('expert_ids 或 limit 必填');
}
$svc = new ExpertFieldAiService();
$result = $svc->batchLinkFromUser($ids, $force);
return jsonSuccess($result);
return jsonSuccess($svc->batchLinkFromUser($ids, $force));
}
/**
* user 更新 field_ai 后,同步到同邮箱 expert
*/
public function syncByUser()
{
$userId = intval($this->request->param('user_id', 0));
@@ -110,7 +127,7 @@ class ExpertFieldAi extends Base
}
/**
* 预览是否可关联
* 预览是否可 user 关联、是否可 AI、上下文摘要
*/
public function preview()
{
@@ -120,7 +137,7 @@ class ExpertFieldAi extends Base
}
$svc = new ExpertFieldAiService();
$result = $svc->previewLink($expertId);
$result = $svc->preview($expertId);
if (empty($result['ok'])) {
return jsonError(isset($result['error']) ? $result['error'] : 'failed');
}
@@ -129,33 +146,58 @@ class ExpertFieldAi extends Base
return jsonSuccess($result);
}
/**
* 统计 field_ai 覆盖
*/
public function statistics()
{
$total = Db::name('expert')->where('state', '<>', 5)->count();
$done = Db::name('expert')->where('state', '<>', 5)->where('field_ai_status', ExpertFieldAiService::STATUS_DONE)->count();
$userLink = Db::name('expert')
->where('state', '<>', 5)
->where('field_ai_source', ExpertFieldAiService::SOURCE_USER_LINK)
->count();
$noUserLink = Db::name('expert')
->where('state', '<>', 5)
->where('field_ai_status', ExpertFieldAiService::STATUS_NO_USER_LINK)
->count();
$pending = Db::name('expert')
->where('state', '<>', 5)
->where('field_ai_status', ExpertFieldAiService::STATUS_PENDING)
->count();
$base = Db::name('expert')->where('state', '<>', 5);
$total = (clone $base)->count();
$done = (clone $base)->where('field_ai_status', ExpertFieldAiService::STATUS_DONE)->count();
$userLink = (clone $base)->where('field_ai_source', ExpertFieldAiService::SOURCE_USER_LINK)->count();
$aiDone = (clone $base)->where('field_ai_source', ExpertFieldAiService::SOURCE_AI)->count();
$pending = (clone $base)->where('field_ai_status', ExpertFieldAiService::STATUS_PENDING)->count();
$noUserLink = (clone $base)->where('field_ai_status', ExpertFieldAiService::STATUS_NO_USER_LINK)->count();
$insufficient = (clone $base)->where('field_ai_status', ExpertFieldAiService::STATUS_INSUFFICIENT)->count();
$failed = (clone $base)->where('field_ai_status', ExpertFieldAiService::STATUS_FAILED)->count();
return jsonSuccess([
'total' => $total,
'done' => $done,
'user_link' => $userLink,
'no_user_link' => $noUserLink,
'ai_done' => $aiDone,
'pending' => $pending,
'no_user_link' => $noUserLink,
'insufficient' => $insufficient,
'failed' => $failed,
'coverage_rate' => $total > 0 ? round($done / $total * 100, 2) . '%' : '0%',
]);
}
private function resolveExpertIds($linkOnly = false)
{
$idsRaw = trim((string)$this->request->param('expert_ids', ''));
$limit = min(max(intval($this->request->param('limit', 0)), 0), 200);
if ($idsRaw !== '') {
return array_filter(array_map('intval', explode(',', $idsRaw)));
}
if ($limit <= 0) {
return [];
}
$query = Db::name('expert')->where('state', '<>', 5);
if ($linkOnly) {
$query->where(function ($q) {
$q->where('field_ai_status', ExpertFieldAiService::STATUS_PENDING)
->whereOr('field_ai_status', ExpertFieldAiService::STATUS_FAILED);
});
} else {
$query->where(function ($q) {
$q->where('field_ai_status', ExpertFieldAiService::STATUS_PENDING)
->whereOr('field_ai_status', ExpertFieldAiService::STATUS_FAILED)
->whereOr('field_ai_status', ExpertFieldAiService::STATUS_NO_USER_LINK);
});
}
return $query->order('expert_id asc')->limit($limit)->column('expert_id');
}
}

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

@@ -39,6 +39,7 @@ class ExpertManage extends Base
$field = trim(isset($data['field']) ? $data['field'] : '');
$state = isset($data['state']) ? $data['state'] : '-1';
$source = trim(isset($data['source']) ? $data['source'] : '');
$country = trim(isset($data['country']) ? $data['country'] : '');
$page = max(1, intval(isset($data['pageIndex']) ? $data['pageIndex'] : 1));
$pageSize = max(1, intval(isset($data['pageSize']) ? $data['pageSize'] : 20));
@@ -74,6 +75,10 @@ class ExpertManage extends Base
$query->where('e.source', $source);
$countQuery->where('e.source', $source);
}
if ($country !== '') {
$query->where('e.country', $country);
$countQuery->where('e.country', $country);
}
// $countQuery = clone $query;
// $total = $countQuery->distinct('e.expert_id')->count();

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

@@ -3311,4 +3311,63 @@ class User extends Base
return jsonSuccess([]);
}
/**
* 根据 user_id 查 user_cv 简历,调用大模型解析用户基本信息。
*
* POST/GET user_id=用户ID也支持 userId
* 简历地址https://submission.tmrjournals.com/public/reviewer/{cv}
* 同机部署时优先读 public/reviewer/ 本地文件。
*/
public function getUserInfoByFile()
{
@set_time_limit(180);
$userId = intval($this->request->param('user_id', $this->request->param('userId', 0)));
if ($userId <= 0) {
return jsonError('请提供 user_id');
}
try {
$service = new \app\common\UserInfoFromFileService();
$result = $service->parseFromUserId($userId);
\think\Log::info('[getUserInfoByFile] user_id=' . $userId . ' ' . json_encode($result['user_info'], JSON_UNESCAPED_UNICODE));
return jsonSuccess([
'message' => '解析完成',
'user_id' => $result['user_id'],
'cv' => $result['cv'],
'cv_url' => $result['cv_url'],
'file' => $result['file'],
'text_length' => $result['text_length'],
'text_preview' => $result['text_preview'],
'user_info' => $result['user_info'],
'print' => $this->formatUserInfoForPrint($result['user_info']),
]);
} catch (\Throwable $e) {
return jsonError($e->getMessage());
}
}
private function formatUserInfoForPrint(array $info)
{
$labels = [
'realname' => '姓名',
'email' => '邮箱',
'phone' => '电话',
'orcid' => 'ORCID',
'technical' => '职称',
'field' => '研究领域',
'company' => 'Institution',
'department' => '科室',
'country' => '国家',
'introduction' => '简介',
];
$lines = [];
foreach ($labels as $key => $label) {
$val = trim((string) ($info[$key] ?? ''));
$lines[] = $label . '' . ($val !== '' ? $val : '(未识别)');
}
return implode("\n", $lines);
}
}

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

@@ -6,13 +6,7 @@ use think\queue\Job;
use app\common\ExpertFieldAiService;
/**
* Expert field_ai 链式任务阶段1邮箱关联 user.field_ai
*
* data:
* - expert_id
* - queue 队列名,默认 ExpertFieldAi
* - force 1=强制重算
* - mode link默认
* Expert field_ai 链式任务:先尝试 user 关联,主流程 AI 总结
*
* Worker: php think queue:work --queue ExpertFieldAi
*/
@@ -23,16 +17,15 @@ class ExpertFieldAiFill
$expertId = isset($data['expert_id']) ? intval($data['expert_id']) : 0;
$queue = isset($data['queue']) ? (string)$data['queue'] : ExpertFieldAiService::QUEUE_NAME;
$force = !empty($data['force']);
$mode = isset($data['mode']) ? (string)$data['mode'] : 'link';
$svc = new ExpertFieldAiService();
if ($expertId > 0 && $mode === 'link') {
$svc->linkFromUser($expertId, $force);
if ($expertId > 0) {
$svc->processExpert($expertId, $force);
}
$job->delete();
$delay = max(0, (int)(isset($data['delay']) ? $data['delay'] : 1));
$svc->enqueueNextLink($delay, $queue, $expertId, $force);
$svc->enqueueNext($delay, $queue, $expertId, $force);
}
}

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

@@ -289,6 +289,57 @@ a.ext:hover { text-decoration: underline; }
margin: 6px 0;
}
.report-rules {
margin-top: 32px;
padding: 18px 22px;
background: #f8fafc;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text-muted);
line-height: 1.7;
}
.report-rules-title {
font-size: 14px;
font-weight: 700;
color: var(--text);
margin: 0 0 12px;
}
.report-rules-sub {
font-size: 13px;
font-weight: 600;
color: var(--text);
margin: 14px 0 8px;
}
.rules-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.rules-table th,
.rules-table td {
border: 1px solid var(--border);
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
.rules-table th {
background: #edf2f7;
color: var(--text);
font-weight: 600;
width: 22%;
}
.rules-table td strong {
color: var(--text);
}
.report-rules ul {
margin: 0;
padding-left: 18px;
}
.report-rules li {
margin: 4px 0;
}
.report-foot {
margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border);
font-size: 12px; color: var(--text-muted); text-align: center; line-height: 1.7;

View File

@@ -26,19 +26,19 @@
<form method="get" action="{$form_action}">
<div class="form-group">
<label class="form-label">ORCID <span class="opt">— 填写后直接出报告</span></label>
<input class="form-input" type="text" name="orcid" placeholder="0000-0002-6388-7847" value="{$orcid|default=''|htmlspecialchars}">
<input class="form-input" type="text" name="orcid" value="{$orcid|default=''|htmlspecialchars}">
</div>
<div class="form-group">
<label class="form-label">姓 Last Name <span class="opt">— 未填 ORCID 时必填</span></label>
<input class="form-input" type="text" name="lastName" placeholder="PENG" value="{$last_name|default=''|htmlspecialchars}">
<input class="form-input" type="text" name="lastName" value="{$last_name|default=''|htmlspecialchars}">
</div>
<div class="form-group">
<label class="form-label">名 First Name <span class="opt">— 选填</span></label>
<input class="form-input" type="text" name="firstName" placeholder="Sijing" value="{$first_name|default=''|htmlspecialchars}">
<input class="form-input" type="text" name="firstName" value="{$first_name|default=''|htmlspecialchars}">
</div>
<div class="form-group">
<label class="form-label">机构 Institution <span class="opt">— 选填,用于候选列表排序</span></label>
<input class="form-input" type="text" name="institution" placeholder="University of Ibadan" value="{$institution|default=''|htmlspecialchars}">
<input class="form-input" type="text" name="institution" value="{$institution|default=''|htmlspecialchars}">
</div>
<button class="btn-primary" type="submit">生成背调报告</button>
</form>

View File

@@ -239,6 +239,32 @@
</div>
{/notempty}
<div class="report-rules">
<p class="report-rules-title">学术指标说明</p>
<table class="rules-table">
<tr><th>论文总数</th><td><strong>OpenAlex、ORCID、PubMed、Scopus</strong> 四项中的<strong>最大值</strong>非相加。OpenAlex 无档案时,可由 ORCID / PubMed 等补全。</td></tr>
<tr><th>总被引</th><td>仅来自 <strong>OpenAlex</strong>;未匹配到作者档案时为 0。</td></tr>
<tr><th>H 指数</th><td>仅来自 <strong>OpenAlex</strong>;未匹配时为 0。</td></tr>
<tr><th>i10 指数</th><td>仅来自 <strong>OpenAlex</strong>(至少 10 次被引的论文数);未匹配时为 0。</td></tr>
<tr><th>PubMed</th><td>独立统计,与论文总数<strong>口径不同</strong>。检索顺序ORCID → 姓名+机构 → 姓名,采用第一个有结果的检索式;列表最多显示最近 10 篇。</td></tr>
<tr><th>研究方向</th><td>来自 OpenAlex 前 5 个主题;无 OpenAlex 档案时不显示。</td></tr>
</table>
<p class="report-rules-sub">风险评级</p>
<ul>
<li><strong>高风险</strong>Retraction Watch 存在学术不端相关记录</li>
<li><strong>中风险</strong>:存在撤稿 / 关注声明</li>
<li><strong>待核实</strong>:论文总数为 0</li>
<li><strong>低风险</strong>H ≥ 10 或论文总数 ≥ 20且无上述不良记录</li>
<li><strong>一般</strong>:其余情况(青年学者常见产出区间)</li>
</ul>
<p class="report-rules-sub">其他说明</p>
<ul>
<li>OpenAlex 匹配:优先 ORCID 直连;有 ORCID 时不使用同名他人数据。</li>
<li>诚信记录:有 DOI 的 ORCID 作品按 DOI 精确比对;无 DOI 时回退姓名+题目匹配(同名需人工核实)。</li>
<li>论文总数有值、总被引/H 为 0通常表示 OpenAlex 未收录该作者,不代表无学术产出。</li>
</ul>
</div>
<p class="report-foot">
数据来源OpenAlex · ORCID · PubMed · Scopus · Retraction Watch<br>
适用于青年编委 / 特约审稿人 / 作者资质初审

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,13 +2,16 @@
namespace app\common;
use app\common\service\LocalModelService;
use think\Db;
use think\Env;
use think\Exception;
use think\Queue;
/**
* Expert 领域总结(方案 C
* 阶段1通过 email 关联 t_user / t_user_reviewer_info复用 user.field_ai
* 阶段2后续对 field_ai_status=4 的记录走 LLM 总结
* 1. 优先尝试 email 关联 user.field_ai(少量)
* 2. 主流程:根据 expert 论文/单位/检索词 AI 总结 field_ai
*/
class ExpertFieldAiService
{
@@ -25,31 +28,44 @@ 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());
}
}
// ===================== 链式队列 =====================
/**
* 启动链式关联(从 expert_id=0 之后找下一位待处理专家)。
* 启动链式处理(关联 + AI主入口)。
*/
public function startChain($force = false, $delay = 1, $queue = '')
{
return $this->enqueueNext($delay, $queue, 0, $force);
}
/** @deprecated 兼容旧名 */
public function startLinkChain($force = false, $delay = 1, $queue = '')
{
return $this->enqueueNextLink($delay, $queue, 0, $force);
return $this->startChain($force, $delay, $queue);
}
/**
* 链式:处理 expert_id > $afterExpertId 的下一位。
*/
public function enqueueNextLink($delay = 1, $queue = '', $afterExpertId = 0, $force = false)
public function enqueueNext($delay = 1, $queue = '', $afterExpertId = 0, $force = false)
{
if ($queue === '') {
$queue = self::QUEUE_NAME;
}
$afterExpertId = intval($afterExpertId);
$expertId = $this->findNextLinkExpertId($afterExpertId, $force);
$expertId = $this->findNextPendingExpertId($afterExpertId, $force);
if ($expertId <= 0) {
$this->log('[ExpertFieldAi] link chain finished after expert_id=' . $afterExpertId);
$this->log('[ExpertFieldAi] chain finished after expert_id=' . $afterExpertId);
return false;
}
@@ -57,7 +73,6 @@ class ExpertFieldAiService
'expert_id' => $expertId,
'queue' => $queue,
'force' => $force ? 1 : 0,
'mode' => 'link',
];
$jobClass = 'app\\api\\job\\ExpertFieldAiFill@fire';
if ($delay > 0) {
@@ -69,12 +84,18 @@ class ExpertFieldAiService
return true;
}
/** @deprecated */
public function enqueueNextLink($delay = 1, $queue = '', $afterExpertId = 0, $force = false)
{
return $this->enqueueNext($delay, $queue, $afterExpertId, $force);
}
// ===================== 主流程 =====================
/**
* 单个 expert尝试从 user 邮箱关联 field_ai
*
* @return array{ok:bool, linked?:bool, skipped?:bool, field_ai?:string, user_id?:int, error?:string}
* 处理单个 expert先关联 user,失败则 AI 总结
*/
public function linkFromUser($expertId, $force = false)
public function processExpert($expertId, $force = false)
{
$expertId = intval($expertId);
if ($expertId <= 0) {
@@ -97,54 +118,85 @@ class ExpertFieldAiService
];
}
$email = strtolower(trim((string)($expert['email'] ?? '')));
if ($email === '') {
$this->updateFieldAi($expertId, '', self::STATUS_NO_USER_LINK, '', 'no email');
return ['ok' => true, 'linked' => false, 'reason' => 'empty email'];
$linkResult = $this->tryLinkFromUser($expertId, $expert, $force);
if (!empty($linkResult['linked'])) {
return array_merge(['ok' => true, 'method' => 'user_link'], $linkResult);
}
$user = Db::name('user')
->where('email', $email)
->where('state', 0)
->field('user_id,email,realname')
->find();
if (!$user) {
$this->updateFieldAi($expertId, '', self::STATUS_NO_USER_LINK, '', 'no matching user');
return ['ok' => true, 'linked' => false, 'reason' => 'user not found'];
if (!$this->isEligible($expertId, $expert)) {
$this->updateFieldAi($expertId, '', self::STATUS_INSUFFICIENT, '', 'insufficient papers/affiliation');
return ['ok' => true, 'insufficient' => true, 'method' => 'ai'];
}
$uri = Db::name('user_reviewer_info')
->where('reviewer_id', intval($user['user_id']))
->where('state', 0)
->find();
$fieldAi = $uri ? trim((string)($uri['field_ai'] ?? '')) : '';
$userStatus = $uri ? intval($uri['field_ai_status']) : 0;
if ($fieldAi === '' || $userStatus !== UserFieldAiService::STATUS_DONE) {
$this->updateFieldAi($expertId, '', self::STATUS_NO_USER_LINK, '', 'user field_ai not ready');
try {
$context = $this->buildContext($expertId, $expert);
$fieldAi = $this->summarizeWithLlm($context);
if ($fieldAi === '') {
throw new Exception('LLM returned empty field_ai');
}
$this->updateFieldAi($expertId, $fieldAi, self::STATUS_DONE, self::SOURCE_AI, 'ai summarized');
return [
'ok' => true,
'linked' => false,
'user_id' => intval($user['user_id']),
'reason' => 'user has no field_ai',
'ok' => true,
'method' => 'ai',
'field_ai' => $fieldAi,
'source' => self::SOURCE_AI,
];
} catch (\Throwable $e) {
$this->updateFieldAi($expertId, '', self::STATUS_FAILED, '', mb_substr($e->getMessage(), 0, 500));
$this->log('[ExpertFieldAi] expert_id=' . $expertId . ' ai fail: ' . $e->getMessage());
return ['ok' => false, 'method' => 'ai', 'error' => $e->getMessage()];
}
$this->updateFieldAi($expertId, $fieldAi, self::STATUS_DONE, self::SOURCE_USER_LINK, 'linked from user_id=' . $user['user_id']);
return [
'ok' => true,
'linked' => true,
'field_ai' => $fieldAi,
'user_id' => intval($user['user_id']),
'source' => self::SOURCE_USER_LINK,
];
}
public function batchProcess(array $expertIds, $force = false)
{
$stats = ['total' => 0, 'linked' => 0, 'ai' => 0, 'skipped' => 0, 'insufficient' => 0, 'failed' => 0];
$details = [];
foreach ($expertIds as $expertId) {
$expertId = intval($expertId);
if ($expertId <= 0) {
continue;
}
$result = $this->processExpert($expertId, $force);
$stats['total']++;
if (empty($result['ok'])) {
$stats['failed']++;
} elseif (!empty($result['skipped'])) {
$stats['skipped']++;
} elseif (!empty($result['linked']) || (isset($result['method']) && $result['method'] === 'user_link')) {
$stats['linked']++;
} elseif (!empty($result['insufficient'])) {
$stats['insufficient']++;
} elseif (isset($result['method']) && $result['method'] === 'ai') {
$stats['ai']++;
}
$details[] = array_merge(['expert_id' => $expertId], $result);
}
return array_merge($stats, ['details' => $details]);
}
// ===================== 关联 user辅助 =====================
/**
* 批量同步(同步执行,适合小批量调试
* 仅做 user 关联(不触发 AI调试。
*/
public function linkFromUser($expertId, $force = false)
{
$expertId = intval($expertId);
$expert = Db::name('expert')->where('expert_id', $expertId)->find();
if (!$expert) {
return ['ok' => false, 'error' => 'expert not found'];
}
$result = $this->tryLinkFromUser($expertId, $expert, $force);
if (empty($result['linked']) && empty($result['skipped'])) {
$this->updateFieldAi($expertId, '', self::STATUS_NO_USER_LINK, '', 'link only: no user match');
}
return array_merge(['ok' => true], $result);
}
public function batchLinkFromUser(array $expertIds, $force = false)
{
$linked = 0;
@@ -181,9 +233,200 @@ class ExpertFieldAiService
];
}
/**
* 预览expert 是否可关联到 user.field_ai。
*/
private function tryLinkFromUser($expertId, $expert = null, $force = false)
{
if ($expert === null) {
$expert = Db::name('expert')->where('expert_id', intval($expertId))->find();
}
if (!$expert) {
return ['linked' => false, 'reason' => 'expert not found'];
}
if (!$force
&& intval($expert['field_ai_status']) === self::STATUS_DONE
&& trim((string)$expert['field_ai']) !== '') {
return [
'linked' => false,
'skipped' => true,
'field_ai' => (string)$expert['field_ai'],
'source' => (string)($expert['field_ai_source'] ?? ''),
];
}
$email = strtolower(trim((string)($expert['email'] ?? '')));
if ($email === '') {
return ['linked' => false, 'reason' => 'empty email'];
}
$user = Db::name('user')->where('email', $email)->where('state', 0)->field('user_id,email,realname')->find();
if (!$user) {
return ['linked' => false, 'reason' => 'user not found'];
}
$uri = Db::name('user_reviewer_info')
->where('reviewer_id', intval($user['user_id']))
->where('state', 0)
->find();
$fieldAi = $uri ? trim((string)($uri['field_ai'] ?? '')) : '';
if ($fieldAi === '' || intval($uri['field_ai_status'] ?? 0) !== UserFieldAiService::STATUS_DONE) {
return ['linked' => false, 'user_id' => intval($user['user_id']), 'reason' => 'user has no field_ai'];
}
$this->updateFieldAi(intval($expertId), $fieldAi, self::STATUS_DONE, self::SOURCE_USER_LINK, 'linked from user_id=' . $user['user_id']);
return [
'linked' => true,
'field_ai' => $fieldAi,
'user_id' => intval($user['user_id']),
'source' => self::SOURCE_USER_LINK,
];
}
public function syncExpertsByUserId($userId, $force = false)
{
$userId = intval($userId);
$user = Db::name('user')->where('user_id', $userId)->where('state', 0)->field('user_id,email')->find();
if (!$user || trim((string)$user['email']) === '') {
return ['ok' => false, 'error' => 'user not found'];
}
$email = strtolower(trim((string)$user['email']));
$expertIds = Db::name('expert')->where('email', $email)->where('state', '<>', 5)->column('expert_id');
if (empty($expertIds)) {
return ['ok' => true, 'synced' => 0, 'msg' => 'no expert with same email'];
}
return array_merge(['ok' => true], $this->batchLinkFromUser($expertIds, $force));
}
// ===================== AI 上下文 =====================
public function isEligible($expertId, $expert = null)
{
if ($expert === null) {
$expert = Db::name('expert')->where('expert_id', intval($expertId))->find();
}
if (!$expert) {
return false;
}
if (trim((string)($expert['affiliation'] ?? '')) !== '') {
return true;
}
$fieldRows = Db::name('expert_field')
->where('expert_id', intval($expertId))
->where('state', 0)
->field('field,paper_title,paper_journal')
->select();
foreach ($fieldRows as $row) {
if (trim((string)($row['paper_title'] ?? '')) !== '') {
return true;
}
if (trim((string)($row['field'] ?? '')) !== '') {
return true;
}
}
return false;
}
public function buildContext($expertId, $expert = null)
{
if ($expert === null) {
$expert = Db::name('expert')->where('expert_id', intval($expertId))->find();
}
$fieldRows = Db::name('expert_field')
->where('expert_id', intval($expertId))
->where('state', 0)
->order('expert_field_id desc')
->select();
$searchKeywords = [];
$papers = [];
$seenPaper = [];
foreach ($fieldRows as $row) {
$kw = trim((string)($row['field'] ?? ''));
if ($kw !== '') {
$searchKeywords[] = $kw;
}
$title = trim((string)($row['paper_title'] ?? ''));
if ($title === '') {
continue;
}
$paperKey = md5($title . '|' . ($row['paper_article_id'] ?? ''));
if (isset($seenPaper[$paperKey])) {
continue;
}
$seenPaper[$paperKey] = true;
$papers[] = [
'title' => mb_substr($title, 0, 300),
'journal' => mb_substr(trim((string)($row['paper_journal'] ?? '')), 0, 120),
'source' => trim((string)($row['source'] ?? '')),
'keyword' => $kw,
];
}
$maxPapers = max(1, min(15, (int)Env::get('expert_field_ai.max_papers', 8)));
$papers = array_slice($papers, 0, $maxPapers);
$searchKeywords = array_values(array_unique(array_filter($searchKeywords)));
// 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 [
'expert' => [
'name' => trim((string)($expert['name'] ?? '')),
'email' => trim((string)($expert['email'] ?? '')),
'affiliation' => trim((string)($expert['affiliation'] ?? '')),
'country' => $countryName,
'source' => trim((string)($expert['source'] ?? '')),
],
'search_keywords' => $searchKeywords,
'papers' => $papers,
'note' => 'search_keywords 是 PubMed 检索词,不代表本人领域;请以论文标题与单位为准。',
];
}
// ===================== 预览 / 统计 =====================
public function preview($expertId)
{
$expertId = intval($expertId);
$expert = Db::name('expert')->where('expert_id', $expertId)->find();
if (!$expert) {
return ['ok' => false, 'error' => 'expert not found'];
}
$linkPreview = $this->previewLink($expertId);
$eligible = $this->isEligible($expertId, $expert);
$context = $eligible ? $this->buildContext($expertId, $expert) : null;
return [
'ok' => true,
'expert_id' => $expertId,
'expert_field_ai' => (string)($expert['field_ai'] ?? ''),
'expert_field_ai_status' => intval($expert['field_ai_status'] ?? 0),
'can_link_user' => !empty($linkPreview['can_link']),
'link_preview' => $linkPreview,
'eligible_for_ai' => $eligible,
'context_preview' => $context,
];
}
public function previewLink($expertId)
{
$expertId = intval($expertId);
@@ -213,8 +456,6 @@ class ExpertFieldAiService
'ok' => true,
'expert_id' => $expertId,
'expert_email' => $email,
'expert_field_ai' => (string)($expert['field_ai'] ?? ''),
'expert_field_ai_status'=> intval($expert['field_ai_status'] ?? 0),
'matched_user_id' => $user ? intval($user['user_id']) : 0,
'matched_user_name' => $user ? (string)$user['realname'] : '',
'user_field_ai' => $uri ? (string)($uri['field_ai'] ?? '') : '',
@@ -223,31 +464,72 @@ class ExpertFieldAiService
];
}
/**
* user 生成 field_ai 后,反向同步到同邮箱 expert可选调用
*/
public function syncExpertsByUserId($userId, $force = false)
// ===================== LLM =====================
private function summarizeWithLlm(array $context)
{
$userId = intval($userId);
$user = Db::name('user')->where('user_id', $userId)->where('state', 0)->field('user_id,email')->find();
if (!$user || trim((string)$user['email']) === '') {
return ['ok' => false, 'error' => 'user not found'];
$payloadJson = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$systemPrompt = '你是学术领域分类助手。根据专家的单位、论文标题与 PubMed 检索上下文,用简体中文总结该专家最主要的研究领域。'
. '注意search_keywords 只是检索词,不可直接当作领域结论,应结合 paper 标题与 affiliation 判断。'
. '要求精确、简洁13 个中文领域词或短短语,用顿号分隔;不要解释、不要英文。'
. '只输出 JSON{"field_ai":"..."}。';
$userPrompt = "请根据以下 JSON 资料总结该专家的主要研究领域:\n" . $payloadJson;
// 按上下文长度动态选模型(小: base.model_url1 / 大: base.model_url
$svc = new LocalModelService();
$res = $svc->chat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
], ['temperature' => 0.2]);
if (empty($res['ok'])) {
throw new Exception('LLM error: ' . (string)($res['error'] ?? 'unknown'));
}
$email = strtolower(trim((string)$user['email']));
$expertIds = Db::name('expert')
->where('email', $email)
->where('state', '<>', 5)
->column('expert_id');
$this->log('[ExpertFieldAi] llm tier=' . ($res['tier'] ?? '') . ' ctx_len=' . ($res['context_len'] ?? 0) . ' url=' . ($res['url'] ?? ''));
if (empty($expertIds)) {
return ['ok' => true, 'synced' => 0, 'msg' => 'no expert with same email'];
$content = trim((string)($res['content'] ?? ''));
$fieldAi = $this->parseFieldAiFromContent($content);
if ($fieldAi === '' && $content !== '') {
$fieldAi = $this->cleanFieldAiText($content);
}
return array_merge(['ok' => true], $this->batchLinkFromUser($expertIds, $force));
return $fieldAi;
}
private function findNextLinkExpertId($afterExpertId, $force)
private function parseFieldAiFromContent($content)
{
$content = trim((string)$content);
if ($content === '') {
return '';
}
$content = preg_replace('/^```[a-zA-Z]*\s*|```$/m', '', $content);
if (preg_match('/\{.*\}/s', $content, $m)) {
$obj = json_decode($m[0], true);
if (is_array($obj) && !empty($obj['field_ai'])) {
return $this->cleanFieldAiText((string)$obj['field_ai']);
}
}
$obj = json_decode($content, true);
if (is_array($obj) && !empty($obj['field_ai'])) {
return $this->cleanFieldAiText((string)$obj['field_ai']);
}
return '';
}
private function cleanFieldAiText($text)
{
$text = trim((string)$text);
$text = trim($text, "\"' \t\n\r");
$text = preg_replace('/\s+/u', '', $text);
if (mb_strlen($text) > 200) {
$text = mb_substr($text, 0, 200);
}
return $text;
}
// ===================== 内部工具 =====================
private function findNextPendingExpertId($afterExpertId, $force)
{
$batch = 50;
$cursor = intval($afterExpertId);
@@ -260,7 +542,8 @@ class ExpertFieldAiService
if (!$force) {
$query->where(function ($q) {
$q->where('field_ai_status', self::STATUS_PENDING)
->whereOr('field_ai_status', self::STATUS_FAILED);
->whereOr('field_ai_status', self::STATUS_FAILED)
->whereOr('field_ai_status', self::STATUS_NO_USER_LINK);
});
}
@@ -289,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 = [
@@ -308,7 +646,7 @@ class ExpertFieldAiService
self::STATUS_DONE => 'done',
self::STATUS_INSUFFICIENT => 'insufficient',
self::STATUS_FAILED => 'failed',
self::STATUS_NO_USER_LINK => 'no_user_link',
self::STATUS_NO_USER_LINK => 'no_user_link',
];
return isset($map[$status]) ? $map[$status] : 'unknown';
}

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

@@ -989,9 +989,14 @@ class AuthorBackgroundService
}
$papers = $this->pubmedFetchSummaries($ids);
}
$urlTerm = $usedTerm;
if (preg_match('/^(.+)\[ORCID\]$/i', $usedTerm, $m)) {
$urlTerm = $m[1];
}
return [
'total' => $total, 'papers' => $papers, 'query' => $usedTerm,
'pubmed_url' => 'https://pubmed.ncbi.nlm.nih.gov/?term=' . urlencode($usedTerm),
'pubmed_url' => 'https://pubmed.ncbi.nlm.nih.gov/?term=' . urlencode($urlTerm),
];
}

View File

@@ -120,6 +120,35 @@ class LLMService
];
}
/**
* 通用对话请求(与参考文献校对共用 postChat / [promotion] 配置)
*
* @param array $messages OpenAI messages
* @param float $temperature
* @return string|null 助手回复正文
*/
public function requestChat(array $messages, $temperature = 0)
{
if ($this->url === '' || $this->model === '') {
\think\Log::warning('LLM requestChat: url or model not configured');
return null;
}
$payload = [
'model' => $this->model,
'temperature' => $temperature,
'messages' => $messages,
];
return $this->postChat($payload);
}
/**
* 解析模型返回的 JSON 对象(去除 markdown 代码块等)
*/
public function parseJsonResponse($raw)
{
return $this->parseJson($raw);
}
/**
* 解析 can_support兼容 is_match 字段
*/

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

@@ -18,23 +18,14 @@ return [
':name' => ['index/hello', ['method' => 'post']],
],
// Author 背调 / Scopus(兼容 /api/author/ 与 /api/Author/
'api/author/index' => 'api/Author/index',
'api/author/background_report' => 'api/Author/background_report',
'api/author/due_diligence' => 'api/Author/due_diligence',
'api/author/background_check' => 'api/Author/background_check',
'api/author/get_hindex' => 'api/Author/get_hindex',
'api/author/get_scopus_id' => 'api/Author/get_scopus_id',
'api/author/check_scopus_cookie' => 'api/Author/check_scopus_cookie',
'api/author/check_elsevier_api' => 'api/Author/check_elsevier_api',
'api/Author/index' => 'api/Author/index',
'api/Author/background_report' => 'api/Author/background_report',
'api/Author/backgroundReport' => 'api/Author/background_report',
'api/Author/due_diligence' => 'api/Author/due_diligence',
'api/Author/dueDiligence' => 'api/Author/due_diligence',
'api/Author/background_check' => 'api/Author/background_check',
'api/Author/get_hindex' => 'api/Author/get_hindex',
'api/Author/get_scopus_id' => 'api/Author/get_scopus_id',
'api/Author/check_elsevier_api' => 'api/Author/check_elsevier_api',
// Author 背调(兼容 /api/author/ 与 /api/Author/
'api/author/index' => 'api/Author/index',
'api/author/background_report' => 'api/Author/background_report',
'api/author/due_diligence' => 'api/Author/due_diligence',
'api/Author/index' => 'api/Author/index',
'api/Author/background_report' => 'api/Author/background_report',
'api/Author/backgroundReport' => 'api/Author/background_report',
'api/Author/due_diligence' => 'api/Author/due_diligence',
'api/Author/dueDiligence' => 'api/Author/due_diligence',
];

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