Compare commits

...

73 Commits

Author SHA1 Message Date
wangjinlei
9e370fe390 Merge remote-tracking branch 'origin/master' 2026-07-06 10:56:47 +08:00
wangjinlei
63416a4567 终审邀请界面,多加一些信息,,,,解决参考文献有时候的中文问题 2026-07-06 10:56:40 +08:00
wyn
da71dfc04e 新逻辑参考文献相关性整合之前的逻辑 2026-06-30 09:30:33 +08:00
wyn
9c8f7cc3b6 强制提交 2026-06-29 10:43:08 +08:00
wyn
5860f78f6d 强制提交 2026-06-29 10:36:38 +08:00
wyn
6fdc4efb6f 参考文献校对升级 2026-06-29 10:23:27 +08:00
wyn
edb3c1b27b 参考文献校对,本地大模型提示词记录 2026-06-25 13:50:03 +08:00
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
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
385 changed files with 8031 additions and 2298 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,6 +4,7 @@ namespace app\api\controller;
use app\common\service\AuthorBackgroundService;
use think\Controller;
use think\Db;
/**
* 作者背调HTML 报告页 + JSON API
@@ -15,6 +16,9 @@ class Author extends Controller
/** @var AuthorBackgroundService */
private $bgService;
/** @var string */
private $articleLookupError = '';
public function __construct(\think\Request $request = null)
{
parent::__construct($request);
@@ -25,7 +29,8 @@ class Author extends Controller
* 作者背调 HTML 页面入口
*
* 1. 传了 ORCID → 直接生成报告
* 2. 未传 ORCID + 姓氏(机构选填)→ 仅按姓名搜 ORCID1 条直接报告,多条显示选择列表
* 2. 传了 articleId稿件作者 ID即 art_aut_id→ 从 t_article_author 补全 ORCID / 姓名 / 机构
* 3. 未传 ORCID + 姓氏(机构选填)→ 仅按姓名搜 ORCID1 条直接报告,多条显示选择列表
*/
public function index()
{
@@ -33,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 === ''
@@ -129,6 +146,8 @@ class Author extends Controller
*/
private function resolveBackgroundParams()
{
$this->articleLookupError = '';
$pick = function (...$keys) {
foreach ($keys as $k) {
$v = trim((string) input('param.' . $k, ''));
@@ -145,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

@@ -1178,6 +1178,107 @@ class Base extends Controller
return $ids;
}
/**
* 解析方括号引用内层(如 1,2 / 3-5展开为文献序号列表。
*
* @return int[]
*/
protected function expandCitationBracketNumbers(string $referencePart): array
{
$referencePart = trim($referencePart);
if ($referencePart === '') {
return [];
}
$referencePart = str_replace(
['', '', '—', '', '', ''],
[',', '-', '-', '-', '-', '-'],
$referencePart
);
$out = [];
$segments = preg_split('/\s*,\s*/', $referencePart);
foreach ($segments as $seg) {
$seg = trim((string)$seg);
if ($seg === '') {
continue;
}
$seg = str_replace(['', '—', '', '', ''], '-', $seg);
if (preg_match('/^(\d+)\s*-\s*(\d+)$/', $seg, $m)) {
$a = intval($m[1]);
$b = intval($m[2]);
if ($a > $b) {
$t = $a;
$a = $b;
$b = $t;
}
for ($i = $a; $i <= $b; $i++) {
$out[] = $i;
}
} else {
$n = intval($seg);
if ($n > 0) {
$out[] = $n;
}
}
}
return $out;
}
/**
* 从正文片段提取被引用的文献序号reference_no = index+1
* 兼容 <mycite data-id="p_refer_id"> 与 <blue>[n]</blue> / [n] 两种形态。
*
* @return int[]
*/
protected function extractCitationRefNosFromMainContent(string $text, int $pArticleId = 0): array
{
if ($text === '') {
return [];
}
$nos = [];
$pReferIds = $this->extractMyciteIds($text);
if (!empty($pReferIds) && $pArticleId > 0) {
$refers = Db::name('production_article_refer')
->where('p_article_id', $pArticleId)
->whereIn('p_refer_id', $pReferIds)
->where('state', 0)
->field('p_refer_id,index')
->select();
$idToNo = [];
foreach ($refers as $row) {
$idToNo[intval($row['p_refer_id'])] = intval($row['index']) + 1;
}
foreach ($pReferIds as $pid) {
if (isset($idToNo[$pid])) {
$nos[] = $idToNo[$pid];
}
}
}
if (preg_match_all('/(?:<\s*blue[^>]*>)?\[([^\]]+)\](?:<\/\s*blue\s*>)?/iu', $text, $m)) {
foreach ($m[1] as $inner) {
$innerNorm = str_replace(
['', '', '—', '', '', ''],
[',', '-', '-', '-', '-', '-'],
trim((string)$inner)
);
if (!preg_match('/^[\d\s,\-]+$/u', $innerNorm)) {
continue;
}
foreach ($this->expandCitationBracketNumbers($innerNorm) as $n) {
if ($n > 0) {
$nos[] = $n;
}
}
}
}
$nos = array_values(array_unique($nos));
sort($nos, SORT_NUMERIC);
return $nos;
}
/**
* table_data二维数组 JSON [[{text,colspan,rowspan},...],...];支持双重 JSON 字符串编码。
*

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

@@ -217,6 +217,24 @@ class Finalreview extends Base
}
}
}
foreach ($aLists as $key => $value) {
$rr = Db::name("article_reviewer_final")->where("reviewer_id",$value['user_id'])->order("id desc")->limit(1)->select();
if(isset($rr[0])){
$aLists[$key]['last_time'] = $rr[0]['invited_time'];
}else{
$aLists[$key]['last_time'] = 0;
}
$info = Db::name("user_reviewer_info")->field("field,field_ai")->where("reviewer_id",$value['user_id'])->find();
if($info){
$aLists[$key]['field'] = $info['field'];
$aLists[$key]['field_ai'] = $info['field_ai'];
}else{
$aLists[$key]['field'] = '';
$aLists[$key]['field_ai'] = '';
}
}
return json_encode(['status' => 1,'msg' => 'success','data' => ['total' => $iCount,'lists' => $aLists]]);
}

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

@@ -7,7 +7,7 @@ use think\Env;
use think\Queue;
use think\Validate;
use app\common\CrossrefService;
use app\common\ReferenceCheckService;
use app\common\ReferenceRelevanceCheckService;
class Preaccept extends Base
{
@@ -27,7 +27,7 @@ class Preaccept extends Base
return;
}
try {
(new ReferenceCheckService())->clearArticleChecksByPArticleId($pArticleId);
(new ReferenceRelevanceCheckService())->clearArticleChecksByPArticleId($pArticleId);
} catch (\Exception $e) {
\think\Log::error(
'resetArticleChecksOnReferChange[' . $sourceTag . '] p_article_id='
@@ -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)),
@@ -1220,6 +1221,14 @@ class Preaccept extends Base
$insert['ctime'] = time();
$this->article_main_log_obj->insert($insert);
// $articleId = intval($am_info['article_id']);
// $amId = intval($data['am_id']);
//
// // 本段引用集合变化(如 10,11 → 11,12时仅清空该 am_id 下的校对明细
// if ($this->hasMainCitationChange($old_content, $new_raw_content, $articleId)) {
// $this->clearMainChecksOnCitationChange($articleId, $amId);
// }
// 判断是否存在“引用删除”(新 content 相对旧 content 缺少 <mycite>
$hasCitationDeletion = $this->hasMyciteDeletion($old_content, $new_raw_content);
@@ -1245,6 +1254,39 @@ class Preaccept extends Base
//返回更新数据 20260119 end
}
/**
* 正文单节保存后,仅清空该 am_id 下已有的引用校对明细(按 article_id 定位)。
*/
private function clearMainChecksOnCitationChange(int $articleId, int $amId)
{
if ($articleId <= 0 || $amId <= 0) {
return;
}
try {
(new ReferenceCheckService())->clearChecksByAmId($articleId, $amId);
} catch (\Exception $e) {
\think\Log::error(
'clearMainChecksOnCitationChange article_id=' . $articleId
. ' am_id=' . $amId . ' ' . $e->getMessage()
);
}
}
/**
* 本段正文引用集合是否变化(增删改任一即 true
* old 多为库内 <blue>[n]</blue>new 多为编辑器提交的 <mycite data-id="p_refer_id">。
*/
private function hasMainCitationChange(string $oldContent, string $newContent, int $articleId): bool
{
$pArticleId = intval(Db::name('production_article')
->where('article_id', $articleId)
->whereIn('state', [0, 2])
->value('p_article_id'));
$oldNos = $this->extractCitationRefNosFromMainContent($oldContent, $pArticleId);
$newNos = $this->extractCitationRefNosFromMainContent($newContent, $pArticleId);
return $oldNos !== $newNos;
}
/**
* 是否发生 <mycite> 删除new 相对 old 少了任意引用 id
*/

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

@@ -12,6 +12,8 @@ use think\Db;
use think\Env;
use think\Queue;
use app\common\ReferenceCheckService;
use app\common\ReferenceRelevanceCheckService;
use app\common\DbReconnectHelper;
/**
* @title 参考文献
* @description 相关方法汇总
@@ -1309,38 +1311,48 @@ class References extends Base
}
return json_encode(['status' => 8,'msg' => 'fail']);
}
/**
* 参考文献第一次校对
* @return \think\response\Json
*/
public function allReferenceCheckAI(){
//获取参数
$aParam = empty($aParam) ? $this->request->post() : $aParam;
// ============================================================
// 参考文献「主题相关性」校对RabbitMQ 链式消费,复用 reference_check 队列)
// 表t_article_reference_relevance_check_result / t_article_reference_relevance_check_batch
// 消费php think reference_check:mq-consume
// ============================================================
//必填值验证
$iPArticleId = empty($aParam['p_article_id']) ? '' : $aParam['p_article_id'];
if(empty($iPArticleId)){
return json_encode(array('status' => 2,'msg' => 'Please select an article' ));
/**
* 启动整篇参考文献相关性校对
* POST: p_article_id必填
*
* 文献摘要/内容优先读 t_production_article_refer.abstract_text、refer_content_cleaned
* 二者都为空时在校对执行阶段抓取并回写 refer 表,校对时始终从 refer 表读取。
*/
public function allReferenceCheckAI()
{
$aParam = $this->request->post();
$iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']);
if ($iPArticleId <= 0) {
return jsonError('Please select an article');
}
//查询文章p_article_id 与 article_id 都要带,下游服务方法两者都用)
$aWhere = ['p_article_id' => $iPArticleId,'state' => ['in',[0,2]]];
$aProductionArticle = Db::name('production_article')->field('p_article_id,article_id')->where($aWhere)->find();
if(empty($aProductionArticle)){
return json_encode(array('status' => 3,'msg' => 'No articles found' ));
$aProductionArticle = Db::name('production_article')
->field('p_article_id,article_id')
->where(['p_article_id' => $iPArticleId, 'state' => ['in', [0, 2]]])
->find();
if (empty($aProductionArticle)) {
return jsonError('No articles found');
}
if($this->checkReferStatus($iPArticleId)==0){
if ($this->checkReferStatus($iPArticleId) == 0) {
return jsonError('Please correct the reference content before running the check.');
}
//已存在校对记录则禁止重复执行第一次校对,提示走重置接口
$iExisting = Db::name('article_reference_check_result')
$existing = Db::name('article_reference_relevance_check_result')
->where('p_article_id', $iPArticleId)
->count();
if(intval($iExisting) > 0){
return jsonError('This article already has a reference check record. Please use the "Reset Check" endpoint to run the check again.');
if (intval($existing) > 0) {
return jsonError('This article already has relevance check records.');
}
try {
$svc = new ReferenceCheckService();
$result = $svc->enqueueByPArticle($aProductionArticle);
DbReconnectHelper::ensure();
$result = (new ReferenceRelevanceCheckService())->enqueueByPArticle($aProductionArticle);
if (empty($result['check_ids'])) {
return jsonError('No reference citations were found in the article.');
}
@@ -1349,8 +1361,156 @@ class References extends Base
return jsonError($e->getMessage());
}
}
/**
* 文献校对重置:删除该文章已有的全部校对明细,并重新入队整篇校对
* 相关性校对进度(同 referenceCheckProgressAI
* POST/GET: p_article_id必填
*/
public function referenceRelevanceCheckProgressAI()
{
$aParam = $this->request->post();
if (empty($aParam)) {
$aParam = $this->request->param();
}
$iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']);
if ($iPArticleId <= 0) {
return json_encode(array('status' => 2, 'msg' => 'Please select an article'));
}
try {
$result = (new ReferenceRelevanceCheckService())->getProgressByPArticleId($iPArticleId);
return jsonSuccess($result);
} catch (\Exception $e) {
return jsonError($e->getMessage());
}
}
/**
* 按 p_article_id 查整篇文章相关性校对总状态(同 referenceCheckArticleStatusAI
* POST/GET: p_article_id必填
*/
public function referenceRelevanceCheckArticleStatusAI()
{
$aParam = $this->request->post();
if (empty($aParam)) {
$aParam = $this->request->param();
}
$iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']);
if ($iPArticleId <= 0) {
return json_encode(array('status' => 2, 'msg' => 'Please select an article'));
}
try {
$result = (new ReferenceRelevanceCheckService())->getArticleProgressStatusByPArticleId($iPArticleId);
return jsonSuccess($result);
} catch (\Exception $e) {
return jsonError($e->getMessage());
}
}
/**
* 按 p_refer_id 查单条参考文献的相关性校对明细与进度(同 referenceCheckDetailsAI
* POST/GET: p_refer_id必填
*/
public function referenceRelevanceCheckDetailsAI()
{
$aParam = $this->request->post();
if (empty($aParam)) {
$aParam = $this->request->param();
}
$iPReferId = empty($aParam['p_refer_id']) ? 0 : intval($aParam['p_refer_id']);
if ($iPReferId <= 0) {
return json_encode(array('status' => 2, 'msg' => 'Please select a reference'));
}
try {
$result = (new ReferenceRelevanceCheckService())->getDetailsByPReferId($iPReferId);
return jsonSuccess($result);
} catch (\Exception $e) {
return jsonError($e->getMessage());
}
}
/**
* 清空并重新执行相关性校对
* POST: p_article_id
*/
public function referenceRelevanceCheckResetAI()
{
$aParam = $this->request->post();
$iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']);
if ($iPArticleId <= 0) {
return jsonError('Please select an article');
}
$aProductionArticle = Db::name('production_article')
->field('p_article_id,article_id')
->where(['p_article_id' => $iPArticleId, 'state' => ['in', [0, 2]]])
->find();
if (empty($aProductionArticle)) {
return jsonError('No articles found');
}
if ($this->checkReferStatus($iPArticleId) == 0) {
return jsonError('Please correct the reference content before running the check.');
}
try {
$result = (new ReferenceRelevanceCheckService())->resetAndRecheckByArticle($aProductionArticle);
return jsonSuccess($result);
} catch (\Exception $e) {
return jsonError($e->getMessage());
}
}
/**
* 仅清空相关性校对记录(不重跑)
* POST: p_article_id
*/
public function referenceRelevanceCheckClearAI()
{
$aParam = $this->request->post();
$iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']);
if ($iPArticleId <= 0) {
return jsonError('p_article_id is required');
}
try {
$deleted = (new ReferenceRelevanceCheckService())->clearByPArticleId($iPArticleId);
return jsonSuccess(['p_article_id' => $iPArticleId, 'deleted' => intval($deleted)]);
} catch (\Exception $e) {
return jsonError($e->getMessage());
}
}
/**
* 仅重跑相关性 status=0 的记录(不清空,不抓摘要,不清洗文献内容)
* POST: p_article_id
*/
public function referenceRelevanceCheckRecheckPendingAI()
{
$aParam = $this->request->post();
$iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']);
if ($iPArticleId <= 0) {
return jsonError('p_article_id is required');
}
try {
$result = (new ReferenceRelevanceCheckService())->recheckPendingOnlyByArticle($iPArticleId);
return jsonSuccess($result);
} catch (\Exception $e) {
return jsonError($e->getMessage());
}
}
/**
* @deprecated 支撑力度校对已下线,请使用 allReferenceCheckAI主题相关性校对
*/
public function allReferenceCheckAI2()
{
return jsonError('Support strength check is deprecated. Please use allReferenceCheckAI.');
}
/**
* 文献校对重置:删除该文章已有的全部相关性校对明细,并重新入队整篇校对
* POST/GET: article_id必填
* @url /api/Article/referenceCheckReset
*/
@@ -1378,7 +1538,7 @@ class References extends Base
return json_encode(array('status' => 4,'msg' => 'Unbound article' ));
}
try {
$result = (new ReferenceCheckService())->resetAndRecheckByArticle($aProductionArticle);
$result = (new ReferenceRelevanceCheckService())->resetAndRecheckByArticle($aProductionArticle);
return jsonSuccess($result);
} catch (\Exception $e) {
return jsonError($e->getMessage());
@@ -1415,7 +1575,7 @@ class References extends Base
}
try {
$deleted = (new ReferenceCheckService())->clearArticleChecksByPArticleId($iPArticleId);
$deleted = (new ReferenceRelevanceCheckService())->clearByPArticleId($iPArticleId);
return jsonSuccess([
'p_article_id' => $iPArticleId,
'deleted' => intval($deleted),
@@ -1426,7 +1586,7 @@ class References extends Base
}
/**
* 按 p_article_id 查整篇引用校对进度(按 reference_no 分组聚合)
* 按 p_article_id 查整篇相关性校对进度(按 reference_no 分组聚合)
*
* POST/GET: p_article_id必填
*
@@ -1439,6 +1599,8 @@ class References extends Base
* records[i].status 与分组同一套数值含义(但 record 不会出现 1=校对中):
* 0 = 待校验 2 = 校对完成 3 = 校对失败
*
* records[i] 含 reason中英双语、reason_en、combined_reason中英双语、combined_reason_en
*
* summary 用字符串键pending / checking / completed / failed
*/
public function referenceCheckProgressAI()
@@ -1453,7 +1615,7 @@ class References extends Base
return json_encode(array('status' => 2, 'msg' => 'Please select an article'));
}
try {
$result = (new ReferenceCheckService())->getProgressByPArticleId($iPArticleId);
$result = (new ReferenceRelevanceCheckService())->getProgressByPArticleId($iPArticleId);
return jsonSuccess($result);
} catch (\Exception $e) {
return jsonError($e->getMessage());
@@ -1461,7 +1623,7 @@ class References extends Base
}
/**
* 按 p_article_id 查整篇文章引用校对总状态(用于前端按钮分流)
* 按 p_article_id 查整篇文章相关性校对总状态(用于前端按钮分流)
*
* POST/GET: p_article_id必填
*
@@ -1495,7 +1657,7 @@ class References extends Base
}
try {
$result = (new ReferenceCheckService())->getArticleProgressStatusByPArticleId($iPArticleId);
$result = (new ReferenceRelevanceCheckService())->getArticleProgressStatusByPArticleId($iPArticleId);
return jsonSuccess($result);
} catch (\Exception $e) {
return jsonError($e->getMessage());
@@ -1523,7 +1685,7 @@ class References extends Base
}
try {
$result = (new ReferenceCheckService())->getArticleCheckQueuePositionByPArticleId($iPArticleId);
$result = (new ReferenceRelevanceCheckService())->getArticleCheckQueuePositionByPArticleId($iPArticleId);
return jsonSuccess($result);
} catch (\Exception $e) {
return jsonError($e->getMessage());
@@ -1531,13 +1693,12 @@ class References extends Base
}
/**
* 某条参考文献下「校对失败」的明细重新校对(异步)
* 某条参考文献下「相关性校对失败」的明细重新校对(异步)
*
* POST/GET: p_refer_id必填
* p_article_id可选
*
* 仅重跑 status=3校对失败的记录不改动 refer_text只重置结果字段后入 RabbitMQ 批次队列。
* 返回p_refer_id、p_article_id、reset、queued、check_ids、queue
*/
public function referenceCheckRecheckFailedAI()
{
@@ -1554,21 +1715,53 @@ class References extends Base
$iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']);
try {
$result = (new ReferenceCheckService())->enqueueRecheckFailedByPReferId($iPReferId, $iPArticleId);
return jsonSuccess([]);
$result = (new ReferenceRelevanceCheckService())->enqueueRecheckFailedByPReferId($iPReferId, $iPArticleId);
return jsonSuccess($result);
} catch (\Exception $e) {
return jsonError($e->getMessage());
}
}
/**
* 按 p_refer_id 查单条参考文献的校对明细与进度
* 某条参考文献下「校对失败」重跑,并联动同一引用标签分组(如 [1,2])全部重跑(异步)
*
* POST/GET: p_refer_id必填
* p_article_id可选
*
* 返回p_refer_id、p_article_id、reset、queued、check_ids、queue
*/
public function referenceCheckRecheckFailedWithGroupAI()
{
$aParam = $this->request->post();
if (empty($aParam)) {
$aParam = $this->request->param();
}
$iPReferId = empty($aParam['p_refer_id']) ? 0 : intval($aParam['p_refer_id']);
if ($iPReferId <= 0) {
return json_encode(array('status' => 2, 'msg' => 'Please select a reference'));
}
$iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']);
try {
$result = (new ReferenceRelevanceCheckService())->enqueueRecheckFailedByPReferIdWithGroup($iPReferId, $iPArticleId);
return jsonSuccess($result);
} catch (\Exception $e) {
return jsonError($e->getMessage());
}
}
/**
* 按 p_refer_id 查单条参考文献的相关性校对明细与进度
*
* POST/GET: p_refer_id必填
*
* 分组进度progress_status(0待/1中/2完成/3失败)、pending、done、failed、pass、
* is_pass、progress_percent、last_updated_at
* list 每项check_id、am_id、status、confidence、reason、is_match、is_pass
* list 每项check_id、am_id、status、is_relevant、relevance_level、relevance_role、
* relevance_score、reason中英双语【中文】/【English】、reason_en、
* combined_*、combined_reason_en、cite_group_refs、cite_check_mode、is_pass
*/
public function referenceCheckDetailsAI()
{
@@ -1583,13 +1776,33 @@ class References extends Base
}
try {
$result = (new ReferenceCheckService())->getCheckDetailsByPReferId($iPReferId);
$result = (new ReferenceRelevanceCheckService())->getDetailsByPReferId($iPReferId);
return jsonSuccess($result);
} catch (\Exception $e) {
return jsonError($e->getMessage());
}
}
/**
* 对校对明细中从未出现过的参考文献p_refer_id 差集)重新扫描全文并入队校对
*
* POST/GET: p_article_id必填
*
* 差集production_article_refer(state=0) 减去 article_reference_check_result 已出现的 p_refer_id。
* 适用:首次校对漏匹配、表格后上传、正文补标等场景。不重置已有明细。
* 前置:须已执行过第一次校对(库中已有校对记录)。
*
* 返回missing_p_refer_ids、matched_p_refer_ids、still_unmatched_p_refer_ids、
* queued、new_reference_nos、check_ids、queue
*/
/**
* @deprecated 支撑力度漏匹配补扫已下线,请清空后使用 allReferenceCheckAI 重新校对
*/
public function referenceCheckRematchNewAI()
{
return jsonError('Support strength rematch is deprecated. Please use referenceCheckResetAI or allReferenceCheckAI.');
}
public function checkReferStatus($p_article_id){
$list = $this->production_article_refer_obj->where('p_article_id', $p_article_id)->where('state', 0)->select();
if (!$list) {
@@ -1604,4 +1817,6 @@ class References extends Base
}
return $frag;
}
}

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

@@ -13,7 +13,7 @@ class ReferenceCheckMqConsume extends Command
protected function configure()
{
$this->setName('reference_check:mq-consume')
->setDescription('Consume RabbitMQ reference check article queue');
->setDescription('Consume RabbitMQ reference relevance check article queue (ref_check.article)');
}
protected function execute(Input $input, Output $output)

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);
@@ -260,26 +273,111 @@ class CrossrefService
}
/**
* 提取标题
* 提取标题(英文优先)
*
* CrossRef 的 title / original-title 可能包含多个语言版本(中文期刊/双语文章常见)。
* 这里优先返回不含中日韩字符的版本,全部为中文时才退回第一个。
*/
public function getTitle($aDoiInfo = [])
{
return $aDoiInfo['title'][0] ?? '';
$candidates = [];
if (!empty($aDoiInfo['title']) && is_array($aDoiInfo['title'])) {
$candidates = array_merge($candidates, $aDoiInfo['title']);
}
if (!empty($aDoiInfo['original-title']) && is_array($aDoiInfo['original-title'])) {
$candidates = array_merge($candidates, $aDoiInfo['original-title']);
}
return $this->pickPreferredLatin($candidates);
}
/**
* 提取期刊/出版社相关信息
* 提取期刊/出版社相关信息(期刊名英文优先)
*/
public function getPublisher($aDoiInfo = [])
{
$containerTitles = (!empty($aDoiInfo['container-title']) && is_array($aDoiInfo['container-title']))
? $aDoiInfo['container-title'] : [];
$shortTitles = (!empty($aDoiInfo['short-container-title']) && is_array($aDoiInfo['short-container-title']))
? $aDoiInfo['short-container-title'] : [];
return [
'title' => isset($aDoiInfo['container-title'][0]) ? $aDoiInfo['container-title'][0] : '',
'short_title' => isset($aDoiInfo['short-container-title'][0]) ? $aDoiInfo['short-container-title'][0] : '',
'title' => $this->pickPreferredLatin($containerTitles),
'short_title' => $this->pickPreferredLatin($shortTitles),
'ISSN' => $aDoiInfo['ISSN'] ?? [],
'publisher' => $aDoiInfo['publisher'] ?? '',
];
}
/**
* 是否包含中日韩CJK字符
*/
public function hasCjk($str)
{
$str = (string)$str;
if ($str === '') {
return false;
}
return preg_match('/[\x{4e00}-\x{9fff}\x{3040}-\x{30ff}\x{ac00}-\x{d7af}\x{3400}-\x{4dbf}]/u', $str) === 1;
}
/**
* 从多个候选字符串中优先挑选「不含 CJK 字符」的一个;
* 全部为空则返回空串,全部含 CJK 则返回第一个非空值。
*
* @param array $candidates
* @return string
*/
private function pickPreferredLatin($candidates)
{
if (!is_array($candidates) || empty($candidates)) {
return '';
}
$firstNonEmpty = '';
foreach ($candidates as $item) {
$s = trim((string)$item);
if ($s === '') {
continue;
}
if ($firstNonEmpty === '') {
$firstNonEmpty = $s;
}
if (!$this->hasCjk($s)) {
return $s;
}
}
return $firstNonEmpty;
}
/**
* 用 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 +398,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,47 @@ 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'])) {
$title = trim((string)($summary['title'] ?? ''));
$jouraRaw = trim((string)($summary['joura'] ?? ''));
// 姓全写 + 名首字母,超过 3 个作者取前 3 个 + et al
$authorCitation = $svc->getAuthorsCitation($summary['raw'] ?? [], 3);
// 英文优先兜底:若 CrossRef 结果的标题/期刊/作者仍含中日韩字符,
// 说明该 DOI 元数据是中文,放弃 CrossRef 路径,改走下方 citation.doi.org(lang=en-US)
$hasCjk = $svc->hasCjk($title) || $svc->hasCjk($jouraRaw) || $svc->hasCjk($authorCitation);
if (!$hasCjk) {
$update_a = [];
$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,84 @@ 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;
}
/**
* 按书目信息检索 PubMed标题 + 第一作者 + 年份)
*/
public function searchByBibliographic($title, $author = '', $year = ''): ?array
{
$title = trim((string)$title);
if ($title === '') {
return null;
}
$terms = ['(' . $this->quoteTerm($title) . '[Title])'];
$author = trim((string)$author);
if ($author !== '') {
$parts = preg_split('/[,;]/', $author);
$first = trim((string)($parts[0] ?? ''));
if ($first !== '') {
$terms[] = '(' . $this->quoteTerm($first) . '[Author])';
}
}
$year = trim((string)$year);
if ($year !== '' && preg_match('/^(19|20)\d{2}$/', $year)) {
$terms[] = '(' . $year . '[pdat])';
}
$pmid = $this->esearch(implode(' AND ', $terms));
if (!$pmid) {
return null;
}
$info = $this->fetchByPmid($pmid);
if (!$info) {
return null;
}
$info['pmid'] = $pmid;
$info['doi'] = $this->extractDoiFromPmidRecord($pmid);
return $info;
}
private function quoteTerm($text)
{
return str_replace('"', '', trim((string)$text));
}
private function extractDoiFromPmidRecord($pmid)
{
$url = $this->base . 'efetch.fcgi?' . http_build_query([
'db' => 'pubmed',
'id' => $pmid,
'retmode' => 'xml',
'tool' => $this->tool,
'email' => $this->email,
]);
$xml = $this->httpGet($url);
if ($xml === '') {
return '';
}
if (preg_match('/<ArticleId IdType="doi">([^<]+)<\/ArticleId>/i', $xml, $m)) {
return trim($m[1]);
}
return '';
}
// ----------------- Internals -----------------
private function esearch(string $term): ?string
@@ -162,6 +241,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 +264,8 @@ class PubmedService
'mesh_terms' => $mesh,
'publication_types' => $pubTypes,
'journal' => $journal,
'journal_iso_abbr' => $journalIsoAbbr,
'journal_medline_ta' => $journalMedlineTa,
'year' => $year,
];
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,10 +3,12 @@
namespace app\common\mq;
use think\Db;
use app\common\ReferenceCheckService;
use app\common\DbReconnectHelper;
use app\common\ReferenceRelevanceCheckService;
/**
* RabbitMQ 消费:按文章串行,文章内 reference_no 升序逐条校对(含低分同步二轮)
* RabbitMQ 消费(队列 reference_check / ref_check.article
* 全局文章串行,文章内 reference_no 升序链式逐条「主题相关性」校对。
*/
class ReferenceCheckArticleWorker
{
@@ -15,18 +17,20 @@ class ReferenceCheckArticleWorker
const BATCH_DONE = 2;
const BATCH_PARTIAL_FAILED = 3;
/** @var ReferenceCheckService */
/** @var ReferenceRelevanceCheckService */
private $svc;
public function __construct()
{
$this->svc = new ReferenceCheckService();
$this->svc = new ReferenceRelevanceCheckService();
}
public function handleMessage(array $payload)
{
DbReconnectHelper::ensure();
$pArticleId = intval(isset($payload['p_article_id']) ? $payload['p_article_id'] : 0);
$batchId = intval(isset($payload['batch_id']) ? $payload['batch_id'] : 0);
$trigger = isset($payload['trigger']) ? (string)$payload['trigger'] : 'enqueue';
if ($pArticleId <= 0 || $batchId <= 0) {
$this->svc->log('ReferenceCheckArticleWorker invalid payload');
return;
@@ -34,7 +38,11 @@ class ReferenceCheckArticleWorker
if (!$this->canStartArticleWork($batchId)) {
$this->svc->log('ReferenceCheckArticleWorker defer batch_id=' . $batchId . ' other article running');
(new ReferenceCheckMqPublisher())->publishArticleStart($pArticleId, $batchId, isset($payload['trigger']) ? $payload['trigger'] : 'enqueue');
(new ReferenceCheckMqPublisher())->publishArticleStart(
$pArticleId,
$batchId,
isset($payload['trigger']) ? $payload['trigger'] : 'enqueue'
);
sleep(3);
return;
}
@@ -46,6 +54,11 @@ class ReferenceCheckArticleWorker
}
}
$this->svc->recoverQueueRowsForArticle($pArticleId);
if ($trigger !== 'recheck_pending_only'
&& ReferenceRelevanceCheckService::PREPARE_LITERATURE_BEFORE_CHECK) {
$this->svc->prepareLiteratureContentByArticle($pArticleId);
}
$this->svc->log('ReferenceCheckArticleWorker start p_article_id=' . $pArticleId . ' batch_id=' . $batchId);
$done = 0;
@@ -59,7 +72,7 @@ class ReferenceCheckArticleWorker
if ($checkId <= 0) {
continue;
}
$result = $this->processOneRow($checkId, $row);
$result = $this->processOneRow($checkId, $row, $trigger === 'recheck_pending_only');
if ($result === 'ok') {
$done++;
} elseif ($result === 'failed') {
@@ -75,7 +88,7 @@ class ReferenceCheckArticleWorker
private function canStartArticleWork($batchId)
{
$running = Db::name('article_reference_check_batch')
$running = Db::name('article_reference_relevance_check_batch')
->where('batch_status', self::BATCH_RUNNING)
->where('id', '<>', intval($batchId))
->count();
@@ -85,7 +98,7 @@ class ReferenceCheckArticleWorker
private function claimBatch($batchId)
{
$now = date('Y-m-d H:i:s');
$affected = Db::name('article_reference_check_batch')
$affected = Db::name('article_reference_relevance_check_batch')
->where('id', intval($batchId))
->whereIn('batch_status', [self::BATCH_WAITING, self::BATCH_RUNNING])
->update([
@@ -97,15 +110,15 @@ class ReferenceCheckArticleWorker
private function getBatch($batchId)
{
return Db::name('article_reference_check_batch')->where('id', intval($batchId))->find();
return Db::name('article_reference_relevance_check_batch')->where('id', intval($batchId))->find();
}
private function fetchNextPendingRow($pArticleId)
{
return Db::name('article_reference_check_result')
return Db::name('article_reference_relevance_check_result')
->where('p_article_id', intval($pArticleId))
->where('queue_status', ReferenceCheckService::QUEUE_PENDING)
->where('status', ReferenceCheckService::RECORD_PENDING)
->where('queue_status', ReferenceRelevanceCheckService::QUEUE_PENDING)
->where('status', ReferenceRelevanceCheckService::RECORD_PENDING)
->order('reference_no asc,am_id asc,text_start asc,id asc')
->find();
}
@@ -113,44 +126,44 @@ class ReferenceCheckArticleWorker
/**
* @return string ok|failed|skip
*/
private function processOneRow($checkId, array $row)
private function processOneRow($checkId, array $row, $skipLiteratureFetch = false)
{
$claimed = Db::name('article_reference_check_result')
DbReconnectHelper::ensure();
$claimed = Db::name('article_reference_relevance_check_result')
->where('id', intval($checkId))
->where('queue_status', ReferenceCheckService::QUEUE_PENDING)
->update(['queue_status' => ReferenceCheckService::QUEUE_RUNNING]);
->where('queue_status', ReferenceRelevanceCheckService::QUEUE_PENDING)
->update(['queue_status' => ReferenceRelevanceCheckService::QUEUE_RUNNING]);
if (intval($claimed) <= 0) {
return 'skip';
}
$retryCount = intval(isset($row['retry_count']) ? $row['retry_count'] : 0);
try {
$this->svc->runReferenceCheckOnce($checkId);
$amId = intval(isset($row['am_id']) ? $row['am_id'] : 0);
if ($amId > 0) {
$this->svc->syncAmRefCheckStatus($amId);
}
$this->svc->markQueueRuntime($checkId, ReferenceCheckService::QUEUE_COMPLETED, $retryCount);
$this->svc->runCheckOnce($checkId, $skipLiteratureFetch);
$this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_COMPLETED, $retryCount);
return 'ok';
} catch (\Exception $e) {
$this->svc->log('ReferenceCheckArticleWorker check_id=' . $checkId . ' err=' . $e->getMessage());
if ($retryCount < ReferenceCheckService::QUEUE_MAX_RETRY) {
$this->svc->markQueueRuntime($checkId, ReferenceCheckService::QUEUE_PENDING, $retryCount + 1);
return $this->processOneRow($checkId, array_merge($row, ['retry_count' => $retryCount + 1]));
DbReconnectHelper::ensure();
if ($retryCount < ReferenceRelevanceCheckService::QUEUE_MAX_RETRY) {
$this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_PENDING, $retryCount + 1);
return $this->processOneRow($checkId, array_merge($row, ['retry_count' => $retryCount + 1]), $skipLiteratureFetch);
}
try {
$this->svc->updateCheckResult($checkId, [
'status' => ReferenceCheckService::RECORD_FAILED,
'error_msg' => $e->getMessage(),
]);
$this->svc->markQueueRuntime($checkId, ReferenceCheckService::QUEUE_FAILED, $retryCount);
$fresh = Db::name('article_reference_relevance_check_result')->where('id', intval($checkId))->find();
$groupRows = !empty($fresh) ? $this->svc->findCitationGroupRowsForWorker($fresh) : [];
if (!empty($groupRows)) {
$this->svc->failGroupWithQueue($groupRows, $e->getMessage(), $retryCount);
} else {
$this->svc->updateCheckResult($checkId, [
'status' => ReferenceRelevanceCheckService::RECORD_FAILED,
'error_msg' => $e->getMessage(),
]);
$this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_FAILED, $retryCount);
}
} catch (\Exception $e2) {
\think\Log::error('ReferenceCheckArticleWorker markFailed: ' . $e2->getMessage());
}
$amId = intval(isset($row['am_id']) ? $row['am_id'] : 0);
if ($amId > 0) {
$this->svc->syncAmRefCheckStatus($amId);
}
return 'failed';
}
}
@@ -166,7 +179,7 @@ class ReferenceCheckArticleWorker
if ($failed > 0) {
$status = self::BATCH_PARTIAL_FAILED;
}
Db::name('article_reference_check_batch')->where('id', intval($batchId))->update([
Db::name('article_reference_relevance_check_batch')->where('id', intval($batchId))->update([
'batch_status' => $status,
'done_count' => intval($done),
'failed_count' => intval($failed),
@@ -179,7 +192,7 @@ class ReferenceCheckArticleWorker
private function publishNextWaitingBatch()
{
$next = Db::name('article_reference_check_batch')
$next = Db::name('article_reference_relevance_check_batch')
->where('batch_status', self::BATCH_WAITING)
->order('id asc')
->find();
@@ -193,8 +206,8 @@ class ReferenceCheckArticleWorker
isset($next['trigger']) ? $next['trigger'] : 'enqueue'
);
} catch (\Exception $e) {
$this->svc->log('publishNextWaitingBatch failed: ' . $e->getMessage());
\think\Log::error('publishNextWaitingBatch: ' . $e->getMessage());
$this->svc->log('ReferenceCheck publishNextWaitingBatch failed: ' . $e->getMessage());
\think\Log::error('ReferenceCheck publishNextWaitingBatch: ' . $e->getMessage());
}
}
}

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

@@ -28,18 +28,17 @@ class LLMService
* @param string $contextText 正文引用处句子
* @param string $referText 参考文献条目(或 refer 格式化文本)
* @param bool $isAgain 是否为 DOI 二次复核
* @param string|null $doiBlock 可选:系统抓取到的 DOI 真实文献内容(仅二次复核使用)
* @param string|null $doiBlock 可选:系统抓取到的 DOI 真实文献内容(仅二次复核使用)
* @param string $citeGroupRefs 引用文献组,如 1,2 或 4,5,6
* @param string $localContext 本引用位置附近上下文(可选)
* @return array{results:array,request_failed?:bool}
*/
public function checkReference($contextText, $referText, $isAgain = false, $doiBlock = null)
public function checkReference($contextText, $referText, $isAgain = false, $doiBlock = null, $citeGroupRefs = '', $localContext = '')
{
// request_failed=true 表示"LLM 通讯/解析层面的失败"(可重试,区别于业务上的"未命中"
// 上游 runReferenceCheckOnce 会据此把 DB.status 置为 3(失败) 并抛异常触发 MQ worker 重试
$fallback = [
'can_support' => false,
'is_match' => false,
'confidence' => 0.0,
'reason' => 'LLM not configured or request failed',
'results' => [],
'request_failed' => true,
'reason' => 'LLM not configured or request failed',
];
if ($this->url === '' || $this->model === '') {
\think\Log::warning('ReferenceCheck LLM: url or model not configured');
@@ -47,15 +46,16 @@ class LLMService
}
$contextText = trim($contextText);
\think\Log::info('llm checkReference:' . $contextText);
$referText = trim($referText);
\think\Log::info('llm referText:' . $referText);
$doiBlock = trim((string)$doiBlock);
$citeGroupRefs = trim((string)$citeGroupRefs);
$localContext = trim((string)$localContext);
if ($contextText === '' || $referText === '') {
// 空文本是入参问题,不是 LLM 故障,不需要重试
return [
'can_support' => false,
'is_match' => false,
'confidence' => 0.0,
'reason' => 'Empty citation context or reference text',
'results' => [],
'reason' => 'Empty citation context or reference text',
];
}
@@ -63,27 +63,30 @@ class LLMService
if (mb_strlen($contextText) > $maxContextLen) {
$contextText = mb_substr($contextText, 0, $maxContextLen);
}
if (mb_strlen($referText) > 4000) {
$referText = mb_substr($referText, 0, 4000);
if (mb_strlen($localContext) > 3000) {
$localContext = mb_substr($localContext, 0, 3000);
}
if (mb_strlen($doiBlock) > 4000) {
$doiBlock = mb_substr($doiBlock, 0, 4000);
if (mb_strlen($referText) > 6000) {
$referText = mb_substr($referText, 0, 6000);
}
if (mb_strlen($doiBlock) > 8000) {
$doiBlock = mb_substr($doiBlock, 0, 8000);
}
if ($isAgain) {
$system = $this->buildReferenceCheckSecondPassPrompt();
$user = $this->buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock);
$user = $this->buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock, $citeGroupRefs, $localContext);
} else {
$system = $this->buildReferenceCheckFirstPassPrompt();
$user = $this->buildReferenceCheckFirstPassUserPrompt($contextText, $referText);
$user = $this->buildReferenceCheckFirstPassUserPrompt($contextText, $referText, $citeGroupRefs, $localContext, $doiBlock);
}
\think\Log::info('ReferenceCheck system head: ' . mb_substr($system, 0, 200));
\think\Log::info('ReferenceCheck user head: ' . mb_substr($user, 0, 600));
// \think\Log::info('ReferenceCheck system head: ' . mb_substr($system, 0, 200));
// \think\Log::info('ReferenceCheck user head: ' . mb_substr($user, 0, 600));
$payload = [
'model' => $this->model,
'model' => $this->model,
'temperature' => 0,
'messages' => [
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
],
@@ -101,23 +104,43 @@ class LLMService
return $fallback;
}
$canSupport = $this->parseCanSupportFromParsed($parsed);
$confidence = $this->snapReferenceCheckConfidence(
$this->normalizeConfidence(isset($parsed['confidence']) ? $parsed['confidence'] : 0),
$canSupport
);
$reason = $this->cleanReason((string)(isset($parsed['reason']) ? $parsed['reason'] : ''));
\think\Log::info(
'ReferenceCheck result: can_support=' . ($canSupport ? '1' : '0')
. ', confidence=' . $confidence
. ', reason=' . $reason
);
return [
'can_support' => $canSupport,
'is_match' => $canSupport,
'confidence' => $confidence,
'reason' => $reason,
$results = $this->parseReferenceCheckResultsFromParsed($parsed, $citeGroupRefs, $localContext, $doiBlock);
if (empty($results)) {
\think\Log::warning('ReferenceCheck LLM: empty results array');
return $fallback;
}
\think\Log::info($results);
return ['results' => $results];
}
/**
* 通用对话请求(与参考文献校对共用 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);
}
/**
@@ -145,83 +168,541 @@ class LLMService
$s = strtolower(trim((string)$value));
return in_array($s, ['1', 'true', 'yes', 'support', 'supported'], true);
}
private function bulidReferenceCheckFirstPassPrompt(){
return <<<'PROMPT'
你是一名护理、医学与科研期刊的资深文献编辑,专门校对「正文引用句」与「对应参考文献条目」是否匹配。
/** 第一次校对:书目条目 vs 正文全文 */
你的目标是严格识别错引、张冠李戴、方法不符、对象不符、结论不成立的问题。
宁可少判 true也不要漏掉错引。
你只能依据用户提供的内容判断:
1. 正文引用句
2. 当前对应参考文献条目
禁止假设已阅读全文。
禁止联网。
禁止脑补文献内容。
禁止根据学科常识推断研究结果。
====================
【核心任务】
判断:
正文在该引用位置表达的核心观点、结论、方法、数据、定义、模型、研究发现、指南依据等,
是否能够被该条参考文献合理支撑。
你判断的是:
“引用是否成立”
不是:
“正文是否正确”。
====================
【总原则(最高优先级)】
采用严格审稿标准:
边界不清时,一律判 false。
宁可误杀(人工复核),不要漏掉错引。
同领域 ≠ 匹配。
同关键词 ≠ 匹配。
相关 ≠ 能支撑。
====================
【强制规则】
1. 严禁关键词硬匹配
不能因为出现:
患者、护理、治疗、研究、模型、算法、深度学习、机器学习、焦虑、效果
等泛化词汇就判定匹配。
必须看:
- 核心对象
- 研究问题
- 方法
- 场景
- 结局指标
- 核心论点
是否一致。
====================
2. 方法学必须严格一致(极重要)
若正文明确提到:
- 算法
- 模型
- 聚类方法
- 深度学习架构
- 统计方法
- 数学模型
- 评价指标
必须要求文献与其存在明确关联。
例如:
不匹配:
- fuzzy clustering ≠ deep learning
- CNN ≠ LSTM
- random forest ≠ SVM
- 聚类 ≠ 分类
- 特征选择 ≠ 分类预测
- 风险因素分析 ≠ 干预研究
仅属于同一“大领域AI/ML
不能判定匹配。
若方法体系不同:
优先判 false + 0.10。
====================
3. 医学护理引用严格一致
若正文涉及:
- 疾病
- 人群
- 护理场景
- 干预措施
- 结局指标
必须基本一致。
例如:
不匹配:
- ICU ≠ 普通病房
- 老年人 ≠ 儿童
- 糖尿病 ≠ 高血压
- 心理护理 ≠ 运动干预
- 焦虑改善 ≠ 生存率提高
====================
4. 强结论必须强证据
正文若出现:
- 显著改善
- 明显降低
- 证实
- 优于
- 有效预测
- 危险因素
- 因果关系
文献必须能合理支撑该强结论。
仅“应用研究”“相关研究”“观察研究”
不能自动支持强结论。
否则 false。
====================
5. 特定证据类型必须一致
正文若明确写:
- RCT/randomized trial
- Meta-analysis
- Guideline
- Systematic review
- Expert consensus
而参考文献类型明显不符:
直接 false。
====================
6. 信息不足从严
若参考文献只有:
作者 + 年份
或信息过少,
无法建立明确关联:
false + 0.30
====================
【判定逻辑】
只有同时满足以下条件,才能 true
1. 主题一致
2. 核心对象一致
3. 核心论点一致
4. 方法/研究方向一致
5. 无明显错引风险
任意一点明显不符:
false。
====================
【评分(只能四选一)】
只能输出:
0.90
0.75
0.30
0.10
禁止任何其他分数。
评分规则:
0.90
明确匹配:
主题、对象、方法、核心论点均明显一致。
0.75
基本匹配:
整体支撑成立,但存在轻微概括或小范围表述差异。
0.30
存疑:
同领域但支撑不足;
信息不足;
需人工复核。
0.10
明确错引:
主题、对象、方法或核心论点明显不符。
硬规则:
is_match=true
只能:
0.75 或 0.90
is_match=false
只能:
0.10 或 0.30
====================
【reason 要求】
仅说明:
1. 是否主题一致;
2. 核心论点/方法是否能支撑。
禁止模糊措辞:
“可能”
“看起来”
“应该”
“疑似”
长度:
20~60字。
====================
【输出要求】
仅输出一行 minified JSON。
禁止 markdown。
禁止解释。
禁止换行。
禁止任何额外内容。
格式:
{"is_match":true|false,"confidence":0.10|0.30|0.75|0.90,"reason":"简体中文说明"}
PROMPT;
}
/** 第一次校对:参考文献真实性与支撑力度 */
private function buildReferenceCheckFirstPassPrompt()
{
return <<<'PROMPT'
你是文献引用校对助手。判断【正文全文】与【参考文献书目】是否相关、能否用于支撑正文中的引用。
【核心原则:从宽判断,避免误杀】
默认倾向 can_support=true。只要文献与正文不是「风马牛不相及」即判为相关、能支撑。
不要求变量一致、不要求结论逐条对应、不要求研究设计相同。
【仅当以下情况才判 can_support=false与正文明显无关
- 学科/主题完全无关(如正文讲深度学习聚类,文献是糖尿病步态检测)。
- 明显张冠李戴(正文断言 A 疗法的效果,文献研究的是完全不同的 B 问题且无关联)。
- 文献条目与正文讨论的对象/场景毫无交集,且无法作背景或理论引用。
【以下情况均应 can_support=true】
- 同一大领域或相邻方向如护理、心理、管理、医学、统计、AI 等相近子领域)。
- 可作背景文献、综述性引用、理论或方法的一般性依据。
- 表述略宽、略有概括、变量名不完全一致,但大方向说得通。
【confidence 固定档位(禁止其它小数)】
can_support=true0.65(有关联但较泛)/ 0.78 / 0.85 / 0.92 / 0.98(非常确定相关)
can_support=false0.15(明确风马牛不相及)/ 0.25 / 0.35 / 0.45(仅当实在无法建立任何合理关联)
【输出】仅一行 minified JSON无 markdown
{"can_support":true|false,"is_match":true|false,"confidence":0.15|0.25|0.35|0.45|0.65|0.78|0.85|0.92|0.98,"reason":"30-80字简体中文"}
is_match 必须与 can_support 相同。
PROMPT;
return $this->buildReferenceCheckSupportSystemPrompt(false);
}
private function buildReferenceCheckFirstPassUserPrompt($contextText, $referText)
private function buildReferenceCheckSupportSystemPrompt($isSecondPass = false)
{
return "【正文全文 article_main.content】\n" . $contextText
. "\n\n【参考文献书目 refer_text】\n" . $referText
. "\n\n请从宽判断:文献与正文非风马牛不相即可判 can_support=true只返回 JSON。";
$prompt = <<<'PROMPT'
你是一名护理、医学、生物医学与科研期刊的资深学术编辑,正在执行“参考文献真实性与支撑力度校对”。
你的任务不是判断“主题是否相关”,而是判断:
【稿件正文中某段被引用内容】是否真的能被【对应编号的参考文献】直接或充分支撑。
你必须严格基于用户提供的材料作出判断,不得凭常识、不得脑补、不得假设参考文献中“可能写过但未提供”的内容。
==================================================
【一、任务目标】
你需要判断:
“正文引用位置的核心论点、结论、背景陈述、机制解释、疗效描述、数据表达或因果表述,
是否能被对应参考文献真实支持。”
这里的“支持”不是指“文献主题相关”或“研究领域接近”,而是指:
参考文献中确实包含足以支持正文该处表述的内容。
==================================================
【二、输出原则:结果必须直接对应数据库行】
你输出的结果将直接写入数据库表 t_article_reference_check_result。
因此:
## 输出必须是 results 数组,数组中的每一个对象对应数据库中的一行,也就是“一个引用位置中的一条参考文献结果”。
换句话说:
- 如果某个引用位置是 [3],则输出 1 条 resultreference_no=3
- 如果某个引用位置是 [1,2],则输出 2 条 result
- 一条对应 reference_no=1
- 一条对应 reference_no=2
每条 result 都必须给出该参考文献“单独”对正文引用句的支撑判断。
如果该引用位置是联合引用citation group 中有多篇文献则除了单条判断外还必须给出该引用组整体的联合判断combined_* 字段)。
==================================================
【三、最重要原则:只看“是否支撑正文核心断言”,不是看“主题是否沾边”】
以下情况不能判为强支撑:
1. 参考文献只和主题大致相关,但没有明确支持正文中的关键表述
2. 正文说的是“疗效提升/死亡率下降/全球高发/耐药/多通路机制”等明确论点,而文献只是在背景里泛泛提到疾病
3. 正文是多层复合句,文献只支撑其中一小部分
4. 正文有因果、比较、趋势、机制、疗效强度等强表述,而文献没有明确证据
5. 文献是基础机制研究,但正文引用它来支撑宏观流行病学、临床治疗现状或指南式结论
6. 文献可以“推测支持”但不是“直接/明确支持”
==================================================
【三b、多 claim 复合句 → 0.78 部分支撑(勿误降到 0.45)】
正文常为 2~4 个连续 claim 的复合句。须逐 claim 比对后综合给分:
- 若文献(含 DOI 摘要)能**明确支撑多数关键概念**(如遗传异质性/多基因改变、多 survival pathway 并存、耐药或治疗挑战),
但**未逐字写出**正文完整因果链(如「异质性→多通路→单靶点疗效下降」),
→ 应判 **partial_support**confidence 通常 **0.78**(边界情况 0.65**不得**仅因文献主标题聚焦某化合物/干预就降到 0.45。
- 0.45 仅用于:文献与 claim 方向明显不符、仅同病沾边、或几乎无可用证据。
**校准样例(单条 [4],须接近此逻辑):**
引用句:
Furthermore, the genomic heterogeneity of colorectal cancer (CRC) presents additional difficulties because tumors frequently make use of several survival pathways at once, which reduces the efficacy of single-target treatments [4].
文献4Sheikhnia et al., thymoquinone CRC 机制综述):
- Claim1 遗传异质性/多基因改变:文献有 APC/KRAS/TP53、MSI/CIN 等 → 支撑较强
- Claim2 多 survival pathway文献列举 PI3K/Akt、Wnt、STAT3、NF-κB 等多通路 → 支撑较强
- Claim3 单靶点疗效下降:文献有 drug resistance/治疗挑战,但未直述因果链 → 部分支撑
- **输出**can_support=1, confidence=**0.78**, support_role=supplementary_support**不是 0.45**
用户消息中若提供【DOI 真实文献内容】,**必须结合摘要判断**,不得仅凭书目标题给分。
==================================================
【四、评分规则】
你必须使用以下 8 个固定分值之一:
0.98 / 0.92 / 0.85 / 0.78 / 0.65 / 0.45 / 0.25 / 0.15
判定含义:
- 0.98 / 0.92 / 0.85 => 强支撑strong_support
- 0.78 / 0.65 => 部分支撑partial_support
- 0.45 / 0.25 => 支撑不足insufficient_support
- 0.15 => 不支撑not_support
can_support 取值规则:
- 若该文献/联合引文整体可判为 strong_support 或 partial_support则 can_support = 1
- 若判为 insufficient_support 或 not_support则 can_support = 0
==================================================
【五、单条文献结果如何判断】
对于每一条参考文献,你必须判断它“单独”能否支撑该引用位置的正文内容,并输出:
- can_support
- confidence
- reason
- support_role
其中:
### support_role 只能取以下值之一
- primary_support该文献本身就是主要证据来源能支撑引用句核心内容
- supplementary_support能支撑部分重要内容但不是主要来源
- minimal_support只提供少量背景或边缘支撑
- no_meaningful_support几乎不能支撑该引用句
### reason 的写法要求
必须使用中文,明确写出:
1. 这篇文献具体支撑正文的哪一部分
2. 哪些部分没有支撑到
3. 是否存在文献类型与引用用途不匹配的问题
4. 为什么给这个分值,而不是更高或更低
==================================================
【六、联合引用的判断规则】
当同一个引用位置包含多篇参考文献时(例如 [1,2] / [4,5,6]),除了逐条给单条结果外,还要额外判断:
“这些文献合起来,是否足以支撑该引用位置的正文内容?”
联合结论输出到:
- combined_can_support
- combined_confidence
- combined_reason
规则:
1. 联合评分不是单条评分平均值
2. 如果其中一篇文献已强支撑,其他文献只是补充,则联合评分可接近主支撑文献
3. 如果多篇文献分别覆盖不同部分,合起来能较完整支撑正文,则联合评分可以高于某些单条评分
4. 但如果最关键的核心断言没有被任何文献明确支撑,则联合评分不能虚高
5. 如果多篇文献都只是零散相关,需要大量推断才能拼出正文结论,则联合评分通常不应过高
==================================================
【七、单引文的 combined_* 字段处理规则】
即使某个引用位置只有 1 条参考文献,也仍然必须输出 combined_* 字段。
此时:
- combined_can_support = can_support
- combined_confidence = confidence
- combined_reason = “该引用位置仅包含单条文献,联合结论等同于该文献的单条结论。” 或等价表述
这样可以保证输出结构统一,便于数据库写入。
==================================================
【八、输出 JSON 结构】
你必须输出合法 JSON且只能输出以下结构
{
"results": [
{
"reference_no": 1,
"cite_group_refs": "1,2",
"can_support": 0,
"confidence": 0.65,
"reason": "中文,单条文献结论",
"support_role": "supplementary_support",
"combined_can_support": 1,
"combined_confidence": 0.85,
"combined_reason": "中文,联合引用整体结论"
}
]
}
==================================================
【九、字段约束】
### 1results 中每个对象都必须包含以下字段:
- reference_no
- cite_group_refs
- can_support
- confidence
- reason
- support_role
- combined_can_support
- combined_confidence
- combined_reason
### 2reference_no
必须对应当前引用位置中的某一条参考文献编号。
### 3cite_group_refs
必须是该引用位置的完整引文组,格式如:
- "3"
- "1,2"
- "4,5,6"
### 4同一引用位置若包含多条参考文献则必须输出多条 result
例如 cite_group_refs = "1,2" 时,必须输出:
- 一条 reference_no=1
- 一条 reference_no=2
### 5同一引用位置下的 combined_* 必须一致
例如同属 "1,2" 的两条 result它们的
- combined_can_support
- combined_confidence
- combined_reason
必须完全一致。
==================================================
【十、禁止事项】
你绝对不能:
- 杜撰文献中不存在的结论
- 把“主题相关”当作“内容支撑”
- 因为是同一疾病就默认支持
- 输出 JSON 以外的任何内容
现在开始,读取用户提供的引用位置正文、参考文献信息和文献内容,输出结果。
PROMPT;
if ($isSecondPass) {
$prompt .= <<<'PROMPT'
==================================================
【二次校对补充DOI 真实文献内容)】
用户消息中会提供【DOI 真实文献内容PubMed/Crossref】。
必须以 DOI 真实内容为准复核支撑力度;书目信息与 DOI 冲突时以 DOI 为准。
仍须输出完整 results 数组,逐条给出单文献判断与联合判断。
PROMPT;
}
return $prompt;
}
/** 第二次校对Crossref 摘要Refer_doi */
private function buildReferenceCheckFirstPassUserPrompt($contextText, $referText, $citeGroupRefs = '', $localContext = '', $doiBlock = '')
{
return $this->buildReferenceCheckSupportUserPrompt($contextText, $referText, $citeGroupRefs, $localContext, $doiBlock);
}
private function buildReferenceCheckSupportUserPrompt($contextText, $referText, $citeGroupRefs, $localContext, $doiBlock)
{
$citeGroupRefs = trim((string)$citeGroupRefs);
$localContext = trim((string)$localContext);
$doiBlock = trim((string)$doiBlock);
$parts = [
"【正文节 t_article_main】\n" . $contextText,
];
if ($citeGroupRefs !== '') {
$mode = strpos($citeGroupRefs, ',') !== false ? '联合引用' : '单独引用';
$parts[] = "【引用文献组 cite_group_refs】{$citeGroupRefs}{$mode}";
}
if ($localContext !== '') {
$parts[] = "【本引用位置附近上下文】\n" . $localContext;
}
$parts[] = "【参考文献书目(按编号列出)】\n" . $referText;
if ($doiBlock !== '') {
$parts[] = "【DOI 真实文献内容PubMed/Crossref一轮校对已提供\n" . $doiBlock;
}
$parts[] = '请严格按 system 要求输出 results 数组 JSON每条 result 对应一个 reference_no并包含 combined_* 字段。';
return implode("\n\n", $parts);
}
/** 第二次校对DOI 真实文献内容复核 */
private function buildReferenceCheckSecondPassPrompt()
{
return <<<'PROMPT'
你是文献引用二次校对助手。已根据 Refer_doi 从 Crossrefhttps://api.crossref.org/works/)获取摘要,请结合【正文全文】复核该文献是否相关。
【核心原则:与第一次相同,从宽判断】
默认倾向 can_support=true。只要 Crossref 摘要(或书目)与正文不是风马牛不相及,即判相关、能支撑。
以【Crossref 摘要】为准;摘要与书目冲突时以摘要为准。
【仅当以下情况才判 can_support=false】
- 摘要显示的研究主题/对象/方法与正文讨论内容完全风马牛不相及。
- 典型风马牛不相及、张冠李戴,且无法解释为背景或泛化引用。
【以下情况均应 can_support=true】
- 摘要与正文属同领域或相近方向,能作背景、理论或方向性支撑。
- 细节不完全一致,但不存在明显矛盾。
【无 Crossref 摘要时】
结合 refer_text 从宽判断;非明显无关仍可 can_support=trueconfidence 建议 0.65。
【confidence 固定档位(禁止其它小数)】
can_support=true0.65 / 0.78 / 0.85 / 0.92 / 0.98
can_support=false0.15 / 0.25 / 0.35 / 0.45
【输出】仅一行 minified JSON
{"can_support":true|false,"is_match":true|false,"confidence":0.15|0.25|0.35|0.45|0.65|0.78|0.85|0.92|0.98,"reason":"30-80字简体中文"}
is_match 必须与 can_support 相同。
PROMPT;
return $this->buildReferenceCheckSupportSystemPrompt(true);
}
private function buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock)
private function buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock, $citeGroupRefs = '', $localContext = '')
{
$doiBlock = trim((string)$doiBlock);
return "【正文全文 article_main.content】\n" . $contextText
. "\n\n【参考文献书目 refer_text】\n" . $referText
. "\n\n【Crossref 摘要】Refer_doi → api.crossref.org/works/\n"
. ($doiBlock !== '' ? $doiBlock : '(未获取到摘要,请结合 refer_text 从宽判断)')
. "\n\n文献与正文非风马牛不相即可判 can_support=true只返回 JSON。";
return $this->buildReferenceCheckSupportUserPrompt(
$contextText,
$referText,
$citeGroupRefs,
$localContext,
$doiBlock !== '' ? $doiBlock : '(未获取到 DOI 摘要或元数据,请结合书目条目从严判断)'
);
}
private function buildReferenceCheckSystemPrompt3()
{
@@ -1140,13 +1621,174 @@ PROMPT;
private function buildReferenceCheckRecheckUserPrompt($contextText, $referText, $doiBlock)
{
return $this->buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock);
return $this->buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock, '', '');
}
/**
* 与 buildReferenceCheckSystemPrompt3 一致的 confidence 档位
* @return array<int, array>
*/
private function getReferenceCheckConfidenceBands($isMatch)
private function parseReferenceCheckResultsFromParsed(array $parsed, $defaultCiteGroupRefs = '', $localContext = '', $doiBlock = '')
{
$rows = [];
if (isset($parsed['results']) && is_array($parsed['results'])) {
$rows = $parsed['results'];
} elseif (isset($parsed['reference_no']) || isset($parsed['confidence'])) {
$rows = [$parsed];
}
$normalized = [];
foreach ($rows as $item) {
if (!is_array($item)) {
continue;
}
$refNo = intval(isset($item['reference_no']) ? $item['reference_no'] : 0);
if ($refNo <= 0) {
continue;
}
$confidence = $this->snapReferenceCheckConfidenceValue(
$this->normalizeConfidence(isset($item['confidence']) ? $item['confidence'] : 0)
);
$canSupport = $this->canSupportFromConfidence($confidence);
if (array_key_exists('can_support', $item)) {
$canSupport = $this->boolFromLlmValue($item['can_support']);
} elseif (array_key_exists('is_match', $item)) {
$canSupport = $this->boolFromLlmValue($item['is_match']);
}
$reason = $this->cleanReason((string)(isset($item['reason']) ? $item['reason'] : ''));
$supportRole = $this->normalizeSupportRole(isset($item['support_role']) ? $item['support_role'] : '');
list($confidence, $canSupport, $supportRole) = $this->applyMultiClaimPartialSupportFloor(
$localContext,
$doiBlock,
$confidence,
$canSupport,
$supportRole,
$reason
);
$combinedConfidence = $this->snapReferenceCheckConfidenceValue(
$this->normalizeConfidence(isset($item['combined_confidence']) ? $item['combined_confidence'] : $confidence)
);
$combinedCanSupport = $this->canSupportFromConfidence($combinedConfidence);
if (array_key_exists('combined_can_support', $item)) {
$combinedCanSupport = $this->boolFromLlmValue($item['combined_can_support']);
}
$citeGroupRefs = trim((string)(isset($item['cite_group_refs']) ? $item['cite_group_refs'] : $defaultCiteGroupRefs));
if ($citeGroupRefs === '' && $defaultCiteGroupRefs !== '') {
$citeGroupRefs = trim((string)$defaultCiteGroupRefs);
}
$normalized[] = [
'reference_no' => $refNo,
'cite_group_refs' => $citeGroupRefs,
'can_support' => $canSupport,
'is_match' => $canSupport,
'confidence' => $confidence,
'reason' => $reason,
'support_role' => $supportRole,
'combined_can_support' => $combinedCanSupport,
'combined_confidence' => $combinedConfidence,
'combined_reason' => $this->cleanReason((string)(isset($item['combined_reason']) ? $item['combined_reason'] : '')),
];
}
return $normalized;
}
private function normalizeSupportRole($role)
{
$role = strtolower(trim((string)$role));
$allowed = [
'primary_support',
'supplementary_support',
'minimal_support',
'no_meaningful_support',
];
return in_array($role, $allowed, true) ? $role : 'no_meaningful_support';
}
private function canSupportFromConfidence($confidence)
{
return floatval($confidence) >= 0.65 - 0.001;
}
/**
* 多通路/异质性 claim + DOI 有多通路证据时,防止误打 0.45(应对齐 0.78 部分支撑)
*/
private function applyMultiClaimPartialSupportFloor($localContext, $doiBlock, $confidence, $canSupport, $supportRole, $reason)
{
$confidence = floatval($confidence);
if ($confidence > 0.45) {
return [$confidence, $canSupport, $supportRole];
}
$claimText = trim((string)$localContext);
if ($claimText === '') {
return [$confidence, $canSupport, $supportRole];
}
$claimIsMechanism = (bool)preg_match(
'/\b(genomic heterogeneity|heterogeneity|survival pathway|pathways at once|single-target|multi.?pathway|genetic alteration|drug resistance|异质性|生存通路|多.*通路|单靶点|耐药)\b/ui',
$claimText
);
if (!$claimIsMechanism) {
return [$confidence, $canSupport, $supportRole];
}
$corpus = trim((string)$doiBlock) . ' ' . trim((string)$reason);
if ($corpus === '') {
return [$confidence, $canSupport, $supportRole];
}
$refHasPathwayEvidence = (bool)preg_match(
'/\b(pathway|PI3K|Akt|mTOR|Wnt|STAT3|NF-κB|NF-kB|genetic alteration|MSI|CIN|drug resistance|signaling|multiple|APC|KRAS|TP53|通路|耐药|信号)\b/ui',
$corpus
);
if (!$refHasPathwayEvidence) {
return [$confidence, $canSupport, $supportRole];
}
$confidence = 0.78;
$canSupport = true;
if ($supportRole === 'no_meaningful_support' || $supportRole === 'minimal_support') {
$supportRole = 'supplementary_support';
}
return [$confidence, $canSupport, $supportRole];
}
private function getReferenceCheckConfidenceBands()
{
return [0.15, 0.25, 0.45, 0.65, 0.78, 0.85, 0.92, 0.98];
}
private function snapReferenceCheckConfidenceValue($confidence)
{
$bands = $this->getReferenceCheckConfidenceBands();
foreach ($bands as $band) {
if (abs($confidence - $band) < 0.001) {
return $band;
}
}
$nearest = $bands[0];
$minDiff = abs($confidence - $nearest);
foreach ($bands as $band) {
$diff = abs($confidence - $band);
if ($diff < $minDiff) {
$minDiff = $diff;
$nearest = $band;
}
}
return $nearest;
}
/**
* @deprecated 兼容旧逻辑
*/
private function getReferenceCheckConfidenceBandsLegacy($isMatch)
{
return $isMatch
? [0.65, 0.78, 0.85, 0.92, 0.98]
@@ -1154,22 +1796,24 @@ PROMPT;
}
/**
* 将模型输出的 confidence 吸附到合法档位(如 0.95 → 0.920.75 → 0.78
* 将模型输出的 confidence 吸附到合法档位
*/
private function snapReferenceCheckConfidence($confidence, $isMatch)
{
$bands = $this->getReferenceCheckConfidenceBands($isMatch);
$snapped = $this->snapReferenceCheckConfidenceValue($confidence);
$bands = $this->getReferenceCheckConfidenceBandsLegacy($isMatch);
if (in_array($snapped, $bands, true)) {
return $snapped;
}
foreach ($bands as $band) {
if (abs($confidence - $band) < 0.001) {
if (abs($snapped - $band) < 0.001) {
return $band;
}
}
$nearest = $bands[0];
$minDiff = abs($confidence - $nearest);
$minDiff = abs($snapped - $nearest);
foreach ($bands as $band) {
$diff = abs($confidence - $band);
$diff = abs($snapped - $band);
if ($diff < $minDiff) {
$minDiff = $diff;
$nearest = $band;

View File

@@ -0,0 +1,219 @@
<?php
namespace app\common\service;
use think\Env;
/**
* 本地模型服务:按上下文长度自动选择模型
*
* - 短上下文 -> 小模型(显存为大模型一半),对应 base.model_url1
* - 长上下文 -> 大模型,对应 base.model_url
*
* 选择规则:上下文字符数 <= 阈值 用小模型;超过阈值 用大模型。
* 两个端点模型名相同base.model
*
* 用法:
* $svc = new LocalModelService();
* $res = $svc->chat([
* ['role' => 'system', 'content' => '...'],
* ['role' => 'user', 'content' => '...'],
* ]);
* // $res['ok'], $res['content'], $res['tier'](small|large), $res['context_len']
*
* // 只要文本结果:
* $text = $svc->complete($systemPrompt, $userPrompt);
*/
class LocalModelService
{
/** 上下文长度阈值(字符数):<= 用小模型,> 用大模型 */
const CONTEXT_THRESHOLD = 1000;
/** 请求超时(秒) */
const TIMEOUT = 120;
/** 小模型端点(短上下文,显存一半) */
private $smallUrl;
/** 大模型端点(长上下文) */
private $largeUrl;
/** 模型名(两端点相同) */
private $model;
/** 上下文长度阈值(字符数) */
private $threshold;
public function __construct()
{
// 小模型 -> base.model_url1大模型 -> base.model_url模型名同为 base.model
$this->smallUrl = $this->normalizeChatUrl((string)Env::get('base.model_url1', ''));
$this->largeUrl = $this->normalizeChatUrl((string)Env::get('base.model_url', ''));
$this->model = trim((string)Env::get('base.model', ''));
$this->threshold = self::CONTEXT_THRESHOLD;
}
/**
* 发起一次对话,按上下文长度自动选模型。
*
* @param array $messages OpenAI 格式 messages
* @param array $options 可选:
* - temperature (float, 默认 0.2)
* - max_tokens (int, 可选)
* - force_tier ('small'|'large') 强制指定模型,跳过长度判断
* - extra (array) 透传到请求体的额外字段
* @return array{ok:bool, content:string, tier:string, model:string, url:string, context_len:int, error:string}
*/
public function chat(array $messages, array $options = [])
{
$contextLen = $this->measureMessages($messages);
$tier = isset($options['force_tier']) && in_array($options['force_tier'], ['small', 'large'], true)
? $options['force_tier']
: $this->pickTier($contextLen);
$endpoint = $this->resolveEndpoint($tier);
$result = [
'ok' => false,
'content' => '',
'tier' => $tier,
'model' => $endpoint['model'],
'url' => $endpoint['url'],
'context_len' => $contextLen,
'error' => '',
];
if ($endpoint['url'] === '' || $endpoint['model'] === '') {
$result['error'] = $tier . ' 模型未配置(检查 .env [base] model_url / model_url1 / model';
return $result;
}
$payload = [
'model' => $endpoint['model'],
'temperature' => isset($options['temperature']) ? (float)$options['temperature'] : 0.2,
'messages' => $messages,
];
if (isset($options['max_tokens']) && intval($options['max_tokens']) > 0) {
$payload['max_tokens'] = intval($options['max_tokens']);
}
if (isset($options['extra']) && is_array($options['extra'])) {
$payload = array_merge($payload, $options['extra']);
}
$content = $this->postChat($endpoint['url'], $payload, $err);
if ($content === null) {
$result['error'] = $err !== '' ? $err : 'LLM 请求失败';
return $result;
}
$result['ok'] = true;
$result['content'] = $content;
return $result;
}
/**
* 便捷方法:传 system + user返回纯文本内容失败返回空字符串
*/
public function complete($systemPrompt, $userPrompt, array $options = [])
{
$messages = [];
if (trim((string)$systemPrompt) !== '') {
$messages[] = ['role' => 'system', 'content' => (string)$systemPrompt];
}
$messages[] = ['role' => 'user', 'content' => (string)$userPrompt];
$res = $this->chat($messages, $options);
return $res['ok'] ? $res['content'] : '';
}
/**
* 根据上下文长度选择 tier。
*/
public function pickTier($contextLen)
{
return $contextLen > $this->threshold ? 'large' : 'small';
}
/**
* 统计 messages 的上下文长度(所有 content 字符数之和)。
*/
public function measureMessages(array $messages)
{
$len = 0;
foreach ($messages as $m) {
if (isset($m['content']) && is_string($m['content'])) {
$len += mb_strlen($m['content']);
}
}
return $len;
}
/**
* 返回某 tier 的端点配置(模型名两端点相同)。
*/
private function resolveEndpoint($tier)
{
$url = $tier === 'large' ? $this->largeUrl : $this->smallUrl;
return ['url' => $url, 'model' => $this->model];
}
private function postChat($url, array $payload, &$err = '')
{
$err = '';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT);
$headers = ['Content-Type: application/json'];
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$raw = curl_exec($ch);
if ($raw === false) {
$err = 'curl error: ' . curl_error($ch);
curl_close($ch);
return null;
}
$httpCode = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE));
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300) {
$err = 'http ' . $httpCode . ': ' . mb_substr((string)$raw, 0, 300);
return null;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
$err = 'invalid json response';
return null;
}
if (isset($data['choices'][0]['message']['content'])) {
return (string)$data['choices'][0]['message']['content'];
}
if (isset($data['content'])) {
return (string)$data['content'];
}
$err = 'no content in response: ' . mb_substr((string)$raw, 0, 300);
return null;
}
/**
* 根地址自动补 /v1/chat/completions。
*/
private function normalizeChatUrl($url)
{
$url = trim((string)$url);
if ($url === '') {
return '';
}
if (stripos($url, 'chat/completions') !== false) {
return $url;
}
return rtrim($url, '/') . '/v1/chat/completions';
}
}

View File

@@ -0,0 +1,670 @@
<?php
namespace app\common\service;
use think\Env;
/**
* 参考文献「主题相关性」LLM 校对(独立于支撑力度校对 LLMService
*/
class ReferenceRelevanceLlmService
{
private $url;
private $model;
private $apiKey;
private $timeout;
private $lastPostError = '';
private $maxSectionChars;
private $maxLocalContextChars;
private $maxReferChars;
private $maxAbstractChars;
public function __construct()
{
$this->url = trim((string)Env::get('promotion.promotion_llm_url', ''));
$this->model = trim((string)Env::get('promotion.promotion_llm_model', ''));
$this->apiKey = trim((string)Env::get('promotion.promotion_llm_api_key', ''));
$this->timeout = max(180, intval(Env::get('promotion.promotion_llm_timeout', 180)));
// 控制发送给 LLM 的上下文长度,降低单次推理耗时(可通过 env 覆盖)
$this->maxSectionChars = max(1500, intval(Env::get('promotion.relevance_llm_max_section_chars', 4500)));
$this->maxLocalContextChars = max(600, intval(Env::get('promotion.relevance_llm_max_local_context_chars', 1800)));
$this->maxReferChars = max(1500, intval(Env::get('promotion.relevance_llm_max_refer_chars', 3500)));
$this->maxAbstractChars = max(1500, intval(Env::get('promotion.relevance_llm_max_abstract_chars', 3500)));
}
/**
* @return array{results:array,request_failed?:bool,reason?:string}
*/
public function checkRelevance($sectionText, $localContext, $referText, $abstractText = '', $citeGroupRefs = '')
{
$fallback = [
'results' => [],
'request_failed' => true,
'reason' => 'LLM not configured or request failed',
];
if ($this->url === '' || $this->model === '') {
return $fallback;
}
$sectionText = trim((string)$sectionText);
$localContext = trim((string)$localContext);
$referText = trim((string)$referText);
$abstractText = trim((string)$abstractText);
if ($sectionText === '' || $referText === '') {
return ['results' => [], 'reason' => 'Empty section or reference text'];
}
if (mb_strlen($sectionText) > $this->maxSectionChars) {
$sectionText = mb_substr($sectionText, 0, $this->maxSectionChars);
}
if (mb_strlen($localContext) > $this->maxLocalContextChars) {
$localContext = mb_substr($localContext, 0, $this->maxLocalContextChars);
}
if (mb_strlen($referText) > $this->maxReferChars) {
$referText = mb_substr($referText, 0, $this->maxReferChars);
}
if (mb_strlen($abstractText) > $this->maxAbstractChars) {
$abstractText = mb_substr($abstractText, 0, $this->maxAbstractChars);
}
$payload = [
'model' => $this->model,
'temperature' => 0,
'messages' => [
['role' => 'system', 'content' => $this->buildSystemPrompt()],
['role' => 'user', 'content' => $this->buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs)],
],
];
$content = $this->postChat($payload);
if ($content === null) {
$reason = $this->lastPostError !== '' ? $this->lastPostError : 'LLM request failed';
return array_merge($fallback, ['reason' => $reason]);
}
$parsed = $this->parseJson($content);
if ($parsed === null) {
return array_merge($fallback, ['reason' => 'LLM response JSON parse failed']);
}
$results = $this->normalizeResults($parsed, $citeGroupRefs, $localContext, $referText, $abstractText);
if (empty($results)) {
return array_merge($fallback, ['reason' => 'LLM returned empty or invalid results']);
}
return ['results' => $results];
}
private function buildSystemPrompt()
{
return <<<'PROMPT'
你是一名护理、医学、生物医学与科研期刊的资深学术编辑,正在执行「参考文献主题相关性校对」。
你的任务:判断【引用位置正文表述】与【对应编号参考文献】在主题、研究对象、疾病/场景/结局方向上是否相关,能否作为该处引用的合理来源。
注意:这是「相关性」校对,侧重引用处具体 claim 与文献内容是否匹配;**不是**判断「是否同一疾病/同一领域」。
==================================================
【零、最硬规则(违反则输出无效)】
1. **单条 relevance_score 只评价该编号文献单独**与引用处的关系;不得因联合组整体合理而抬高弱相关文献的单条分。
2. **禁止「同病高分」**:正文与文献都涉及 CRC不等于单条可给 0.85~0.92。
**但若引用处 claim 本身就是机制/通路/异质性/耐药/治疗挑战**,且**研究主语一致**(同一疾病/同一化合物/同一干预对象),文献(含摘要/清洗内容)讨论同病多通路、遗传改变、耐药等,应给 **0.65~0.78**,不得误降到 0.45。
**主语不一致时仍适用本条禁止高分**:引用处主语为化合物 X文献却是其他植物/提取物/计算预测,即使提到 X 或相同通路名,也不得因此给 0.78+。
3. 引用处若为**流行病学/负担类 claim**most common、incidence、mortality、burden、全球高发等
- 机制研究、分子通路、细胞增殖/迁移、血管生成等**原始研究** → 单条通常 **0.45 或更低**`is_relevant=0``minimal_relevance`
- 不得因摘要提到 colorectal cancer 就给 0.92
- 仅当文献为流行病学综述/公共卫生研究,或明确讨论发病率、死亡率、疾病负担时,单条才可 **0.85~0.92**
4. **联合分写在 combined_relevance_score**,与单条分必须可分离(例如 [1,2] 时文献1=0.45、文献2=0.92、联合=0.92)。
5. **「来源/化学分类」型句子**naturally occurring、pentacyclic triterpenoid、found in fruits/vegetables/medicinal plants、并列举具体植物学名
- 先判文献类型:来源综述 / 生物活性综述 最适合;**抗癌治疗综述**对「来源分布」claim 通常仅 **0.65**
- 单篇可差异化打分(如 0.92 / 0.92 / 0.65**不得**因联合而三篇都给高分
- 若原句含**具体列举项**(如多个植物学名),而材料未逐一核实全部学名,联合分通常 **≤0.85**(不得给 0.98
6. **多要素综括句**(一句同时塞入:药学/研究兴趣 + 大量前临床研究 + 多种活性[抗炎/抗氧化/抗癌等] + 多个癌种/对象列举):
- 单篇即使是综述,通常仅 partially_related ~ near-direct**0.78~0.86****不轻易给 0.92**(单篇难逐项覆盖全部要素)
- **联合分是整句覆盖度评估,可低于最高单条分**:若整句要素需多篇拼合、且含作者整合概括,联合通常 **0.72~0.78partially_related**,不给 0.85+
7. **联合分不是「取最高单条分」**:当各单篇都只覆盖整句一部分、需互补拼合时,联合分应反映「整句作为一个整体被支撑的完整度」,**允许低于任何一篇单条分**。
8. **主语/研究对象层级必须对齐**:引用处主语为某化合物/分子如「X has been demonstrated…」文献核心对象须为 **X 本身**或以 X 为核心的实验/综述。
- **植物提取物/混合物**研究、**其他物种/其他植物**的计算预测、成分表中顺带出现 X → 通常 **0.45 或更低**
- **关键:提取物即使 X 含量很高(如 50%+)且显示了抗癌/凋亡活性,活性归因于提取物整体而非 X 单体单独验证 → 仍属 weakly_related≤0.45)、`minimal_relevance`**,不得评为 supplementary_relevance/0.78
- 只有当文献**针对 X 单体单独做了验证**X monomer 处理、X 单独剂量效应等)时,主语才算对齐,方可进入 0.65+
- **不得**因摘要/讨论出现与引用句相同的通路名、凋亡、抗癌等词就给 0.78+
9. **证据层级与 demonstrated / mechanistically**
- 本文实验结果或针对 X 的系统综述 > 计算预测/混合成分推测
- **讨论Discussion转引他人关于 X 的机制总结 ≠ 该文自身证据**;据此最多 **0.45~0.65**,不得评为 highly_related
- in silico / computational prediction 不足以支撑「has been demonstrated to mechanistically…」式强语气 claim 的高分
10. **点名通路/功能结局须逐项核对**:原句逐条列举通路(如 PI3K/AKT、MAPK、NF-κB或结局增殖、凋亡、血管生成、炎症信号等**每一项单独核对是否在本文证据中成立**(非仅背景提及)。
- 讨论转述既往文献 ≠ 本文证明该项
- 缺原句任一点名项(如 angiogenesis→ 单条通常 **不得 0.78+**
- **「覆盖部分结局」不足以进入 0.78**:原句点名了多条通路 + 多个结局,文献仅命中其中 1~2 个结局(如仅凋亡/增殖),且**点名通路在本文结果中全部缺失(仅讨论转引)**或主语层级不对 → 单条 **限 0.45weakly_related / minimal_relevance**,不得给 0.65~0.78
- 仅同领域沾边 12 项、主语或机制层级不对 → **0.45**
- **进入 0.65~0.78 的前提**主语对齐X 单体)+ 本文自身结果命中原句点名通路/结局的多数项;几乎全部明确对应 → **0.85+**
11. **文献「主题粒度」必须匹配 claim「主题粒度」**:引用处为**疾病总论型 claim**(流行病学负担、标准/多模态治疗现状与局限、基因组异质性、单靶点治疗受限、亟需新策略等总体背景)时:
- 最适合的来源是**疾病总体综述 / 分子病理综述 / 精准肿瘤学 / 耐药综述**;此类文献正面、系统地为该总论 claim 提供依据 → 可 **0.85+**
- **单一药物 / 单一成分 / 单一通路的专题综述**如「某化合物抗某癌A review」即使同病、同大方向也只是专题视角、并非为该总论 claim 做系统总结 → 通常 **partially_related0.72~0.78****不得给 0.85+**
- **单基因 / 单通路的机制原始研究**对纯流行病学负担 claim → 仍按规则 3 给 **0.45**
- 判断要点:文献类型是否「为该总论 claim 本身做系统综述/总论」;仅同病同方向、或只支撑整段中某一两句(如「需要更安全的新策略」),不足以进入 highly_related
==================================================
【一、必须先拆解 claim】
从【本引用位置附近上下文】中提炼最小主张单元Claim A, Claim B…**不要**把整句笼统归为「大概讲抗癌」。例如:
- **主语/研究对象**(化合物单体 vs 植物提取物 vs 其他物种是否「X has been demonstrated」
- **证据语气与层级**demonstrated / mechanistically vs predict / suggest本文结果 vs 讨论转引)
- **claim 主题粒度**:是否为疾病总论型(流行病学负担 / 治疗现状与局限 / 基因组异质性 / 单靶点受限 / 亟需新策略);若是,要求「总体综述 / 分子病理 / 精准肿瘤学 / 耐药综述」类来源,单一药物专题综述只算 partially_related
- 疾病流行病学(高发、死亡率)
- **点名通路/分子机制**PI3K/AKT、MAPK、NF-κB 等,须逐项)
- **点名功能结局**(抑制增殖、凋亡、血管生成、炎症信号等,须逐项)
- 治疗/干预现状
- **化合物化学类别**(如 pentacyclic triterpenoid
- **天然来源分布**fruits / vegetables / medicinal plants
- **具体列举项**(植物学名、药名、基因名等,须逐项核对)
==================================================
【二、逐篇文献单独判断(每条 result 对应一个 reference_no
对 cite_group_refs 中的每一篇文献,单独输出:
- 该文献与引用处哪些 claim 主题相关、哪些不相关(含具体列举项是否覆盖)
- 文献类型是否匹配引用用途(来源综述 / 生物活性综述 / 机制研究 / 流行病学综述 / 抗癌治疗综述等)
- relevance_score只能使用 0.98 / 0.92 / 0.85 / 0.78 / 0.65 / 0.45 / 0.25 / 0.15
- relevance_levelhighly_related | partially_related | weakly_related | unrelated
- is_relevantscore>=0.65 为 1否则 0
- relevance_role
- primary_relevance该文献是引用处主题的主要相关来源
- supplementary_relevance部分相关、补充性
- minimal_relevance仅边缘/背景沾边
- no_meaningful_relevance与引用处核心表述基本无关
- reason中英双语结论格式固定为两行
【中文】(中文结论,须写明:①文献类型与**核心研究对象** ②**本文自身证据**覆盖了哪些 claim / 哪些未覆盖 ③主语/claim 不匹配须明确写出 ④为何此分值)
【English】与中文对应的英文结论语义一致
- reason_en仅英文结论与 reason 中【English】段相同勿留空
主语/层级不对 → 单条 **0.45**,不得因讨论提及相同通路给 0.78
引用处 claim 为「化合物 X 经 PI3K/AKT 等机制 demonstrated…」文献为其他植物提取物或计算预测、仅在讨论转引他人 X 机制 → 0.45weakly_relatedis_relevant=0。
机制文引用流行病学句 → 单条 **0.45**,不得 0.92
文献为 CRC 机制研究,引用处 claim 为全球高发/死亡率,文献无流行病学数据 → 0.45minimal_relevanceis_relevant=0。
==================================================
【三、联合引用 combined_*(同一 cite_group_refs 内各行必须一致)】
当 cite_group_refs 为 "1,2" 等多篇时,除逐篇判断外,必须给出引用组整体结论:
- 这些文献合起来,是否足以支撑/匹配该引用位置的整体表述?
- combined_relevance_score八档固定分值之一**不是单条平均分**
- 若一篇已强相关、其余仅弱补充,联合分可接近主相关文献,但**不必等于最高单条分**
- 若原句含具体列举项(学名等)且材料未逐一核实,联合分通常 **0.85**,不给 0.98
- 若核心 claim 无任何文献明确覆盖,联合分不能虚高
- 多篇联合仍缺主语对齐、缺原句点名通路/结局、或主要靠讨论转引 → 联合分通常 **≤0.45~0.65**,不得因单篇讨论出现相同关键词给到 0.78+
- combined_is_relevantcombined_relevance_score>=0.65 为 1
- combined_relevance_level与 combined 分数对应的等级
- combined_reason中英双语综合结论格式同 reason【中文】/【English】说明各文献分工及最终分值理由
- combined_reason_en仅英文综合结论与 combined_reason 中【English】段相同
单条引用时combined_* 与单条一致combined_reason / combined_reason_en 可与 reason / reason_en 相同。
==================================================
【四、评分与等级对照】
0.98 / 0.92 / 0.85 = highly_related
文献直接支持整句主旨,大部分关键要素都在文中明确出现
0.78 / 0.65 = partially_related
文献只支撑其中一部分,或支撑方式偏间接
0.45 = weakly_related
只是同领域文献,但与句子事实对应很弱
0.25 / 0.15 = unrelated
基本不支撑该句
≤0.15 = not_support
不支撑
==================================================
【五、输出 JSON仅 JSON无 markdown
{
"results": [
{
"reference_no": 1,
"cite_group_refs": "1,2",
"is_relevant": 0,
"relevance_score": 0.45,
"relevance_level": "weakly_related",
"relevance_role": "minimal_relevance",
"reason": "【中文】中文单条结论\n【English】English single-reference conclusion",
"reason_en": "English single-reference conclusion",
"combined_is_relevant": 1,
"combined_relevance_score": 0.92,
"combined_relevance_level": "highly_related",
"combined_reason": "【中文】中文联合结论\n【English】English combined conclusion",
"combined_reason_en": "English combined conclusion"
},
{
"reference_no": 2,
"cite_group_refs": "1,2",
...
}
]
}
PROMPT;
}
private function buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs)
{
$parts = ["【正文节 t_article_main】\n" . $sectionText];
if (trim((string)$citeGroupRefs) !== '') {
$mode = strpos($citeGroupRefs, ',') !== false ? '联合引用' : '单独引用';
$parts[] = "【引用文献组 cite_group_refs】{$citeGroupRefs}{$mode}";
}
if ($localContext !== '') {
$parts[] = "【本引用位置附近上下文(优先据此拆解 claim\n" . $localContext;
}
$parts[] = "【参考文献书目(按编号)】\n" . $referText;
if ($abstractText !== '') {
$parts[] = "【文献摘要/清洗后内容Europe PMC·PubMed·Crossref·PDF\n" . $abstractText;
}
$parts[] = '请先拆解最小主张单元(主语层级、证据来源、点名通路/结局逐项核对),判断每篇文献类型与**本文自身证据**,再**逐篇独立**给出单条 relevance_score讨论转引、提取物/计算预测不得抬高;弱相关文献不得因联合而高分),最后给出 combined_*。reason / combined_reason 必须中英双语(【中文】/【English】并分别填写 reason_en / combined_reason_en。仅输出 results 数组 JSON。';
return implode("\n\n", $parts);
}
private function normalizeResults(array $parsed, $defaultCiteGroupRefs, $localContext = '', $referText = '', $abstractText = '')
{
$rows = [];
if (isset($parsed['results']) && is_array($parsed['results'])) {
$rows = $parsed['results'];
} elseif (isset($parsed['reference_no']) || isset($parsed['relevance_score'])) {
$rows = [$parsed];
}
$bands = $this->getScoreBands();
$localContext = trim((string)$localContext);
$referText = trim((string)$referText);
$abstractText = trim((string)$abstractText);
$out = [];
foreach ($rows as $item) {
if (!is_array($item)) {
continue;
}
$refNo = intval(isset($item['reference_no']) ? $item['reference_no'] : 0);
if ($refNo <= 0) {
continue;
}
$score = $this->snapScore(floatval(isset($item['relevance_score']) ? $item['relevance_score'] : 0), $bands);
$isRelevant = $score >= 0.65 - 0.001;
if (array_key_exists('is_relevant', $item)) {
$isRelevant = $this->boolVal($item['is_relevant']);
}
$level = $this->levelFromScore($score, isset($item['relevance_level']) ? $item['relevance_level'] : '');
$role = $this->normalizeRelevanceRole(isset($item['relevance_role']) ? $item['relevance_role'] : '');
list($reason, $reasonEn) = $this->normalizeBilingualReason(
isset($item['reason']) ? $item['reason'] : '',
isset($item['reason_en']) ? $item['reason_en'] : ''
);
list($score, $level, $isRelevant, $role) = $this->enforceSingleReferenceConsistency(
$score,
$level,
$isRelevant,
$role,
$bands
);
$combinedScore = $this->snapScore(
floatval(isset($item['combined_relevance_score']) ? $item['combined_relevance_score'] : $score),
$bands
);
$combinedRelevant = $combinedScore >= 0.65 - 0.001;
if (array_key_exists('combined_is_relevant', $item)) {
$combinedRelevant = $this->boolVal($item['combined_is_relevant']);
}
$combinedLevel = $this->levelFromScore(
$combinedScore,
isset($item['combined_relevance_level']) ? $item['combined_relevance_level'] : ''
);
list($combinedScore, $combinedLevel, $combinedRelevant) = $this->enforceCombinedConsistency(
$combinedScore,
$combinedLevel,
$combinedRelevant,
$bands
);
$citeGroupRefs = trim((string)(isset($item['cite_group_refs']) ? $item['cite_group_refs'] : $defaultCiteGroupRefs));
if ($citeGroupRefs === '' && $defaultCiteGroupRefs !== '') {
$citeGroupRefs = trim((string)$defaultCiteGroupRefs);
}
list($combinedReason, $combinedReasonEn) = $this->normalizeBilingualReason(
isset($item['combined_reason']) ? $item['combined_reason'] : '',
isset($item['combined_reason_en']) ? $item['combined_reason_en'] : ''
);
if ($combinedReason === '' && $combinedReasonEn === '') {
list($combinedReason, $combinedReasonEn) = [$reason, $reasonEn];
}
$out[] = [
'reference_no' => $refNo,
'cite_group_refs' => $citeGroupRefs,
'is_relevant' => $isRelevant ? 1 : 0,
'relevance_score' => $score,
'relevance_level' => $level,
'relevance_role' => $role,
'reason' => $reason,
'reason_en' => $reasonEn,
'combined_is_relevant' => $combinedRelevant ? 1 : 0,
'combined_relevance_score' => $combinedScore,
'combined_relevance_level' => $combinedLevel,
'combined_reason' => $combinedReason,
'combined_reason_en' => $combinedReasonEn,
];
}
$out = $this->syncCombinedFieldsAcrossGroup($out);
return $out;
}
private function enforceSingleReferenceConsistency($score, $level, $isRelevant, $role, array $bands)
{
$score = floatval($score);
if ($role === 'no_meaningful_relevance') {
if ($score > 0.25) {
$score = 0.25;
}
$level = 'unrelated';
$isRelevant = false;
} elseif ($role === 'minimal_relevance') {
if ($score > 0.45) {
$score = 0.45;
}
$level = 'weakly_related';
$isRelevant = false;
} elseif ($role === 'supplementary_relevance') {
if ($score > 0.78) {
$score = 0.78;
}
$level = $this->levelFromScore($score, $level);
} elseif ($role === 'primary_relevance') {
if ($score < 0.85) {
$score = 0.85;
}
$isRelevant = true;
$level = $this->levelFromScore($score, $level);
}
if ($level === 'weakly_related' && $score > 0.45) {
$score = 0.45;
$isRelevant = false;
} elseif ($level === 'unrelated' && $score > 0.25) {
$score = 0.25;
$isRelevant = false;
} elseif ($level === 'highly_related' && $score < 0.85) {
$score = 0.85;
$isRelevant = true;
} elseif ($level === 'partially_related') {
if ($score > 0.78) {
$score = 0.78;
}
if ($score < 0.65) {
$score = 0.65;
}
$isRelevant = true;
}
if (!$isRelevant && $score >= 0.65) {
$score = 0.45;
$level = 'weakly_related';
}
if ($isRelevant && $score < 0.65) {
$score = 0.65;
$level = 'partially_related';
}
$score = $this->snapScore($score, $bands);
$level = $this->levelFromScore($score, $level);
return [$score, $level, $isRelevant, $role];
}
private function enforceCombinedConsistency($combinedScore, $combinedLevel, $combinedRelevant, array $bands)
{
$combinedScore = $this->snapScore(floatval($combinedScore), $bands);
$combinedLevel = $this->levelFromScore($combinedScore, $combinedLevel);
$combinedRelevant = $combinedScore >= 0.65 - 0.001;
return [$combinedScore, $combinedLevel, $combinedRelevant];
}
private function syncCombinedFieldsAcrossGroup(array $out)
{
$groups = [];
foreach ($out as $idx => $row) {
$key = (string)$row['cite_group_refs'];
if ($key === '') {
$key = 'ref:' . $row['reference_no'];
}
$groups[$key][] = $idx;
}
foreach ($groups as $indices) {
if (count($indices) <= 1) {
continue;
}
$bestIdx = $indices[0];
$bestScore = floatval($out[$bestIdx]['combined_relevance_score']);
foreach ($indices as $idx) {
$s = floatval($out[$idx]['combined_relevance_score']);
if ($s >= $bestScore) {
$bestScore = $s;
$bestIdx = $idx;
}
}
$src = $out[$bestIdx];
foreach ($indices as $idx) {
$out[$idx]['combined_is_relevant'] = intval($src['combined_is_relevant']);
$out[$idx]['combined_relevance_score'] = floatval($src['combined_relevance_score']);
$out[$idx]['combined_relevance_level'] = (string)$src['combined_relevance_level'];
$out[$idx]['combined_reason'] = (string)$src['combined_reason'];
$out[$idx]['combined_reason_en'] = (string)$src['combined_reason_en'];
}
}
return $out;
}
private function getScoreBands()
{
return [0.15, 0.25, 0.45, 0.65, 0.78, 0.85, 0.92, 0.98];
}
private function snapScore($score, array $bands)
{
foreach ($bands as $band) {
if (abs($score - $band) < 0.001) {
return $band;
}
}
$nearest = $bands[0];
$minDiff = abs($score - $nearest);
foreach ($bands as $band) {
$diff = abs($score - $band);
if ($diff < $minDiff) {
$minDiff = $diff;
$nearest = $band;
}
}
return $nearest;
}
private function levelFromScore($score, $levelHint = '')
{
$levelHint = strtolower(trim((string)$levelHint));
$allowed = ['highly_related', 'partially_related', 'weakly_related', 'unrelated'];
if (in_array($levelHint, $allowed, true)) {
return $levelHint;
}
$aliases = [
'highly_related' => ['highly_related', 'high_related', 'strong_related', 'strong_relevance'],
'partially_related' => ['partially_related', 'partial_related', 'moderate_related'],
'weakly_related' => ['weakly_related', 'weak_related', 'low_related', 'insufficient'],
'unrelated' => ['unrelated', 'not_related', 'irrelevant', 'no_meaningful_relevance'],
];
foreach ($aliases as $canonical => $list) {
if (in_array($levelHint, $list, true)) {
return $canonical;
}
}
$score = floatval($score);
if ($score >= 0.85) {
return 'highly_related';
}
if ($score >= 0.65) {
return 'partially_related';
}
if ($score >= 0.45) {
return 'weakly_related';
}
return 'unrelated';
}
private function normalizeRelevanceRole($role)
{
$role = strtolower(trim((string)$role));
$map = [
'primary_relevance' => ['primary_relevance', 'primary_support', 'primary'],
'supplementary_relevance' => ['supplementary_relevance', 'supplementary_support', 'supplementary'],
'minimal_relevance' => ['minimal_relevance', 'minimal_support', 'minimal'],
'no_meaningful_relevance' => ['no_meaningful_relevance', 'no_meaningful_support', 'none'],
];
foreach ($map as $canonical => $aliases) {
if ($role === $canonical || in_array($role, $aliases, true)) {
return $canonical;
}
}
return 'no_meaningful_relevance';
}
private function cleanReason($reason)
{
$reason = trim(preg_replace('/[ \t]+/u', ' ', (string)$reason));
$reason = trim(preg_replace("/\n{3,}/u", "\n\n", $reason));
return mb_substr($reason, 0, 2000);
}
/**
* @return array{0:string,1:string} [bilingual reason, english only]
*/
private function normalizeBilingualReason($reason, $reasonEn)
{
$reason = trim((string)$reason);
$reasonEn = $this->cleanReason($reasonEn);
if ($reasonEn === '' && preg_match('/【English】\s*(.+)$/us', $reason, $m)) {
$reasonEn = $this->cleanReason($m[1]);
}
$zh = '';
if (preg_match('/【中文】\s*(.*?)(?:\n【English】|$)/us', $reason, $m)) {
$zh = trim($m[1]);
} elseif ($reason !== '' && strpos($reason, '【English】') === false) {
$zh = trim($reason);
}
if ($zh !== '' && $reasonEn !== '' && strpos($reason, '【English】') === false) {
$reason = "【中文】{$zh}\n【English】{$reasonEn}";
} elseif ($zh !== '' && $reasonEn !== '' && strpos($reason, '【中文】') === false) {
$reason = "【中文】{$zh}\n【English】{$reasonEn}";
} else {
$reason = $this->cleanReason($reason);
}
if ($reasonEn === '' && $zh !== '') {
$reasonEn = '';
}
return [$reason, $reasonEn];
}
private function boolVal($v)
{
if (is_bool($v)) {
return $v;
}
if (is_numeric($v)) {
return intval($v) !== 0;
}
$s = strtolower(trim((string)$v));
return in_array($s, ['1', 'true', 'yes', 'y'], true);
}
private function postChat(array $payload)
{
$this->lastPostError = '';
try {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $this->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_CONNECTTIMEOUT, min(15, $this->timeout));
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
$headers = ['Content-Type: application/json'];
if ($this->apiKey !== '') {
$headers[] = 'Authorization: Bearer ' . $this->apiKey;
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$raw = curl_exec($ch);
if ($raw === false) {
$this->lastPostError = 'LLM curl error: ' . curl_error($ch);
\think\Log::warning('ReferenceRelevanceLlm: ' . $this->lastPostError);
curl_close($ch);
return null;
}
$httpCode = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE));
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300) {
$snippet = mb_substr(trim((string)$raw), 0, 200);
$this->lastPostError = 'LLM HTTP ' . $httpCode . ($snippet !== '' ? ': ' . $snippet : '');
\think\Log::warning('ReferenceRelevanceLlm: ' . $this->lastPostError);
return null;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
$this->lastPostError = 'LLM response is not valid JSON';
return null;
}
if (isset($data['choices'][0]['message']['content'])) {
return (string)$data['choices'][0]['message']['content'];
}
if (isset($data['content'])) {
return (string)$data['content'];
}
$this->lastPostError = 'LLM response missing content field';
} catch (\Exception $e) {
$this->lastPostError = 'LLM exception: ' . $e->getMessage();
\think\Log::warning('ReferenceRelevanceLlm: ' . $this->lastPostError);
}
return null;
}
private function parseJson($raw)
{
$raw = trim((string)$raw);
if ($raw === '') {
return null;
}
$raw = preg_replace('/^```[a-zA-Z]*\s*|```$/m', '', $raw);
$raw = trim($raw);
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return $decoded;
}
if (preg_match('/\{[\s\S]*\}/', $raw, $m)) {
$decoded = json_decode($m[0], true);
if (is_array($decoded)) {
return $decoded;
}
}
return null;
}
}

View File

@@ -28,7 +28,8 @@
"paypal/paypal-server-sdk": "^0.6.1",
"guzzlehttp/guzzle": "^7.9",
"php-amqplib/php-amqplib": "^2.12",
"tectalic/openai": "^1.6"
"tectalic/openai": "^1.6",
"smalot/pdfparser": "^2.0"
},
"autoload": {
"psr-4": {

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

View File

@@ -1,5 +1,6 @@
-- 为预览标记增加原文位置字段([70-73] 展开为 4 条时共用 cite_tag_* / text_*
ALTER TABLE `t_reference_check_result`
-- 已合并到 article_reference_check_result_alter_columns.sql表名 t_article_reference_check_result
ALTER TABLE `t_article_reference_check_result`
ADD COLUMN `cite_tag_start` int(11) NOT NULL DEFAULT 0 COMMENT 'blue标签起始字节偏移' AFTER `reference_raw`,
ADD COLUMN `cite_tag_end` int(11) NOT NULL DEFAULT 0 COMMENT 'blue标签结束字节偏移' AFTER `cite_tag_start`,
ADD COLUMN `text_start` int(11) NOT NULL DEFAULT 0 COMMENT '引用句起始字节偏移' AFTER `cite_tag_end`,

View File

@@ -6,5 +6,10 @@ $vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'Attribute' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/Attribute.php',
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
'PhpToken' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/PhpToken.php',
'Stringable' => $vendorDir . '/myclabs/php-enum/stubs/Stringable.php',
'UnhandledMatchError' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/UnhandledMatchError.php',
'ValueError' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/ValueError.php',
);

View File

@@ -7,12 +7,13 @@ $baseDir = dirname($vendorDir);
return array(
'0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => $vendorDir . '/symfony/polyfill-mbstring/bootstrap.php',
'6e3fae29631ef280660b3cdad06f25a8' => $vendorDir . '/symfony/deprecation-contracts/function.php',
'a4a119a56e50fbb293281d9a48007e0e' => $vendorDir . '/symfony/polyfill-php80/bootstrap.php',
'9c67151ae59aff4788964ce8eb2a0f43' => $vendorDir . '/clue/stream-filter/src/functions_include.php',
'7b11c4dc42b3b3023073cb14e519683c' => $vendorDir . '/ralouphie/getallheaders/src/getallheaders.php',
'2cffec82183ee1cea088009cef9a6fc3' => $vendorDir . '/ezyang/htmlpurifier/library/HTMLPurifier.composer.php',
'8cff32064859f4559445b89279f3199c' => $vendorDir . '/php-http/message/src/filters.php',
'decc78cc4436b1292c6c0d151b19445c' => $vendorDir . '/phpseclib/phpseclib/phpseclib/bootstrap.php',
'6e3fae29631ef280660b3cdad06f25a8' => $vendorDir . '/symfony/deprecation-contracts/function.php',
'9b552a3cc426e3287cc811caefa3cf53' => $vendorDir . '/topthink/think-helper/src/helper.php',
'37a3dc5111fe8f707ab4c132ef1dbc62' => $vendorDir . '/guzzlehttp/guzzle/src/functions_include.php',
'1cfd2761b63b0a29ed23657ea394cb2d' => $vendorDir . '/topthink/think-captcha/src/helper.php',

View File

@@ -6,6 +6,7 @@ $vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'Smalot\\PdfParser\\' => array($vendorDir . '/smalot/pdfparser/src'),
'Rs\\Json' => array($vendorDir . '/php-jsonpointer/php-jsonpointer/src'),
'PHPExcel' => array($vendorDir . '/phpoffice/phpexcel/Classes'),
'HTMLPurifier' => array($vendorDir . '/ezyang/htmlpurifier/library'),

View File

@@ -15,11 +15,12 @@ return array(
'ZipStream\\' => array($vendorDir . '/maennchen/zipstream-php/src'),
'Unirest\\' => array($vendorDir . '/apimatic/unirest-php/src'),
'Tectalic\\OpenAi\\' => array($vendorDir . '/tectalic/openai/src'),
'Symfony\\Polyfill\\Php80\\' => array($vendorDir . '/symfony/polyfill-php80'),
'Symfony\\Polyfill\\Mbstring\\' => array($vendorDir . '/symfony/polyfill-mbstring'),
'Symfony\\Component\\HttpFoundation\\' => array($vendorDir . '/symfony/http-foundation'),
'Spatie\\DataTransferObject\\' => array($vendorDir . '/spatie/data-transfer-object/src'),
'Psr\\SimpleCache\\' => array($vendorDir . '/psr/simple-cache/src'),
'Psr\\Log\\' => array($vendorDir . '/psr/log/src'),
'Psr\\Log\\' => array($vendorDir . '/psr/log/Psr/Log'),
'Psr\\Http\\Message\\' => array($vendorDir . '/psr/http-factory/src', $vendorDir . '/psr/http-message/src'),
'Psr\\Http\\Client\\' => array($vendorDir . '/psr/http-client/src'),
'PhpOffice\\PhpWord\\' => array($vendorDir . '/phpoffice/phpword/src/PhpWord'),
@@ -30,6 +31,7 @@ return array(
'ParagonIE\\ConstantTime\\' => array($vendorDir . '/paragonie/constant_time_encoding/src'),
'PHPMailer\\PHPMailer\\' => array($vendorDir . '/phpmailer/phpmailer/src'),
'Nyholm\\Psr7\\' => array($vendorDir . '/nyholm/psr7/src'),
'MyCLabs\\Enum\\' => array($vendorDir . '/myclabs/php-enum/src'),
'Matrix\\' => array($vendorDir . '/markbaker/matrix/classes/src'),
'Http\\Message\\MultipartStream\\' => array($vendorDir . '/php-http/multipart-stream-builder/src'),
'Http\\Message\\' => array($vendorDir . '/php-http/message/src'),
@@ -39,7 +41,6 @@ return array(
'GuzzleHttp\\' => array($vendorDir . '/guzzlehttp/guzzle/src'),
'Core\\' => array($vendorDir . '/apimatic/core/src'),
'CoreInterfaces\\' => array($vendorDir . '/apimatic/core-interfaces/src'),
'Composer\\Pcre\\' => array($vendorDir . '/composer/pcre/src'),
'Complex\\' => array($vendorDir . '/markbaker/complex/classes/src'),
'Clue\\StreamFilter\\' => array($vendorDir . '/clue/stream-filter/src'),
);

View File

@@ -22,6 +22,8 @@ class ComposerAutoloaderInit2bc4f313dba415539e266f7ac2c87dcd
return self::$loader;
}
require __DIR__ . '/platform_check.php';
spl_autoload_register(array('ComposerAutoloaderInit2bc4f313dba415539e266f7ac2c87dcd', 'loadClassLoader'), true, true);
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
spl_autoload_unregister(array('ComposerAutoloaderInit2bc4f313dba415539e266f7ac2c87dcd', 'loadClassLoader'));

View File

@@ -8,12 +8,13 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd
{
public static $files = array (
'0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => __DIR__ . '/..' . '/symfony/polyfill-mbstring/bootstrap.php',
'6e3fae29631ef280660b3cdad06f25a8' => __DIR__ . '/..' . '/symfony/deprecation-contracts/function.php',
'a4a119a56e50fbb293281d9a48007e0e' => __DIR__ . '/..' . '/symfony/polyfill-php80/bootstrap.php',
'9c67151ae59aff4788964ce8eb2a0f43' => __DIR__ . '/..' . '/clue/stream-filter/src/functions_include.php',
'7b11c4dc42b3b3023073cb14e519683c' => __DIR__ . '/..' . '/ralouphie/getallheaders/src/getallheaders.php',
'2cffec82183ee1cea088009cef9a6fc3' => __DIR__ . '/..' . '/ezyang/htmlpurifier/library/HTMLPurifier.composer.php',
'8cff32064859f4559445b89279f3199c' => __DIR__ . '/..' . '/php-http/message/src/filters.php',
'decc78cc4436b1292c6c0d151b19445c' => __DIR__ . '/..' . '/phpseclib/phpseclib/phpseclib/bootstrap.php',
'6e3fae29631ef280660b3cdad06f25a8' => __DIR__ . '/..' . '/symfony/deprecation-contracts/function.php',
'9b552a3cc426e3287cc811caefa3cf53' => __DIR__ . '/..' . '/topthink/think-helper/src/helper.php',
'37a3dc5111fe8f707ab4c132ef1dbc62' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/functions_include.php',
'1cfd2761b63b0a29ed23657ea394cb2d' => __DIR__ . '/..' . '/topthink/think-captcha/src/helper.php',
@@ -50,6 +51,7 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd
),
'S' =>
array (
'Symfony\\Polyfill\\Php80\\' => 23,
'Symfony\\Polyfill\\Mbstring\\' => 26,
'Symfony\\Component\\HttpFoundation\\' => 33,
'Spatie\\DataTransferObject\\' => 26,
@@ -74,6 +76,7 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd
),
'M' =>
array (
'MyCLabs\\Enum\\' => 13,
'Matrix\\' => 7,
),
'H' =>
@@ -92,7 +95,6 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd
array (
'Core\\' => 5,
'CoreInterfaces\\' => 15,
'Composer\\Pcre\\' => 14,
'Complex\\' => 8,
'Clue\\StreamFilter\\' => 18,
),
@@ -138,6 +140,10 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd
array (
0 => __DIR__ . '/..' . '/tectalic/openai/src',
),
'Symfony\\Polyfill\\Php80\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/polyfill-php80',
),
'Symfony\\Polyfill\\Mbstring\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/polyfill-mbstring',
@@ -156,7 +162,7 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd
),
'Psr\\Log\\' =>
array (
0 => __DIR__ . '/..' . '/psr/log/src',
0 => __DIR__ . '/..' . '/psr/log/Psr/Log',
),
'Psr\\Http\\Message\\' =>
array (
@@ -199,6 +205,10 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd
array (
0 => __DIR__ . '/..' . '/nyholm/psr7/src',
),
'MyCLabs\\Enum\\' =>
array (
0 => __DIR__ . '/..' . '/myclabs/php-enum/src',
),
'Matrix\\' =>
array (
0 => __DIR__ . '/..' . '/markbaker/matrix/classes/src',
@@ -235,10 +245,6 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd
array (
0 => __DIR__ . '/..' . '/apimatic/core-interfaces/src',
),
'Composer\\Pcre\\' =>
array (
0 => __DIR__ . '/..' . '/composer/pcre/src',
),
'Complex\\' =>
array (
0 => __DIR__ . '/..' . '/markbaker/complex/classes/src',
@@ -250,6 +256,13 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd
);
public static $prefixesPsr0 = array (
'S' =>
array (
'Smalot\\PdfParser\\' =>
array (
0 => __DIR__ . '/..' . '/smalot/pdfparser/src',
),
),
'R' =>
array (
'Rs\\Json' =>
@@ -274,7 +287,12 @@ class ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd
);
public static $classMap = array (
'Attribute' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/Attribute.php',
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
'PhpToken' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/PhpToken.php',
'Stringable' => __DIR__ . '/..' . '/myclabs/php-enum/stubs/Stringable.php',
'UnhandledMatchError' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/UnhandledMatchError.php',
'ValueError' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/ValueError.php',
);
public static function getInitializer(ClassLoader $loader)

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
'name' => 'topthink/think',
'pretty_version' => 'dev-master',
'version' => 'dev-master',
'reference' => 'bbd690ca0f68c671ece05e82edc88ee7a68b82ed',
'reference' => '1d54946fef97376f7c2789af83a1616dd6f7a380',
'type' => 'project',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@@ -11,9 +11,9 @@
),
'versions' => array(
'apimatic/core' => array(
'pretty_version' => '0.3.16',
'version' => '0.3.16.0',
'reference' => 'ae4ab4ca26a41be41718f33c703d67b7a767c07b',
'pretty_version' => '0.3.17',
'version' => '0.3.17.0',
'reference' => 'a48a583f686ee3786432b976c795a2817ec095b3',
'type' => 'library',
'install_path' => __DIR__ . '/../apimatic/core',
'aliases' => array(),
@@ -55,15 +55,6 @@
'aliases' => array(),
'dev_requirement' => false,
),
'composer/pcre' => array(
'pretty_version' => '3.3.2',
'version' => '3.3.2.0',
'reference' => 'b2bed4734f0cc156ee1fe9c0da2550420d99a21e',
'type' => 'library',
'install_path' => __DIR__ . '/./pcre',
'aliases' => array(),
'dev_requirement' => false,
),
'ezyang/htmlpurifier' => array(
'pretty_version' => 'v4.19.0',
'version' => '4.19.0.0',
@@ -92,18 +83,18 @@
'dev_requirement' => false,
),
'guzzlehttp/psr7' => array(
'pretty_version' => '2.8.0',
'version' => '2.8.0.0',
'reference' => '21dc724a0583619cd1652f673303492272778051',
'pretty_version' => '2.9.0',
'version' => '2.9.0.0',
'reference' => '7d0ed42f28e42d61352a7a79de682e5e67fec884',
'type' => 'library',
'install_path' => __DIR__ . '/../guzzlehttp/psr7',
'aliases' => array(),
'dev_requirement' => false,
),
'maennchen/zipstream-php' => array(
'pretty_version' => '3.1.2',
'version' => '3.1.2.0',
'reference' => 'aeadcf5c412332eb426c0f9b4485f6accba2a99f',
'pretty_version' => '2.1.0',
'version' => '2.1.0.0',
'reference' => 'c4c5803cc1f93df3d2448478ef79394a5981cc58',
'type' => 'library',
'install_path' => __DIR__ . '/../maennchen/zipstream-php',
'aliases' => array(),
@@ -127,6 +118,15 @@
'aliases' => array(),
'dev_requirement' => false,
),
'myclabs/php-enum' => array(
'pretty_version' => '1.8.5',
'version' => '1.8.5.0',
'reference' => 'e7be26966b7398204a234f8673fdad5ac6277802',
'type' => 'library',
'install_path' => __DIR__ . '/../myclabs/php-enum',
'aliases' => array(),
'dev_requirement' => false,
),
'nyholm/psr7' => array(
'pretty_version' => '1.8.2',
'version' => '1.8.2.0',
@@ -137,9 +137,9 @@
'dev_requirement' => false,
),
'paragonie/constant_time_encoding' => array(
'pretty_version' => 'v3.1.3',
'version' => '3.1.3.0',
'reference' => 'd5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77',
'pretty_version' => 'v2.8.2',
'version' => '2.8.2.0',
'reference' => 'e30811f7bc69e4b5b6d5783e712c06c8eabf0226',
'type' => 'library',
'install_path' => __DIR__ . '/../paragonie/constant_time_encoding',
'aliases' => array(),
@@ -194,9 +194,9 @@
'dev_requirement' => false,
),
'php-http/message' => array(
'pretty_version' => '1.16.1',
'version' => '1.16.1.0',
'reference' => '5997f3289332c699fa2545c427826272498a2088',
'pretty_version' => '1.16.2',
'version' => '1.16.2.0',
'reference' => '06dd5e8562f84e641bf929bfe699ee0f5ce8080a',
'type' => 'library',
'install_path' => __DIR__ . '/../php-http/message',
'aliases' => array(),
@@ -209,9 +209,9 @@
),
),
'php-http/multipart-stream-builder' => array(
'pretty_version' => '1.3.1',
'version' => '1.3.1.0',
'reference' => 'ed56da23b95949ae4747378bed8a5b61a2fdae24',
'pretty_version' => '1.4.2',
'version' => '1.4.2.0',
'reference' => '10086e6de6f53489cca5ecc45b6f468604d3460e',
'type' => 'library',
'install_path' => __DIR__ . '/../php-http/multipart-stream-builder',
'aliases' => array(),
@@ -227,18 +227,18 @@
'dev_requirement' => false,
),
'phpmailer/phpmailer' => array(
'pretty_version' => 'v6.11.1',
'version' => '6.11.1.0',
'reference' => 'd9e3b36b47f04b497a0164c5a20f92acb4593284',
'pretty_version' => 'v6.12.0',
'version' => '6.12.0.0',
'reference' => 'd1ac35d784bf9f5e61b424901d5a014967f15b12',
'type' => 'library',
'install_path' => __DIR__ . '/../phpmailer/phpmailer',
'aliases' => array(),
'dev_requirement' => false,
),
'phpoffice/math' => array(
'pretty_version' => '0.2.0',
'version' => '0.2.0.0',
'reference' => 'fc2eb6d1a61b058d5dac77197059db30ee3c8329',
'pretty_version' => '0.3.0',
'version' => '0.3.0.0',
'reference' => 'fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a',
'type' => 'library',
'install_path' => __DIR__ . '/../phpoffice/math',
'aliases' => array(),
@@ -254,18 +254,18 @@
'dev_requirement' => false,
),
'phpoffice/phpspreadsheet' => array(
'pretty_version' => '1.30.1',
'version' => '1.30.1.0',
'reference' => 'fa8257a579ec623473eabfe49731de5967306c4c',
'pretty_version' => '1.25.2',
'version' => '1.25.2.0',
'reference' => 'a317a09e7def49852400a4b3eca4a4b0790ceeb5',
'type' => 'library',
'install_path' => __DIR__ . '/../phpoffice/phpspreadsheet',
'aliases' => array(),
'dev_requirement' => false,
),
'phpoffice/phpword' => array(
'pretty_version' => '1.3.0',
'version' => '1.3.0.0',
'reference' => '8392134ce4b5dba65130ba956231a1602b848b7f',
'pretty_version' => '1.4.0',
'version' => '1.4.0.0',
'reference' => '6d75328229bc93790b37e93741adf70646cea958',
'type' => 'library',
'install_path' => __DIR__ . '/../phpoffice/phpword',
'aliases' => array(),
@@ -297,9 +297,9 @@
),
),
'psr/http-factory' => array(
'pretty_version' => '1.0.2',
'version' => '1.0.2.0',
'reference' => 'e616d01114759c4c489f93b099585439f795fe35',
'pretty_version' => '1.1.0',
'version' => '1.1.0.0',
'reference' => '2b4765fddfe3b508ac62f829e852b1501d3f6e8a',
'type' => 'library',
'install_path' => __DIR__ . '/../psr/http-factory',
'aliases' => array(),
@@ -313,9 +313,9 @@
),
),
'psr/http-message' => array(
'pretty_version' => '2.0',
'version' => '2.0.0.0',
'reference' => '402d35bcb92c70c026d1a6a9883f06b2ead23d71',
'pretty_version' => '1.1',
'version' => '1.1.0.0',
'reference' => 'cb6ce4845ce34a8ad9e68117c10ee90a29919eba',
'type' => 'library',
'install_path' => __DIR__ . '/../psr/http-message',
'aliases' => array(),
@@ -329,18 +329,18 @@
),
),
'psr/log' => array(
'pretty_version' => '3.0.1',
'version' => '3.0.1.0',
'reference' => '79dff0b268932c640297f5208d6298f71855c03e',
'pretty_version' => '1.1.4',
'version' => '1.1.4.0',
'reference' => 'd49695b909c3b7628b6289db5479a1c204601f11',
'type' => 'library',
'install_path' => __DIR__ . '/../psr/log',
'aliases' => array(),
'dev_requirement' => false,
),
'psr/simple-cache' => array(
'pretty_version' => '3.0.0',
'version' => '3.0.0.0',
'reference' => '764e0b3939f5ca87cb904f570ef9be2d78a07865',
'pretty_version' => '1.0.1',
'version' => '1.0.1.0',
'reference' => '408d5eafb83c57f6365a3ca330ff23aa4a5fa39b',
'type' => 'library',
'install_path' => __DIR__ . '/../psr/simple-cache',
'aliases' => array(),
@@ -355,6 +355,15 @@
'aliases' => array(),
'dev_requirement' => false,
),
'smalot/pdfparser' => array(
'pretty_version' => 'v2.9.0',
'version' => '2.9.0.0',
'reference' => '6b53144fcb24af77093d4150dd7d0dd571f25761',
'type' => 'library',
'install_path' => __DIR__ . '/../smalot/pdfparser',
'aliases' => array(),
'dev_requirement' => false,
),
'spatie/data-transfer-object' => array(
'pretty_version' => '1.14.1',
'version' => '1.14.1.0',
@@ -365,32 +374,41 @@
'dev_requirement' => false,
),
'symfony/deprecation-contracts' => array(
'pretty_version' => 'v3.6.0',
'version' => '3.6.0.0',
'reference' => '63afe740e99a13ba87ec199bb07bbdee937a5b62',
'pretty_version' => 'v2.5.4',
'version' => '2.5.4.0',
'reference' => '605389f2a7e5625f273b53960dc46aeaf9c62918',
'type' => 'library',
'install_path' => __DIR__ . '/../symfony/deprecation-contracts',
'aliases' => array(),
'dev_requirement' => false,
),
'symfony/http-foundation' => array(
'pretty_version' => 'v8.0.1',
'version' => '8.0.1.0',
'reference' => '3690740e2e8b19d877f20d4f10b7a489cddf0fe2',
'pretty_version' => 'v5.4.50',
'version' => '5.4.50.0',
'reference' => '1a0706e8b8041046052ea2695eb8aeee04f97609',
'type' => 'library',
'install_path' => __DIR__ . '/../symfony/http-foundation',
'aliases' => array(),
'dev_requirement' => false,
),
'symfony/polyfill-mbstring' => array(
'pretty_version' => 'v1.32.0',
'version' => '1.32.0.0',
'reference' => '6d857f4d76bd4b343eac26d6b539585d2bc56493',
'pretty_version' => 'v1.37.0',
'version' => '1.37.0.0',
'reference' => '6a21eb99c6973357967f6ce3708cd55a6bec6315',
'type' => 'library',
'install_path' => __DIR__ . '/../symfony/polyfill-mbstring',
'aliases' => array(),
'dev_requirement' => false,
),
'symfony/polyfill-php80' => array(
'pretty_version' => 'v1.37.0',
'version' => '1.37.0.0',
'reference' => 'dfb55726c3a76ea3b6459fcfda1ec2d80a682411',
'type' => 'library',
'install_path' => __DIR__ . '/../symfony/polyfill-php80',
'aliases' => array(),
'dev_requirement' => false,
),
'tectalic/openai' => array(
'pretty_version' => 'v1.6.0',
'version' => '1.6.0.0',
@@ -412,7 +430,7 @@
'topthink/think' => array(
'pretty_version' => 'dev-master',
'version' => 'dev-master',
'reference' => 'bbd690ca0f68c671ece05e82edc88ee7a68b82ed',
'reference' => '1d54946fef97376f7c2789af83a1616dd6f7a380',
'type' => 'project',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@@ -428,18 +446,18 @@
'dev_requirement' => false,
),
'topthink/think-helper' => array(
'pretty_version' => 'v3.1.11',
'version' => '3.1.11.0',
'reference' => '1d6ada9b9f3130046bf6922fe1bd159c8d88a33c',
'pretty_version' => 'v3.1.12',
'version' => '3.1.12.0',
'reference' => 'fe277121112a8f1c872e169a733ca80bb11c4acb',
'type' => 'library',
'install_path' => __DIR__ . '/../topthink/think-helper',
'aliases' => array(),
'dev_requirement' => false,
),
'topthink/think-image' => array(
'pretty_version' => 'v1.0.7',
'version' => '1.0.7.0',
'reference' => '8586cf47f117481c6d415b20f7dedf62e79d5512',
'pretty_version' => 'v1.0.8',
'version' => '1.0.8.0',
'reference' => 'd1d748cbb2fe2f29fca6138cf96cb8b5113892f1',
'type' => 'library',
'install_path' => __DIR__ . '/../topthink/think-image',
'aliases' => array(),

View File

@@ -48,7 +48,7 @@ This software is distributed under the [LGPL 2.1](https://www.gnu.org/licenses/o
PHPMailer is available on [Packagist](https://packagist.org/packages/phpmailer/phpmailer) (using semantic versioning), and installation via [Composer](https://getcomposer.org) is the recommended way to install PHPMailer. Just add this line to your `composer.json` file:
```json
"phpmailer/phpmailer": "^6.11.1"
"phpmailer/phpmailer": "^6.12.0"
```
or run

View File

@@ -1 +1 @@
6.11.1
6.12.0

View File

@@ -49,15 +49,14 @@
},
"suggest": {
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
"ext-imap": "Needed to support advanced email address parsing according to RFC822",
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
"hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
"league/oauth2-google": "Needed for Google XOAUTH2 authentication",
"psr/log": "For optional PSR-3 debug logging",
"symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)",
"thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication"
"thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication",
"symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)"
},
"autoload": {
"psr-4": {
@@ -72,7 +71,6 @@
"license": "LGPL-2.1-only",
"scripts": {
"check": "./vendor/bin/phpcs",
"style": "./vendor/bin/phpcbf",
"test": "./vendor/bin/phpunit --no-coverage",
"coverage": "./vendor/bin/phpunit",
"lint": [

View File

@@ -9,7 +9,7 @@
*/
$PHPMAILER_LANG['authenticate'] = 'Error SMTP: Imposible autentificar.';
$PHPMAILER_LANG['buggy_php'] = 'Tu versión de PHP ha sido afectada por un bug que puede resultar en mensajes corruptos. Para arreglarlo, cambia a enviar usando SMTP, deshabilita la opción mail.add_x_header en tu php.ini, cambia a MacOS o Linux, o actualiza tu PHP a la versión 7.0.17+ o 7.1.3+.';
$PHPMAILER_LANG['buggy_php'] = 'Tu versión de PHP está afectada por un bug que puede resultar en mensajes corruptos. Para arreglarlo, cambia a enviar usando SMTP, deshabilita la opción mail.add_x_header en tu php.ini, cambia a MacOS o Linux, o actualiza tu PHP a la versión 7.0.17+ o 7.1.3+.';
$PHPMAILER_LANG['connect_host'] = 'Error SMTP: Imposible conectar al servidor SMTP.';
$PHPMAILER_LANG['data_not_accepted'] = 'Error SMTP: Datos no aceptados.';
$PHPMAILER_LANG['empty_message'] = 'El cuerpo del mensaje está vacío.';
@@ -18,7 +18,7 @@ $PHPMAILER_LANG['execute'] = 'Imposible ejecutar: ';
$PHPMAILER_LANG['extension_missing'] = 'Extensión faltante: ';
$PHPMAILER_LANG['file_access'] = 'Imposible acceder al archivo: ';
$PHPMAILER_LANG['file_open'] = 'Error de Archivo: Imposible abrir el archivo: ';
$PHPMAILER_LANG['from_failed'] = 'La siguiente dirección de remitente falló: ';
$PHPMAILER_LANG['from_failed'] = 'La(s) siguiente(s) direcciones de remitente fallaron: ';
$PHPMAILER_LANG['instantiate'] = 'Imposible crear una instancia de la función Mail.';
$PHPMAILER_LANG['invalid_address'] = 'Imposible enviar: dirección de email inválido: ';
$PHPMAILER_LANG['invalid_header'] = 'Nombre o valor de encabezado no válido';
@@ -34,5 +34,3 @@ $PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP Connect() falló.';
$PHPMAILER_LANG['smtp_detail'] = 'Detalle: ';
$PHPMAILER_LANG['smtp_error'] = 'Error del servidor SMTP: ';
$PHPMAILER_LANG['variable_set'] = 'No se pudo configurar la variable: ';
$PHPMAILER_LANG['imap_recommended'] = 'No se recomienda usar el analizador de direcciones simplificado. Instala la extensión IMAP de PHP para un análisis RFC822 más completo.';
$PHPMAILER_LANG['deprecated_argument'] = 'El argumento $useimap ha quedado obsoleto';

View File

@@ -561,9 +561,9 @@ class PHPMailer
* string $body the email body
* string $from email address of sender
* string $extra extra information of possible use
* 'smtp_transaction_id' => last smtp transaction id
* "smtp_transaction_id' => last smtp transaction id
*
* @var callable|callable-string
* @var string
*/
public $action_function = '';
@@ -711,7 +711,7 @@ class PHPMailer
*
* @var array
*/
protected static $language = [];
protected $language = [];
/**
* The number of errors encountered.
@@ -768,7 +768,7 @@ class PHPMailer
*
* @var string
*/
const VERSION = '6.11.1';
const VERSION = '6.12.0';
/**
* Error severity: message only, continue processing.
@@ -1102,7 +1102,7 @@ class PHPMailer
//At-sign is missing.
$error_message = sprintf(
'%s (%s): %s',
self::lang('invalid_address'),
$this->lang('invalid_address'),
$kind,
$address
);
@@ -1187,7 +1187,7 @@ class PHPMailer
if (!in_array($kind, ['to', 'cc', 'bcc', 'Reply-To'])) {
$error_message = sprintf(
'%s: %s',
self::lang('Invalid recipient kind'),
$this->lang('Invalid recipient kind'),
$kind
);
$this->setError($error_message);
@@ -1201,7 +1201,7 @@ class PHPMailer
if (!static::validateAddress($address)) {
$error_message = sprintf(
'%s (%s): %s',
self::lang('invalid_address'),
$this->lang('invalid_address'),
$kind,
$address
);
@@ -1220,16 +1220,12 @@ class PHPMailer
return true;
}
} else {
foreach ($this->ReplyTo as $replyTo) {
if (0 === strcasecmp($replyTo[0], $address)) {
return false;
}
}
$this->ReplyTo[] = [$address, $name];
} elseif (!array_key_exists(strtolower($address), $this->ReplyTo)) {
$this->ReplyTo[strtolower($address)] = [$address, $name];
return true;
}
return false;
}
@@ -1242,18 +1238,15 @@ class PHPMailer
* @see https://www.andrew.cmu.edu/user/agreen1/testing/mrbs/web/Mail/RFC822.php A more careful implementation
*
* @param string $addrstr The address list string
* @param null $useimap Deprecated argument since 6.11.0.
* @param bool $useimap Whether to use the IMAP extension to parse the list
* @param string $charset The charset to use when decoding the address list string.
*
* @return array
*/
public static function parseAddresses($addrstr, $useimap = null, $charset = self::CHARSET_ISO88591)
public static function parseAddresses($addrstr, $useimap = true, $charset = self::CHARSET_ISO88591)
{
if ($useimap !== null) {
trigger_error(self::lang('deprecated_argument'), E_USER_DEPRECATED);
}
$addresses = [];
if (function_exists('imap_rfc822_parse_adrlist')) {
if ($useimap && function_exists('imap_rfc822_parse_adrlist')) {
//Use this built-in parser if it's available
$list = imap_rfc822_parse_adrlist($addrstr, '');
// Clear any potential IMAP errors to get rid of notices being thrown at end of script.
@@ -1263,13 +1256,20 @@ class PHPMailer
'.SYNTAX-ERROR.' !== $address->host &&
static::validateAddress($address->mailbox . '@' . $address->host)
) {
//Decode the name part if it's present and maybe encoded
//Decode the name part if it's present and encoded
if (
property_exists($address, 'personal')
&& is_string($address->personal)
&& $address->personal !== ''
property_exists($address, 'personal') &&
//Check for a Mbstring constant rather than using extension_loaded, which is sometimes disabled
defined('MB_CASE_UPPER') &&
preg_match('/^=\?.*\?=$/s', $address->personal)
) {
$address->personal = static::decodeHeader($address->personal, $charset);
$origCharset = mb_internal_encoding();
mb_internal_encoding($charset);
//Undo any RFC2047-encoded spaces-as-underscores
$address->personal = str_replace('_', '=20', $address->personal);
//Decode the name
$address->personal = mb_decode_mimeheader($address->personal);
mb_internal_encoding($origCharset);
}
$addresses[] = [
@@ -1280,51 +1280,40 @@ class PHPMailer
}
} else {
//Use this simpler parser
$addresses = static::parseSimplerAddresses($addrstr, $charset);
}
return $addresses;
}
/**
* Parse a string containing one or more RFC822-style comma-separated email addresses
* with the form "display name <address>" into an array of name/address pairs.
* Uses a simpler parser that does not require the IMAP extension but doesnt support
* the full RFC822 spec. For full RFC822 support, use the PHP IMAP extension.
*
* @param string $addrstr The address list string
* @param string $charset The charset to use when decoding the address list string.
*
* @return array
*/
protected static function parseSimplerAddresses($addrstr, $charset)
{
// Emit a runtime notice to recommend using the IMAP extension for full RFC822 parsing
trigger_error(self::lang('imap_recommended'), E_USER_NOTICE);
$addresses = [];
$list = explode(',', $addrstr);
foreach ($list as $address) {
$address = trim($address);
//Is there a separate name part?
if (strpos($address, '<') === false) {
//No separate name, just use the whole thing
if (static::validateAddress($address)) {
$addresses[] = [
'name' => '',
'address' => $address,
];
}
} else {
$parsed = static::parseEmailString($address);
$email = $parsed['email'];
if (static::validateAddress($email)) {
$name = static::decodeHeader($parsed['name'], $charset);
$addresses[] = [
//Remove any surrounding quotes and spaces from the name
'name' => trim($name, '\'" '),
'address' => $email,
];
$list = explode(',', $addrstr);
foreach ($list as $address) {
$address = trim($address);
//Is there a separate name part?
if (strpos($address, '<') === false) {
//No separate name, just use the whole thing
if (static::validateAddress($address)) {
$addresses[] = [
'name' => '',
'address' => $address,
];
}
} else {
list($name, $email) = explode('<', $address);
$email = trim(str_replace('>', '', $email));
$name = trim($name);
if (static::validateAddress($email)) {
//Check for a Mbstring constant rather than using extension_loaded, which is sometimes disabled
//If this name is encoded, decode it
if (defined('MB_CASE_UPPER') && preg_match('/^=\?.*\?=$/s', $name)) {
$origCharset = mb_internal_encoding();
mb_internal_encoding($charset);
//Undo any RFC2047-encoded spaces-as-underscores
$name = str_replace('_', '=20', $name);
//Decode the name
$name = mb_decode_mimeheader($name);
mb_internal_encoding($origCharset);
}
$addresses[] = [
//Remove any surrounding quotes and spaces from the name
'name' => trim($name, '\'" '),
'address' => $email,
];
}
}
}
}
@@ -1332,42 +1321,6 @@ class PHPMailer
return $addresses;
}
/**
* Parse a string containing an email address with an optional name
* and divide it into a name and email address.
*
* @param string $input The email with name.
*
* @return array{name: string, email: string}
*/
private static function parseEmailString($input)
{
$input = trim((string)$input);
if ($input === '') {
return ['name' => '', 'email' => ''];
}
$pattern = '/^\s*(?:(?:"([^"]*)"|\'([^\']*)\'|([^<]*?))\s*)?<\s*([^>]+)\s*>\s*$/';
if (preg_match($pattern, $input, $matches)) {
$name = '';
// Double quotes including special scenarios.
if (isset($matches[1]) && $matches[1] !== '') {
$name = $matches[1];
// Single quotes including special scenarios.
} elseif (isset($matches[2]) && $matches[2] !== '') {
$name = $matches[2];
// Simplest scenario, name and email are in the format "Name <email>".
} elseif (isset($matches[3])) {
$name = trim($matches[3]);
}
return ['name' => $name, 'email' => trim($matches[4])];
}
return ['name' => '', 'email' => $input];
}
/**
* Set the From and FromName properties.
*
@@ -1381,10 +1334,6 @@ class PHPMailer
*/
public function setFrom($address, $name = '', $auto = true)
{
if (is_null($name)) {
//Helps avoid a deprecation warning in the preg_replace() below
$name = '';
}
$address = trim((string)$address);
$name = trim(preg_replace('/[\r\n]+/', '', $name)); //Strip breaks and trim
//Don't validate now addresses with IDN. Will be done in send().
@@ -1396,7 +1345,7 @@ class PHPMailer
) {
$error_message = sprintf(
'%s (From): %s',
self::lang('invalid_address'),
$this->lang('invalid_address'),
$address
);
$this->setError($error_message);
@@ -1652,7 +1601,7 @@ class PHPMailer
&& ini_get('mail.add_x_header') === '1'
&& stripos(PHP_OS, 'WIN') === 0
) {
trigger_error(self::lang('buggy_php'), E_USER_WARNING);
trigger_error($this->lang('buggy_php'), E_USER_WARNING);
}
try {
@@ -1682,7 +1631,7 @@ class PHPMailer
call_user_func_array([$this, 'addAnAddress'], $params);
}
if (count($this->to) + count($this->cc) + count($this->bcc) < 1) {
throw new Exception(self::lang('provide_address'), self::STOP_CRITICAL);
throw new Exception($this->lang('provide_address'), self::STOP_CRITICAL);
}
//Validate From, Sender, and ConfirmReadingTo addresses
@@ -1699,7 +1648,7 @@ class PHPMailer
if (!static::validateAddress($this->{$address_kind})) {
$error_message = sprintf(
'%s (%s): %s',
self::lang('invalid_address'),
$this->lang('invalid_address'),
$address_kind,
$this->{$address_kind}
);
@@ -1721,7 +1670,7 @@ class PHPMailer
$this->setMessageType();
//Refuse to send an empty message unless we are specifically allowing it
if (!$this->AllowEmpty && empty($this->Body)) {
throw new Exception(self::lang('empty_message'), self::STOP_CRITICAL);
throw new Exception($this->lang('empty_message'), self::STOP_CRITICAL);
}
//Trim subject consistently
@@ -1860,10 +1809,8 @@ class PHPMailer
} else {
$sendmailFmt = '%s -oi -f%s -t';
}
} elseif ($this->Mailer === 'qmail') {
$sendmailFmt = '%s';
} else {
//Allow sendmail to choose a default envelope sender. It may
//allow sendmail to choose a default envelope sender. It may
//seem preferable to force it to use the From header as with
//SMTP, but that introduces new problems (see
//<https://github.com/PHPMailer/PHPMailer/issues/2298>), and
@@ -1881,35 +1828,33 @@ class PHPMailer
foreach ($this->SingleToArray as $toAddr) {
$mail = @popen($sendmail, 'w');
if (!$mail) {
throw new Exception(self::lang('execute') . $this->Sendmail, self::STOP_CRITICAL);
throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL);
}
$this->edebug("To: {$toAddr}");
fwrite($mail, 'To: ' . $toAddr . "\n");
fwrite($mail, $header);
fwrite($mail, $body);
$result = pclose($mail);
$addrinfo = static::parseAddresses($toAddr, null, $this->CharSet);
foreach ($addrinfo as $addr) {
$this->doCallback(
($result === 0),
[[$addr['address'], $addr['name']]],
$this->cc,
$this->bcc,
$this->Subject,
$body,
$this->From,
[]
);
}
$addrinfo = static::parseAddresses($toAddr, true, $this->CharSet);
$this->doCallback(
($result === 0),
[[$addrinfo['address'], $addrinfo['name']]],
$this->cc,
$this->bcc,
$this->Subject,
$body,
$this->From,
[]
);
$this->edebug("Result: " . ($result === 0 ? 'true' : 'false'));
if (0 !== $result) {
throw new Exception(self::lang('execute') . $this->Sendmail, self::STOP_CRITICAL);
throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL);
}
}
} else {
$mail = @popen($sendmail, 'w');
if (!$mail) {
throw new Exception(self::lang('execute') . $this->Sendmail, self::STOP_CRITICAL);
throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL);
}
fwrite($mail, $header);
fwrite($mail, $body);
@@ -1926,7 +1871,7 @@ class PHPMailer
);
$this->edebug("Result: " . ($result === 0 ? 'true' : 'false'));
if (0 !== $result) {
throw new Exception(self::lang('execute') . $this->Sendmail, self::STOP_CRITICAL);
throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL);
}
}
@@ -2065,19 +2010,17 @@ class PHPMailer
if ($this->SingleTo && count($toArr) > 1) {
foreach ($toArr as $toAddr) {
$result = $this->mailPassthru($toAddr, $this->Subject, $body, $header, $params);
$addrinfo = static::parseAddresses($toAddr, null, $this->CharSet);
foreach ($addrinfo as $addr) {
$this->doCallback(
$result,
[[$addr['address'], $addr['name']]],
$this->cc,
$this->bcc,
$this->Subject,
$body,
$this->From,
[]
);
}
$addrinfo = static::parseAddresses($toAddr, true, $this->CharSet);
$this->doCallback(
$result,
[[$addrinfo['address'], $addrinfo['name']]],
$this->cc,
$this->bcc,
$this->Subject,
$body,
$this->From,
[]
);
}
} else {
$result = $this->mailPassthru($to, $this->Subject, $body, $header, $params);
@@ -2087,7 +2030,7 @@ class PHPMailer
ini_set('sendmail_from', $old_from);
}
if (!$result) {
throw new Exception(self::lang('instantiate'), self::STOP_CRITICAL);
throw new Exception($this->lang('instantiate'), self::STOP_CRITICAL);
}
return true;
@@ -2173,12 +2116,12 @@ class PHPMailer
$header = static::stripTrailingWSP($header) . static::$LE . static::$LE;
$bad_rcpt = [];
if (!$this->smtpConnect($this->SMTPOptions)) {
throw new Exception(self::lang('smtp_connect_failed'), self::STOP_CRITICAL);
throw new Exception($this->lang('smtp_connect_failed'), self::STOP_CRITICAL);
}
//If we have recipient addresses that need Unicode support,
//but the server doesn't support it, stop here
if ($this->UseSMTPUTF8 && !$this->smtp->getServerExt('SMTPUTF8')) {
throw new Exception(self::lang('no_smtputf8'), self::STOP_CRITICAL);
throw new Exception($this->lang('no_smtputf8'), self::STOP_CRITICAL);
}
//Sender already validated in preSend()
if ('' === $this->Sender) {
@@ -2190,7 +2133,7 @@ class PHPMailer
$this->smtp->xclient($this->SMTPXClient);
}
if (!$this->smtp->mail($smtp_from)) {
$this->setError(self::lang('from_failed') . $smtp_from . ' : ' . implode(',', $this->smtp->getError()));
$this->setError($this->lang('from_failed') . $smtp_from . ' : ' . implode(',', $this->smtp->getError()));
throw new Exception($this->ErrorInfo, self::STOP_CRITICAL);
}
@@ -2212,7 +2155,7 @@ class PHPMailer
//Only send the DATA command if we have viable recipients
if ((count($this->all_recipients) > count($bad_rcpt)) && !$this->smtp->data($header . $body)) {
throw new Exception(self::lang('data_not_accepted'), self::STOP_CRITICAL);
throw new Exception($this->lang('data_not_accepted'), self::STOP_CRITICAL);
}
$smtp_transaction_id = $this->smtp->getLastTransactionID();
@@ -2243,7 +2186,7 @@ class PHPMailer
foreach ($bad_rcpt as $bad) {
$errstr .= $bad['to'] . ': ' . $bad['error'];
}
throw new Exception(self::lang('recipients_failed') . $errstr, self::STOP_CONTINUE);
throw new Exception($this->lang('recipients_failed') . $errstr, self::STOP_CONTINUE);
}
return true;
@@ -2297,7 +2240,7 @@ class PHPMailer
$hostinfo
)
) {
$this->edebug(self::lang('invalid_hostentry') . ' ' . trim($hostentry));
$this->edebug($this->lang('invalid_hostentry') . ' ' . trim($hostentry));
//Not a valid host entry
continue;
}
@@ -2309,7 +2252,7 @@ class PHPMailer
//Check the host name is a valid name or IP address before trying to use it
if (!static::isValidHost($hostinfo[2])) {
$this->edebug(self::lang('invalid_host') . ' ' . $hostinfo[2]);
$this->edebug($this->lang('invalid_host') . ' ' . $hostinfo[2]);
continue;
}
$prefix = '';
@@ -2329,7 +2272,7 @@ class PHPMailer
if (static::ENCRYPTION_STARTTLS === $secure || static::ENCRYPTION_SMTPS === $secure) {
//Check for an OpenSSL constant rather than using extension_loaded, which is sometimes disabled
if (!$sslext) {
throw new Exception(self::lang('extension_missing') . 'openssl', self::STOP_CRITICAL);
throw new Exception($this->lang('extension_missing') . 'openssl', self::STOP_CRITICAL);
}
}
$host = $hostinfo[2];
@@ -2381,7 +2324,7 @@ class PHPMailer
$this->oauth
)
) {
throw new Exception(self::lang('authenticate'));
throw new Exception($this->lang('authenticate'));
}
return true;
@@ -2431,7 +2374,7 @@ class PHPMailer
*
* @return bool Returns true if the requested language was loaded, false otherwise.
*/
public static function setLanguage($langcode = 'en', $lang_path = '')
public function setLanguage($langcode = 'en', $lang_path = '')
{
//Backwards compatibility for renamed language codes
$renamed_langcodes = [
@@ -2480,9 +2423,6 @@ class PHPMailer
'smtp_error' => 'SMTP server error: ',
'variable_set' => 'Cannot set or reset variable: ',
'no_smtputf8' => 'Server does not support SMTPUTF8 needed to send to Unicode addresses',
'imap_recommended' => 'Using simplified address parser is not recommended. ' .
'Install the PHP IMAP extension for full RFC822 parsing.',
'deprecated_argument' => 'Argument $useimap is deprecated',
];
if (empty($lang_path)) {
//Calculate an absolute path so it can work if CWD is not here
@@ -2549,7 +2489,7 @@ class PHPMailer
}
}
}
self::$language = $PHPMAILER_LANG;
$this->language = $PHPMAILER_LANG;
return $foundlang; //Returns false if language not found
}
@@ -2561,11 +2501,11 @@ class PHPMailer
*/
public function getTranslations()
{
if (empty(self::$language)) {
self::setLanguage(); // Set the default language.
if (empty($this->language)) {
$this->setLanguage(); // Set the default language.
}
return self::$language;
return $this->language;
}
/**
@@ -2988,6 +2928,10 @@ class PHPMailer
//Create unique IDs and preset boundaries
$this->setBoundaries();
if ($this->sign_key_file) {
$body .= $this->getMailMIME() . static::$LE;
}
$this->setWordWrap();
$bodyEncoding = $this->Encoding;
@@ -3019,12 +2963,6 @@ class PHPMailer
if (static::ENCODING_BASE64 !== $altBodyEncoding && static::hasLineLongerThanMax($this->AltBody)) {
$altBodyEncoding = static::ENCODING_QUOTED_PRINTABLE;
}
if ($this->sign_key_file) {
$this->Encoding = $bodyEncoding;
$body .= $this->getMailMIME() . static::$LE;
}
//Use this as a preamble in all multipart message types
$mimepre = '';
switch ($this->message_type) {
@@ -3206,12 +3144,12 @@ class PHPMailer
if ($this->isError()) {
$body = '';
if ($this->exceptions) {
throw new Exception(self::lang('empty_message'), self::STOP_CRITICAL);
throw new Exception($this->lang('empty_message'), self::STOP_CRITICAL);
}
} elseif ($this->sign_key_file) {
try {
if (!defined('PKCS7_TEXT')) {
throw new Exception(self::lang('extension_missing') . 'openssl');
throw new Exception($this->lang('extension_missing') . 'openssl');
}
$file = tempnam(sys_get_temp_dir(), 'srcsign');
@@ -3249,7 +3187,7 @@ class PHPMailer
$body = $parts[1];
} else {
@unlink($signed);
throw new Exception(self::lang('signing') . openssl_error_string());
throw new Exception($this->lang('signing') . openssl_error_string());
}
} catch (Exception $exc) {
$body = '';
@@ -3394,7 +3332,7 @@ class PHPMailer
) {
try {
if (!static::fileIsAccessible($path)) {
throw new Exception(self::lang('file_access') . $path, self::STOP_CONTINUE);
throw new Exception($this->lang('file_access') . $path, self::STOP_CONTINUE);
}
//If a MIME type is not specified, try to work it out from the file name
@@ -3407,7 +3345,7 @@ class PHPMailer
$name = $filename;
}
if (!$this->validateEncoding($encoding)) {
throw new Exception(self::lang('encoding') . $encoding);
throw new Exception($this->lang('encoding') . $encoding);
}
$this->attachment[] = [
@@ -3568,11 +3506,11 @@ class PHPMailer
{
try {
if (!static::fileIsAccessible($path)) {
throw new Exception(self::lang('file_open') . $path, self::STOP_CONTINUE);
throw new Exception($this->lang('file_open') . $path, self::STOP_CONTINUE);
}
$file_buffer = file_get_contents($path);
if (false === $file_buffer) {
throw new Exception(self::lang('file_open') . $path, self::STOP_CONTINUE);
throw new Exception($this->lang('file_open') . $path, self::STOP_CONTINUE);
}
$file_buffer = $this->encodeString($file_buffer, $encoding);
@@ -3625,9 +3563,9 @@ class PHPMailer
$encoded = $this->encodeQP($str);
break;
default:
$this->setError(self::lang('encoding') . $encoding);
$this->setError($this->lang('encoding') . $encoding);
if ($this->exceptions) {
throw new Exception(self::lang('encoding') . $encoding);
throw new Exception($this->lang('encoding') . $encoding);
}
break;
}
@@ -3733,42 +3671,6 @@ class PHPMailer
return trim(static::normalizeBreaks($encoded));
}
/**
* Decode an RFC2047-encoded header value
* Attempts multiple strategies so it works even when the mbstring extension is disabled.
*
* @param string $value The header value to decode
* @param string $charset The target charset to convert to, defaults to ISO-8859-1 for BC
*
* @return string The decoded header value
*/
public static function decodeHeader($value, $charset = self::CHARSET_ISO88591)
{
if (!is_string($value) || $value === '') {
return '';
}
// Detect the presence of any RFC2047 encoded-words
$hasEncodedWord = (bool) preg_match('/=\?.*\?=/s', $value);
if ($hasEncodedWord && defined('MB_CASE_UPPER')) {
$origCharset = mb_internal_encoding();
// Always decode to UTF-8 to provide a consistent, modern output encoding.
mb_internal_encoding($charset);
if (PHP_VERSION_ID < 80300) {
// Undo any RFC2047-encoded spaces-as-underscores.
$value = str_replace('_', '=20', $value);
} else {
// PHP 8.3+ already interprets underscores as spaces. Remove additional
// linear whitespace between adjacent encoded words to avoid double spacing.
$value = preg_replace('/(\?=)\s+(=\?)/', '$1$2', $value);
}
// Decode the header value
$value = mb_decode_mimeheader($value);
mb_internal_encoding($origCharset);
}
return $value;
}
/**
* Check if a string contains multi-byte characters.
*
@@ -3938,7 +3840,7 @@ class PHPMailer
}
if (!$this->validateEncoding($encoding)) {
throw new Exception(self::lang('encoding') . $encoding);
throw new Exception($this->lang('encoding') . $encoding);
}
//Append to $attachment array
@@ -3997,7 +3899,7 @@ class PHPMailer
) {
try {
if (!static::fileIsAccessible($path)) {
throw new Exception(self::lang('file_access') . $path, self::STOP_CONTINUE);
throw new Exception($this->lang('file_access') . $path, self::STOP_CONTINUE);
}
//If a MIME type is not specified, try to work it out from the file name
@@ -4006,7 +3908,7 @@ class PHPMailer
}
if (!$this->validateEncoding($encoding)) {
throw new Exception(self::lang('encoding') . $encoding);
throw new Exception($this->lang('encoding') . $encoding);
}
$filename = (string) static::mb_pathinfo($path, PATHINFO_BASENAME);
@@ -4072,7 +3974,7 @@ class PHPMailer
}
if (!$this->validateEncoding($encoding)) {
throw new Exception(self::lang('encoding') . $encoding);
throw new Exception($this->lang('encoding') . $encoding);
}
//Append to $attachment array
@@ -4329,7 +4231,7 @@ class PHPMailer
}
if (strpbrk($name . $value, "\r\n") !== false) {
if ($this->exceptions) {
throw new Exception(self::lang('invalid_header'));
throw new Exception($this->lang('invalid_header'));
}
return false;
@@ -4353,15 +4255,15 @@ class PHPMailer
if ('smtp' === $this->Mailer && null !== $this->smtp) {
$lasterror = $this->smtp->getError();
if (!empty($lasterror['error'])) {
$msg .= ' ' . self::lang('smtp_error') . $lasterror['error'];
$msg .= ' ' . $this->lang('smtp_error') . $lasterror['error'];
if (!empty($lasterror['detail'])) {
$msg .= ' ' . self::lang('smtp_detail') . $lasterror['detail'];
$msg .= ' ' . $this->lang('smtp_detail') . $lasterror['detail'];
}
if (!empty($lasterror['smtp_code'])) {
$msg .= ' ' . self::lang('smtp_code') . $lasterror['smtp_code'];
$msg .= ' ' . $this->lang('smtp_code') . $lasterror['smtp_code'];
}
if (!empty($lasterror['smtp_code_ex'])) {
$msg .= ' ' . self::lang('smtp_code_ex') . $lasterror['smtp_code_ex'];
$msg .= ' ' . $this->lang('smtp_code_ex') . $lasterror['smtp_code_ex'];
}
}
}
@@ -4486,21 +4388,21 @@ class PHPMailer
*
* @return string
*/
protected static function lang($key)
protected function lang($key)
{
if (count(self::$language) < 1) {
self::setLanguage(); //Set the default language
if (count($this->language) < 1) {
$this->setLanguage(); //Set the default language
}
if (array_key_exists($key, self::$language)) {
if (array_key_exists($key, $this->language)) {
if ('smtp_connect_failed' === $key) {
//Include a link to troubleshooting docs on SMTP connection failure.
//This is by far the biggest cause of support questions
//but it's usually not PHPMailer's fault.
return self::$language[$key] . ' https://github.com/PHPMailer/PHPMailer/wiki/Troubleshooting';
return $this->language[$key] . ' https://github.com/PHPMailer/PHPMailer/wiki/Troubleshooting';
}
return self::$language[$key];
return $this->language[$key];
}
//Return the key as a fallback
@@ -4515,7 +4417,7 @@ class PHPMailer
*/
private function getSmtpErrorMessage($base_key)
{
$message = self::lang($base_key);
$message = $this->lang($base_key);
$error = $this->smtp->getError();
if (!empty($error['error'])) {
$message .= ' ' . $error['error'];
@@ -4559,7 +4461,7 @@ class PHPMailer
//Ensure name is not empty, and that neither name nor value contain line breaks
if (empty($name) || strpbrk($name . $value, "\r\n") !== false) {
if ($this->exceptions) {
throw new Exception(self::lang('invalid_header'));
throw new Exception($this->lang('invalid_header'));
}
return false;
@@ -4952,7 +4854,7 @@ class PHPMailer
return true;
}
$this->setError(self::lang('variable_set') . $name);
$this->setError($this->lang('variable_set') . $name);
return false;
}
@@ -5090,7 +4992,7 @@ class PHPMailer
{
if (!defined('PKCS7_TEXT')) {
if ($this->exceptions) {
throw new Exception(self::lang('extension_missing') . 'openssl');
throw new Exception($this->lang('extension_missing') . 'openssl');
}
return '';

View File

@@ -46,7 +46,7 @@ class POP3
*
* @var string
*/
const VERSION = '6.11.1';
const VERSION = '6.12.0';
/**
* Default POP3 port number.

View File

@@ -35,7 +35,7 @@ class SMTP
*
* @var string
*/
const VERSION = '6.11.1';
const VERSION = '6.12.0';
/**
* SMTP line break constant.
@@ -205,7 +205,6 @@ class SMTP
'Haraka' => '/[\d]{3} Message Queued \((.*)\)/',
'ZoneMTA' => '/[\d]{3} Message queued as (.*)/',
'Mailjet' => '/[\d]{3} OK queued as (.*)/',
'Gsmtp' => '/[\d]{3} 2\.0\.0 OK (.*) - gsmtp/',
];
/**
@@ -634,41 +633,10 @@ class SMTP
return false;
}
$oauth = $OAuth->getOauth64();
/*
* An SMTP command line can have a maximum length of 512 bytes, including the command name,
* so the base64-encoded OAUTH token has a maximum length of:
* 512 - 13 (AUTH XOAUTH2) - 2 (CRLF) = 497 bytes
* If the token is longer than that, the command and the token must be sent separately as described in
* https://www.rfc-editor.org/rfc/rfc4954#section-4
*/
if ($oauth === '') {
//Sending an empty auth token is legitimate, but it must be encoded as '='
//to indicate it's not a 2-part command
if (!$this->sendCommand('AUTH', 'AUTH XOAUTH2 =', 235)) {
return false;
}
} elseif (strlen($oauth) <= 497) {
//Authenticate using a token in the initial-response part
if (!$this->sendCommand('AUTH', 'AUTH XOAUTH2 ' . $oauth, 235)) {
return false;
}
} else {
//The token is too long, so we need to send it in two parts.
//Send the auth command without a token and expect a 334
if (!$this->sendCommand('AUTH', 'AUTH XOAUTH2', 334)) {
return false;
}
//Send the token
if (!$this->sendCommand('OAuth TOKEN', $oauth, [235, 334])) {
return false;
}
//If the server answers with 334, send an empty line and wait for a 235
if (
substr($this->last_reply, 0, 3) === '334'
&& $this->sendCommand('AUTH End', '', 235)
) {
return false;
}
//Start authentication
if (!$this->sendCommand('AUTH', 'AUTH XOAUTH2 ' . $oauth, 235)) {
return false;
}
break;
default:
@@ -1341,16 +1309,7 @@ class SMTP
//stream_select returns false when the `select` system call is interrupted
//by an incoming signal, try the select again
if (
stripos($message, 'interrupted system call') !== false ||
(
// on applications with a different locale than english, the message above is not found because
// it's translated. So we also check for the SOCKET_EINTR constant which is defined under
// Windows and UNIX-like platforms (if available on the platform).
defined('SOCKET_EINTR') &&
stripos($message, 'stream_select(): Unable to select [' . SOCKET_EINTR . ']') !== false
)
) {
if (stripos($message, 'interrupted system call') !== false) {
$this->edebug(
'SMTP -> get_lines(): retrying stream_select',
self::DEBUG_LOWLEVEL

View File

@@ -45,6 +45,7 @@ jobs:
- '8.1'
- '8.2'
- '8.3'
- '8.4'
steps:
- name: Setup PHP
uses: shivammathur/setup-php@v2
@@ -75,6 +76,7 @@ jobs:
- '8.1'
- '8.2'
- '8.3'
- '8.4'
steps:
- name: Setup PHP
uses: shivammathur/setup-php@v2

View File

@@ -48,7 +48,7 @@ Math is an open source project licensed under the terms of [MIT](https://github.
| **Simple** | Fraction | :material-check: | :material-check: |
| | Superscript | :material-check: | |
| **Architectural** | Row | :material-check: | :material-check: |
| | Semantics | | |
| | Semantics | :material-check: | |
## Contributing

View File

@@ -57,7 +57,9 @@ nav:
- Writers: 'usage/writers.md'
- Credits: 'credits.md'
- Releases:
- '0.1.0 (WIP)': 'changes/0.1.0.md'
- '0.3.0 (WIP)': 'changes/0.3.0.md'
- '0.2.0': 'changes/0.2.0.md'
- '0.1.0': 'changes/0.1.0.md'
- Developers:
- 'Coveralls': 'https://coveralls.io/github/PHPOffice/Math'
- 'Code Coverage': 'coverage/index.html'

View File

@@ -10,6 +10,7 @@ use PhpOffice\Math\Element;
use PhpOffice\Math\Exception\InvalidInputException;
use PhpOffice\Math\Exception\NotImplementedException;
use PhpOffice\Math\Math;
use PhpOffice\Math\Reader\Security\XmlScanner;
class MathML implements ReaderInterface
{
@@ -22,8 +23,17 @@ class MathML implements ReaderInterface
/** @var DOMXPath */
private $xpath;
/** @var XmlScanner */
private $xmlScanner;
public function __construct()
{
$this->xmlScanner = XmlScanner::getInstance();
}
public function read(string $content): ?Math
{
$content = $this->xmlScanner->scan($content);
$content = str_replace(
[
'&InvisibleTimes;',
@@ -35,7 +45,7 @@ class MathML implements ReaderInterface
);
$this->dom = new DOMDocument();
$this->dom->loadXML($content, LIBXML_DTDLOAD);
$this->dom->loadXML($content);
$this->math = new Math();
$this->parseNode(null, $this->math);

View File

@@ -40,6 +40,26 @@ class MathML implements WriterInterface
{
$tagName = $this->getElementTagName($element);
// Element\AbstractGroupElement
if ($element instanceof Element\Semantics) {
$this->output->startElement($tagName);
// Write elements
foreach ($element->getElements() as $childElement) {
$this->writeElementItem($childElement);
}
// Write annotations
foreach ($element->getAnnotations() as $encoding => $annotation) {
$this->output->startElement('annotation');
$this->output->writeAttribute('encoding', $encoding);
$this->output->text($annotation);
$this->output->endElement();
}
$this->output->endElement();
return;
}
// Element\AbstractGroupElement
if ($element instanceof Element\AbstractGroupElement) {
$this->output->startElement($tagName);
@@ -121,6 +141,9 @@ class MathML implements WriterInterface
if ($element instanceof Element\Operator) {
return 'mo';
}
if ($element instanceof Element\Semantics) {
return 'semantics';
}
throw new NotImplementedException(sprintf(
'%s : The element of the class `%s` has no tag name',

View File

@@ -7,6 +7,7 @@ namespace Tests\PhpOffice\Math\Reader;
use PhpOffice\Math\Element;
use PhpOffice\Math\Exception\InvalidInputException;
use PhpOffice\Math\Exception\NotImplementedException;
use PhpOffice\Math\Exception\SecurityException;
use PhpOffice\Math\Math;
use PhpOffice\Math\Reader\MathML;
use PHPUnit\Framework\TestCase;
@@ -294,4 +295,15 @@ class MathMLTest extends TestCase
$reader = new MathML();
$math = $reader->read($content);
}
public function testReadSecurity(): void
{
$this->expectException(SecurityException::class);
$this->expectExceptionMessage('Detected use of ENTITY in XML, loading aborted to prevent XXE/XEE attacks');
$content = '<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE x SYSTEM "php://filter/convert.base64-decode/zlib.inflate/resource=data:,7Ztdb9owFIbv%2bRVZJ9armNjOZ2k7QUaL%2bRYO2nqFUnBFNQaMptP272cnNFuTsBbSskg1iATZzvGxn/ccX3A4fdfoecS7UsrK1A98hV5Rr9FVjlaz1UmlcnM7D9i6MlkufrB1AK79O2bqKltMllMWt96KL6ADwci7sJ4Yu0vr9/tlwKbqan27CPzrOXvevFGrbRvOGIseaCa7TAxok1x44xahXzQEcdKPKZPevap3RZw920I0VscWGLlU1efPsy0c5cbV1AoI7ZuOMCZW12nkcP9Q2%2bQObBNmL6ajg8s6xJqmJTrq5NIArX6zVk8Zcwwt4fPuLvHnbeBSvpdIQ6g93MvUv3CHqKNrmtEW4EYmCr5gDT5QzyNWE4x6xO1/aqQmgMhGYgaVDFUnScKltbFnaJoKHRuHK0L1pIkuaYselMe9cPUqRmm5C51u00kkhy1S3aBougkl7e4d6RGaTYeSehdCjAG/O/p%2bYfKyQsoLmgdlmsFYQFDjh6GWJyGE0ZfMX08EZtwNTdAYud7nLcksnwppA2UnqpCzgyDo1QadAU3vLOQZ82EHMxAi0KVcq7rzas5xD6AQoeqkYkgk02abukkJ/z%2bNvkj%2bjUy16Ba5d/S8anhBLwt44EgGkoFkIBlIBpKBZCAZSAaSgWQgGUgGkoFkIBlIBpKBZCAZSAaSgWQgGUgGxWOwW2nF7kt%2by7/Kb3ag2GUTUgBvXAAxiKxt4Is3sB4WniVrOvhwzB0CXerg5GN9esGRQv7RgQdMmMO9sIwtc/sIJUOCsY4ee7f7FIWu2Si4euKan8wg58nFsEIXxYGntgZqMog3Z2FrgPhgyzIOlsmijowqwb0jyMqMoGEbarqdOpP/iqFISMkSVFG1Z5p8f3OK%2bxAZ7gClpgUPg70rq0T2RIkcup/0newQ7NbcUXv/DPl4LL/N7hdfn2dp07pmd8v79YSdVVgwqcyWd8HC/8aOzkunf6r%2b2c8bpSxK/6uPmlf%2br/nSnyrHcduH99iqKiz7HwLxTLMgEM0QWUDjb3ji8NdHPslZmV%2bqR%2bfH56Xyxni1VGbV0m8=" []><foo></foo>M';
$reader = new MathML();
$math = $reader->read($content);
}
}

View File

@@ -80,6 +80,35 @@ class MathMLTest extends WriterTestCase
$this->assertIsSchemaMathMLValid($output);
}
public function testWriteSemantics(): void
{
$opTimes = new Element\Operator('&InvisibleTimes;');
$math = new Math();
$semantics = new Element\Semantics();
$semantics->add(new Element\Identifier('y'));
$semantics->addAnnotation('application/x-tex', ' y ');
$math->add($semantics);
$writer = new MathML();
$output = $writer->write($math);
$expected = '<?xml version="1.0" encoding="UTF-8"?>'
. PHP_EOL
. '<!DOCTYPE math PUBLIC "-//W3C//DTD MathML 2.0//EN" "http://www.w3.org/Math/DTD/mathml2/mathml2.dtd">'
. '<math xmlns="http://www.w3.org/1998/Math/MathML">'
. '<semantics>'
. '<mi>y</mi>'
. '<annotation encoding="application/x-tex"> y </annotation>'
. '</semantics>'
. '</math>'
. PHP_EOL;
$this->assertEquals($expected, $output);
$this->assertIsSchemaMathMLValid($output);
}
public function testWriteNotImplemented(): void
{
$this->expectException(NotImplementedException::class);

View File

@@ -1,6 +1,6 @@
PHPWord, a pure PHP library for reading and writing word processing documents.
Copyright (c) 2010-2016 PHPWord.
Copyright (c) 2010-2025 PHPWord.
PHPWord is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License version 3 as published by

View File

@@ -1,11 +1,11 @@
# ![PHPWord](https://rawgit.com/PHPOffice/PHPWord/develop/docs/images/phpword.svg "PHPWord")
[![Latest Stable Version](https://poser.pugx.org/phpoffice/phpword/v/stable.png)](https://packagist.org/packages/phpoffice/phpword)
[![Latest Stable Version](https://poser.pugx.org/phpoffice/phpword/v)](https://packagist.org/packages/phpoffice/phpword)
[![Coverage Status](https://coveralls.io/repos/github/PHPOffice/PHPWord/badge.svg?branch=master)](https://coveralls.io/github/PHPOffice/PHPWord?branch=master)
[![Total Downloads](https://poser.pugx.org/phpoffice/phpword/downloads.png)](https://packagist.org/packages/phpoffice/phpword)
[![License](https://poser.pugx.org/phpoffice/phpword/license.png)](https://packagist.org/packages/phpoffice/phpword)
[![CI](https://github.com/PHPOffice/PHPWord/actions/workflows/ci.yml/badge.svg)](https://github.com/PHPOffice/PHPWord/actions/workflows/ci.yml)
[![Join the chat at https://gitter.im/PHPOffice/PHPWord](https://img.shields.io/badge/GITTER-join%20chat-green.svg)](https://gitter.im/PHPOffice/PHPWord)
[![Total Downloads](https://poser.pugx.org/phpoffice/phpword/downloads)](https://packagist.org/packages/phpoffice/phpword)
[![License](https://poser.pugx.org/phpoffice/phpword/license)](https://packagist.org/packages/phpoffice/phpword)
Branch Master : [![PHPWord](https://github.com/PHPOffice/PHPWord/actions/workflows/php.yml/badge.svg?branch=master)](https://github.com/PHPOffice/PHPWord/actions/workflows/php.yml)
PHPWord is a library written in pure PHP that provides a set of classes to write to and read from different document file formats. The current version of PHPWord supports Microsoft [Office Open XML](http://en.wikipedia.org/wiki/Office_Open_XML) (OOXML or OpenXML), OASIS [Open Document Format for Office Applications](http://en.wikipedia.org/wiki/OpenDocument) (OpenDocument or ODF), [Rich Text Format](http://en.wikipedia.org/wiki/Rich_Text_Format) (RTF), HTML, and PDF.
@@ -81,7 +81,6 @@ The following is a basic usage example of the PHPWord library.
```php
<?php
require_once 'bootstrap.php';
// Creating the new document...
$phpWord = new \PhpOffice\PhpWord\PhpWord();

View File

@@ -8,7 +8,7 @@
],
"homepage": "https://phpoffice.github.io/PHPWord/",
"type": "library",
"license": "LGPL-3.0",
"license": "LGPL-3.0-only",
"authors": [
{
"name": "Mark Baker"
@@ -36,20 +36,67 @@
],
"scripts": {
"test": [
"phpunit --color=always"
"@php vendor/bin/phpunit --color=always"
],
"test-no-coverage": [
"phpunit --color=always --no-coverage"
"@php vendor/bin/phpunit --color=always --no-coverage"
],
"check": [
"php-cs-fixer fix --ansi --dry-run --diff",
"phpcs --report-width=200 --report-summary --report-full samples/ src/ tests/ --ignore=src/PhpWord/Shared/PCLZip --standard=PSR2 -n",
"phpmd src/,tests/ text ./phpmd.xml.dist --exclude pclzip.lib.php",
"@php vendor/bin/php-cs-fixer fix --ansi --dry-run --diff",
"@php vendor/bin/phpmd src/,tests/ text ./phpmd.xml.dist --exclude pclzip.lib.php",
"@test-no-coverage",
"phpstan analyse --ansi"
"@php vendor/bin/phpstan analyse --ansi"
],
"fix": [
"php-cs-fixer fix --ansi"
"@php vendor/bin/php-cs-fixer fix --ansi"
],
"samples": [
"php samples/Sample_01_SimpleText.php",
"php samples/Sample_02_TabStops.php",
"php samples/Sample_03_Sections.php",
"php samples/Sample_04_Textrun.php",
"php samples/Sample_05_Multicolumn.php",
"php samples/Sample_06_Footnote.php",
"php samples/Sample_07_TemplateCloneRow.php",
"php samples/Sample_08_ParagraphPagination.php",
"php samples/Sample_09_Tables.php",
"php samples/Sample_10_EastAsianFontStyle.php",
"php samples/Sample_11_ReadWord97.php",
"php samples/Sample_11_ReadWord2007.php",
"php samples/Sample_12_HeaderFooter.php",
"php samples/Sample_13_Images.php",
"php samples/Sample_14_ListItem.php",
"php samples/Sample_15_Link.php",
"php samples/Sample_16_Object.php",
"php samples/Sample_17_TitleTOC.php",
"php samples/Sample_18_Watermark.php",
"php samples/Sample_19_TextBreak.php",
"php samples/Sample_20_BGColor.php",
"php samples/Sample_21_TableRowRules.php",
"php samples/Sample_22_CheckBox.php",
"php samples/Sample_23_TemplateBlock.php",
"php samples/Sample_24_ReadODText.php",
"php samples/Sample_25_TextBox.php",
"php samples/Sample_26_Html.php",
"php samples/Sample_27_Field.php",
"php samples/Sample_28_ReadRTF.php",
"php samples/Sample_29_Line.php",
"php samples/Sample_30_ReadHTML.php",
"php samples/Sample_31_Shape.php",
"php samples/Sample_32_Chart.php",
"php samples/Sample_33_FormField.php",
"php samples/Sample_34_SDT.php",
"php samples/Sample_35_InternalLink.php",
"php samples/Sample_36_RTL.php",
"php samples/Sample_37_Comments.php",
"php samples/Sample_38_Protection.php",
"php samples/Sample_39_TrackChanges.php",
"php samples/Sample_40_TemplateSetComplexValue.php",
"php samples/Sample_41_TemplateSetChart.php",
"php samples/Sample_42_TemplateSetCheckbox.php",
"php samples/Sample_43_RTLDefault.php",
"php samples/Sample_44_ExtractVariablesFromReaderWord2007.php",
"php samples/Sample_45_Autoloader.php"
]
},
"scripts-descriptions": {
@@ -58,34 +105,28 @@
"check": "Runs PHP CheckStyle and PHP Mess detector",
"fix": "Fixes issues found by PHP-CS"
},
"config": {
"platform": {
"php": "8.0"
}
},
"require": {
"php": "^7.1|^8.0",
"ext-dom": "*",
"ext-gd": "*",
"ext-zip": "*",
"ext-json": "*",
"ext-xml": "*",
"phpoffice/math": "^0.2"
"phpoffice/math": "^0.3"
},
"require-dev": {
"ext-zip": "*",
"ext-gd": "*",
"ext-libxml": "*",
"dompdf/dompdf": "^2.0",
"mpdf/mpdf": "^8.1",
"phpmd/phpmd": "^2.13",
"phpunit/phpunit": ">=7.0",
"tecnickcom/tcpdf": "^6.5",
"symfony/process": "^4.4 || ^5.0",
"dompdf/dompdf": "^2.0 || ^3.0",
"friendsofphp/php-cs-fixer": "^3.3",
"phpstan/phpstan-phpunit": "@stable"
"mpdf/mpdf": "^7.0 || ^8.0",
"phpmd/phpmd": "^2.13",
"phpstan/phpstan": "^0.12.88 || ^1.0.0",
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": ">=7.0",
"symfony/process": "^4.4 || ^5.0",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"ext-zip": "Allows writing OOXML and ODF",
"ext-gd2": "Allows adding images",
"ext-xmlwriter": "Allows writing OOXML and ODF",
"ext-xsl": "Allows applying XSL style sheet to headers, to main document part, and to footers of an OOXML template",
"dompdf/dompdf": "Allows writing PDF"

View File

@@ -63,6 +63,7 @@ nav:
- OLE Object: 'usage/elements/oleobject.md'
- Page Break: 'usage/elements/pagebreak.md'
- Preserve Text: 'usage/elements/preservetext.md'
- Ruby: 'usage/elements/ruby.md'
- Text: 'usage/elements/text.md'
- TextBox: 'usage/elements/textbox.md'
- Text Break: 'usage/elements/textbreak.md'
@@ -87,7 +88,9 @@ nav:
- Credits: 'credits.md'
- Releases:
- '1.x':
- '1.3.0 (WIP)': 'changes/1.x/1.3.0.md'
- '1.5.0 (WIP)': 'changes/1.x/1.5.0.md'
- '1.4.0': 'changes/1.x/1.4.0.md'
- '1.3.0': 'changes/1.x/1.3.0.md'
- '1.2.0': 'changes/1.x/1.2.0.md'
- '1.1.0': 'changes/1.x/1.1.0.md'
- '1.0.0': 'changes/1.x/1.0.0.md'

View File

@@ -375,11 +375,6 @@ parameters:
count: 1
path: src/PhpWord/Shared/Drawing.php
-
message: "#^Binary operation \"\\*\" between string and 50 results in an error\\.$#"
count: 1
path: src/PhpWord/Shared/Html.php
-
message: "#^Call to an undefined method DOMNode\\:\\:getAttribute\\(\\)\\.$#"
count: 1
@@ -435,6 +430,11 @@ parameters:
count: 1
path: src/PhpWord/Shared/Html.php
-
message: "#^Method PhpOffice\\\\PhpWord\\\\Shared\\\\Html\\:\\:parseRuby\\(\\) has no return type specified\\.$#"
count: 1
path: src/PhpWord/Shared/Html.php
-
message: "#^Method PhpOffice\\\\PhpWord\\\\Shared\\\\Html\\:\\:parseStyleDeclarations\\(\\) has no return type specified\\.$#"
count: 1
@@ -447,7 +447,7 @@ parameters:
-
message: "#^Parameter \\#1 \\$attribute of static method PhpOffice\\\\PhpWord\\\\Shared\\\\Html\\:\\:parseStyle\\(\\) expects DOMAttr, DOMNode given\\.$#"
count: 1
count: 3
path: src/PhpWord/Shared/Html.php
-
@@ -685,11 +685,6 @@ parameters:
count: 1
path: src/PhpWord/Style/Cell.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Cell\\:\\:\\$shading is never written, only read\\.$#"
count: 1
path: src/PhpWord/Style/Cell.php
-
message: "#^Method PhpOffice\\\\PhpWord\\\\Style\\\\Chart\\:\\:getMajorTickPosition\\(\\) has no return type specified\\.$#"
count: 1
@@ -760,41 +755,6 @@ parameters:
count: 1
path: src/PhpWord/Style/Font.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Font\\:\\:\\$allCaps is never written, only read\\.$#"
count: 1
path: src/PhpWord/Style/Font.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Font\\:\\:\\$doubleStrikethrough is never written, only read\\.$#"
count: 1
path: src/PhpWord/Style/Font.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Font\\:\\:\\$lang is never written, only read\\.$#"
count: 1
path: src/PhpWord/Style/Font.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Font\\:\\:\\$paragraph is never written, only read\\.$#"
count: 1
path: src/PhpWord/Style/Font.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Font\\:\\:\\$shading is never written, only read\\.$#"
count: 1
path: src/PhpWord/Style/Font.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Font\\:\\:\\$smallCaps is never written, only read\\.$#"
count: 1
path: src/PhpWord/Style/Font.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Font\\:\\:\\$strikethrough is never written, only read\\.$#"
count: 1
path: src/PhpWord/Style/Font.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Line\\:\\:\\$weight \\(int\\) does not accept float\\|int\\|null\\.$#"
count: 1
@@ -815,21 +775,6 @@ parameters:
count: 1
path: src/PhpWord/Style/Paragraph.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Paragraph\\:\\:\\$indentation is never written, only read\\.$#"
count: 1
path: src/PhpWord/Style/Paragraph.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Paragraph\\:\\:\\$shading is never written, only read\\.$#"
count: 1
path: src/PhpWord/Style/Paragraph.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Paragraph\\:\\:\\$spacing is never written, only read\\.$#"
count: 1
path: src/PhpWord/Style/Paragraph.php
-
message: "#^Result of && is always false\\.$#"
count: 1
@@ -840,36 +785,6 @@ parameters:
count: 1
path: src/PhpWord/Style/Section.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Section\\:\\:\\$lineNumbering is never written, only read\\.$#"
count: 1
path: src/PhpWord/Style/Section.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Shape\\:\\:\\$extrusion is never written, only read\\.$#"
count: 1
path: src/PhpWord/Style/Shape.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Shape\\:\\:\\$fill is never written, only read\\.$#"
count: 1
path: src/PhpWord/Style/Shape.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Shape\\:\\:\\$frame is never written, only read\\.$#"
count: 1
path: src/PhpWord/Style/Shape.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Shape\\:\\:\\$outline is never written, only read\\.$#"
count: 1
path: src/PhpWord/Style/Shape.php
-
message: "#^Property PhpOffice\\\\PhpWord\\\\Style\\\\Shape\\:\\:\\$shadow is never written, only read\\.$#"
count: 1
path: src/PhpWord/Style/Shape.php
-
message: "#^Method PhpOffice\\\\PhpWord\\\\Style\\\\TOC\\:\\:setTabLeader\\(\\) should return PhpOffice\\\\PhpWord\\\\Style\\\\TOC but returns PhpOffice\\\\PhpWord\\\\Style\\\\Tab\\.$#"
count: 1
@@ -1120,11 +1035,6 @@ parameters:
count: 1
path: src/PhpWord/Writer/AbstractWriter.php
-
message: "#^PHPDoc tag @param has invalid value \\(\\\\PhpOffice\\\\PhpWord\\\\PhpWord\\)\\: Unexpected token \"\\\\n \\*\", expected variable at offset 78$#"
count: 1
path: src/PhpWord/Writer/AbstractWriter.php
-
message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\HTML\\\\Element\\\\AbstractElement\\:\\:write\\(\\) has no return type specified\\.$#"
count: 1
@@ -1146,14 +1056,14 @@ parameters:
path: src/PhpWord/Writer/ODText/Element/Table.php
-
message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\ODText\\\\Element\\\\Text\\:\\:replacetabs\\(\\) has parameter \\$text with no type specified\\.$#"
message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\ODText\\\\Element\\\\AbstractElement\\:\\:replaceTabs\\(\\) has parameter \\$text with no type specified\\.$#"
count: 1
path: src/PhpWord/Writer/ODText/Element/Text.php
path: src/PhpWord/Writer/ODText/Element/AbstractElement.php
-
message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\ODText\\\\Element\\\\Text\\:\\:replacetabs\\(\\) has parameter \\$xmlWriter with no type specified\\.$#"
message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\ODText\\\\Element\\\\AbstractElement\\:\\:replaceTabs\\(\\) has parameter \\$xmlWriter with no type specified\\.$#"
count: 1
path: src/PhpWord/Writer/ODText/Element/Text.php
path: src/PhpWord/Writer/ODText/Element/AbstractElement.php
-
message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\ODText\\\\Element\\\\Text\\:\\:writeChangeInsertion\\(\\) has parameter \\$start with no type specified\\.$#"
@@ -1315,11 +1225,6 @@ parameters:
count: 1
path: src/PhpWord/Writer/RTF/Style/Border.php
-
message: "#^PHPDoc tag @param has invalid value \\(\\\\PhpOffice\\\\PhpWord\\\\PhpWord\\)\\: Unexpected token \"\\\\n \", expected variable at offset 86$#"
count: 1
path: src/PhpWord/Writer/Word2007.php
-
message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\Word2007\\\\Element\\\\AbstractElement\\:\\:write\\(\\) has no return type specified\\.$#"
count: 1
@@ -1426,29 +1331,29 @@ parameters:
path: tests/PhpWordTests/AbstractTestReader.php
-
message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbeddedTest\\:\\:getBaseUrl\\(\\) has no return type specified\\.$#"
message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbedded\\:\\:getBaseUrl\\(\\) has no return type specified\\.$#"
count: 1
path: tests/PhpWordTests/AbstractWebServerEmbeddedTest.php
path: tests/PhpWordTests/AbstractWebServerEmbedded.php
-
message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbeddedTest\\:\\:getRemoteBmpImageUrl\\(\\) has no return type specified\\.$#"
message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbedded\\:\\:getRemoteBmpImageUrl\\(\\) has no return type specified\\.$#"
count: 1
path: tests/PhpWordTests/AbstractWebServerEmbeddedTest.php
path: tests/PhpWordTests/AbstractWebServerEmbedded.php
-
message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbeddedTest\\:\\:getRemoteGifImageUrl\\(\\) has no return type specified\\.$#"
message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbedded\\:\\:getRemoteGifImageUrl\\(\\) has no return type specified\\.$#"
count: 1
path: tests/PhpWordTests/AbstractWebServerEmbeddedTest.php
path: tests/PhpWordTests/AbstractWebServerEmbedded.php
-
message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbeddedTest\\:\\:getRemoteImageUrl\\(\\) has no return type specified\\.$#"
message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbedded\\:\\:getRemoteImageUrl\\(\\) has no return type specified\\.$#"
count: 1
path: tests/PhpWordTests/AbstractWebServerEmbeddedTest.php
path: tests/PhpWordTests/AbstractWebServerEmbedded.php
-
message: "#^Property PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbeddedTest\\:\\:\\$httpServer has no type specified\\.$#"
message: "#^Property PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbedded\\:\\:\\$httpServer has no type specified\\.$#"
count: 1
path: tests/PhpWordTests/AbstractWebServerEmbeddedTest.php
path: tests/PhpWordTests/AbstractWebServerEmbedded.php
-
message: "#^Parameter \\#1 \\$width of class PhpOffice\\\\PhpWord\\\\Element\\\\Cell constructor expects int\\|null, string given\\.$#"
@@ -1789,6 +1694,11 @@ parameters:
message: "#^Cannot access property \\$length on DOMNodeList\\<DOMNode\\>\\|false\\.$#"
count: 9
path: tests/PhpWordTests/Writer/HTML/ElementTest.php
-
message: "#^Cannot access property \\$length on DOMNodeList\\<DOMNode\\>\\|false\\.$#"
count: 2
path: tests/PhpWordTests/Writer/HTML/Element/RubyTest.php
-
message: "#^Cannot call method item\\(\\) on DOMNodeList\\<DOMNode\\>\\|false\\.$#"

View File

@@ -14,6 +14,7 @@ outputEscapingEnabled = false
defaultFontName = Arial
defaultFontSize = 10
defaultFontColor = 000000
[Paper]

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -21,6 +22,7 @@ namespace PhpOffice\PhpWord\Collection;
* Collection abstract class.
*
* @since 0.10.0
*
* @template T
*/
abstract class AbstractCollection

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -23,6 +24,7 @@ use PhpOffice\PhpWord\Element\Bookmark;
* Bookmarks collection.
*
* @since 0.12.0
*
* @extends AbstractCollection<Bookmark>
*/
class Bookmarks extends AbstractCollection

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -23,6 +24,7 @@ use PhpOffice\PhpWord\Element\Chart;
* Charts collection.
*
* @since 0.12.0
*
* @extends AbstractCollection<Chart>
*/
class Charts extends AbstractCollection

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -23,6 +24,7 @@ use PhpOffice\PhpWord\Element\Comment;
* Comments collection.
*
* @since 0.12.0
*
* @extends AbstractCollection<Comment>
*/
class Comments extends AbstractCollection

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -23,6 +24,7 @@ use PhpOffice\PhpWord\Element\Endnote;
* Endnotes collection.
*
* @since 0.10.0
*
* @extends AbstractCollection<Endnote>
*/
class Endnotes extends AbstractCollection

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -23,6 +24,7 @@ use PhpOffice\PhpWord\Element\Footnote;
* Footnotes collection.
*
* @since 0.10.0
*
* @extends AbstractCollection<Footnote>
*/
class Footnotes extends AbstractCollection

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -23,6 +24,7 @@ use PhpOffice\PhpWord\Element\Title;
* Titles collection.
*
* @since 0.10.0
*
* @extends AbstractCollection<Title>
*/
class Titles extends AbstractCollection

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -49,6 +50,7 @@ use ReflectionClass;
* @method FormField addFormField(string $type, mixed $fStyle = null, mixed $pStyle = null)
* @method SDT addSDT(string $type)
* @method Formula addFormula(Math $math)
* @method Ruby addRuby(TextRun $baseText, TextRun $rubyText, \PhpOffice\PhpWord\ComplexType\RubyProperties $properties)
* @method \PhpOffice\PhpWord\Element\OLEObject addObject(string $source, mixed $style = null) deprecated, use addOLEObject instead
*
* @since 0.10.0
@@ -58,7 +60,7 @@ abstract class AbstractContainer extends AbstractElement
/**
* Elements collection.
*
* @var \PhpOffice\PhpWord\Element\AbstractElement[]
* @var AbstractElement[]
*/
protected $elements = [];
@@ -80,7 +82,7 @@ abstract class AbstractContainer extends AbstractElement
* @param mixed $function
* @param mixed $args
*
* @return \PhpOffice\PhpWord\Element\AbstractElement
* @return AbstractElement
*/
public function __call($function, $args)
{
@@ -90,7 +92,7 @@ abstract class AbstractContainer extends AbstractElement
'Footnote', 'Endnote', 'CheckBox', 'TextBox', 'Field',
'Line', 'Shape', 'Title', 'TOC', 'PageBreak',
'Chart', 'FormField', 'SDT', 'Comment',
'Formula',
'Formula', 'Ruby',
];
$functions = [];
foreach ($elements as $element) {
@@ -130,7 +132,7 @@ abstract class AbstractContainer extends AbstractElement
*
* @param string $elementName
*
* @return \PhpOffice\PhpWord\Element\AbstractElement
* @return AbstractElement
*/
protected function addElement($elementName)
{
@@ -149,7 +151,7 @@ abstract class AbstractContainer extends AbstractElement
$elementArgs = $args;
array_shift($elementArgs); // Shift the $elementName off the beginning of array
/** @var \PhpOffice\PhpWord\Element\AbstractElement $element Type hint */
/** @var AbstractElement $element Type hint */
$element = $reflection->newInstanceArgs($elementArgs);
// Set parent container
@@ -165,7 +167,7 @@ abstract class AbstractContainer extends AbstractElement
/**
* Get all elements.
*
* @return \PhpOffice\PhpWord\Element\AbstractElement[]
* @return AbstractElement[]
*/
public function getElements()
{
@@ -177,7 +179,7 @@ abstract class AbstractContainer extends AbstractElement
*
* @param int $index
*
* @return null|\PhpOffice\PhpWord\Element\AbstractElement
* @return null|AbstractElement
*/
public function getElement($index)
{
@@ -191,13 +193,13 @@ abstract class AbstractContainer extends AbstractElement
/**
* Removes the element at requested index.
*
* @param int|\PhpOffice\PhpWord\Element\AbstractElement $toRemove
* @param AbstractElement|int $toRemove
*/
public function removeElement($toRemove): void
{
if (is_int($toRemove) && array_key_exists($toRemove, $this->elements)) {
unset($this->elements[$toRemove]);
} elseif ($toRemove instanceof \PhpOffice\PhpWord\Element\AbstractElement) {
} elseif ($toRemove instanceof AbstractElement) {
foreach ($this->elements as $key => $element) {
if ($element->getElementId() === $toRemove->getElementId()) {
unset($this->elements[$key]);
@@ -253,7 +255,7 @@ abstract class AbstractContainer extends AbstractElement
'Footnote' => ['Section', 'TextRun', 'Cell', 'ListItemRun'],
'Endnote' => ['Section', 'TextRun', 'Cell'],
'PreserveText' => ['Section', 'Header', 'Footer', 'Cell'],
'Title' => ['Section', 'Cell'],
'Title' => ['Section', 'Cell', 'Header'],
'TOC' => ['Section'],
'PageBreak' => ['Section'],
'Chart' => ['Section', 'Cell'],

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -148,8 +149,6 @@ abstract class AbstractElement
/**
* Get PhpWord.
*
* @return ?PhpWord
*/
public function getPhpWord(): ?PhpWord
{
@@ -256,7 +255,7 @@ abstract class AbstractElement
*/
public function setElementId(): void
{
$this->elementId = substr(md5(mt_rand()), 0, 6);
$this->elementId = substr(md5((string) mt_rand()), 0, 6);
}
/**
@@ -291,8 +290,6 @@ abstract class AbstractElement
/**
* Get comments start.
*
* @return Comments
*/
public function getCommentsRangeStart(): ?Comments
{
@@ -301,8 +298,6 @@ abstract class AbstractElement
/**
* Get comment start.
*
* @return Comment
*/
public function getCommentRangeStart(): ?Comment
{
@@ -339,8 +334,6 @@ abstract class AbstractElement
/**
* Get comments end.
*
* @return Comments
*/
public function getCommentsRangeEnd(): ?Comments
{
@@ -349,8 +342,6 @@ abstract class AbstractElement
/**
* Get comment end.
*
* @return Comment
*/
public function getCommentRangeEnd(): ?Comment
{

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -39,7 +40,7 @@ class Cell extends AbstractContainer
/**
* Cell style.
*
* @var ?\PhpOffice\PhpWord\Style\Cell
* @var ?CellStyle
*/
private $style;
@@ -47,7 +48,7 @@ class Cell extends AbstractContainer
* Create new instance.
*
* @param null|int $width
* @param array|\PhpOffice\PhpWord\Style\Cell $style
* @param array|CellStyle $style
*/
public function __construct($width = null, $style = null)
{
@@ -58,7 +59,7 @@ class Cell extends AbstractContainer
/**
* Get cell style.
*
* @return ?\PhpOffice\PhpWord\Style\Cell
* @return ?CellStyle
*/
public function getStyle()
{

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -50,7 +51,7 @@ class Chart extends AbstractElement
/**
* Chart style.
*
* @var ?\PhpOffice\PhpWord\Style\Chart
* @var ?ChartStyle
*/
private $style;
@@ -120,7 +121,7 @@ class Chart extends AbstractElement
/**
* Get chart style.
*
* @return ?\PhpOffice\PhpWord\Style\Chart
* @return ?ChartStyle
*/
public function getStyle()
{

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -89,7 +90,7 @@ class Comment extends TrackChange
/**
* Get the element where this comment starts.
*
* @return \PhpOffice\PhpWord\Element\AbstractElement
* @return AbstractElement
*/
public function getStartElement()
{
@@ -108,7 +109,7 @@ class Comment extends TrackChange
/**
* Get the element where this comment ends.
*
* @return \PhpOffice\PhpWord\Element\AbstractElement
* @return AbstractElement
*/
public function getEndElement()
{

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -128,16 +129,16 @@ class Field extends AbstractElement
/**
* Font style.
*
* @var \PhpOffice\PhpWord\Style\Font|string
* @var Font|string
*/
protected $fontStyle;
/**
* Set Font style.
*
* @param array|\PhpOffice\PhpWord\Style\Font|string $style
* @param array|Font|string $style
*
* @return \PhpOffice\PhpWord\Style\Font|string
* @return Font|string
*/
public function setFontStyle($style = null)
{
@@ -158,7 +159,7 @@ class Field extends AbstractElement
/**
* Get Font style.
*
* @return \PhpOffice\PhpWord\Style\Font|string
* @return Font|string
*/
public function getFontStyle()
{
@@ -172,7 +173,7 @@ class Field extends AbstractElement
* @param array $properties
* @param array $options
* @param null|string|TextRun $text
* @param array|\PhpOffice\PhpWord\Style\Font|string $fontStyle
* @param array|Font|string $fontStyle
*/
public function __construct($type = null, $properties = [], $options = [], $text = null, $fontStyle = null)
{

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -29,7 +30,7 @@ class Footnote extends AbstractContainer
/**
* Paragraph style.
*
* @var null|\PhpOffice\PhpWord\Style\Paragraph|string
* @var null|Paragraph|string
*/
protected $paragraphStyle;
@@ -43,7 +44,7 @@ class Footnote extends AbstractContainer
/**
* Create new instance.
*
* @param array|\PhpOffice\PhpWord\Style\Paragraph|string $paragraphStyle
* @param array|Paragraph|string $paragraphStyle
*/
public function __construct($paragraphStyle = null)
{
@@ -54,7 +55,7 @@ class Footnote extends AbstractContainer
/**
* Get paragraph style.
*
* @return null|\PhpOffice\PhpWord\Style\Paragraph|string
* @return null|Paragraph|string
*/
public function getParagraphStyle()
{

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -27,7 +28,7 @@ class Line extends AbstractElement
/**
* Line style.
*
* @var ?\PhpOffice\PhpWord\Style\Line
* @var ?LineStyle
*/
private $style;
@@ -44,7 +45,7 @@ class Line extends AbstractElement
/**
* Get line style.
*
* @return ?\PhpOffice\PhpWord\Style\Line
* @return ?LineStyle
*/
public function getStyle()
{

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -43,14 +44,14 @@ class Link extends AbstractElement
/**
* Font style.
*
* @var null|\PhpOffice\PhpWord\Style\Font|string
* @var null|Font|string
*/
private $fontStyle;
/**
* Paragraph style.
*
* @var null|\PhpOffice\PhpWord\Style\Paragraph|string
* @var null|Paragraph|string
*/
private $paragraphStyle;
@@ -98,10 +99,8 @@ class Link extends AbstractElement
/**
* Get link text.
*
* @return string
*/
public function getText()
public function getText(): string
{
return $this->text;
}
@@ -109,7 +108,7 @@ class Link extends AbstractElement
/**
* Get Text style.
*
* @return null|\PhpOffice\PhpWord\Style\Font|string
* @return null|Font|string
*/
public function getFontStyle()
{
@@ -119,7 +118,7 @@ class Link extends AbstractElement
/**
* Get Paragraph style.
*
* @return null|\PhpOffice\PhpWord\Style\Paragraph|string
* @return null|Paragraph|string
*/
public function getParagraphStyle()
{

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -28,14 +29,14 @@ class ListItem extends AbstractElement
/**
* Element style.
*
* @var ?\PhpOffice\PhpWord\Style\ListItem
* @var ?ListItemStyle
*/
private $style;
/**
* Text object.
*
* @var \PhpOffice\PhpWord\Element\Text
* @var Text
*/
private $textObject;
@@ -71,7 +72,7 @@ class ListItem extends AbstractElement
/**
* Get style.
*
* @return ?\PhpOffice\PhpWord\Style\ListItem
* @return ?ListItemStyle
*/
public function getStyle()
{
@@ -81,7 +82,7 @@ class ListItem extends AbstractElement
/**
* Get Text object.
*
* @return \PhpOffice\PhpWord\Element\Text
* @return Text
*/
public function getTextObject()
{

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -32,7 +33,7 @@ class ListItemRun extends TextRun
/**
* ListItem Style.
*
* @var ?\PhpOffice\PhpWord\Style\ListItem
* @var ?ListItemStyle
*/
private $style;
@@ -66,7 +67,7 @@ class ListItemRun extends TextRun
/**
* Get ListItem style.
*
* @return ?\PhpOffice\PhpWord\Style\ListItem
* @return ?ListItemStyle
*/
public function getStyle()
{

View File

@@ -1,4 +1,5 @@
<?php
/**
* This file is part of PHPWord - A pure PHP library for reading and writing
* word processing documents.
@@ -35,7 +36,7 @@ class OLEObject extends AbstractElement
/**
* Image Style.
*
* @var ?\PhpOffice\PhpWord\Style\Image
* @var ?ImageStyle
*/
private $style;
@@ -100,7 +101,7 @@ class OLEObject extends AbstractElement
/**
* Get object style.
*
* @return ?\PhpOffice\PhpWord\Style\Image
* @return ?ImageStyle
*/
public function getStyle()
{

Some files were not shown because too many files have changed in this diff Show More