Compare commits
31 Commits
checkrefer
...
0a2b053718
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a2b053718 | ||
|
|
738ffa847f | ||
| 83a8b6272c | |||
| aee9b00c6f | |||
|
|
9cfa2fccc3 | ||
|
|
633ec028b0 | ||
| 1d54946fef | |||
| 752494dbdb | |||
| 654d57c7ee | |||
| 09ec133f6c | |||
| fccf7844fb | |||
| c658fc6196 | |||
| 66c3b86bd8 | |||
|
|
93d25de094 | ||
|
|
28023be44a | ||
|
|
0ee7c38000 | ||
|
|
bbd690ca0f | ||
|
|
2155fc1207 | ||
|
|
cc55bd528d | ||
| d3ae05f851 | |||
| 208490cf41 | |||
| 991ca7ce8c | |||
|
|
39d37538c5 | ||
|
|
43f1e6c87d | ||
| ff7e373633 | |||
| 100f3cf35c | |||
| 53e568b48d | |||
| f2294b375c | |||
|
|
32ce69fa5f | ||
|
|
0ee6a575f7 | ||
|
|
9092f0ca8e |
8
.env
8
.env
@@ -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
|
||||
@@ -80,6 +85,9 @@ citation_chat_model = qwen2.5:7b
|
||||
citation_chat_api_key =
|
||||
citation_chat_timeout = 120
|
||||
|
||||
[scopus]
|
||||
api_key = 54f915fc96e86b94ae8722ce7fdd69c7
|
||||
|
||||
[emailtemplete]
|
||||
pre = '<!doctype html>
|
||||
<html lang="zh">
|
||||
|
||||
@@ -1574,8 +1574,8 @@ class Article extends Base
|
||||
return json(['code' => 1, 'msg' => "Before proceeding to the next step, you need more than two available reviewer comments, and the total number of accept is greater than the total number of rejected."]);
|
||||
}
|
||||
}
|
||||
//接收必须查重小于20%
|
||||
if ($data['state'] == 6 && $article_info['repetition'] > 25) {
|
||||
//接收必须查重小于25%,2026.5.29更改,获取查重的来源
|
||||
if ($data['state'] == 6 && $this->getArticleRepetition($article_info['article_id']) > 25) {
|
||||
return jsonError("Submissions with a repetition rate greater than thirty percent will not be accepted");
|
||||
}
|
||||
//预接收有时间限定必须大于14天
|
||||
|
||||
450
application/api/controller/Author.php
Normal file
450
application/api/controller/Author.php
Normal file
@@ -0,0 +1,450 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\service\AuthorBackgroundService;
|
||||
use think\Controller;
|
||||
use think\Db;
|
||||
|
||||
/**
|
||||
* 作者背调:HTML 报告页 + JSON API
|
||||
*
|
||||
* 主接口:index / background_report / due_diligence
|
||||
*/
|
||||
class Author extends Controller
|
||||
{
|
||||
/** @var AuthorBackgroundService */
|
||||
private $bgService;
|
||||
|
||||
/** @var string */
|
||||
private $articleLookupError = '';
|
||||
|
||||
public function __construct(\think\Request $request = null)
|
||||
{
|
||||
parent::__construct($request);
|
||||
$this->bgService = new AuthorBackgroundService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 作者背调 HTML 页面入口
|
||||
*
|
||||
* 1. 传了 ORCID → 直接生成报告
|
||||
* 2. 传了 articleId(稿件作者 ID,即 art_aut_id)→ 从 t_article_author 补全 ORCID / 姓名 / 机构
|
||||
* 3. 未传 ORCID + 姓氏(机构选填)→ 仅按姓名搜 ORCID;1 条直接报告,多条显示选择列表
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
@set_time_limit(120);
|
||||
|
||||
$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 === ''
|
||||
&& $params['last_name'] === ''
|
||||
&& $params['first_name'] === ''
|
||||
&& $params['institution'] === ''
|
||||
) {
|
||||
$this->assign('form_action', $formAction);
|
||||
return $this->fetch('author/index');
|
||||
}
|
||||
|
||||
// 1. 有 ORCID → 直接报告页
|
||||
if ($orcidNorm !== '') {
|
||||
return $this->renderReportPage($params, $formAction);
|
||||
}
|
||||
|
||||
// 2. 无 ORCID → 姓氏必填,机构选填
|
||||
if ($params['last_name'] === '') {
|
||||
$this->assign([
|
||||
'form_action' => $formAction,
|
||||
'error_msg' => '未填 ORCID 时,请填写姓氏',
|
||||
'last_name' => $params['last_name'],
|
||||
'first_name' => $params['first_name'],
|
||||
'institution' => $params['institution'],
|
||||
]);
|
||||
return $this->fetch('author/index');
|
||||
}
|
||||
|
||||
// 3. 仅按姓名搜 ORCID(机构只做排序校验)
|
||||
$search = $this->bgService->searchOrcidCandidates(
|
||||
$params['last_name'],
|
||||
$params['first_name'],
|
||||
$params['institution']
|
||||
);
|
||||
$candidates = $search['candidates'] ?? [];
|
||||
|
||||
if (empty($candidates)) {
|
||||
return $this->renderOrcidRequiredPage($params, $formAction, '已在 OpenAlex、ORCID 官网、Scopus 按姓名检索,未找到带 ORCID 的作者');
|
||||
}
|
||||
|
||||
if (count($candidates) > 1) {
|
||||
$this->assignCandidateListView($candidates, $params, $formAction);
|
||||
return $this->fetch('author/select_orcid');
|
||||
}
|
||||
|
||||
return $this->redirect($this->buildReportEntryUrl($formAction, $params, $candidates[0]['orcid']));
|
||||
}
|
||||
|
||||
/**
|
||||
* 医学期刊作者背景调查报告(ORCID 必填)
|
||||
*
|
||||
* POST/GET 参数:
|
||||
* orcid / orcid_id ORCID(必填)
|
||||
* lastName / last_name 姓(选填,用于 PubMed 辅助检索与报告展示)
|
||||
* firstName / first_name 名(选填)
|
||||
* institution / affiliation 机构(选填)
|
||||
*/
|
||||
public function background_report()
|
||||
{
|
||||
@set_time_limit(120);
|
||||
|
||||
$params = $this->resolveBackgroundParams();
|
||||
$result = $this->bgService->buildReport(
|
||||
$params['orcid'],
|
||||
$params['last_name'],
|
||||
$params['first_name'],
|
||||
$params['institution']
|
||||
);
|
||||
|
||||
if (empty($result['ok'])) {
|
||||
$code = !empty($result['need_select']) ? 2 : 0;
|
||||
return json([
|
||||
'code' => $code,
|
||||
'msg' => $result['msg'] ?? '查询失败',
|
||||
'data' => $result['data'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return json([
|
||||
'code' => 1,
|
||||
'msg' => 'success',
|
||||
'data' => $result['data'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** 与 background_report 相同(路由兼容) */
|
||||
public function due_diligence()
|
||||
{
|
||||
return $this->background_report();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析背调查询参数(兼容多种命名)
|
||||
*/
|
||||
private function resolveBackgroundParams()
|
||||
{
|
||||
$this->articleLookupError = '';
|
||||
|
||||
$pick = function (...$keys) {
|
||||
foreach ($keys as $k) {
|
||||
$v = trim((string) input('param.' . $k, ''));
|
||||
if ($v === '') {
|
||||
$v = trim((string) input('post.' . $k, ''));
|
||||
}
|
||||
if ($v === '') {
|
||||
$v = trim((string) input('get.' . $k, ''));
|
||||
}
|
||||
if ($v !== '') {
|
||||
return $v;
|
||||
}
|
||||
}
|
||||
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' => $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()
|
||||
{
|
||||
// 生产环境未配置伪静态时需带 index.php,如 /public/index.php/api/author/index
|
||||
return rtrim($this->request->baseFile(), '/') . '/api/author/index';
|
||||
}
|
||||
|
||||
private function renderReportPage(array $params, $formAction)
|
||||
{
|
||||
$result = $this->bgService->buildReport(
|
||||
$params['orcid'],
|
||||
$params['last_name'],
|
||||
$params['first_name'],
|
||||
$params['institution']
|
||||
);
|
||||
|
||||
if (empty($result['ok'])) {
|
||||
$data = $result['data'] ?? [];
|
||||
if (!empty($result['need_select'])) {
|
||||
$this->assignCandidateListView($data['candidates'] ?? [], $params, $formAction);
|
||||
return $this->fetch('author/select_orcid');
|
||||
}
|
||||
if (!empty($data['orcid_required'])) {
|
||||
return $this->renderOrcidRequiredPage($params, $formAction, $data['hint'] ?? '');
|
||||
}
|
||||
$this->assign([
|
||||
'form_action' => $formAction,
|
||||
'error_msg' => $result['msg'] ?? '查询失败',
|
||||
]);
|
||||
return $this->fetch('author/index');
|
||||
}
|
||||
|
||||
$this->assignReportView($result['data'], $formAction);
|
||||
return $this->fetch('author/report');
|
||||
}
|
||||
|
||||
private function renderOrcidRequiredPage(array $params, $formAction, $hint = '')
|
||||
{
|
||||
$this->assign([
|
||||
'form_action' => $formAction,
|
||||
'submitted_name' => trim($params['first_name'] . ' ' . $params['last_name']),
|
||||
'submitted_institution' => $params['institution'],
|
||||
'last_name' => $params['last_name'],
|
||||
'first_name' => $params['first_name'],
|
||||
'institution' => $params['institution'],
|
||||
'hint' => $hint,
|
||||
]);
|
||||
return $this->fetch('author/orcid_required');
|
||||
}
|
||||
|
||||
private function buildReportEntryUrl($formAction, array $params, $orcid)
|
||||
{
|
||||
return $formAction . '?' . http_build_query(
|
||||
array_filter([
|
||||
'orcid' => $orcid,
|
||||
'lastName' => $params['last_name'] ?? '',
|
||||
'firstName' => $params['first_name'] ?? '',
|
||||
'institution' => $params['institution'] ?? '',
|
||||
], function ($v) {
|
||||
return trim((string) $v) !== '';
|
||||
}),
|
||||
'',
|
||||
'&',
|
||||
PHP_QUERY_RFC3986
|
||||
);
|
||||
}
|
||||
|
||||
private function assignCandidateListView(array $candidates, array $params, $formAction)
|
||||
{
|
||||
foreach ($candidates as $idx => $item) {
|
||||
$candidates[$idx]['report_url'] = $this->buildReportEntryUrl(
|
||||
$formAction,
|
||||
$params,
|
||||
$item['orcid'] ?? ''
|
||||
);
|
||||
$candidates[$idx]['matched_class'] = !empty($item['institution_matched']) ? 'match' : '';
|
||||
$name = trim((string) ($item['display_name'] ?? ''));
|
||||
$candidates[$idx]['avatar_letter'] = $name !== ''
|
||||
? mb_strtoupper(mb_substr($name, 0, 1))
|
||||
: '?';
|
||||
}
|
||||
|
||||
$this->assign([
|
||||
'form_action' => $formAction,
|
||||
'candidates' => $candidates,
|
||||
'candidate_count' => count($candidates),
|
||||
'submitted_name' => trim(($params['first_name'] ?? '') . ' ' . ($params['last_name'] ?? '')),
|
||||
'submitted_institution' => $params['institution'] ?? '',
|
||||
'last_name' => $params['last_name'] ?? '',
|
||||
'first_name' => $params['first_name'] ?? '',
|
||||
'institution' => $params['institution'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
private function assignReportView(array $report, $formAction)
|
||||
{
|
||||
$dupPaperCount = 0;
|
||||
$duplicates = $report['duplicates'] ?? [];
|
||||
foreach ($duplicates as $idx => $dg) {
|
||||
$duplicates[$idx]['paper_count'] = count($dg['papers'] ?? []);
|
||||
$dupPaperCount += $duplicates[$idx]['paper_count'];
|
||||
foreach ($duplicates[$idx]['papers'] as $pi => $dp) {
|
||||
$src = strtolower((string) ($dp['source'] ?? 'orcid'));
|
||||
$duplicates[$idx]['papers'][$pi]['source_class'] = in_array($src, ['orcid', 'pubmed'], true) ? $src : 'orcid';
|
||||
}
|
||||
}
|
||||
$report['duplicates'] = $duplicates;
|
||||
|
||||
$rw = $report['retraction_watch'] ?? [];
|
||||
$items = $rw['items'] ?? [];
|
||||
foreach ($items as $idx => $it) {
|
||||
$title = !empty($it['author_title']) ? $it['author_title'] : ($it['title'] ?? '');
|
||||
$items[$idx]['display_title'] = mb_substr($title, 0, 120);
|
||||
$items[$idx]['reason_short'] = mb_substr((string) ($it['reason'] ?? ''), 0, 200);
|
||||
$linkUrl = trim((string) ($it['url'] ?? ''));
|
||||
if ($linkUrl === '') {
|
||||
$linkUrl = 'https://retractionwatch.com/?s=' . rawurlencode((string) ($it['title'] ?? ''));
|
||||
}
|
||||
$items[$idx]['link_url'] = $linkUrl;
|
||||
}
|
||||
$report['retraction_watch']['items'] = $items;
|
||||
|
||||
$riskLevel = (string) ($report['conclusion']['risk_level'] ?? '');
|
||||
$riskClass = 'risk-default';
|
||||
if (strpos($riskLevel, '高风险') !== false) {
|
||||
$riskClass = 'risk-high';
|
||||
} elseif (strpos($riskLevel, '中风险') !== false) {
|
||||
$riskClass = 'risk-mid';
|
||||
} elseif (strpos($riskLevel, '低风险') !== false) {
|
||||
$riskClass = 'risk-low';
|
||||
}
|
||||
|
||||
$this->assign([
|
||||
'form_action' => $formAction,
|
||||
'report' => $report,
|
||||
'risk_class' => $riskClass,
|
||||
'orcid_affiliations_text' => implode(';', $report['basic']['orcid_affiliations'] ?? []),
|
||||
'openalex_institutions_text' => implode(';', $report['basic']['openalex_institutions'] ?? []),
|
||||
'topics_text' => implode(';', $report['metrics']['topics'] ?? []),
|
||||
'rw_match_total' => (int) ($rw['doi_match_count'] ?? 0)
|
||||
+ (int) ($rw['name_match_count'] ?? 0)
|
||||
+ (int) ($rw['name_loose_match_count'] ?? 0),
|
||||
'dup_group_count' => count($duplicates),
|
||||
'dup_paper_count' => $dupPaperCount,
|
||||
'pubmed_list_count' => min(10, count($report['pubmed_papers'] ?? [])),
|
||||
'orcid_section_num' => (($report['metrics']['pubmed_total'] ?? 0) > 0) ? '七' : '六',
|
||||
]);
|
||||
}
|
||||
}
|
||||
386
application/api/controller/BackgroundCheck.php
Normal file
386
application/api/controller/BackgroundCheck.php
Normal file
@@ -0,0 +1,386 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use think\Cache;
|
||||
use app\common\BackgroundCheckService;
|
||||
|
||||
/**
|
||||
* 背景调查控制器
|
||||
*
|
||||
* 功能:查询学者公开学术指标 + 撤稿/不端公开记录 + 按领域批量筛查
|
||||
*
|
||||
* 接口:
|
||||
* api/background_check/demo - 快速体验
|
||||
* api/background_check/searchAuthor - 按姓名搜索学者
|
||||
* api/background_check/checkProfile - 完整背景调查报告
|
||||
* api/background_check/checkRetractionByDoi - 单篇 DOI 撤稿详情(CrossRef + RW)
|
||||
* api/background_check/batchScreenByField - 按领域批量筛查专家
|
||||
*/
|
||||
class BackgroundCheck extends Base
|
||||
{
|
||||
/** @var BackgroundCheckService */
|
||||
private $service;
|
||||
|
||||
public function __construct(\think\Request $request = null)
|
||||
{
|
||||
parent::__construct($request);
|
||||
$this->service = new BackgroundCheckService();
|
||||
}
|
||||
|
||||
// ===================== 公开 API =====================
|
||||
|
||||
/**
|
||||
* Demo:快速体验
|
||||
*
|
||||
* @param string orcid 可选,默认 0000-0002-2582-7480
|
||||
*/
|
||||
public function demo()
|
||||
{
|
||||
$orcid = $this->service->cleanOrcid($this->request->param('orcid', '0000-0002-2582-7480'));
|
||||
|
||||
$report = $this->buildProfileReport([
|
||||
'orcid' => $orcid,
|
||||
'with_crossref_detail'=> 1,
|
||||
]);
|
||||
if (!$report['success']) {
|
||||
return jsonError($report['error']);
|
||||
}
|
||||
|
||||
$report['data']['_demo_note'] = 'Demo 接口,正式使用请调用 checkProfile 或 batchScreenByField';
|
||||
return jsonSuccess($report['data']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按姓名搜索学者
|
||||
*
|
||||
* @param string name 学者姓名(必填)
|
||||
* @param string affiliation 单位关键词(可选)
|
||||
* @param int limit 返回条数,默认5,最大20
|
||||
*/
|
||||
public function searchAuthor()
|
||||
{
|
||||
$name = trim($this->request->param('name', ''));
|
||||
if ($name === '') {
|
||||
return jsonError('name不能为空');
|
||||
}
|
||||
|
||||
$affiliation = trim($this->request->param('affiliation', ''));
|
||||
$limit = min(max(intval($this->request->param('limit', 5)), 1), 20);
|
||||
|
||||
$cacheKey = 'bg_search_' . md5($name . $affiliation . $limit);
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached) {
|
||||
return jsonSuccess($cached);
|
||||
}
|
||||
|
||||
$filter = 'display_name.search:' . $name;
|
||||
if ($affiliation !== '') {
|
||||
$filter .= ',last_known_institutions.display_name.search:' . $affiliation;
|
||||
}
|
||||
|
||||
$res = $this->service->openAlexGet('/authors', [
|
||||
'search' => $name,
|
||||
'filter' => $filter,
|
||||
'sort' => 'cited_by_count:desc',
|
||||
'per-page' => $limit,
|
||||
]);
|
||||
|
||||
if (!$res['success']) {
|
||||
return jsonError($res['error']);
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($res['data']['results'] ?? [] as $author) {
|
||||
$list[] = $this->service->formatAuthorBrief($author);
|
||||
}
|
||||
|
||||
$result = [
|
||||
'query' => ['name' => $name, 'affiliation' => $affiliation],
|
||||
'total' => $res['data']['meta']['count'] ?? count($list),
|
||||
'list' => $list,
|
||||
];
|
||||
|
||||
Cache::set($cacheKey, $result, 1800);
|
||||
return jsonSuccess($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整背景调查报告
|
||||
*
|
||||
* @param string openalex_id OpenAlex 作者ID
|
||||
* @param string orcid ORCID
|
||||
* @param string name 姓名
|
||||
* @param string affiliation 单位关键词
|
||||
* @param int user_id 本地用户ID(可选)
|
||||
* @param int with_crossref_detail 是否补充 CrossRef 撤稿详情,默认1
|
||||
*/
|
||||
public function checkProfile()
|
||||
{
|
||||
$params = [
|
||||
'openalex_id' => trim($this->request->param('openalex_id', '')),
|
||||
'orcid' => $this->service->cleanOrcid($this->request->param('orcid', '')),
|
||||
'name' => trim($this->request->param('name', '')),
|
||||
'affiliation' => trim($this->request->param('affiliation', '')),
|
||||
'user_id' => intval($this->request->param('user_id', 0)),
|
||||
'with_crossref_detail' => intval($this->request->param('with_crossref_detail', 1)),
|
||||
];
|
||||
|
||||
if ($params['openalex_id'] === '' && $params['orcid'] === '' && $params['name'] === '') {
|
||||
return jsonError('请至少提供 openalex_id、orcid 或 name 之一');
|
||||
}
|
||||
|
||||
$report = $this->buildProfileReport($params);
|
||||
if (!$report['success']) {
|
||||
return jsonError($report['error']);
|
||||
}
|
||||
|
||||
return jsonSuccess($report['data']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单篇 DOI 撤稿详情(CrossRef + Retraction Watch 来源标记)
|
||||
*
|
||||
* @param string doi 文章 DOI(必填)
|
||||
*/
|
||||
public function checkRetractionByDoi()
|
||||
{
|
||||
$doi = trim($this->request->param('doi', ''));
|
||||
if ($doi === '') {
|
||||
return jsonError('doi不能为空');
|
||||
}
|
||||
|
||||
$res = $this->service->fetchCrossRefWork($doi);
|
||||
if (!$res['success']) {
|
||||
return jsonError($res['error']);
|
||||
}
|
||||
|
||||
$detail = $this->service->parseCrossRefRetractionDetail($doi, $res['message']);
|
||||
$sources = $detail['retraction_detail']['sources'] ?? [];
|
||||
$fromRw = false;
|
||||
foreach ($sources as $src) {
|
||||
if (stripos($src, 'retraction-watch') !== false || stripos($src, 'retraction_watch') !== false) {
|
||||
$fromRw = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return jsonSuccess([
|
||||
'doi' => $detail['doi'],
|
||||
'title' => $detail['title'],
|
||||
'journal' => $detail['journal'],
|
||||
'publisher' => $detail['publisher'],
|
||||
'authors' => $detail['authors'],
|
||||
'is_retracted' => $detail['is_retracted'],
|
||||
'from_retraction_watch' => $fromRw || !empty($detail['retraction_detail']['record_ids']),
|
||||
'retraction_detail' => $detail['retraction_detail'],
|
||||
'url' => $detail['url'],
|
||||
'data_sources' => ['CrossRef', 'Retraction Watch'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按领域批量筛查专家(含撤稿风险标签)
|
||||
*
|
||||
* @param string keyword 领域关键词(必填),如 immunotherapy、cancer
|
||||
* @param int min_h_index 最低 h-index,默认5
|
||||
* @param int limit 每页数量,默认10,最大30
|
||||
* @param int page 页码,默认1
|
||||
* @param int check_retraction 是否检查撤稿,默认1
|
||||
*/
|
||||
public function batchScreenByField()
|
||||
{
|
||||
$keyword = trim($this->request->param('keyword', ''));
|
||||
if ($keyword === '') {
|
||||
return jsonError('keyword不能为空');
|
||||
}
|
||||
|
||||
$options = [
|
||||
'min_h_index' => intval($this->request->param('min_h_index', 5)),
|
||||
'limit' => min(max(intval($this->request->param('limit', 10)), 1), 30),
|
||||
'page' => max(intval($this->request->param('page', 1)), 1),
|
||||
];
|
||||
$checkRetraction = intval($this->request->param('check_retraction', 1));
|
||||
|
||||
$cacheKey = 'bg_batch_' . md5(json_encode([$keyword, $options, $checkRetraction]));
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached) {
|
||||
return jsonSuccess($cached);
|
||||
}
|
||||
|
||||
$searchRes = $this->service->searchAuthorsByField($keyword, $options);
|
||||
if (!$searchRes['success']) {
|
||||
return jsonError($searchRes['error']);
|
||||
}
|
||||
|
||||
$data = $searchRes['data'];
|
||||
$screened = [];
|
||||
$riskStats = ['low' => 0, 'medium' => 0, 'high' => 0];
|
||||
|
||||
foreach ($data['list'] as $author) {
|
||||
$item = [
|
||||
'profile' => $author,
|
||||
'metrics' => [
|
||||
'works_count' => $author['works_count'],
|
||||
'cited_by_count' => $author['cited_by_count'],
|
||||
'h_index' => $author['h_index'],
|
||||
'level_label' => $this->service->parseAuthorMetrics([
|
||||
'works_count' => $author['works_count'],
|
||||
'cited_by_count' => $author['cited_by_count'],
|
||||
'summary_stats' => ['h_index' => $author['h_index']],
|
||||
])['level_label'],
|
||||
],
|
||||
];
|
||||
|
||||
if ($checkRetraction) {
|
||||
$openAlexId = $author['openalex_id'];
|
||||
$oaRetractions = $this->service->fetchRetractedWorksOpenAlex($openAlexId);
|
||||
$rwRetractions = $this->service->fetchRetractionWatchByAuthor($author['name']);
|
||||
$merged = $this->service->mergeRetractionRecords($oaRetractions, $rwRetractions, false);
|
||||
|
||||
$metrics = ['works_count' => $author['works_count']];
|
||||
$risk = $this->service->assessRisk($metrics, $merged);
|
||||
|
||||
$item['retractions'] = [
|
||||
'count' => $merged['count'],
|
||||
'openalex_count'=> $merged['openalex_count'],
|
||||
'rw_count' => $merged['rw_count'],
|
||||
'rw_only_count' => $merged['rw_only_count'],
|
||||
'list' => array_slice($merged['list'], 0, 3),
|
||||
];
|
||||
$item['risk_assessment'] = $risk;
|
||||
$riskStats[$risk['level']]++;
|
||||
} else {
|
||||
$item['risk_assessment'] = [
|
||||
'level' => 'unknown',
|
||||
'level_label' => '未检测',
|
||||
];
|
||||
}
|
||||
|
||||
$screened[] = $item;
|
||||
usleep(300000);
|
||||
}
|
||||
|
||||
$result = [
|
||||
'query' => [
|
||||
'keyword' => $keyword,
|
||||
'topic_id' => $data['topic_id'],
|
||||
'min_h_index' => $options['min_h_index'],
|
||||
'page' => $options['page'],
|
||||
'limit' => $options['limit'],
|
||||
'check_retraction' => $checkRetraction,
|
||||
],
|
||||
'total' => $data['total'],
|
||||
'risk_stats' => $checkRetraction ? $riskStats : null,
|
||||
'list' => $screened,
|
||||
'disclaimer' => '批量筛查基于公开数据,同名作者可能存在误匹配,高风险条目需人工复核。',
|
||||
];
|
||||
|
||||
Cache::set($cacheKey, $result, 900);
|
||||
return jsonSuccess($result);
|
||||
}
|
||||
|
||||
// ===================== 核心逻辑 =====================
|
||||
|
||||
/**
|
||||
* 构建完整背景调查报告
|
||||
*/
|
||||
private function buildProfileReport($params)
|
||||
{
|
||||
$withCrossRef = !empty($params['with_crossref_detail']);
|
||||
|
||||
$author = $this->service->resolveAuthor($params);
|
||||
if (!$author['success']) {
|
||||
return $author;
|
||||
}
|
||||
|
||||
$authorData = $author['data'];
|
||||
$openAlexId = $this->service->extractOpenAlexId($authorData['id']);
|
||||
$authorName = $authorData['display_name'] ?? '';
|
||||
|
||||
$metrics = $this->service->parseAuthorMetrics($authorData);
|
||||
$topics = $this->service->parseResearchTopics($authorData);
|
||||
$recentWorks = $this->service->fetchRecentWorks($openAlexId, 5);
|
||||
|
||||
// 扩展1: OpenAlex 撤稿
|
||||
$oaRetractions = $this->service->fetchRetractedWorksOpenAlex($openAlexId);
|
||||
|
||||
// 扩展1: Retraction Watch 二次核对(CrossRef 来源)
|
||||
$rwRetractions = $this->service->fetchRetractionWatchByAuthor($authorName);
|
||||
|
||||
// 扩展2: 合并并按需补充 CrossRef 撤稿详情
|
||||
$retractions = $this->service->mergeRetractionRecords(
|
||||
$oaRetractions,
|
||||
$rwRetractions,
|
||||
$withCrossRef
|
||||
);
|
||||
|
||||
$risk = $this->service->assessRisk($metrics, $retractions);
|
||||
|
||||
$localInfo = null;
|
||||
if (!empty($params['user_id'])) {
|
||||
$localInfo = $this->fetchLocalUserInfo($params['user_id']);
|
||||
}
|
||||
|
||||
$dataSources = ['OpenAlex', 'Retraction Watch (via CrossRef)'];
|
||||
if ($withCrossRef) {
|
||||
$dataSources[] = 'CrossRef';
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'profile' => $this->service->formatAuthorBrief($authorData),
|
||||
'metrics' => $metrics,
|
||||
'research_topics' => $topics,
|
||||
'recent_works' => $recentWorks,
|
||||
'retractions' => $retractions,
|
||||
'risk_assessment' => $risk,
|
||||
'local_info' => $localInfo,
|
||||
'data_sources' => $dataSources,
|
||||
'disclaimer' => '本报告仅基于公开学术数据,不能替代正式背调或机构调查;未公开的不端行为无法检测。',
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并本地用户信息
|
||||
*/
|
||||
private function fetchLocalUserInfo($userId)
|
||||
{
|
||||
$user = $this->user_obj->where('user_id', $userId)->find();
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reviewer = $this->user_reviewer_info_obj
|
||||
->where('reviewer_id', $userId)
|
||||
->where('state', 0)
|
||||
->find();
|
||||
|
||||
$majors = $this->major_to_user_obj
|
||||
->where('user_id', $userId)
|
||||
->where('state', 0)
|
||||
->select();
|
||||
|
||||
$majorList = [];
|
||||
foreach ($majors as $m) {
|
||||
$majorList[] = [
|
||||
'major_id' => $m['major_id'],
|
||||
'path' => getMajorStr($m['major_id']),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'realname' => $user['realname'] ?? '',
|
||||
'email' => $user['email'] ?? '',
|
||||
'orcid' => $user['orcid'] ?? '',
|
||||
'company' => $reviewer['company'] ?? '',
|
||||
'title' => $reviewer['technical'] ?? '',
|
||||
'field' => $reviewer['field'] ?? '',
|
||||
'majors' => $majorList,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -741,6 +741,36 @@ class Base extends Controller
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文章查重百分比
|
||||
*
|
||||
* 规则:优先取自动查重(t_plagiarism_check)最新一条已完成(state=3)的 similarity_score;
|
||||
* 没有自动查重结果时,回落到主表 t_article.repetition。
|
||||
*
|
||||
* @param int $article_id
|
||||
* @return float 查重百分比(如 12.34)
|
||||
*/
|
||||
public function getArticleRepetition($article_id)
|
||||
{
|
||||
$article_id = intval($article_id);
|
||||
if ($article_id <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$auto = Db::name('plagiarism_check')
|
||||
->where('article_id', $article_id)
|
||||
->where('state', 3)
|
||||
->order('check_id desc')
|
||||
->value('similarity_score');
|
||||
|
||||
if ($auto !== null && $auto !== '') {
|
||||
return floatval($auto);
|
||||
}
|
||||
|
||||
$repetition = $this->article_obj->where('article_id', $article_id)->value('repetition');
|
||||
return floatval($repetition);
|
||||
}
|
||||
|
||||
/**获取标准化用户库的人
|
||||
* @return void
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
203
application/api/controller/ExpertFieldAi.php
Normal file
203
application/api/controller/ExpertFieldAi.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use think\Db;
|
||||
use app\common\ExpertFieldAiService;
|
||||
|
||||
/**
|
||||
* Expert 领域 AI 总结(方案 C:少量 user 关联 + 主流程 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 startChain()
|
||||
{
|
||||
$force = intval($this->request->param('force', 0)) === 1;
|
||||
$delay = max(0, intval($this->request->param('delay', 1)));
|
||||
|
||||
$svc = new ExpertFieldAiService();
|
||||
$started = $svc->startChain($force, $delay);
|
||||
|
||||
return jsonSuccess([
|
||||
'started' => $started,
|
||||
'queue' => ExpertFieldAiService::QUEUE_NAME,
|
||||
'force' => $force,
|
||||
'msg' => $started ? 'chain enqueued' : 'no pending experts',
|
||||
]);
|
||||
}
|
||||
|
||||
/** 兼容旧接口名 */
|
||||
public function startLinkChain()
|
||||
{
|
||||
return $this->startChain();
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步处理单个 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()
|
||||
{
|
||||
$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->linkFromUser($expertId, $force);
|
||||
if (empty($result['ok'])) {
|
||||
return jsonError(isset($result['error']) ? $result['error'] : 'failed');
|
||||
}
|
||||
return jsonSuccess($result);
|
||||
}
|
||||
|
||||
public function linkBatch()
|
||||
{
|
||||
$force = intval($this->request->param('force', 0)) === 1;
|
||||
$ids = $this->resolveExpertIds(true);
|
||||
if (empty($ids)) {
|
||||
return jsonError('expert_ids 或 limit 必填');
|
||||
}
|
||||
|
||||
$svc = new ExpertFieldAiService();
|
||||
return jsonSuccess($svc->batchLinkFromUser($ids, $force));
|
||||
}
|
||||
|
||||
public function syncByUser()
|
||||
{
|
||||
$userId = intval($this->request->param('user_id', 0));
|
||||
$force = intval($this->request->param('force', 0)) === 1;
|
||||
if ($userId <= 0) {
|
||||
return jsonError('user_id required');
|
||||
}
|
||||
|
||||
$svc = new ExpertFieldAiService();
|
||||
$result = $svc->syncExpertsByUserId($userId, $force);
|
||||
if (empty($result['ok'])) {
|
||||
return jsonError(isset($result['error']) ? $result['error'] : 'failed');
|
||||
}
|
||||
return jsonSuccess($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览:是否可 user 关联、是否可 AI、上下文摘要
|
||||
*/
|
||||
public function preview()
|
||||
{
|
||||
$expertId = intval($this->request->param('expert_id', 0));
|
||||
if ($expertId <= 0) {
|
||||
return jsonError('expert_id required');
|
||||
}
|
||||
|
||||
$svc = new ExpertFieldAiService();
|
||||
$result = $svc->preview($expertId);
|
||||
if (empty($result['ok'])) {
|
||||
return jsonError(isset($result['error']) ? $result['error'] : 'failed');
|
||||
}
|
||||
|
||||
$result['field_ai_status_text'] = $svc->statusLabel(intval($result['expert_field_ai_status']));
|
||||
return jsonSuccess($result);
|
||||
}
|
||||
|
||||
public function statistics()
|
||||
{
|
||||
$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,
|
||||
'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');
|
||||
}
|
||||
}
|
||||
@@ -39,22 +39,28 @@ 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));
|
||||
|
||||
$query = Db::name('expert')->alias('e');
|
||||
$countQuery = Db::name('expert')->alias('e');
|
||||
$needJoin = ($field !== '');
|
||||
|
||||
if ($needJoin) {
|
||||
$query->join('t_expert_field ef', 'ef.expert_id = e.expert_id AND ef.state = 0', 'inner');
|
||||
$countQuery->join('t_expert_field ef', 'ef.expert_id = e.expert_id AND ef.state = 0', 'inner');
|
||||
if ($field !== '') {
|
||||
$query->where('ef.field', 'like', '%' . $field . '%');
|
||||
$countQuery->where('ef.field', 'like', '%' . $field . '%');
|
||||
$fieldExpertIds = Db::name('expert_field')
|
||||
->where('state', 0)
|
||||
->where('field', 'like', '%' . $field . '%')
|
||||
->column('expert_id');
|
||||
$fieldExpertIds = array_values(array_unique(array_filter(array_map('intval', $fieldExpertIds))));
|
||||
|
||||
$fieldWhere = function ($q) use ($field, $fieldExpertIds) {
|
||||
$q->where('e.field_ai', 'like', '%' . $field . '%');
|
||||
if (!empty($fieldExpertIds)) {
|
||||
$q->whereOr('e.expert_id', 'in', $fieldExpertIds);
|
||||
}
|
||||
$query->group('e.expert_id');
|
||||
$countQuery->group('e.expert_id');
|
||||
};
|
||||
$query->where($fieldWhere);
|
||||
$countQuery->where($fieldWhere);
|
||||
}
|
||||
|
||||
if ($state !== '-1' && $state !== '') {
|
||||
@@ -62,17 +68,21 @@ class ExpertManage extends Base
|
||||
$countQuery->where('e.state', intval($state));
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
$query->where('e.name|e.email|e.affiliation', 'like', '%' . $keyword . '%');
|
||||
$countQuery->where('e.name|e.email|e.affiliation', 'like', '%' . $keyword . '%');
|
||||
$query->where('e.name|e.email|e.affiliation|e.field_ai', 'like', '%' . $keyword . '%');
|
||||
$countQuery->where('e.name|e.email|e.affiliation|e.field_ai', 'like', '%' . $keyword . '%');
|
||||
}
|
||||
if ($source !== '') {
|
||||
$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();
|
||||
$total = $needJoin ? count($countQuery->group('e.expert_id')->column('e.expert_id')) : $countQuery->count();
|
||||
$total = $countQuery->count();
|
||||
|
||||
$list = $query
|
||||
->field('e.*')
|
||||
|
||||
@@ -37,14 +37,14 @@ class Plagiarism extends Base
|
||||
* article_id 必填
|
||||
* file_url 选填;不传则按 article_id 在 t_article_file 找 manuscirpt
|
||||
* editor_id 选填;触发人 user_id(前端拿不到也可以传 0)
|
||||
* check_type 选填;full(默认全文)| body_only(正文)| both(各提交一条)
|
||||
* type 选填;full(默认全文)| body_only(正文)| both(各提交一条)
|
||||
*/
|
||||
public function submit()
|
||||
{
|
||||
$articleId = intval($this->request->param('article_id', 0));
|
||||
$fileUrl = trim($this->request->param('file_url', ''));
|
||||
$editorId = intval($this->request->param('editor_id', 0));
|
||||
$checkType = trim($this->request->param('check_type', 'full'));
|
||||
$checkType = trim($this->request->param('type', 'full'));
|
||||
|
||||
if ($articleId <= 0) {
|
||||
return jsonError('article_id required');
|
||||
|
||||
@@ -3345,14 +3345,23 @@ class Production extends Base
|
||||
return jsonError('To the editor: PROOF is the final form before the article goes online. The PROOF link step not be opened if you have not completed the previous steps.');
|
||||
}
|
||||
//发送邮件
|
||||
// $tt = "Dear Author,<br/><br/>";
|
||||
// $tt .= "Please confirm proof of your manuscript on the submission website within 48 hours. (https://submission.tmrjournals.com)<br/><br/>";
|
||||
// // $tt .= "<a href='https://submission.tmrjournals.com/api/Production/editProofFromEmail/articleId/".$p_info['article_id']."'>Click here to confirm the proof.</a><br/>";
|
||||
// $tt .= "<a href='https://submission.tmrjournals.com'>Click here to view and confirm the proof.</a><br/><br/>";
|
||||
// $tt .= "If your response is not received, we will regard the author's consent to the version if the time exceeds.<br/><br/>";
|
||||
// $tt .= "If you have any questions, please feel free to contact us.<br/>";
|
||||
// $tt .= "Note: Double-check the authors' information carefully to ensure they are correct.";
|
||||
$tt = "Dear Author,<br/><br/>";
|
||||
$tt .= "Please confirm proof of your manuscript on the submission website within 48 hours. (https://submission.tmrjournals.com)<br/><br/>";
|
||||
// $tt .= "<a href='https://submission.tmrjournals.com/api/Production/editProofFromEmail/articleId/".$p_info['article_id']."'>Click here to confirm the proof.</a><br/>";
|
||||
$tt .= "We hope this email finds you well.<br/>";
|
||||
$tt .= "The proof version of your manuscript has been generated and uploaded to our editorial system.Please log in to the system and confirm the proof within 48 hours:<br/><br/>";
|
||||
$tt .= "Manuscript ID: ".$article_info["accept_sn"]."<br/>";
|
||||
$tt .= "Title:".$article_info["title"]."<br/><br/>";
|
||||
$tt .= "<a href='https://submission.tmrjournals.com'>Click here to view and confirm the proof.</a><br/><br/>";
|
||||
$tt .= "If your response is not received, we will regard the author's consent to the version if the time exceeds.<br/><br/>";
|
||||
$tt .= "If you have any questions, please feel free to contact us.<br/>";
|
||||
$tt .= "Note: Double-check the authors' information carefully to ensure they are correct.";
|
||||
|
||||
$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/";
|
||||
|
||||
// $maidata['email'] = '751475802@qq.com';
|
||||
$maidata['email'] = $user_info['email'];
|
||||
|
||||
@@ -1329,18 +1329,21 @@ class References extends Base
|
||||
return json_encode(array('status' => 3,'msg' => 'No articles found' ));
|
||||
}
|
||||
if($this->checkReferStatus($iPArticleId)==0){
|
||||
return jsonError('请修正完文献内容再进行校对。');
|
||||
return jsonError('Please correct the reference content before running the check.');
|
||||
}
|
||||
//已存在校对记录则禁止重复执行第一次校对,提示走重置接口
|
||||
$iExisting = Db::name('article_reference_check_result')
|
||||
->where('p_article_id', $iPArticleId)
|
||||
->count();
|
||||
if(intval($iExisting) > 0){
|
||||
return jsonError('该文章已存在校对记录,请使用"重置校对"接口重新校对。');
|
||||
return jsonError('This article already has a reference check record. Please use the "Reset Check" endpoint to run the check again.');
|
||||
}
|
||||
try {
|
||||
$svc = new ReferenceCheckService();
|
||||
$result = $svc->enqueueByPArticle($aProductionArticle);
|
||||
if (empty($result['check_ids'])) {
|
||||
return jsonError('No reference citations were found in the article.');
|
||||
}
|
||||
return jsonSuccess($result);
|
||||
} catch (\Exception $e) {
|
||||
return jsonError($e->getMessage());
|
||||
@@ -1368,7 +1371,7 @@ class References extends Base
|
||||
return json_encode(array('status' => 3,'msg' => 'No articles found' ));
|
||||
}
|
||||
if($this->checkReferStatus($iPArticleId)==0){
|
||||
return jsonError('请修正完文献内容再进行校对。');
|
||||
return jsonError('Please correct the reference content before running the check.');
|
||||
}
|
||||
$iArticleId = empty($aProductionArticle['article_id']) ? 0 : $aProductionArticle['article_id'];
|
||||
if(empty($iArticleId)){
|
||||
@@ -1533,7 +1536,7 @@ class References extends Base
|
||||
* POST/GET: p_refer_id(必填)
|
||||
* p_article_id(可选)
|
||||
*
|
||||
* 仅重跑 status=3(校对失败)的记录;不改动 refer_text,只重置结果字段后入 ReferenceCheck 队列。
|
||||
* 仅重跑 status=3(校对失败)的记录;不改动 refer_text,只重置结果字段后入 RabbitMQ 批次队列。
|
||||
* 返回:p_refer_id、p_article_id、reset、queued、check_ids、queue
|
||||
*/
|
||||
public function referenceCheckRecheckFailedAI()
|
||||
|
||||
@@ -2269,7 +2269,8 @@ class Reviewer extends Base
|
||||
$where['t_user.email'] = ['like',"%" . $data["email"] . "%"];
|
||||
}
|
||||
if(isset($data['field'])&&$data['field']!=''){
|
||||
$where['t_user_reviewer_info.field'] = ['like',"%" . $data["field"] . "%"];
|
||||
// field 参数同时匹配原始 field 与 AI 总结 field_ai
|
||||
$where['t_user_reviewer_info.field|t_user_reviewer_info.field_ai'] = ['like',"%" . $data["field"] . "%"];
|
||||
}
|
||||
if (isset($data['major_id'])&&$data['major_id']!=0){
|
||||
$where['t_user_reviewer_info.major'] = ['in',$this->majorids($data['major_id'])];
|
||||
@@ -2306,7 +2307,7 @@ class Reviewer extends Base
|
||||
$list = $this->reviewer_to_journal_obj
|
||||
->join("t_user", "t_user.user_id = t_reviewer_to_journal.reviewer_id", "left")
|
||||
->join("t_user_reviewer_info", "t_user_reviewer_info.reviewer_id = t_reviewer_to_journal.reviewer_id", "left")
|
||||
->field('t_user.account,t_user.email,t_user.realname,t_user_reviewer_info.company,t_user_reviewer_info.field,t_user_reviewer_info.last_invite_time,t_user.user_id,t_user.rs_num')
|
||||
->field('t_user.account,t_user.email,t_user.realname,t_user_reviewer_info.company,t_user_reviewer_info.field,t_user_reviewer_info.field_ai,t_user_reviewer_info.last_invite_time,t_user.user_id,t_user.rs_num')
|
||||
->where($where)->where(function($query) use ($iTeenDaysLater) {
|
||||
$query->where('t_user_reviewer_info.last_invite_time', '<', $iTeenDaysLater)
|
||||
->whereOr('t_user_reviewer_info.last_invite_time', '=', 0);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,10 @@ class Workbench extends Base
|
||||
$aArticle[$key]['is_draft'] = 1;
|
||||
}
|
||||
|
||||
//查重信息
|
||||
$aArticle[$key]['repetition'] = $this->getArticleRepetition($value['article_id']);
|
||||
|
||||
|
||||
// //付款信息
|
||||
// $iPsId = empty($aOrder[$value['article_id']]) ? 0 : $aOrder[$value['article_id']];
|
||||
// $aArticle[$key]['pay_time'] = empty($aPaystation[$iPsId]['pay_time']) ? '' : $aPaystation[$iPsId]['pay_time'];
|
||||
@@ -345,6 +349,11 @@ class Workbench extends Base
|
||||
public function updateArticleState(){
|
||||
//获取参数
|
||||
$aParam = empty($aParam) ? $this->request->post() : $aParam;
|
||||
|
||||
|
||||
Db::name('article')->where('article_id' ,$aParam['article_id'])->limit(1)->update(['is_user_act' => 2]);
|
||||
|
||||
|
||||
//主键ID
|
||||
$iActId = empty($aParam['act_id']) ? 0 : $aParam['act_id'];
|
||||
//主键父ID
|
||||
|
||||
31
application/api/job/ExpertFieldAiFill.php
Normal file
31
application/api/job/ExpertFieldAiFill.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\job;
|
||||
|
||||
use think\queue\Job;
|
||||
use app\common\ExpertFieldAiService;
|
||||
|
||||
/**
|
||||
* Expert field_ai 链式任务:先尝试 user 关联,主流程 AI 总结
|
||||
*
|
||||
* Worker: php think queue:work --queue ExpertFieldAi
|
||||
*/
|
||||
class ExpertFieldAiFill
|
||||
{
|
||||
public function fire(Job $job, $data)
|
||||
{
|
||||
$expertId = isset($data['expert_id']) ? intval($data['expert_id']) : 0;
|
||||
$queue = isset($data['queue']) ? (string)$data['queue'] : ExpertFieldAiService::QUEUE_NAME;
|
||||
$force = !empty($data['force']);
|
||||
|
||||
$svc = new ExpertFieldAiService();
|
||||
if ($expertId > 0) {
|
||||
$svc->processExpert($expertId, $force);
|
||||
}
|
||||
|
||||
$job->delete();
|
||||
|
||||
$delay = max(0, (int)(isset($data['delay']) ? $data['delay'] : 1));
|
||||
$svc->enqueueNext($delay, $queue, $expertId, $force);
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
<?php
|
||||
namespace app\api\job;
|
||||
|
||||
use think\Db;
|
||||
use think\queue\Job;
|
||||
use app\common\QueueJob;
|
||||
use app\common\QueueRedis;
|
||||
use app\common\ReferenceCheckService;
|
||||
|
||||
class ReferenceCheck
|
||||
{
|
||||
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);
|
||||
|
||||
$rawBody = empty($job->getRawBody()) ? '' : $job->getRawBody();
|
||||
$jobData = empty($rawBody) ? [] : json_decode($rawBody, true);
|
||||
$jobId = empty($jobData['id']) ? 'unknown' : $jobData['id'];
|
||||
|
||||
$sRedisKey = '';
|
||||
$sRedisValue = '';
|
||||
|
||||
$this->oQueueJob->log("-----------队列任务开始-----------");
|
||||
$this->oQueueJob->log("当前任务ID: {$jobId}, 尝试次数: {$job->attempts()}");
|
||||
|
||||
try {
|
||||
$checkId = intval(isset($data['check_id']) ? $data['check_id'] : 0);
|
||||
if ($checkId <= 0 && !empty($jobData['data']['check_id'])) {
|
||||
$checkId = intval($jobData['data']['check_id']);
|
||||
}
|
||||
if ($checkId <= 0) {
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
$row = Db::name('article_reference_check_result')->where('id', $checkId)->find();
|
||||
if (empty($row)) {
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (intval($row['status']) === ReferenceCheckService::RECORD_COMPLETED) {
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
$sClassName = get_class($this);
|
||||
$sRedisKey = "queue_job:{$sClassName}:{$checkId}";
|
||||
$sRedisValue = uniqid() . '_' . getmypid();
|
||||
|
||||
$svc = new ReferenceCheckService();
|
||||
$svc->clearReferenceCheckQueueLock($checkId);
|
||||
|
||||
if (!$this->oQueueJob->acquireLock($sRedisKey, $sRedisValue, $job)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$svc->runReferenceCheckOnce($checkId);
|
||||
|
||||
$amId = intval(isset($row['am_id']) ? $row['am_id'] : 0);
|
||||
if ($amId > 0) {
|
||||
$svc->syncAmRefCheckStatus($amId);
|
||||
}
|
||||
$this->QueueRedis->finishJob($sRedisKey, 'completed', $this->completedExprie, $sRedisValue);
|
||||
$job->delete();
|
||||
$this->oQueueJob->log("任务执行成功 | 日志ID: {$sRedisKey}");
|
||||
} catch (\Exception $e) {
|
||||
$this->oQueueJob->log('ReferenceCheck error: ' . $e->getMessage());
|
||||
if ($job->attempts() >= 3) {
|
||||
$this->markFailed($checkId, $e->getMessage());
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
$job->release(30);
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
private function markFailed($checkId, $msg)
|
||||
{
|
||||
$row = Db::name('article_reference_check_result')->where('id', $checkId)->find();
|
||||
try {
|
||||
(new ReferenceCheckService())->updateCheckResult($checkId, [
|
||||
'status' => ReferenceCheckService::RECORD_FAILED,
|
||||
'error_msg' => $msg,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
\think\Log::error('ReferenceCheck markFailed: ' . $e->getMessage());
|
||||
}
|
||||
$amId = empty($row) ? 0 : intval(isset($row['am_id']) ? $row['am_id'] : 0);
|
||||
if ($amId > 0) {
|
||||
(new ReferenceCheckService())->syncAmRefCheckStatus($amId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
<?php
|
||||
namespace app\api\job;
|
||||
|
||||
use think\Db;
|
||||
use think\queue\Job;
|
||||
use app\common\QueueJob;
|
||||
use app\common\QueueRedis;
|
||||
use app\common\ReferenceCheckService;
|
||||
use app\common\service\LLMService;
|
||||
|
||||
class ReferenceCheckTwo
|
||||
{
|
||||
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);
|
||||
|
||||
$rawBody = empty($job->getRawBody()) ? '' : $job->getRawBody();
|
||||
$jobData = empty($rawBody) ? [] : json_decode($rawBody, true);
|
||||
$jobId = empty($jobData['id']) ? 'unknown' : $jobData['id'];
|
||||
|
||||
$sRedisKey = '';
|
||||
$sRedisValue = '';
|
||||
|
||||
$this->oQueueJob->log("-----------队列任务开始-----------");
|
||||
$this->oQueueJob->log("当前任务ID: {$jobId}, 尝试次数: {$job->attempts()}");
|
||||
|
||||
try {
|
||||
$checkId = intval(isset($data['check_id']) ? $data['check_id'] : 0);
|
||||
if ($checkId <= 0 && !empty($jobData['data']['check_id'])) {
|
||||
$checkId = intval($jobData['data']['check_id']);
|
||||
}
|
||||
$sClassName = get_class($this);
|
||||
$sRedisKey = "queue_job_two:{$sClassName}:{$checkId}";
|
||||
$sRedisValue = uniqid() . '_' . getmypid();
|
||||
|
||||
if (!$this->oQueueJob->acquireLock($sRedisKey, $sRedisValue, $job)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($checkId <= 0) {
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
$row = Db::name('article_reference_check_result')->where('id', $checkId)->find();
|
||||
if (empty($row)) {
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
// if (intval($row['status']) === ReferenceCheckService::RECORD_COMPLETED) {
|
||||
// $job->delete();
|
||||
// return;
|
||||
// }
|
||||
|
||||
try {
|
||||
$svc = new ReferenceCheckService();
|
||||
|
||||
$contentA = $svc->resolveMainContentForJob($row);
|
||||
$referText = trim((string)(isset($row['refer_text']) ? $row['refer_text'] : ''));
|
||||
$refer = null;
|
||||
|
||||
if (intval($row['p_refer_id']) > 0) {
|
||||
$refer = Db::name('production_article_refer')
|
||||
->where('p_refer_id', intval($row['p_refer_id']))
|
||||
->where('state', 0)
|
||||
->find();
|
||||
}
|
||||
|
||||
$payload = $svc->prepareRecheckPayload(is_array($refer) ? $refer : [], $referText);
|
||||
$doiBlock = $payload['doi_block'];
|
||||
|
||||
if ($contentA === '' || $referText === '') {
|
||||
$this->markFailed($checkId, 'Missing article_main.content or refer_text');
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
$llm = new LLMService();
|
||||
$llmResult = $llm->checkReference($contentA, $referText, true, $doiBlock);
|
||||
|
||||
$requestFailed = !empty($llmResult['request_failed']);
|
||||
$canSupport = $svc->parseLlmCanSupport($llmResult);
|
||||
$tag = $payload['has_abstract']
|
||||
? ('[Crossref复核' . ($payload['doi_used'] !== '' ? ' ' . $payload['doi_used'] : '') . ']')
|
||||
: '[Crossref复核-无摘要]';
|
||||
$reason = $tag . ' ' . (isset($llmResult['reason']) ? $llmResult['reason'] : '');
|
||||
|
||||
// LLM 通讯失败:写 status=RECORD_FAILED(3) 并抛异常触发队列重试
|
||||
if ($requestFailed) {
|
||||
$svc->updateCheckResult($checkId, [
|
||||
'confidence' => floatval($llmResult['confidence']),
|
||||
'reason' => $reason,
|
||||
'status' => ReferenceCheckService::RECORD_FAILED,
|
||||
'error_msg' => isset($llmResult['reason']) ? $llmResult['reason'] : 'LLM request failed',
|
||||
]);
|
||||
throw new \RuntimeException(isset($llmResult['reason']) ? $llmResult['reason'] : 'LLM request failed');
|
||||
}
|
||||
|
||||
$affected = $svc->updateCheckResult($checkId, [
|
||||
'can_support' => $canSupport ? 1 : 0,
|
||||
'is_match' => $canSupport ? 1 : 0,
|
||||
'confidence' => floatval($llmResult['confidence']),
|
||||
'reason' => $reason,
|
||||
'status' => ReferenceCheckService::RECORD_COMPLETED,
|
||||
'error_msg' => '',
|
||||
]);
|
||||
$this->oQueueJob->log("Crossref复核写入 id={$checkId} affected={$affected} can_support=" . ($canSupport ? 1 : 0) . " confidence=" . floatval($llmResult['confidence']));
|
||||
|
||||
$amId = intval(isset($row['am_id']) ? $row['am_id'] : 0);
|
||||
if ($amId > 0) {
|
||||
$svc->syncAmRefCheckStatus($amId);
|
||||
}
|
||||
$this->QueueRedis->finishJob($sRedisKey, 'completed', $this->completedExprie, $sRedisValue);
|
||||
$job->delete();
|
||||
$this->oQueueJob->log("任务执行成功 | 日志ID: {$sRedisKey}");
|
||||
} catch (\Exception $e) {
|
||||
$this->oQueueJob->log('ReferenceCheckTwo error: ' . $e->getMessage());
|
||||
if ($job->attempts() >= 3) {
|
||||
$this->markFailed($checkId, $e->getMessage());
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
$job->release(30);
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
private function markFailed($checkId, $msg)
|
||||
{
|
||||
$row = Db::name('article_reference_check_result')->where('id', $checkId)->find();
|
||||
try {
|
||||
(new ReferenceCheckService())->updateCheckResult($checkId, [
|
||||
'status' => ReferenceCheckService::RECORD_FAILED,
|
||||
'error_msg' => $msg,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
\think\Log::error('ReferenceCheckTwo markFailed: ' . $e->getMessage());
|
||||
}
|
||||
$amId = empty($row) ? 0 : intval(isset($row['am_id']) ? $row['am_id'] : 0);
|
||||
if ($amId > 0) {
|
||||
(new ReferenceCheckService())->syncAmRefCheckStatus($amId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,6 @@ class UserFieldAiFill
|
||||
$job->delete();
|
||||
|
||||
$delay = max(0, (int) (isset($data['delay']) ? $data['delay'] : 1));
|
||||
// $svc->enqueueNextFieldAi($delay, $queue, $userId, $force);
|
||||
$svc->enqueueNextFieldAi($delay, $queue, $userId, $force);
|
||||
}
|
||||
}
|
||||
|
||||
366
application/api/view/author/_styles.html
Normal file
366
application/api/view/author/_styles.html
Normal file
@@ -0,0 +1,366 @@
|
||||
<style>
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-dark: #1d4ed8;
|
||||
--primary-light: #dbeafe;
|
||||
--accent: #0d9488;
|
||||
--accent-light: #ccfbf1;
|
||||
--bg: #f1f5f9;
|
||||
--bg-gradient: linear-gradient(135deg, #eef2ff 0%, #f0fdfa 50%, #f8fafc 100%);
|
||||
--card: #ffffff;
|
||||
--text: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--radius: 14px;
|
||||
--radius-sm: 8px;
|
||||
--shadow: 0 4px 24px rgba(15, 23, 42, 0.06);
|
||||
--shadow-hover: 0 8px 32px rgba(37, 99, 235, 0.12);
|
||||
--success: #059669;
|
||||
--success-bg: #d1fae5;
|
||||
--warn: #d97706;
|
||||
--warn-bg: #fef3c7;
|
||||
--danger: #dc2626;
|
||||
--danger-bg: #fee2e2;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg-gradient);
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 32px 20px 48px;
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: var(--primary); text-decoration: none; transition: color .15s; }
|
||||
a:hover { color: var(--primary-dark); }
|
||||
.page { max-width: 920px; margin: 0 auto; }
|
||||
.page-narrow { max-width: 560px; margin: 0 auto; }
|
||||
.page-form { max-width: 480px; margin: 0 auto; }
|
||||
|
||||
/* 顶栏 */
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 20px; font-size: 14px; color: var(--text-muted);
|
||||
}
|
||||
.topbar a {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 14px; background: var(--card); border-radius: 999px;
|
||||
border: 1px solid var(--border); box-shadow: 0 1px 3px rgba(0,0,0,.04);
|
||||
}
|
||||
.topbar a:hover { border-color: var(--primary-light); background: #f8fafc; }
|
||||
|
||||
/* 卡片 */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid rgba(255,255,255, .8);
|
||||
padding: 32px 36px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.card-icon {
|
||||
width: 56px; height: 56px; margin: 0 auto 14px;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||
border-radius: 16px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 26px; box-shadow: 0 4px 14px rgba(37, 99, 235, .25);
|
||||
}
|
||||
.card-title { font-size: 22px; font-weight: 700; color: var(--text); margin: 0 0 6px; letter-spacing: -.02em; }
|
||||
.card-subtitle { font-size: 14px; color: var(--text-muted); margin: 0; }
|
||||
|
||||
/* 提示框 */
|
||||
.alert {
|
||||
padding: 12px 16px; border-radius: var(--radius-sm);
|
||||
font-size: 13px; line-height: 1.65; margin-bottom: 20px;
|
||||
}
|
||||
.alert-info { background: var(--primary-light); color: #1e40af; border: 1px solid #bfdbfe; }
|
||||
.alert-error { background: var(--danger-bg); color: #991b1b; border: 1px solid #fecaca; }
|
||||
.alert-warn { background: var(--warn-bg); color: #92400e; border: 1px solid #fde68a; }
|
||||
|
||||
/* 表单 */
|
||||
.form-group { margin-bottom: 18px; }
|
||||
.form-label {
|
||||
display: block; font-size: 13px; font-weight: 600;
|
||||
color: var(--text); margin-bottom: 6px;
|
||||
}
|
||||
.form-label .opt { font-weight: 400; color: var(--text-muted); font-size: 12px; }
|
||||
.form-input {
|
||||
width: 100%; padding: 11px 14px;
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
font-size: 15px; color: var(--text);
|
||||
background: #fafbfc; transition: border-color .15s, box-shadow .15s, background .15s;
|
||||
}
|
||||
.form-input:focus {
|
||||
outline: none; border-color: var(--primary);
|
||||
background: #fff; box-shadow: 0 0 0 3px rgba(37, 99, 235, .12);
|
||||
}
|
||||
.form-input::placeholder { color: #94a3b8; }
|
||||
.btn-primary {
|
||||
width: 100%; margin-top: 8px; padding: 13px 20px;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
color: #fff; border: none; border-radius: var(--radius-sm);
|
||||
font-size: 16px; font-weight: 600; cursor: pointer;
|
||||
box-shadow: 0 4px 14px rgba(37, 99, 235, .3);
|
||||
transition: transform .15s, box-shadow .15s;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(37, 99, 235, .35);
|
||||
}
|
||||
.form-foot { font-size: 12px; color: var(--text-muted); margin-top: 20px; text-align: center; line-height: 1.7; }
|
||||
|
||||
/* 标签 */
|
||||
.badge {
|
||||
display: inline-block; padding: 3px 10px; border-radius: 999px;
|
||||
font-size: 11px; font-weight: 700; letter-spacing: .02em;
|
||||
}
|
||||
.badge-success { background: var(--success-bg); color: var(--success); }
|
||||
.badge-info { background: var(--primary-light); color: var(--primary-dark); }
|
||||
.badge-warn { background: var(--warn-bg); color: var(--warn); }
|
||||
.badge-danger { background: var(--danger-bg); color: var(--danger); }
|
||||
|
||||
/* 候选列表 */
|
||||
.candidate-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.candidate-card {
|
||||
display: block; position: relative;
|
||||
padding: 18px 20px 18px 72px;
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
background: #fafbfc; text-decoration: none; color: inherit;
|
||||
transition: border-color .2s, box-shadow .2s, transform .2s, background .2s;
|
||||
}
|
||||
.candidate-card:hover {
|
||||
border-color: var(--primary); background: #fff;
|
||||
box-shadow: var(--shadow-hover); transform: translateY(-2px);
|
||||
}
|
||||
.candidate-card.match {
|
||||
border-color: #6ee7b7; background: linear-gradient(135deg, #f0fdf4 0%, #fff 100%);
|
||||
}
|
||||
.candidate-card.match:hover { border-color: var(--success); }
|
||||
.candidate-avatar {
|
||||
position: absolute; left: 18px; top: 50%; transform: translateY(-50%);
|
||||
width: 44px; height: 44px; border-radius: 12px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: #fff; font-size: 18px; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.candidate-card.match .candidate-avatar {
|
||||
background: linear-gradient(135deg, var(--success) 0%, #34d399 100%);
|
||||
}
|
||||
.candidate-name { font-size: 16px; font-weight: 700; color: var(--text); margin-bottom: 4px; padding-right: 100px; }
|
||||
.candidate-orcid { font-size: 13px; color: var(--primary); font-family: ui-monospace, monospace; margin-bottom: 4px; }
|
||||
.candidate-affil { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
|
||||
.candidate-meta { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.candidate-action {
|
||||
position: absolute; right: 18px; top: 50%; transform: translateY(-50%);
|
||||
font-size: 13px; font-weight: 600; color: var(--primary);
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.candidate-card:hover .candidate-action { color: var(--primary-dark); }
|
||||
|
||||
/* 信息块 */
|
||||
.info-panel {
|
||||
background: #f8fafc; border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); padding: 16px 18px; margin-bottom: 22px;
|
||||
}
|
||||
.info-panel dt { font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; margin-top: 10px; }
|
||||
.info-panel dt:first-child { margin-top: 0; }
|
||||
.info-panel dd { margin: 4px 0 0; font-size: 15px; color: var(--text); }
|
||||
|
||||
/* 报告页 */
|
||||
.report-hero {
|
||||
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 55%, #0d9488 100%);
|
||||
color: #fff; margin: -32px -36px 28px; padding: 28px 36px 24px;
|
||||
}
|
||||
.report-hero h1 { font-size: 22px; margin: 0 0 12px; font-weight: 700; letter-spacing: -.02em; }
|
||||
.report-meta { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.report-chip {
|
||||
font-size: 12px; padding: 5px 12px; border-radius: 999px;
|
||||
background: rgba(255,255,255,.15); backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255,255,255,.2);
|
||||
}
|
||||
.risk-banner {
|
||||
padding: 16px 20px; border-radius: var(--radius-sm); margin-bottom: 24px;
|
||||
font-size: 15px; font-weight: 600; display: flex; align-items: flex-start; gap: 12px;
|
||||
}
|
||||
.risk-banner::before { font-size: 22px; line-height: 1; }
|
||||
.risk-low { background: var(--success-bg); color: #065f46; border: 1px solid #a7f3d0; }
|
||||
.risk-low::before { content: "✓"; }
|
||||
.risk-mid { background: var(--warn-bg); color: #92400e; border: 1px solid #fde68a; }
|
||||
.risk-mid::before { content: "⚠"; }
|
||||
.risk-high { background: var(--danger-bg); color: #991b1b; border: 1px solid #fecaca; }
|
||||
.risk-high::before { content: "✕"; }
|
||||
.risk-default { background: #f1f5f9; color: var(--text); border: 1px solid var(--border); }
|
||||
.risk-default::before { content: "ℹ"; }
|
||||
|
||||
.sec-title {
|
||||
font-size: 16px; font-weight: 700; color: var(--text);
|
||||
margin: 28px 0 14px; padding-left: 14px;
|
||||
border-left: 4px solid var(--primary);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.sec-num {
|
||||
display: inline-flex; width: 24px; height: 24px; border-radius: 6px;
|
||||
background: var(--primary-light); color: var(--primary);
|
||||
font-size: 12px; font-weight: 800; align-items: center; justify-content: center;
|
||||
}
|
||||
.data-table {
|
||||
width: 100%; border-collapse: separate; border-spacing: 0;
|
||||
font-size: 14px; margin-bottom: 8px;
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden;
|
||||
}
|
||||
.data-table th, .data-table td {
|
||||
padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.data-table tr:last-child th, .data-table tr:last-child td { border-bottom: none; }
|
||||
.data-table th {
|
||||
background: #f8fafc; width: 30%; font-weight: 600;
|
||||
color: var(--text-muted); font-size: 13px;
|
||||
}
|
||||
.data-table td { color: var(--text); }
|
||||
.data-table tr:hover td { background: #fafbfc; }
|
||||
|
||||
.tag { display: inline-block; padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 700; }
|
||||
.tag-ok { background: var(--success-bg); color: var(--success); }
|
||||
.tag-warn { background: var(--warn-bg); color: var(--warn); }
|
||||
.tag-bad { background: var(--danger-bg); color: var(--danger); }
|
||||
|
||||
.paper-item { font-size: 14px; padding: 10px 0; border-bottom: 1px dashed var(--border); line-height: 1.65; }
|
||||
.paper-item:last-child { border-bottom: none; }
|
||||
.paper-link { color: var(--primary); font-weight: 500; }
|
||||
.paper-link:hover { text-decoration: underline; }
|
||||
.paper-meta { color: var(--text-muted); font-size: 12px; margin-left: 6px; }
|
||||
|
||||
.dup-card {
|
||||
margin: 12px 0; padding: 14px 16px;
|
||||
background: #fffbeb; border: 1px solid #fde68a;
|
||||
border-left: 4px solid var(--warn); border-radius: var(--radius-sm);
|
||||
}
|
||||
.src-tag {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 4px;
|
||||
font-size: 11px; font-weight: 700; margin-right: 4px;
|
||||
}
|
||||
.src-orcid { background: var(--accent-light); color: #0f766e; }
|
||||
.src-pubmed { background: var(--primary-light); color: var(--primary-dark); }
|
||||
.match-doi { background: var(--success-bg); color: var(--success); }
|
||||
.match-name { background: var(--warn-bg); color: var(--warn); }
|
||||
.match-loose { background: var(--danger-bg); color: var(--danger); }
|
||||
|
||||
.btn-scopus {
|
||||
display: inline-flex; align-items: center; gap: 8px; margin-top: 10px;
|
||||
padding: 12px 22px; background: linear-gradient(135deg, #ea580c 0%, #c2410c 100%);
|
||||
color: #fff !important; border-radius: var(--radius-sm);
|
||||
font-size: 14px; font-weight: 700; text-decoration: none;
|
||||
box-shadow: 0 4px 14px rgba(234, 88, 12, .25);
|
||||
transition: transform .15s, box-shadow .15s;
|
||||
}
|
||||
.btn-scopus:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(234, 88, 12, .3); color: #fff !important; }
|
||||
a.ext { color: var(--primary); font-weight: 500; }
|
||||
a.ext:hover { text-decoration: underline; }
|
||||
|
||||
.report-notes {
|
||||
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.75;
|
||||
}
|
||||
.report-notes-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.report-notes ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.report-notes li {
|
||||
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;
|
||||
}
|
||||
.stat-row { display: flex; flex-wrap: wrap; gap: 12px; margin: 16px 0 8px; }
|
||||
.stat-box {
|
||||
flex: 1; min-width: 120px; padding: 14px 16px;
|
||||
background: #f8fafc; border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
text-align: center;
|
||||
}
|
||||
.stat-box strong { display: block; font-size: 24px; color: var(--primary); font-weight: 800; line-height: 1.2; }
|
||||
.stat-box span { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body { padding: 16px 12px 32px; }
|
||||
.card { padding: 24px 20px; }
|
||||
.report-hero { margin: -24px -20px 20px; padding: 22px 20px; }
|
||||
.candidate-card { padding-left: 18px; padding-top: 52px; }
|
||||
.candidate-avatar { top: 16px; left: 18px; transform: none; }
|
||||
.candidate-name { padding-right: 0; }
|
||||
.candidate-action { top: auto; bottom: 16px; right: 18px; transform: none; }
|
||||
.data-table th, .data-table td { padding: 10px 12px; font-size: 13px; }
|
||||
}
|
||||
</style>
|
||||
52
application/api/view/author/index.html
Normal file
52
application/api/view/author/index.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>作者背调查询</title>
|
||||
{include file="author/_styles" /}
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-form">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon">📋</div>
|
||||
<h1 class="card-title">医学期刊 · 作者背景调查</h1>
|
||||
<p class="card-subtitle">青年编委 / 特约审稿人 / 作者资质初审</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
填写 <strong>ORCID</strong> 可直接生成报告;未填 ORCID 时<strong>姓氏必填</strong>、机构选填,系统按姓名检索后供您选择。
|
||||
</div>
|
||||
|
||||
{notempty name="error_msg"}
|
||||
<div class="alert alert-error">{$error_msg|htmlspecialchars}</div>
|
||||
{/notempty}
|
||||
|
||||
<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" 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" 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" 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" value="{$institution|default=''|htmlspecialchars}">
|
||||
</div>
|
||||
<button class="btn-primary" type="submit">生成背调报告</button>
|
||||
</form>
|
||||
|
||||
<p class="form-foot">
|
||||
作者可在 <a href="https://orcid.org/" target="_blank" rel="noopener">orcid.org</a> 注册或查询自己的 ORCID iD
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
55
application/api/view/author/orcid_required.html
Normal file
55
application/api/view/author/orcid_required.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>缺少 ORCID</title>
|
||||
{include file="author/_styles" /}
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-narrow">
|
||||
<div class="topbar" style="justify-content:center;">
|
||||
<a href="{$form_action}">← 返回查询页</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon" style="background:linear-gradient(135deg,#f59e0b 0%,#ef4444 100%);">⚠</div>
|
||||
<h1 class="card-title" style="color:#dc2626;">无法自动匹配 ORCID</h1>
|
||||
<p class="card-subtitle">请手动补充 ORCID 后重新生成报告</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warn">
|
||||
系统已按 <strong>姓名</strong> 在 OpenAlex、ORCID 官网、Scopus 检索,未找到可用结果。
|
||||
{notempty name="hint"}<br><br>{$hint|htmlspecialchars}{/notempty}
|
||||
</div>
|
||||
|
||||
{notempty name="submitted_name"}
|
||||
<dl class="info-panel">
|
||||
<dt>已提交姓名</dt>
|
||||
<dd>{$submitted_name|htmlspecialchars}</dd>
|
||||
{notempty name="submitted_institution"}
|
||||
<dt>已提交机构</dt>
|
||||
<dd>{$submitted_institution|htmlspecialchars}</dd>
|
||||
{/notempty}
|
||||
</dl>
|
||||
{/notempty}
|
||||
|
||||
<form method="get" action="{$form_action}">
|
||||
<div class="form-group">
|
||||
<label class="form-label">请填写 ORCID <span style="color:#dc2626">*</span></label>
|
||||
<input class="form-input" type="text" name="orcid" placeholder="0000-0002-6388-7847" required autofocus>
|
||||
</div>
|
||||
<input type="hidden" name="lastName" value="{$last_name|htmlspecialchars}">
|
||||
<input type="hidden" name="firstName" value="{$first_name|htmlspecialchars}">
|
||||
<input type="hidden" name="institution" value="{$institution|htmlspecialchars}">
|
||||
<button class="btn-primary" type="submit">补充 ORCID 并重新生成</button>
|
||||
</form>
|
||||
|
||||
<p class="form-foot">
|
||||
<a href="https://orcid.org/orcid-search/search" target="_blank" rel="noopener">在 ORCID 官网查找 ↗</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
275
application/api/view/author/report.html
Normal file
275
application/api/view/author/report.html
Normal file
@@ -0,0 +1,275 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>作者背调报告 · {$report.basic.display_name|htmlspecialchars}</title>
|
||||
{include file="author/_styles" /}
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="topbar">
|
||||
<a href="{$form_action}">← 返回查询页</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="report-hero">
|
||||
<h1>作者背景调查报告</h1>
|
||||
<div class="report-meta">
|
||||
<span class="report-chip">{$report.basic.display_name|htmlspecialchars}</span>
|
||||
{notempty name="report.query.orcid"}
|
||||
<span class="report-chip">ORCID {$report.query.orcid|htmlspecialchars}</span>
|
||||
{/notempty}
|
||||
<span class="report-chip">{$report.query.way|htmlspecialchars}</span>
|
||||
<span class="report-chip">{$report.report_at|htmlspecialchars}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="risk-banner {$risk_class}">
|
||||
<div>{$report.conclusion.risk_level|htmlspecialchars}</div>
|
||||
</div>
|
||||
|
||||
<div class="sec-title"><span class="sec-num">1</span>学术指标</div>
|
||||
<div class="stat-row">
|
||||
<div class="stat-box"><strong>{$report.metrics.works_count}</strong><span>论文总数</span></div>
|
||||
<div class="stat-box"><strong>{$report.metrics.cited_by_count}</strong><span>总被引</span></div>
|
||||
<div class="stat-box"><strong>{$report.metrics.h_index}</strong><span>H 指数</span></div>
|
||||
<div class="stat-box"><strong>{$report.metrics.i10_index}</strong><span>i10 指数</span></div>
|
||||
<div class="stat-box"><strong>{$report.metrics.pubmed_total}</strong><span>PubMed</span></div>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
{notempty name="report.metrics.pubmed_query"}
|
||||
<tr><th>PubMed 检索式</th><td>
|
||||
{$report.metrics.pubmed_query|htmlspecialchars}
|
||||
{gt name="report.metrics.pubmed_total" value="0"}
|
||||
— <a class="ext" href="{$report.metrics.pubmed_url|htmlspecialchars}" target="_blank" rel="noopener">在 PubMed 打开 ↗</a>
|
||||
{/gt}
|
||||
</td></tr>
|
||||
{/notempty}
|
||||
{notempty name="report.metrics.topics"}
|
||||
<tr><th>主要研究方向</th><td>{$topics_text|htmlspecialchars}</td></tr>
|
||||
{/notempty}
|
||||
</table>
|
||||
|
||||
<div class="sec-title"><span class="sec-num">2</span>诚信 / 不良记录</div>
|
||||
<div class="alert alert-info" style="font-size:13px;">
|
||||
<strong>比对策略:</strong>
|
||||
① 有 DOI 的 ORCID 作品 → 撤稿库 DOI 精确比对 |
|
||||
② 无 DOI → 姓名+题目回退匹配(同名有风险)<br>
|
||||
已比对 DOI <strong>{$report.retraction_watch.checked_doi_count|default=0}</strong> 篇 ·
|
||||
无 DOI <strong>{$report.retraction_watch.no_doi_count|default=0}</strong> 篇
|
||||
{gt name="rw_match_total" value="0"}
|
||||
· 命中:
|
||||
<span class="src-tag match-doi">DOI {$report.retraction_watch.doi_match_count|default=0}</span>
|
||||
<span class="src-tag match-name">姓名+题目 {$report.retraction_watch.name_match_count|default=0}</span>
|
||||
<span class="src-tag match-loose">姓名+机构 {$report.retraction_watch.name_loose_match_count|default=0}</span>
|
||||
{/gt}
|
||||
</div>
|
||||
{empty name="report.retraction_watch.ok"}
|
||||
<p><span class="tag tag-warn">⚠ {$report.retraction_watch.msg|default='撤稿数据库暂不可用'|htmlspecialchars}</span></p>
|
||||
{else/}
|
||||
{empty name="report.retraction_watch.items"}
|
||||
<p><span class="tag tag-ok">✅ 未匹配到撤稿 / 关注声明记录</span></p>
|
||||
{else/}
|
||||
<p style="margin-bottom:14px;">
|
||||
{gt name="report.retraction_watch.misconduct_count" value="0"}
|
||||
<span class="tag tag-bad">发现不端相关记录</span>
|
||||
{else/}
|
||||
<span class="tag tag-warn">发现撤稿 / 关注声明</span>
|
||||
{/gt}
|
||||
撤稿 {$report.retraction_watch.retraction_count|default=0} 条 · 不端 {$report.retraction_watch.misconduct_count|default=0} 条
|
||||
</p>
|
||||
<ul style="list-style:none;padding:0;margin:0;">
|
||||
{volist name="report.retraction_watch.items" id="it"}
|
||||
<li class="paper-item" style="padding:14px 0;">
|
||||
{if condition="$it['match_type'] eq 'doi'"}
|
||||
<span class="src-tag match-doi">{$it.match_label|htmlspecialchars}</span>
|
||||
{elseif condition="$it['match_type'] eq 'name'"/}
|
||||
<span class="src-tag match-name">{$it.match_label|htmlspecialchars}</span>
|
||||
{else/}
|
||||
<span class="src-tag match-loose">{$it.match_label|htmlspecialchars}</span>
|
||||
{/if}
|
||||
<strong>{$it.nature|htmlspecialchars}</strong> —
|
||||
<a class="paper-link" href="{$it.link_url|htmlspecialchars}" target="_blank" rel="noopener">{$it.display_title|htmlspecialchars} ↗</a>
|
||||
{notempty name="it.misconduct"}<em style="color:#dc2626;"> [不端]</em>{/notempty}
|
||||
<br><small style="color:var(--text-muted);">
|
||||
{notempty name="it.doi"}DOI <a class="ext" href="https://doi.org/{$it.doi|htmlspecialchars}" target="_blank">{$it.doi|htmlspecialchars}</a> · {/notempty}
|
||||
{$it.reason_short|htmlspecialchars} · {$it.date|htmlspecialchars}
|
||||
</small>
|
||||
</li>
|
||||
{/volist}
|
||||
</ul>
|
||||
{/empty}
|
||||
{/empty}
|
||||
|
||||
<div class="sec-title"><span class="sec-num">3</span>基本信息</div>
|
||||
<table class="data-table">
|
||||
<tr><th>姓名</th><td><strong>{$report.basic.display_name|htmlspecialchars}</strong></td></tr>
|
||||
<tr><th>ORCID</th><td><a class="ext" href="{$report.basic.orcid_url|htmlspecialchars}" target="_blank" rel="noopener">{$report.basic.orcid|htmlspecialchars} ↗</a></td></tr>
|
||||
{notempty name="report.basic.orcid_affiliations"}
|
||||
<tr><th>ORCID 单位</th><td>{$orcid_affiliations_text|htmlspecialchars}</td></tr>
|
||||
{/notempty}
|
||||
{notempty name="report.basic.openalex_institutions"}
|
||||
<tr><th>OpenAlex 机构</th><td>{$openalex_institutions_text|htmlspecialchars}</td></tr>
|
||||
{/notempty}
|
||||
{notempty name="report.basic.openalex_url"}
|
||||
<tr><th>OpenAlex 档案</th><td><a class="ext" href="{$report.basic.openalex_url|htmlspecialchars}" target="_blank" rel="noopener">查看学者页面 ↗</a></td></tr>
|
||||
{/notempty}
|
||||
{notempty name="report.basic.scopus_url"}
|
||||
<tr><th>Scopus 作者页</th><td>
|
||||
<a class="ext" href="{$report.basic.scopus_url|htmlspecialchars}" target="_blank" rel="noopener">
|
||||
打开 Scopus 档案(ID: {$report.basic.scopus_id|htmlspecialchars})↗
|
||||
</a>
|
||||
</td></tr>
|
||||
{/notempty}
|
||||
</table>
|
||||
|
||||
<div class="sec-title"><span class="sec-num">4</span>Scopus 检索</div>
|
||||
{notempty name="report.scopus.search_url"}
|
||||
<p style="font-size:14px;color:var(--text-muted);margin:0 0 10px;">
|
||||
检索方式:{notempty name="report.query.orcid"}ORCID {$report.query.orcid|htmlspecialchars}{else/}{$report.query.first_name|htmlspecialchars} {$report.query.last_name|htmlspecialchars} · {$report.query.institution|htmlspecialchars}{/notempty}
|
||||
</p>
|
||||
<a class="btn-scopus" href="{$report.scopus.search_url|htmlspecialchars}" target="_blank" rel="noopener">打开 Scopus 作者检索 ↗</a>
|
||||
<p style="font-size:12px;color:var(--text-muted);margin-top:10px;">需机构 Scopus 订阅账号登录后查看完整数据</p>
|
||||
{else/}
|
||||
<div class="alert alert-warn">请填写 ORCID,或填写姓名与机构后生成 Scopus 链接。</div>
|
||||
{/notempty}
|
||||
|
||||
{notempty name="report.scopus.api.ok"}
|
||||
<p style="margin-top:18px;font-size:14px;"><strong>Scopus API:</strong> {$report.scopus.api.msg|htmlspecialchars}</p>
|
||||
{notempty name="report.scopus.api.entries"}
|
||||
<table class="data-table" style="margin-top:10px;">
|
||||
<tr>
|
||||
<th>姓名</th><th>机构</th><th>论文</th><th>被引</th><th>H指数</th><th>操作</th>
|
||||
</tr>
|
||||
{volist name="report.scopus.api.entries" id="se"}
|
||||
<tr>
|
||||
<td>{$se.name|htmlspecialchars}</td>
|
||||
<td>{$se.affiliation|htmlspecialchars}</td>
|
||||
<td>{$se.document_count}</td>
|
||||
<td>{$se.cited_by_count}</td>
|
||||
<td>{$se.h_index}</td>
|
||||
<td>
|
||||
{notempty name="se.url"}
|
||||
<a class="ext" href="{$se.url|htmlspecialchars}" target="_blank" rel="noopener">进入 ↗</a>
|
||||
{else/}—{/notempty}
|
||||
</td>
|
||||
</tr>
|
||||
{/volist}
|
||||
</table>
|
||||
{/notempty}
|
||||
{else/}
|
||||
<p style="font-size:13px;color:var(--text-muted);margin-top:12px;">{$report.scopus.api.msg|default='未配置 Scopus API Key'|htmlspecialchars}</p>
|
||||
{/notempty}
|
||||
|
||||
<div class="sec-title"><span class="sec-num">5</span>本地辅助检测</div>
|
||||
<p>
|
||||
题目重复:
|
||||
{empty name="report.duplicates"}
|
||||
<span class="tag tag-ok">未发现</span>
|
||||
{else/}
|
||||
<span class="tag tag-warn">{$dup_group_count} 组重复(共 {$dup_paper_count} 篇)</span>
|
||||
{/empty}
|
||||
</p>
|
||||
{notempty name="report.duplicates"}
|
||||
{volist name="report.duplicates" id="dg" key="gi"}
|
||||
<div class="dup-card">
|
||||
<strong>第 {$gi} 组:</strong>{$dg.title|htmlspecialchars}
|
||||
<small style="color:var(--text-muted);">({$dg.paper_count} 篇相同)</small>
|
||||
<ul style="margin:10px 0 0;padding-left:18px;">
|
||||
{volist name="dg.papers" id="dp" key="pi"}
|
||||
<li class="paper-item" style="border:none;padding:6px 0;">
|
||||
<span class="src-tag src-{$dp.source_class}">{$dp.source|default='未知'|htmlspecialchars}</span>
|
||||
[{$dp.year|htmlspecialchars}]
|
||||
{notempty name="dp.open_url"}
|
||||
<a class="paper-link" href="{$dp.open_url|htmlspecialchars}" target="_blank" rel="noopener">{$dp.title|htmlspecialchars} ↗</a>
|
||||
{else/}{$dp.title|htmlspecialchars}{/notempty}
|
||||
{notempty name="dp.journal"} <em style="color:var(--text-muted);">— {$dp.journal|htmlspecialchars}</em>{/notempty}
|
||||
</li>
|
||||
{/volist}
|
||||
</ul>
|
||||
</div>
|
||||
{/volist}
|
||||
{/notempty}
|
||||
|
||||
{gt name="report.metrics.pubmed_total" value="0"}
|
||||
<div class="sec-title"><span class="sec-num">6</span>PubMed 文献({$report.metrics.pubmed_total} 篇)</div>
|
||||
{empty name="report.pubmed_papers"}
|
||||
<p style="color:var(--text-muted);font-size:14px;">未获取到文献详情,请点击上方 PubMed 链接查看。</p>
|
||||
{else/}
|
||||
{volist name="report.pubmed_papers" id="p" key="i" length="10"}
|
||||
<div class="paper-item">{$i}. [{$p.year|htmlspecialchars}]
|
||||
{notempty name="p.open_url"}
|
||||
<a class="paper-link" href="{$p.open_url|htmlspecialchars}" target="_blank" rel="noopener">{$p.title|htmlspecialchars} ↗</a>
|
||||
{else/}{$p.title|htmlspecialchars}{/notempty}
|
||||
{notempty name="p.journal"} <em style="color:var(--text-muted);">— {$p.journal|htmlspecialchars}</em>{/notempty}
|
||||
<span class="paper-meta">
|
||||
{notempty name="p.pmid"}<a class="ext" href="https://pubmed.ncbi.nlm.nih.gov/{$p.pmid|htmlspecialchars}/" target="_blank">PubMed</a>{/notempty}
|
||||
{notempty name="p.doi"} · <a class="ext" href="https://doi.org/{$p.doi|htmlspecialchars}" target="_blank">DOI</a>{/notempty}
|
||||
</span>
|
||||
</div>
|
||||
{/volist}
|
||||
{/empty}
|
||||
{/gt}
|
||||
|
||||
{notempty name="report.orcid_papers.papers"}
|
||||
<div class="sec-title"><span class="sec-num">{$orcid_section_num}</span>ORCID 作品({$report.orcid_papers.total} 条)</div>
|
||||
{volist name="report.orcid_papers.papers" id="p" key="i" length="10"}
|
||||
<div class="paper-item">{$i}. [{$p.year|htmlspecialchars}]
|
||||
{notempty name="p.open_url"}
|
||||
<a class="paper-link" href="{$p.open_url|htmlspecialchars}" target="_blank" rel="noopener">{$p.title|htmlspecialchars} ↗</a>
|
||||
{else/}{$p.title|htmlspecialchars}{/notempty}
|
||||
{notempty name="p.journal"} <em style="color:var(--text-muted);">— {$p.journal|htmlspecialchars}</em>{/notempty}
|
||||
<span class="paper-meta">
|
||||
{notempty name="p.doi"}<a class="ext" href="https://doi.org/{$p.doi|htmlspecialchars}" target="_blank">DOI</a>{/notempty}
|
||||
{notempty name="p.pmid"} · <a class="ext" href="https://pubmed.ncbi.nlm.nih.gov/{$p.pmid|htmlspecialchars}/" target="_blank">PubMed</a>{/notempty}
|
||||
</span>
|
||||
</div>
|
||||
{/volist}
|
||||
{/notempty}
|
||||
|
||||
{notempty name="report.conclusion.notes"}
|
||||
<div class="report-notes">
|
||||
<p class="report-notes-title">说明</p>
|
||||
<ul>
|
||||
{volist name="report.conclusion.notes" id="note"}
|
||||
<li>{$note|htmlspecialchars}</li>
|
||||
{/volist}
|
||||
</ul>
|
||||
</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>
|
||||
适用于青年编委 / 特约审稿人 / 作者资质初审
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
48
application/api/view/author/select_orcid.html
Normal file
48
application/api/view/author/select_orcid.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>选择作者 ORCID</title>
|
||||
{include file="author/_styles" /}
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="topbar">
|
||||
<a href="{$form_action}">← 返回查询页</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header" style="text-align:left;border-bottom:none;padding-bottom:0;margin-bottom:20px;">
|
||||
<h1 class="card-title" style="text-align:left;">匹配到 {$candidate_count} 位作者</h1>
|
||||
<p class="card-subtitle" style="text-align:left;margin-top:8px;">
|
||||
检索姓名:<strong>{$submitted_name|htmlspecialchars}</strong>
|
||||
{notempty name="submitted_institution"} · 参考机构:<strong>{$submitted_institution|htmlspecialchars}</strong>{/notempty}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
已按<strong>姓名</strong>检索 ORCID。若填写了机构,<span class="badge badge-success">机构一致</span> 的候选项排在最前,请点击确认后生成报告。
|
||||
</div>
|
||||
|
||||
<div class="candidate-list">
|
||||
{volist name="candidates" id="item"}
|
||||
<a class="candidate-card {$item.matched_class}" href="{$item.report_url|htmlspecialchars}">
|
||||
<div class="candidate-avatar">{$item.avatar_letter|htmlspecialchars}</div>
|
||||
<span class="candidate-action">生成报告 →</span>
|
||||
<div class="candidate-name">{$item.display_name|default='(姓名未知)'|htmlspecialchars}</div>
|
||||
<div class="candidate-orcid">{$item.orcid|htmlspecialchars}</div>
|
||||
{notempty name="item.affiliations_text"}
|
||||
<div class="candidate-affil">{$item.affiliations_text|htmlspecialchars}</div>
|
||||
{/notempty}
|
||||
<div class="candidate-meta">
|
||||
{eq name="item.institution_matched" value="1"}<span class="badge badge-success">机构一致</span>{/eq}
|
||||
{notempty name="item.sources_text"}<span class="badge badge-info">{$item.sources_text|htmlspecialchars}</span>{/notempty}
|
||||
</div>
|
||||
</a>
|
||||
{/volist}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,4 +9,6 @@
|
||||
// | Author: yunwuxin <448901948@qq.com>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
return [];
|
||||
return [
|
||||
'app\\command\\ReferenceCheckMqConsume',
|
||||
];
|
||||
|
||||
77
application/command/ReferenceCheckMqConsume.php
Normal file
77
application/command/ReferenceCheckMqConsume.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
use app\common\mq\RabbitMqConfig;
|
||||
use app\common\mq\ReferenceCheckArticleWorker;
|
||||
|
||||
class ReferenceCheckMqConsume extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('reference_check:mq-consume')
|
||||
->setDescription('Consume RabbitMQ reference check article queue');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
if (!class_exists('\\PhpAmqpLib\\Connection\\AMQPStreamConnection')) {
|
||||
$output->writeln('<error>php-amqplib not installed. Run: php composer.phar require php-amqplib/php-amqplib:^2.12</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$rc = RabbitMqConfig::referenceCheck();
|
||||
$exchange = isset($rc['exchange']) ? $rc['exchange'] : 'reference_check';
|
||||
$queue = isset($rc['queue']) ? $rc['queue'] : 'ref_check.article';
|
||||
$routeKey = isset($rc['route_key']) ? $rc['route_key'] : 'article.start';
|
||||
|
||||
$conn = new \PhpAmqpLib\Connection\AMQPStreamConnection(
|
||||
RabbitMqConfig::get('host', '127.0.0.1'),
|
||||
intval(RabbitMqConfig::get('port', 5672)),
|
||||
RabbitMqConfig::get('user', 'guest'),
|
||||
RabbitMqConfig::get('password', 'guest'),
|
||||
RabbitMqConfig::get('vhost', '/')
|
||||
);
|
||||
$ch = $conn->channel();
|
||||
$ch->exchange_declare($exchange, 'direct', false, true, false);
|
||||
$dlq = isset($rc['dlq']) ? $rc['dlq'] : 'ref_check.article.dlq';
|
||||
$ch->queue_declare($dlq, false, true, false, false);
|
||||
$ch->queue_declare($queue, false, true, false, false, false, new \PhpAmqpLib\Wire\AMQPTable([
|
||||
'x-dead-letter-exchange' => '',
|
||||
'x-dead-letter-routing-key' => $dlq,
|
||||
]));
|
||||
$ch->queue_bind($queue, $exchange, $routeKey);
|
||||
$ch->basic_qos(null, 1, null);
|
||||
|
||||
$output->writeln('Waiting on queue: ' . $queue);
|
||||
|
||||
$worker = new ReferenceCheckArticleWorker();
|
||||
$callback = function ($msg) use ($worker, $output) {
|
||||
$payload = json_decode($msg->body, true);
|
||||
if (!is_array($payload)) {
|
||||
$msg->ack();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$worker->handleMessage($payload);
|
||||
$msg->ack();
|
||||
} catch (\Exception $e) {
|
||||
\think\Log::error('reference_check:mq-consume ' . $e->getMessage());
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
$msg->nack(false, false);
|
||||
}
|
||||
};
|
||||
|
||||
$ch->basic_consume($queue, '', false, false, false, false, $callback);
|
||||
while ($ch->is_consuming()) {
|
||||
$ch->wait();
|
||||
}
|
||||
|
||||
$ch->close();
|
||||
$conn->close();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
705
application/common/BackgroundCheckService.php
Normal file
705
application/common/BackgroundCheckService.php
Normal file
@@ -0,0 +1,705 @@
|
||||
<?php
|
||||
|
||||
namespace app\common;
|
||||
|
||||
/**
|
||||
* 背景调查公共服务
|
||||
* 封装 OpenAlex / CrossRef / Retraction Watch 数据查询
|
||||
*/
|
||||
class BackgroundCheckService
|
||||
{
|
||||
private $openAlexBase = 'https://api.openalex.org';
|
||||
private $crossRefBase = 'https://api.crossref.org';
|
||||
private $mailto = 'publisher@tmrjournals.com';
|
||||
|
||||
// ===================== OpenAlex =====================
|
||||
|
||||
public function openAlexGet($path, $query = [])
|
||||
{
|
||||
$query['mailto'] = $this->mailto;
|
||||
$url = $this->openAlexBase . $path . '?' . http_build_query($query);
|
||||
|
||||
$result = $this->httpGet($url, [
|
||||
'Accept: application/json',
|
||||
'User-Agent: TMRJournals-BackgroundCheck/1.0 (mailto:' . $this->mailto . ')',
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$data = json_decode($result['body'], true);
|
||||
if (!is_array($data)) {
|
||||
return ['success' => false, 'error' => 'OpenAlex返回数据格式异常'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'data' => $data];
|
||||
}
|
||||
|
||||
public function resolveAuthor($params)
|
||||
{
|
||||
if (!empty($params['openalex_id'])) {
|
||||
$id = preg_replace('/^https?:\/\/openalex\.org\//', '', $params['openalex_id']);
|
||||
$res = $this->openAlexGet('/authors/' . urlencode($id));
|
||||
if (!$res['success']) {
|
||||
return ['success' => false, 'error' => $res['error']];
|
||||
}
|
||||
return ['success' => true, 'data' => $res['data']];
|
||||
}
|
||||
|
||||
if (!empty($params['orcid'])) {
|
||||
$orcid = $this->cleanOrcid($params['orcid']);
|
||||
$res = $this->openAlexGet('/authors/https://orcid.org/' . $orcid);
|
||||
if (!$res['success']) {
|
||||
return ['success' => false, 'error' => '未在 OpenAlex 找到该 ORCID 对应学者'];
|
||||
}
|
||||
return ['success' => true, 'data' => $res['data']];
|
||||
}
|
||||
|
||||
if (empty($params['name'])) {
|
||||
return ['success' => false, 'error' => '请提供 openalex_id、orcid 或 name'];
|
||||
}
|
||||
|
||||
$filter = 'display_name.search:' . $params['name'];
|
||||
if (!empty($params['affiliation'])) {
|
||||
$filter .= ',last_known_institutions.display_name.search:' . $params['affiliation'];
|
||||
}
|
||||
|
||||
$res = $this->openAlexGet('/authors', [
|
||||
'search' => $params['name'],
|
||||
'filter' => $filter,
|
||||
'sort' => 'cited_by_count:desc',
|
||||
'per-page' => 1,
|
||||
]);
|
||||
|
||||
if (!$res['success']) {
|
||||
return ['success' => false, 'error' => $res['error']];
|
||||
}
|
||||
|
||||
$results = $res['data']['results'] ?? [];
|
||||
if (empty($results)) {
|
||||
return ['success' => false, 'error' => '未找到匹配学者,请补充 affiliation 或使用 orcid'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'data' => $results[0]];
|
||||
}
|
||||
|
||||
public function fetchRetractedWorksOpenAlex($openAlexId)
|
||||
{
|
||||
$res = $this->openAlexGet('/works', [
|
||||
'filter' => 'authorships.author.id:' . $openAlexId . ',is_retracted:true',
|
||||
'sort' => 'publication_date:desc',
|
||||
'per-page' => 25,
|
||||
]);
|
||||
|
||||
if (!$res['success']) {
|
||||
return ['count' => 0, 'list' => [], 'error' => $res['error']];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($res['data']['results'] ?? [] as $work) {
|
||||
$list[] = $this->formatOpenAlexWork($work);
|
||||
}
|
||||
|
||||
return ['count' => count($list), 'list' => $list, 'source' => 'openalex'];
|
||||
}
|
||||
|
||||
public function fetchRecentWorks($openAlexId, $limit = 5)
|
||||
{
|
||||
$res = $this->openAlexGet('/works', [
|
||||
'filter' => 'authorships.author.id:' . $openAlexId,
|
||||
'sort' => 'publication_date:desc',
|
||||
'per-page' => $limit,
|
||||
]);
|
||||
|
||||
if (!$res['success']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($res['data']['results'] ?? [] as $work) {
|
||||
$item = $this->formatOpenAlexWork($work);
|
||||
$item['is_retracted'] = !empty($work['is_retracted']);
|
||||
$list[] = $item;
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按领域/关键词批量搜索学者(OpenAlex)
|
||||
*/
|
||||
public function searchAuthorsByField($keyword, $options = [])
|
||||
{
|
||||
$minHIndex = intval($options['min_h_index'] ?? 5);
|
||||
$limit = min(max(intval($options['limit'] ?? 10), 1), 30);
|
||||
$page = max(intval($options['page'] ?? 1), 1);
|
||||
|
||||
$topicId = $this->resolveTopicId($keyword);
|
||||
$filters = [];
|
||||
|
||||
if ($topicId !== '') {
|
||||
$filters[] = 'topics.id:' . $topicId;
|
||||
}
|
||||
if ($minHIndex > 0) {
|
||||
$filters[] = 'summary_stats.h_index:>' . $minHIndex;
|
||||
}
|
||||
|
||||
$query = [
|
||||
'sort' => 'cited_by_count:desc',
|
||||
'per-page' => $limit,
|
||||
'page' => $page,
|
||||
];
|
||||
|
||||
if (!empty($filters)) {
|
||||
$query['filter'] = implode(',', $filters);
|
||||
$query['search'] = $keyword;
|
||||
} else {
|
||||
$query['search'] = $keyword;
|
||||
}
|
||||
|
||||
$res = $this->openAlexGet('/authors', $query);
|
||||
if (!$res['success']) {
|
||||
return ['success' => false, 'error' => $res['error']];
|
||||
}
|
||||
|
||||
$authors = [];
|
||||
foreach ($res['data']['results'] ?? [] as $author) {
|
||||
$authors[] = $this->formatAuthorBrief($author);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'keyword' => $keyword,
|
||||
'topic_id' => $topicId,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'total' => $res['data']['meta']['count'] ?? count($authors),
|
||||
'list' => $authors,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveTopicId($keyword)
|
||||
{
|
||||
$res = $this->openAlexGet('/topics', [
|
||||
'search' => $keyword,
|
||||
'sort' => 'works_count:desc',
|
||||
'per-page' => 1,
|
||||
]);
|
||||
|
||||
if (!$res['success']) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$results = $res['data']['results'] ?? [];
|
||||
if (empty($results)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->extractOpenAlexId($results[0]['id'] ?? '');
|
||||
}
|
||||
|
||||
// ===================== CrossRef =====================
|
||||
|
||||
public function cleanDoi($doi)
|
||||
{
|
||||
$doi = trim($doi);
|
||||
$doi = preg_replace('/^https?:\/\/doi\.org\//', '', $doi);
|
||||
$doi = preg_replace('/^doi:\s*/i', '', $doi);
|
||||
return trim($doi);
|
||||
}
|
||||
|
||||
public function fetchCrossRefWork($doi)
|
||||
{
|
||||
$doi = $this->cleanDoi($doi);
|
||||
if ($doi === '') {
|
||||
return ['success' => false, 'error' => 'DOI为空'];
|
||||
}
|
||||
|
||||
$url = $this->crossRefBase . '/works/' . urlencode($doi);
|
||||
$result = $this->httpGet($url, [
|
||||
'Accept: application/json',
|
||||
'User-Agent: TMRJournals-BackgroundCheck/1.0 (mailto:' . $this->mailto . ')',
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return ['success' => false, 'error' => $result['error']];
|
||||
}
|
||||
|
||||
if ($result['http_code'] == 404) {
|
||||
return ['success' => false, 'error' => 'DOI在CrossRef中未找到'];
|
||||
}
|
||||
if ($result['http_code'] != 200) {
|
||||
return ['success' => false, 'error' => 'CrossRef返回 HTTP ' . $result['http_code']];
|
||||
}
|
||||
|
||||
$data = json_decode($result['body'], true);
|
||||
if (!isset($data['message'])) {
|
||||
return ['success' => false, 'error' => 'CrossRef返回数据格式异常'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => $data['message']];
|
||||
}
|
||||
|
||||
public function parseCrossRefRetractionDetail($doi, $message)
|
||||
{
|
||||
$retraction = $this->detectCrossRefRetraction($message);
|
||||
|
||||
return [
|
||||
'doi' => $this->cleanDoi($doi),
|
||||
'title' => isset($message['title'][0]) ? $message['title'][0] : '',
|
||||
'is_retracted' => $retraction['is_retracted'],
|
||||
'retraction_detail' => $retraction['retraction_detail'],
|
||||
'journal' => isset($message['container-title'][0]) ? $message['container-title'][0] : '',
|
||||
'publisher' => $message['publisher'] ?? '',
|
||||
'published_date' => isset($message['published-print']) ? $this->parseDateParts($message['published-print']) : '',
|
||||
'authors' => $this->parseCrossRefAuthors($message['author'] ?? []),
|
||||
'url' => $message['URL'] ?? ('https://doi.org/' . $this->cleanDoi($doi)),
|
||||
];
|
||||
}
|
||||
|
||||
public function enrichRetractionsWithCrossRef($retractionList)
|
||||
{
|
||||
$enriched = [];
|
||||
foreach ($retractionList as $item) {
|
||||
$doi = $this->cleanDoi($item['doi'] ?? '');
|
||||
if ($doi === '') {
|
||||
$item['crossref'] = ['success' => false, 'error' => '无DOI'];
|
||||
$enriched[] = $item;
|
||||
continue;
|
||||
}
|
||||
|
||||
$res = $this->fetchCrossRefWork($doi);
|
||||
if (!$res['success']) {
|
||||
$item['crossref'] = ['success' => false, 'error' => $res['error']];
|
||||
} else {
|
||||
$item['crossref'] = [
|
||||
'success' => true,
|
||||
'data' => $this->parseCrossRefRetractionDetail($doi, $res['message']),
|
||||
];
|
||||
}
|
||||
|
||||
$enriched[] = $item;
|
||||
usleep(200000);
|
||||
}
|
||||
|
||||
return $enriched;
|
||||
}
|
||||
|
||||
private function detectCrossRefRetraction($message)
|
||||
{
|
||||
$isRetracted = false;
|
||||
$retractionDetail = [
|
||||
'sources' => [],
|
||||
'retraction_notices' => [],
|
||||
'record_ids' => [],
|
||||
];
|
||||
|
||||
foreach (['updated-by', 'update-to'] as $field) {
|
||||
if (!isset($message[$field]) || !is_array($message[$field])) {
|
||||
continue;
|
||||
}
|
||||
foreach ($message[$field] as $update) {
|
||||
$updateType = strtolower($update['type'] ?? '');
|
||||
$updateLabel = strtolower($update['label'] ?? '');
|
||||
if (strpos($updateType, 'retract') === false && strpos($updateLabel, 'retract') === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isRetracted = true;
|
||||
$source = $update['source'] ?? 'publisher';
|
||||
$retractionDetail['sources'][] = $source;
|
||||
|
||||
$notice = [
|
||||
'type' => $update['type'] ?? '',
|
||||
'label' => $update['label'] ?? '',
|
||||
'source' => $source,
|
||||
'notice_doi'=> $update['DOI'] ?? '',
|
||||
'date' => isset($update['updated']) ? $this->parseDateParts($update['updated']) : '',
|
||||
'record_id' => $update['record-id'] ?? '',
|
||||
];
|
||||
$retractionDetail['retraction_notices'][] = $notice;
|
||||
|
||||
if (!empty($notice['record_id'])) {
|
||||
$retractionDetail['record_ids'][] = $notice['record_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$type = strtolower($message['type'] ?? '');
|
||||
$subtype = strtolower($message['subtype'] ?? '');
|
||||
if (strpos($type, 'retract') !== false || strpos($subtype, 'retract') !== false) {
|
||||
$isRetracted = true;
|
||||
$retractionDetail['is_retraction_notice'] = true;
|
||||
}
|
||||
|
||||
if (isset($message['relation']) && is_array($message['relation'])) {
|
||||
foreach ($message['relation'] as $relType => $relations) {
|
||||
if (strpos(strtolower($relType), 'retract') !== false) {
|
||||
$isRetracted = true;
|
||||
$retractionDetail['relation'] = [$relType => $relations];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$retractionDetail['sources'] = array_values(array_unique($retractionDetail['sources']));
|
||||
$retractionDetail['record_ids'] = array_values(array_unique($retractionDetail['record_ids']));
|
||||
|
||||
return ['is_retracted' => $isRetracted, 'retraction_detail' => $retractionDetail];
|
||||
}
|
||||
|
||||
// ===================== Retraction Watch (via CrossRef) =====================
|
||||
|
||||
/**
|
||||
* 通过 CrossRef 检索 Retraction Watch 来源的撤稿记录(按作者姓名)
|
||||
*/
|
||||
public function fetchRetractionWatchByAuthor($authorName)
|
||||
{
|
||||
$url = $this->crossRefBase . '/works?' . http_build_query([
|
||||
'query.author' => $authorName,
|
||||
'filter' => 'update-type:retraction',
|
||||
'rows' => 25,
|
||||
'mailto' => $this->mailto,
|
||||
]);
|
||||
|
||||
$result = $this->httpGet($url, [
|
||||
'Accept: application/json',
|
||||
'User-Agent: TMRJournals-BackgroundCheck/1.0 (mailto:' . $this->mailto . ')',
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return ['count' => 0, 'list' => [], 'error' => $result['error']];
|
||||
}
|
||||
|
||||
if ($result['http_code'] != 200) {
|
||||
return ['count' => 0, 'list' => [], 'error' => 'CrossRef返回 HTTP ' . $result['http_code']];
|
||||
}
|
||||
|
||||
$data = json_decode($result['body'], true);
|
||||
$items = $data['message']['items'] ?? [];
|
||||
|
||||
$list = [];
|
||||
foreach ($items as $message) {
|
||||
$parsed = $this->parseCrossRefRetractionDetail($message['DOI'] ?? '', $message);
|
||||
if (!$parsed['is_retracted']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rwSources = array_filter($parsed['retraction_detail']['sources'] ?? [], function ($s) {
|
||||
return stripos($s, 'retraction-watch') !== false || stripos($s, 'retraction_watch') !== false;
|
||||
});
|
||||
|
||||
$list[] = [
|
||||
'title' => $parsed['title'],
|
||||
'doi' => $parsed['doi'],
|
||||
'journal' => $parsed['journal'],
|
||||
'publisher' => $parsed['publisher'],
|
||||
'published_date' => $parsed['published_date'],
|
||||
'is_retracted' => true,
|
||||
'retraction_detail' => $parsed['retraction_detail'],
|
||||
'from_retraction_watch' => !empty($rwSources) || !empty($parsed['retraction_detail']['record_ids']),
|
||||
'source' => 'retraction_watch',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'count' => count($list),
|
||||
'list' => $list,
|
||||
'source' => 'retraction_watch',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 OpenAlex + Retraction Watch 撤稿记录(按 DOI 去重)
|
||||
*/
|
||||
public function mergeRetractionRecords($openAlexRetractions, $rwRetractions, $withCrossRefDetail = false)
|
||||
{
|
||||
$merged = [];
|
||||
$doiMap = [];
|
||||
|
||||
foreach ([$openAlexRetractions, $rwRetractions] as $sourceData) {
|
||||
foreach ($sourceData['list'] ?? [] as $item) {
|
||||
$doi = $this->cleanDoi($item['doi'] ?? '');
|
||||
$key = $doi !== '' ? strtolower($doi) : md5(json_encode($item));
|
||||
|
||||
if (!isset($doiMap[$key])) {
|
||||
$doiMap[$key] = [
|
||||
'title' => $item['title'] ?? '',
|
||||
'doi' => $doi,
|
||||
'journal' => $item['journal'] ?? '',
|
||||
'publication_date' => $item['publication_date'] ?? ($item['published_date'] ?? ''),
|
||||
'sources' => [],
|
||||
'retraction_detail'=> $item['retraction_detail'] ?? [],
|
||||
'from_retraction_watch' => !empty($item['from_retraction_watch']),
|
||||
];
|
||||
}
|
||||
|
||||
$src = $item['source'] ?? 'unknown';
|
||||
if (!in_array($src, $doiMap[$key]['sources'])) {
|
||||
$doiMap[$key]['sources'][] = $src;
|
||||
}
|
||||
if (!empty($item['from_retraction_watch'])) {
|
||||
$doiMap[$key]['from_retraction_watch'] = true;
|
||||
}
|
||||
if (!empty($item['retraction_detail']) && empty($doiMap[$key]['retraction_detail'])) {
|
||||
$doiMap[$key]['retraction_detail'] = $item['retraction_detail'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$merged = array_values($doiMap);
|
||||
|
||||
if ($withCrossRefDetail) {
|
||||
$merged = $this->enrichRetractionsWithCrossRef($merged);
|
||||
}
|
||||
|
||||
$rwOnlyCount = 0;
|
||||
foreach ($merged as $row) {
|
||||
if (!empty($row['from_retraction_watch']) && count($row['sources'] ?? []) <= 1) {
|
||||
$rwOnlyCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'count' => count($merged),
|
||||
'openalex_count' => intval($openAlexRetractions['count'] ?? 0),
|
||||
'rw_count' => intval($rwRetractions['count'] ?? 0),
|
||||
'rw_only_count' => $rwOnlyCount,
|
||||
'list' => $merged,
|
||||
];
|
||||
}
|
||||
|
||||
// ===================== 格式化 =====================
|
||||
|
||||
public function formatAuthorBrief($author)
|
||||
{
|
||||
$institutions = [];
|
||||
foreach ($author['last_known_institutions'] ?? [] as $inst) {
|
||||
$institutions[] = [
|
||||
'name' => $inst['display_name'] ?? '',
|
||||
'country' => $inst['country_code'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'openalex_id' => $this->extractOpenAlexId($author['id'] ?? ''),
|
||||
'name' => $author['display_name'] ?? '',
|
||||
'orcid' => $this->extractOrcid($author['orcid'] ?? ''),
|
||||
'works_count' => intval($author['works_count'] ?? 0),
|
||||
'cited_by_count' => intval($author['cited_by_count'] ?? 0),
|
||||
'h_index' => intval($author['summary_stats']['h_index'] ?? 0),
|
||||
'institutions' => $institutions,
|
||||
'openalex_url' => $author['id'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
public function parseAuthorMetrics($author)
|
||||
{
|
||||
$stats = $author['summary_stats'] ?? [];
|
||||
|
||||
return [
|
||||
'works_count' => intval($author['works_count'] ?? 0),
|
||||
'cited_by_count' => intval($author['cited_by_count'] ?? 0),
|
||||
'h_index' => intval($stats['h_index'] ?? 0),
|
||||
'i10_index' => intval($stats['i10_index'] ?? 0),
|
||||
'two_year_mean_cited' => round(floatval($stats['2yr_mean_citedness'] ?? 0), 2),
|
||||
'level_label' => $this->getAcademicLevelLabel($stats),
|
||||
];
|
||||
}
|
||||
|
||||
public function parseResearchTopics($author)
|
||||
{
|
||||
$topics = [];
|
||||
foreach ($author['x_concepts'] ?? [] as $concept) {
|
||||
if (empty($concept['display_name'])) {
|
||||
continue;
|
||||
}
|
||||
$topics[] = [
|
||||
'name' => $concept['display_name'],
|
||||
'score' => round(floatval($concept['score'] ?? 0), 3),
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($topics)) {
|
||||
foreach ($author['topics'] ?? [] as $topic) {
|
||||
if (empty($topic['display_name'])) {
|
||||
continue;
|
||||
}
|
||||
$topics[] = [
|
||||
'name' => $topic['display_name'],
|
||||
'score' => round(floatval($topic['score'] ?? 0), 3),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return array_slice($topics, 0, 8);
|
||||
}
|
||||
|
||||
public function assessRisk($metrics, $retractions)
|
||||
{
|
||||
$retractionCount = intval($retractions['count'] ?? 0);
|
||||
$rwOnlyCount = intval($retractions['rw_only_count'] ?? 0);
|
||||
$level = 'low';
|
||||
$score = 0;
|
||||
$reasons = [];
|
||||
|
||||
if ($retractionCount === 0) {
|
||||
$level = 'low';
|
||||
$score = 10;
|
||||
$reasons[] = 'OpenAlex 与 Retraction Watch 均未发现撤稿记录';
|
||||
} elseif ($retractionCount === 1) {
|
||||
$level = 'medium';
|
||||
$score = 50;
|
||||
$reasons[] = '发现 1 篇撤稿论文,建议人工核实撤稿原因';
|
||||
} else {
|
||||
$level = 'high';
|
||||
$score = 80 + min($retractionCount * 5, 20);
|
||||
$reasons[] = '发现 ' . $retractionCount . ' 篇撤稿论文,存在较高学术风险';
|
||||
}
|
||||
|
||||
if ($rwOnlyCount > 0) {
|
||||
$reasons[] = 'Retraction Watch 额外发现 ' . $rwOnlyCount . ' 条 OpenAlex 未收录的撤稿记录';
|
||||
if ($level === 'low') {
|
||||
$level = 'medium';
|
||||
$score = max($score, 45);
|
||||
}
|
||||
}
|
||||
|
||||
$worksCount = max(intval($metrics['works_count'] ?? 0), 1);
|
||||
$retractionRate = round($retractionCount / $worksCount * 100, 2);
|
||||
if ($retractionCount > 0 && $retractionRate >= 5) {
|
||||
$reasons[] = '撤稿率 ' . $retractionRate . '%,比例偏高';
|
||||
if ($level === 'medium') {
|
||||
$level = 'high';
|
||||
$score = max($score, 70);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'level' => $level,
|
||||
'level_label' => $this->getRiskLevelLabel($level),
|
||||
'score' => min($score, 100),
|
||||
'retraction_count' => $retractionCount,
|
||||
'retraction_rate' => $retractionRate . '%',
|
||||
'rw_only_count' => $rwOnlyCount,
|
||||
'reasons' => $reasons,
|
||||
];
|
||||
}
|
||||
|
||||
// ===================== 内部工具 =====================
|
||||
|
||||
private function formatOpenAlexWork($work)
|
||||
{
|
||||
return [
|
||||
'title' => $work['display_name'] ?? '',
|
||||
'doi' => $this->extractDoi($work),
|
||||
'publication_date' => $work['publication_date'] ?? '',
|
||||
'journal' => $work['primary_location']['source']['display_name'] ?? '',
|
||||
'cited_by_count' => intval($work['cited_by_count'] ?? 0),
|
||||
'openalex_url' => $work['id'] ?? '',
|
||||
'source' => 'openalex',
|
||||
];
|
||||
}
|
||||
|
||||
private function parseCrossRefAuthors($authorList)
|
||||
{
|
||||
if (empty($authorList) || !is_array($authorList)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($authorList as $a) {
|
||||
$result[] = [
|
||||
'given' => $a['given'] ?? '',
|
||||
'family' => $a['family'] ?? '',
|
||||
'name' => isset($a['name']) ? $a['name'] : trim(($a['given'] ?? '') . ' ' . ($a['family'] ?? '')),
|
||||
'orcid' => $a['ORCID'] ?? '',
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function parseDateParts($dateObj)
|
||||
{
|
||||
if (!isset($dateObj['date-parts'][0])) {
|
||||
return '';
|
||||
}
|
||||
$parts = $dateObj['date-parts'][0];
|
||||
$y = isset($parts[0]) ? $parts[0] : '';
|
||||
$m = isset($parts[1]) ? sprintf('%02d', $parts[1]) : '';
|
||||
$d = isset($parts[2]) ? sprintf('%02d', $parts[2]) : '';
|
||||
if ($y && $m && $d) {
|
||||
return "{$y}-{$m}-{$d}";
|
||||
}
|
||||
if ($y && $m) {
|
||||
return "{$y}-{$m}";
|
||||
}
|
||||
return (string)$y;
|
||||
}
|
||||
|
||||
private function getAcademicLevelLabel($stats)
|
||||
{
|
||||
$h = intval($stats['h_index'] ?? 0);
|
||||
if ($h >= 50) return '国际顶尖学者';
|
||||
if ($h >= 30) return '资深专家';
|
||||
if ($h >= 15) return '活跃研究者';
|
||||
if ($h >= 5) return '青年学者';
|
||||
if ($h > 0) return '初入领域';
|
||||
return '暂无足够公开数据';
|
||||
}
|
||||
|
||||
private function getRiskLevelLabel($level)
|
||||
{
|
||||
$map = ['low' => '低风险', 'medium' => '中风险', 'high' => '高风险'];
|
||||
return $map[$level] ?? '未知';
|
||||
}
|
||||
|
||||
public function extractOpenAlexId($id)
|
||||
{
|
||||
return preg_replace('/^https?:\/\/openalex\.org\//', '', $id);
|
||||
}
|
||||
|
||||
public function extractOrcid($orcid)
|
||||
{
|
||||
if ($orcid === '') return '';
|
||||
return preg_replace('/^https?:\/\/orcid\.org\//', '', $orcid);
|
||||
}
|
||||
|
||||
public function cleanOrcid($orcid)
|
||||
{
|
||||
$orcid = trim($orcid);
|
||||
$orcid = preg_replace('/^https?:\/\/orcid\.org\//', '', $orcid);
|
||||
return trim($orcid);
|
||||
}
|
||||
|
||||
private function extractDoi($work)
|
||||
{
|
||||
$doi = $work['doi'] ?? '';
|
||||
return preg_replace('/^https?:\/\/doi\.org\//', '', $doi);
|
||||
}
|
||||
|
||||
private function httpGet($url, $headers = [])
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
return ['success' => false, 'error' => 'HTTP请求失败: ' . $error];
|
||||
}
|
||||
curl_close($ch);
|
||||
|
||||
return ['success' => true, 'body' => $body, 'http_code' => $httpCode];
|
||||
}
|
||||
}
|
||||
659
application/common/ExpertFieldAiService.php
Normal file
659
application/common/ExpertFieldAiService.php
Normal file
@@ -0,0 +1,659 @@
|
||||
<?php
|
||||
|
||||
namespace app\common;
|
||||
|
||||
use app\common\service\LocalModelService;
|
||||
use think\Db;
|
||||
use think\Env;
|
||||
use think\Exception;
|
||||
use think\Queue;
|
||||
|
||||
/**
|
||||
* Expert 领域总结(方案 C)
|
||||
* 1. 优先尝试 email 关联 user.field_ai(少量)
|
||||
* 2. 主流程:根据 expert 论文/单位/检索词 AI 总结 field_ai
|
||||
*/
|
||||
class ExpertFieldAiService
|
||||
{
|
||||
const QUEUE_NAME = 'ExpertFieldAi';
|
||||
|
||||
const STATUS_PENDING = 0;
|
||||
const STATUS_DONE = 1;
|
||||
const STATUS_INSUFFICIENT = 2;
|
||||
const STATUS_FAILED = 3;
|
||||
const STATUS_NO_USER_LINK = 4;
|
||||
|
||||
const SOURCE_USER_LINK = 'user_link';
|
||||
const SOURCE_AI = 'ai';
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 链式队列 =====================
|
||||
|
||||
/**
|
||||
* 启动链式处理(关联 + 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->startChain($force, $delay, $queue);
|
||||
}
|
||||
|
||||
public function enqueueNext($delay = 1, $queue = '', $afterExpertId = 0, $force = false)
|
||||
{
|
||||
if ($queue === '') {
|
||||
$queue = self::QUEUE_NAME;
|
||||
}
|
||||
$afterExpertId = intval($afterExpertId);
|
||||
$expertId = $this->findNextPendingExpertId($afterExpertId, $force);
|
||||
if ($expertId <= 0) {
|
||||
$this->log('[ExpertFieldAi] chain finished after expert_id=' . $afterExpertId);
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'expert_id' => $expertId,
|
||||
'queue' => $queue,
|
||||
'force' => $force ? 1 : 0,
|
||||
];
|
||||
$jobClass = 'app\\api\\job\\ExpertFieldAiFill@fire';
|
||||
if ($delay > 0) {
|
||||
Queue::later($delay, $jobClass, $data, $queue);
|
||||
} else {
|
||||
Queue::push($jobClass, $data, $queue);
|
||||
}
|
||||
$this->log('[ExpertFieldAi] enqueued expert_id=' . $expertId . ' queue=' . $queue);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
public function enqueueNextLink($delay = 1, $queue = '', $afterExpertId = 0, $force = false)
|
||||
{
|
||||
return $this->enqueueNext($delay, $queue, $afterExpertId, $force);
|
||||
}
|
||||
|
||||
// ===================== 主流程 =====================
|
||||
|
||||
/**
|
||||
* 处理单个 expert:先关联 user,失败则 AI 总结。
|
||||
*/
|
||||
public function processExpert($expertId, $force = false)
|
||||
{
|
||||
$expertId = intval($expertId);
|
||||
if ($expertId <= 0) {
|
||||
return ['ok' => false, 'error' => 'invalid expert_id'];
|
||||
}
|
||||
|
||||
$expert = Db::name('expert')->where('expert_id', $expertId)->find();
|
||||
if (!$expert) {
|
||||
return ['ok' => false, 'error' => 'expert not found'];
|
||||
}
|
||||
|
||||
if (!$force
|
||||
&& intval($expert['field_ai_status']) === self::STATUS_DONE
|
||||
&& trim((string)$expert['field_ai']) !== '') {
|
||||
return [
|
||||
'ok' => true,
|
||||
'skipped' => true,
|
||||
'field_ai' => (string)$expert['field_ai'],
|
||||
'source' => (string)($expert['field_ai_source'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$linkResult = $this->tryLinkFromUser($expertId, $expert, $force);
|
||||
if (!empty($linkResult['linked'])) {
|
||||
return array_merge(['ok' => true, 'method' => 'user_link'], $linkResult);
|
||||
}
|
||||
|
||||
if (!$this->isEligible($expertId, $expert)) {
|
||||
$this->updateFieldAi($expertId, '', self::STATUS_INSUFFICIENT, '', 'insufficient papers/affiliation');
|
||||
return ['ok' => true, 'insufficient' => true, 'method' => 'ai'];
|
||||
}
|
||||
|
||||
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,
|
||||
'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()];
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
$skipped = 0;
|
||||
$noLink = 0;
|
||||
$failed = 0;
|
||||
$details = [];
|
||||
|
||||
foreach ($expertIds as $expertId) {
|
||||
$expertId = intval($expertId);
|
||||
if ($expertId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$result = $this->linkFromUser($expertId, $force);
|
||||
if (empty($result['ok'])) {
|
||||
$failed++;
|
||||
} elseif (!empty($result['skipped'])) {
|
||||
$skipped++;
|
||||
} elseif (!empty($result['linked'])) {
|
||||
$linked++;
|
||||
} else {
|
||||
$noLink++;
|
||||
}
|
||||
$details[] = array_merge(['expert_id' => $expertId], $result);
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => count($details),
|
||||
'linked' => $linked,
|
||||
'skipped' => $skipped,
|
||||
'no_link' => $noLink,
|
||||
'failed' => $failed,
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
|
||||
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);
|
||||
$expert = Db::name('expert')->where('expert_id', $expertId)->find();
|
||||
if (!$expert) {
|
||||
return ['ok' => false, 'error' => 'expert not found'];
|
||||
}
|
||||
|
||||
$email = strtolower(trim((string)($expert['email'] ?? '')));
|
||||
$user = null;
|
||||
$uri = null;
|
||||
if ($email !== '') {
|
||||
$user = Db::name('user')->where('email', $email)->where('state', 0)->field('user_id,email,realname')->find();
|
||||
if ($user) {
|
||||
$uri = Db::name('user_reviewer_info')
|
||||
->where('reviewer_id', intval($user['user_id']))
|
||||
->where('state', 0)
|
||||
->find();
|
||||
}
|
||||
}
|
||||
|
||||
$canLink = $user && $uri
|
||||
&& trim((string)($uri['field_ai'] ?? '')) !== ''
|
||||
&& intval($uri['field_ai_status']) === UserFieldAiService::STATUS_DONE;
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'expert_id' => $expertId,
|
||||
'expert_email' => $email,
|
||||
'matched_user_id' => $user ? intval($user['user_id']) : 0,
|
||||
'matched_user_name' => $user ? (string)$user['realname'] : '',
|
||||
'user_field_ai' => $uri ? (string)($uri['field_ai'] ?? '') : '',
|
||||
'user_field_ai_status' => $uri ? intval($uri['field_ai_status']) : 0,
|
||||
'can_link' => $canLink,
|
||||
];
|
||||
}
|
||||
|
||||
// ===================== LLM =====================
|
||||
|
||||
private function summarizeWithLlm(array $context)
|
||||
{
|
||||
$payloadJson = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$systemPrompt = '你是学术领域分类助手。根据专家的单位、论文标题与 PubMed 检索上下文,用简体中文总结该专家最主要的研究领域。'
|
||||
. '注意:search_keywords 只是检索词,不可直接当作领域结论,应结合 paper 标题与 affiliation 判断。'
|
||||
. '要求:精确、简洁,1~3 个中文领域词或短短语,用顿号分隔;不要解释、不要英文。'
|
||||
. '只输出 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'));
|
||||
}
|
||||
|
||||
$this->log('[ExpertFieldAi] llm tier=' . ($res['tier'] ?? '') . ' ctx_len=' . ($res['context_len'] ?? 0) . ' url=' . ($res['url'] ?? ''));
|
||||
|
||||
$content = trim((string)($res['content'] ?? ''));
|
||||
$fieldAi = $this->parseFieldAiFromContent($content);
|
||||
if ($fieldAi === '' && $content !== '') {
|
||||
$fieldAi = $this->cleanFieldAiText($content);
|
||||
}
|
||||
return $fieldAi;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
while (true) {
|
||||
$query = Db::name('expert')
|
||||
->where('expert_id', '>', $cursor)
|
||||
->where('state', '<>', 5);
|
||||
|
||||
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_NO_USER_LINK);
|
||||
});
|
||||
}
|
||||
|
||||
$ids = $query->order('expert_id asc')->limit($batch)->column('expert_id');
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ($ids as $expertId) {
|
||||
$expertId = intval($expertId);
|
||||
$cursor = $expertId;
|
||||
|
||||
if (!$force) {
|
||||
$row = Db::name('expert')->where('expert_id', $expertId)->field('field_ai,field_ai_status')->find();
|
||||
if ($row
|
||||
&& intval($row['field_ai_status']) === self::STATUS_DONE
|
||||
&& trim((string)$row['field_ai']) !== '') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $expertId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
];
|
||||
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 = [
|
||||
self::STATUS_PENDING => 'pending',
|
||||
self::STATUS_DONE => 'done',
|
||||
self::STATUS_INSUFFICIENT => 'insufficient',
|
||||
self::STATUS_FAILED => 'failed',
|
||||
self::STATUS_NO_USER_LINK => 'no_user_link',
|
||||
];
|
||||
return isset($map[$status]) ? $map[$status] : 'unknown';
|
||||
}
|
||||
|
||||
public function log($msg)
|
||||
{
|
||||
$line = date('Y-m-d H:i:s') . ' ' . $msg . PHP_EOL;
|
||||
@file_put_contents($this->logFile, $line, FILE_APPEND);
|
||||
}
|
||||
}
|
||||
@@ -509,6 +509,24 @@ class PromotionService
|
||||
}
|
||||
}
|
||||
|
||||
// 仅当模板真正引用了 LLM 占位符(llm_description / ai_content_analysis /
|
||||
// ai_advised_topics / llm_advised_topics)时才调用 LLM,避免无谓的请求与开销。
|
||||
$llmNeed = $this->detectLlmTemplateNeed(
|
||||
$task['template_id'],
|
||||
$task['journal_id'],
|
||||
intval($task['style_id'] ?? 0)
|
||||
);
|
||||
|
||||
if (!$llmNeed['need']) {
|
||||
$llmText = '';
|
||||
$llmStatus = 0;
|
||||
$advisedText = '';
|
||||
$advisedStatus = 0;
|
||||
$expert['llm_description'] = '';
|
||||
$expert['ai_advised_topics'] = '';
|
||||
$expert['role'] = $this->mapExpertTypeRole($expertType);
|
||||
$this->log("prepareSingleEmail log_id={$logId} skip_llm (template has no llm placeholders)");
|
||||
} else {
|
||||
// 一次 LLM 调用生成两段内容(description + advised_topics)
|
||||
$llmResult = [
|
||||
'description' => '',
|
||||
@@ -551,6 +569,7 @@ class PromotionService
|
||||
$expert['ai_advised_topics'] = $advisedText;
|
||||
$expert['role'] = $this->mapExpertTypeRole($expertType);
|
||||
}
|
||||
}
|
||||
|
||||
$expertVars = $this->buildExpertVars($expert);
|
||||
$journalVars = $this->buildJournalVars($journal);
|
||||
@@ -935,6 +954,73 @@ class PromotionService
|
||||
|
||||
// ==================== Template Rendering ====================
|
||||
|
||||
/**
|
||||
* 检测邮件模板(含 style 头尾)是否包含需要 LLM 生成的占位符。
|
||||
*
|
||||
* @return array{need:bool,need_description:bool,need_advised_topics:bool,tags:array<int,string>}
|
||||
*/
|
||||
public function detectLlmTemplateNeed($templateId, $journalId, $styleId = 0)
|
||||
{
|
||||
$descriptionTags = ['llm_description', 'ai_content_analysis'];
|
||||
$advisedTags = ['ai_advised_topics', 'llm_advised_topics'];
|
||||
$allTags = array_merge($descriptionTags, $advisedTags);
|
||||
|
||||
$parts = [];
|
||||
$tpl = Db::name('mail_template')
|
||||
->where('template_id', $templateId)
|
||||
->where('journal_id', $journalId)
|
||||
->where('state', 0)
|
||||
->find();
|
||||
if ($tpl) {
|
||||
$parts[] = (string)($tpl['subject'] ?? '');
|
||||
$parts[] = (string)($tpl['body_html'] ?? '');
|
||||
}
|
||||
$styleId = intval($styleId);
|
||||
if ($styleId > 0) {
|
||||
$style = Db::name('mail_style')->where('style_id', $styleId)->where('state', 0)->find();
|
||||
if ($style) {
|
||||
$parts[] = (string)($style['header_html'] ?? '');
|
||||
$parts[] = (string)($style['footer_html'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$haystack = implode("\n", $parts);
|
||||
$found = [];
|
||||
foreach ($allTags as $tag) {
|
||||
if ($this->templateContainsVar($haystack, $tag)) {
|
||||
$found[] = $tag;
|
||||
}
|
||||
}
|
||||
|
||||
$needDesc = (bool) array_intersect($found, $descriptionTags);
|
||||
$needAdvised = (bool) array_intersect($found, $advisedTags);
|
||||
|
||||
return [
|
||||
'need' => !empty($found),
|
||||
'need_description' => $needDesc,
|
||||
'need_advised_topics' => $needAdvised,
|
||||
'tags' => $found,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板是否包含某变量占位符(支持 {{ var }} 与 {var})。
|
||||
*/
|
||||
protected function templateContainsVar($haystack, $varName)
|
||||
{
|
||||
if (!is_string($haystack) || $haystack === '' || $varName === '') {
|
||||
return false;
|
||||
}
|
||||
$quoted = preg_quote($varName, '/');
|
||||
if (preg_match('/\{\{\s*' . $quoted . '\s*\}\}/', $haystack)) {
|
||||
return true;
|
||||
}
|
||||
if (strpos($haystack, '{' . $varName . '}') !== false) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function renderFromTemplate($templateId, $journalId, $varsJson, $styleId = 0)
|
||||
{
|
||||
$tpl = Db::name('mail_template')->where('template_id', $templateId)->where('journal_id', $journalId)->where('state', 0)->find();
|
||||
|
||||
@@ -4,16 +4,17 @@ namespace app\common;
|
||||
|
||||
use think\Db;
|
||||
use think\Env;
|
||||
use think\Queue;
|
||||
use app\common\service\LLMService;
|
||||
use app\common\mq\ReferenceCheckMqPublisher;
|
||||
|
||||
/**
|
||||
* 正文 <blue>[n]</blue> 引用与 t_production_article_refer(index+1=n)相关性校对。
|
||||
* LLM 配置与 PromotionLlmService 相同;单条任务走 ReferenceCheck 队列。
|
||||
* LLM 配置与 PromotionLlmService 相同;异步任务走 RabbitMQ(一篇一条消息)。
|
||||
*/
|
||||
class ReferenceCheckService
|
||||
{
|
||||
const QUEUE_NAME = 'ReferenceCheck';
|
||||
/** API 返回:异步传输方式(RabbitMQ 文章批次) */
|
||||
const TRANSPORT_RABBITMQ = 'rabbitmq';
|
||||
|
||||
/** t_article_main.type */
|
||||
const MAIN_TYPE_TEXT = 0;
|
||||
@@ -29,6 +30,9 @@ class ReferenceCheckService
|
||||
/** @var bool|null t_article_main 是否已有 ref_check_status 列 */
|
||||
private static $amRefCheckStatusColumnExists = null;
|
||||
|
||||
/** 单条任务最多重试次数(不含首次执行) */
|
||||
const QUEUE_MAX_RETRY = 1;
|
||||
|
||||
/**
|
||||
* 引用校对状态(生命周期顺序:0→1→2→3 = 待→进行→完成→失败)
|
||||
*
|
||||
@@ -56,6 +60,12 @@ class ReferenceCheckService
|
||||
const RECORD_COMPLETED = 2; // 校对完成
|
||||
const RECORD_FAILED = 3; // 校对失败
|
||||
|
||||
/** 队列执行状态(queue_status) */
|
||||
const QUEUE_PENDING = 0; // 已入队待执行
|
||||
const QUEUE_RUNNING = 1; // worker 正在执行
|
||||
const QUEUE_COMPLETED = 2; // 执行完成
|
||||
const QUEUE_FAILED = 3; // 最终失败(重试耗尽)
|
||||
|
||||
/** LLM 评分(confidence)通过阈值:>= 该值视为"通过" */
|
||||
const PASS_CONFIDENCE_THRESHOLD = 0.65;
|
||||
|
||||
@@ -69,6 +79,12 @@ class ReferenceCheckService
|
||||
const BLUE_TAG_REGEX = '/<blue>\[([\d,,\-\x{2013}\x{2014}\x{2212}\x{2010}\x{2011}\s]+)\]<\/blue>/u';
|
||||
const BLUE_TAG_REGEX_BRACKET_OUTSIDE = '/\[<blue>([\d,,\-\x{2013}\x{2014}\x{2212}\x{2010}\x{2011}\s]+)<\/blue>\]/u';
|
||||
|
||||
private $logFile;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->logFile = ROOT_PATH . 'runtime' . DS . 'plagiarism_task.log';
|
||||
}
|
||||
/**
|
||||
* 兼容无 ?? 的 PHP 版本
|
||||
*/
|
||||
@@ -77,6 +93,27 @@ class ReferenceCheckService
|
||||
return isset($arr[$key]) ? $arr[$key] : $default;
|
||||
}
|
||||
|
||||
/** 新建/重置校对明细时的队列初始字段 */
|
||||
private function newCheckRecordFields(array $fields, $queueStatus = self::QUEUE_PENDING, $retryCount = 0)
|
||||
{
|
||||
$fields['queue_status'] = intval($queueStatus);
|
||||
$fields['retry_count'] = max(0, intval($retryCount));
|
||||
return $fields;
|
||||
}
|
||||
|
||||
public function markQueueRuntime($checkId, $queueStatus, $retryCount = null)
|
||||
{
|
||||
$checkId = intval($checkId);
|
||||
if ($checkId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
$fields = ['queue_status' => intval($queueStatus)];
|
||||
if ($retryCount !== null) {
|
||||
$fields['retry_count'] = max(0, intval($retryCount));
|
||||
}
|
||||
return Db::name('article_reference_check_result')->where('id', $checkId)->update($fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并匹配两种 blue 引用排版,按在正文中的起始位置排序。
|
||||
*
|
||||
@@ -128,7 +165,7 @@ class ReferenceCheckService
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$checkId = Db::name('article_reference_check_result')->insertGetId([
|
||||
$checkId = Db::name('article_reference_check_result')->insertGetId($this->newCheckRecordFields([
|
||||
'article_id' => intval($this->arrGet($extra, 'article_id', 0)),
|
||||
'am_id' => intval($this->arrGet($extra, 'am_id', 0)),
|
||||
'p_article_id' => intval($this->arrGet($extra, 'p_article_id', 0)),
|
||||
@@ -145,14 +182,14 @@ class ReferenceCheckService
|
||||
'status' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
]));
|
||||
|
||||
$amId = intval($this->arrGet($extra, 'am_id', 0));
|
||||
if ($amId > 0) {
|
||||
$this->setAmRefCheckStatus($amId, self::AM_STATUS_RUNNING);
|
||||
}
|
||||
|
||||
$this->pushJob(intval($checkId), intval($this->arrGet($extra, 'queue_delay', 0)));
|
||||
$this->startArticleCheckQueue([intval($checkId)], intval($this->arrGet($extra, 'p_article_id', 0)), 'enqueue');
|
||||
|
||||
return ['check_id' => $checkId, 'queued' => 1];
|
||||
}
|
||||
@@ -190,7 +227,8 @@ class ReferenceCheckService
|
||||
}
|
||||
|
||||
$skipped = 0;
|
||||
$delay = 0;
|
||||
$pendingJobs = [];
|
||||
$now = date('Y-m-d H:i:s');
|
||||
foreach ($citations as $cite) {
|
||||
foreach ($cite['reference_numbers'] as $refNo) {
|
||||
$referIndex = $refNo - 1;
|
||||
@@ -201,9 +239,7 @@ class ReferenceCheckService
|
||||
$refer = $referMap[$referIndex];
|
||||
$referText = $this->formatReferForLlm($refer);
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
// [70-73] 展开为 reference_no=70,71,72,73 共 4 条记录
|
||||
$checkId = Db::name('article_reference_check_result')->insertGetId([
|
||||
$checkId = Db::name('article_reference_check_result')->insertGetId($this->newCheckRecordFields([
|
||||
'article_id' => $main['article_id'],
|
||||
'p_article_id' => $pArticleId,
|
||||
'am_id' => intval($main['am_id']),
|
||||
@@ -214,19 +250,24 @@ class ReferenceCheckService
|
||||
'p_refer_id' => $referMap[$referIndex]['p_refer_id'],
|
||||
'text_start' => $cite['text_start'],
|
||||
'text_end' => $cite['text_end'],
|
||||
'status' => self::RECORD_PENDING,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$this->pushJob(intval($checkId), $delay);
|
||||
$checkIds[] = $checkId;
|
||||
$delay += 1;
|
||||
]));
|
||||
$pendingJobs[] = [
|
||||
'check_id' => intval($checkId),
|
||||
'reference_no' => intval($refNo),
|
||||
'am_id' => intval($main['am_id']),
|
||||
'text_start' => intval($cite['text_start']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'enqueue');
|
||||
$this->setAmRefCheckStatus($amId, self::AM_STATUS_RUNNING);
|
||||
}
|
||||
/**
|
||||
* 手工触发:对已完成且 confidence<=0.65 的记录入队 DOI 第二轮复核
|
||||
* 手工触发:对已完成且 confidence<=0.65 的记录同步执行 Crossref 二轮复核
|
||||
*/
|
||||
public function enqueueSecondPassByArticle($articleId)
|
||||
{
|
||||
@@ -247,7 +288,7 @@ class ReferenceCheckService
|
||||
$delay2 = 0;
|
||||
foreach ($rows as $checkLog) {
|
||||
$rowId = $this->resolveCheckRowId($checkLog);
|
||||
if ($this->maybeEnqueueSecondPass($rowId, floatval($checkLog['confidence']))) {
|
||||
if ($this->runSecondPassIfNeeded($rowId, floatval($checkLog['confidence']))) {
|
||||
$checkIds2[] = $rowId;
|
||||
$delay2 += 1;
|
||||
}
|
||||
@@ -299,7 +340,7 @@ class ReferenceCheckService
|
||||
$referText = $this->formatReferForLlm($refer);
|
||||
|
||||
// [70-73] 展开为 reference_no=70,71,72,73 共 4 条记录;先入队表,再按文献号正序校对
|
||||
$checkId = Db::name('article_reference_check_result')->insertGetId([
|
||||
$checkId = Db::name('article_reference_check_result')->insertGetId($this->newCheckRecordFields([
|
||||
'article_id' => $main['article_id'],
|
||||
'p_article_id' => $pArticleId,
|
||||
'am_id' => $amId,
|
||||
@@ -310,9 +351,10 @@ class ReferenceCheckService
|
||||
'p_refer_id' => $referMap[$referIndex]['p_refer_id'],
|
||||
'text_start' => $cite['text_start'],
|
||||
'text_end' => $cite['text_end'],
|
||||
'status' => self::RECORD_PENDING,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
]));
|
||||
|
||||
$pendingJobs[] = [
|
||||
'check_id' => intval($checkId),
|
||||
@@ -325,8 +367,7 @@ class ReferenceCheckService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$checkIds = $this->pushJobsSortedByReferenceNo($pendingJobs);
|
||||
$checkIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'enqueue');
|
||||
foreach (array_keys($amIdsWithJobs) as $amId) {
|
||||
$this->setAmRefCheckStatus($amId, self::AM_STATUS_RUNNING);
|
||||
}
|
||||
@@ -337,7 +378,7 @@ class ReferenceCheckService
|
||||
'queued' => $queued,
|
||||
'skipped' => $skipped,
|
||||
'check_ids' => $checkIds,
|
||||
'queue' => self::QUEUE_NAME,
|
||||
'queue' => self::TRANSPORT_RABBITMQ,
|
||||
];
|
||||
}
|
||||
public function enqueueByArticle($articleId){
|
||||
@@ -386,7 +427,7 @@ class ReferenceCheckService
|
||||
$referText = $this->formatReferForLlm($refer);
|
||||
|
||||
// [70-73] 展开为 reference_no=70,71,72,73 共 4 条记录;先入队表,再按文献号正序校对
|
||||
$checkId = Db::name('article_reference_check_result')->insertGetId([
|
||||
$checkId = Db::name('article_reference_check_result')->insertGetId($this->newCheckRecordFields([
|
||||
'article_id' => $main['article_id'],
|
||||
'p_article_id' => $pArticleId,
|
||||
'am_id' => $amId,
|
||||
@@ -397,9 +438,10 @@ class ReferenceCheckService
|
||||
'p_refer_id' => $referMap[$referIndex]['p_refer_id'],
|
||||
'text_start' => $cite['text_start'],
|
||||
'text_end' => $cite['text_end'],
|
||||
'status' => self::RECORD_PENDING,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
]));
|
||||
|
||||
$pendingJobs[] = [
|
||||
'check_id' => intval($checkId),
|
||||
@@ -413,7 +455,7 @@ class ReferenceCheckService
|
||||
}
|
||||
}
|
||||
|
||||
$checkIds = $this->pushJobsSortedByReferenceNo($pendingJobs);
|
||||
$checkIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'enqueue');
|
||||
foreach (array_keys($amIdsWithJobs) as $amId) {
|
||||
$this->setAmRefCheckStatus($amId, self::AM_STATUS_RUNNING);
|
||||
}
|
||||
@@ -424,7 +466,7 @@ class ReferenceCheckService
|
||||
'queued' => $queued,
|
||||
'skipped' => $skipped,
|
||||
'check_ids' => $checkIds,
|
||||
'queue' => self::QUEUE_NAME,
|
||||
'queue' => self::TRANSPORT_RABBITMQ,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -524,14 +566,6 @@ class ReferenceCheckService
|
||||
->whereIn('state', [0, 2])
|
||||
->value('article_id'));
|
||||
|
||||
// 先清掉旧记录对应的队列 Redis 锁,避免在途 worker 写回数据
|
||||
$oldIds = Db::name('article_reference_check_result')
|
||||
->where('p_article_id', $pArticleId)
|
||||
->column('id');
|
||||
foreach ($oldIds as $oldId) {
|
||||
$this->clearReferenceCheckQueueLock(intval($oldId));
|
||||
}
|
||||
|
||||
$deleted = Db::name('article_reference_check_result')
|
||||
->where('p_article_id', $pArticleId)
|
||||
->delete();
|
||||
@@ -553,14 +587,6 @@ class ReferenceCheckService
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 先清掉旧记录对应的队列 Redis 锁,否则同 check_id 在 TTL 内不会再次执行
|
||||
$oldIds = Db::name('article_reference_check_result')
|
||||
->where('article_id', $articleId)
|
||||
->column('id');
|
||||
foreach ($oldIds as $oldId) {
|
||||
$this->clearReferenceCheckQueueLock(intval($oldId));
|
||||
}
|
||||
|
||||
$deleted = Db::name('article_reference_check_result')->where('article_id', $articleId)->delete();
|
||||
if ($this->hasAmRefCheckStatusColumn()) {
|
||||
Db::name('article_main')
|
||||
@@ -1518,7 +1544,7 @@ class ReferenceCheckService
|
||||
* 编辑某条文献内容后,按 p_refer_id 异步重新校对该文献对应的全部 check 明细
|
||||
*
|
||||
* 流程:刷新 refer_text/refer_index → 重置 status/is_match/confidence/reason
|
||||
* → 设节级 ref_check_status=RUNNING → 投递到 ReferenceCheck 队列
|
||||
* → 设节级 ref_check_status=RUNNING → 投递 RabbitMQ 文章批次
|
||||
*
|
||||
* 与 recheckByRefer 的差异:本方法**不**在请求内同步跑 LLM,仅入队,立即返回。
|
||||
* 前端可调 getProgressByPArticleId 轮询进度。
|
||||
@@ -1567,11 +1593,11 @@ class ReferenceCheckService
|
||||
'reset' => 0,
|
||||
'queued' => 0,
|
||||
'check_ids' => [],
|
||||
'queue' => self::QUEUE_NAME,
|
||||
'queue' => self::TRANSPORT_RABBITMQ,
|
||||
];
|
||||
}
|
||||
|
||||
$resetFields = [
|
||||
$resetFields = $this->newCheckRecordFields([
|
||||
'refer_text' => $referText,
|
||||
'refer_index' => $referenceNo,
|
||||
'reference_no' => $referenceNo,
|
||||
@@ -1582,14 +1608,13 @@ class ReferenceCheckService
|
||||
'reason' => '',
|
||||
'error_msg' => '',
|
||||
'updated_at' => $now,
|
||||
];
|
||||
], self::QUEUE_PENDING, 0);
|
||||
|
||||
$pendingJobs = [];
|
||||
$amIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$checkId = $this->resolveCheckRowId($row);
|
||||
Db::name('article_reference_check_result')->where('id', $checkId)->update($resetFields);
|
||||
$this->clearReferenceCheckQueueLock($checkId);
|
||||
$pendingJobs[] = [
|
||||
'check_id' => $checkId,
|
||||
'reference_no' => $referenceNo,
|
||||
@@ -1606,7 +1631,7 @@ class ReferenceCheckService
|
||||
$this->setAmRefCheckStatus($amId, self::AM_STATUS_RUNNING);
|
||||
}
|
||||
|
||||
$checkIds = $this->pushJobsSortedByReferenceNo($pendingJobs);
|
||||
$checkIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'enqueue');
|
||||
|
||||
return [
|
||||
'p_refer_id' => $pReferId,
|
||||
@@ -1615,7 +1640,7 @@ class ReferenceCheckService
|
||||
'reset' => count($rows),
|
||||
'queued' => count($checkIds),
|
||||
'check_ids' => $checkIds,
|
||||
'queue' => self::QUEUE_NAME,
|
||||
'queue' => self::TRANSPORT_RABBITMQ,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1652,7 +1677,7 @@ class ReferenceCheckService
|
||||
'reset' => 0,
|
||||
'queued' => 0,
|
||||
'check_ids' => [],
|
||||
'queue' => self::QUEUE_NAME,
|
||||
'queue' => self::TRANSPORT_RABBITMQ,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1661,7 +1686,7 @@ class ReferenceCheckService
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$resetFields = [
|
||||
$resetFields = $this->newCheckRecordFields([
|
||||
'status' => self::RECORD_PENDING,
|
||||
'is_match' => 0,
|
||||
'can_support' => 0,
|
||||
@@ -1669,14 +1694,13 @@ class ReferenceCheckService
|
||||
'reason' => '',
|
||||
'error_msg' => '',
|
||||
'updated_at' => $now,
|
||||
];
|
||||
], self::QUEUE_PENDING, 0);
|
||||
|
||||
$pendingJobs = [];
|
||||
$amIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$checkId = $this->resolveCheckRowId($row);
|
||||
Db::name('article_reference_check_result')->where('id', $checkId)->update($resetFields);
|
||||
$this->clearReferenceCheckQueueLock($checkId);
|
||||
$pendingJobs[] = [
|
||||
'check_id' => $checkId,
|
||||
'reference_no' => intval($this->arrGet($row, 'reference_no', 0)),
|
||||
@@ -1693,7 +1717,7 @@ class ReferenceCheckService
|
||||
$this->setAmRefCheckStatus($amId, self::AM_STATUS_RUNNING);
|
||||
}
|
||||
|
||||
$checkIds = $this->pushJobsSortedByReferenceNo($pendingJobs);
|
||||
$checkIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'recheck_failed');
|
||||
|
||||
return [
|
||||
'p_refer_id' => $pReferId,
|
||||
@@ -1701,7 +1725,7 @@ class ReferenceCheckService
|
||||
'reset' => count($rows),
|
||||
'queued' => count($checkIds),
|
||||
'check_ids' => $checkIds,
|
||||
'queue' => self::QUEUE_NAME,
|
||||
'queue' => self::TRANSPORT_RABBITMQ,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1735,11 +1759,11 @@ class ReferenceCheckService
|
||||
'reset' => 0,
|
||||
'queued' => 0,
|
||||
'check_ids' => [],
|
||||
'queue' => self::QUEUE_NAME,
|
||||
'queue' => self::TRANSPORT_RABBITMQ,
|
||||
];
|
||||
}
|
||||
|
||||
$resetFields = [
|
||||
$resetFields = $this->newCheckRecordFields([
|
||||
'refer_text' => $referText,
|
||||
'p_refer_id' => $pReferId,
|
||||
'p_article_id' => $pArticleId,
|
||||
@@ -1751,7 +1775,7 @@ class ReferenceCheckService
|
||||
'reason' => '',
|
||||
'error_msg' => '',
|
||||
'updated_at' => $now,
|
||||
];
|
||||
], self::QUEUE_PENDING, 0);
|
||||
|
||||
$pendingJobs = [];
|
||||
$amIds = [];
|
||||
@@ -1790,7 +1814,6 @@ class ReferenceCheckService
|
||||
foreach ($pendingJobs as $job) {
|
||||
$checkId = intval($job['check_id']);
|
||||
$checkIds[] = $checkId;
|
||||
$this->clearReferenceCheckQueueLock($checkId);
|
||||
try {
|
||||
$results[] = $this->runReferenceCheckOnce($checkId);
|
||||
} catch (\Exception $e) {
|
||||
@@ -1819,31 +1842,6 @@ class ReferenceCheckService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除队列 Redis 完成标记,避免重检任务被 acquireLock 静默丢弃
|
||||
*/
|
||||
public function clearReferenceCheckQueueLock($checkId)
|
||||
{
|
||||
$checkId = intval($checkId);
|
||||
if ($checkId <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$keys = [];
|
||||
foreach (['queue_job', 'queue_job_two'] as $prefix) {
|
||||
$class = $prefix === 'queue_job_two'
|
||||
? 'app\\api\\job\\ReferenceCheckTwo'
|
||||
: 'app\\api\\job\\ReferenceCheck';
|
||||
$base = $prefix . ':' . $class . ':' . $checkId;
|
||||
$keys[] = $base;
|
||||
$keys[] = $base . ':status';
|
||||
}
|
||||
QueueRedis::getInstance()->deleteRedisKeys($keys);
|
||||
} catch (\Exception $e) {
|
||||
\think\Log::warning('clearReferenceCheckQueueLock id=' . $checkId . ' ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次引用 LLM 校对(同步,写回 article_reference_check_result)
|
||||
*/
|
||||
@@ -1884,8 +1882,7 @@ class ReferenceCheckService
|
||||
$confidence = floatval(isset($llmResult['confidence']) ? $llmResult['confidence'] : 0);
|
||||
$reason = isset($llmResult['reason']) ? $llmResult['reason'] : '';
|
||||
|
||||
// LLM 通讯失败:写 status=RECORD_FAILED(3) + error_msg,抛异常让队列 worker 走 release(30) 重试;
|
||||
// 重试 3 次后 ReferenceCheck::markFailed 会保持 status=3 收尾
|
||||
// LLM 通讯失败:写 status=RECORD_FAILED(3) + error_msg,抛异常由 MQ worker 重试
|
||||
if ($requestFailed) {
|
||||
$this->updateCheckResult($checkId, [
|
||||
'confidence' => $confidence,
|
||||
@@ -1893,7 +1890,6 @@ class ReferenceCheckService
|
||||
'status' => self::RECORD_FAILED,
|
||||
'error_msg' => $reason,
|
||||
]);
|
||||
$this->clearReferenceCheckQueueLock($checkId);
|
||||
throw new \RuntimeException($reason !== '' ? $reason : 'LLM request failed');
|
||||
}
|
||||
|
||||
@@ -1906,8 +1902,9 @@ class ReferenceCheckService
|
||||
'error_msg' => '',
|
||||
]);
|
||||
|
||||
$this->clearReferenceCheckQueueLock($checkId);
|
||||
$this->maybeEnqueueSecondPass($checkId, $confidence);
|
||||
if ($confidence <= self::PASS_CONFIDENCE_THRESHOLD) {
|
||||
$this->runSecondPassBlocking($checkId, $row, $contentA, $refer, $contentB);
|
||||
}
|
||||
|
||||
return [
|
||||
'check_id' => $checkId,
|
||||
@@ -1918,6 +1915,82 @@ class ReferenceCheckService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 低分结果的二轮 DOI 复核(同步阻塞执行;失败重试一次)
|
||||
*/
|
||||
public function runSecondPassBlocking($checkId, array $row, $contentA, $refer, $referText)
|
||||
{
|
||||
$checkId = intval($checkId);
|
||||
if ($checkId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = $this->prepareRecheckPayload(is_array($refer) ? $refer : [], trim((string)$referText));
|
||||
if (empty($payload['has_abstract']) || trim((string)$payload['doi_block']) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lastError = '';
|
||||
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||
try {
|
||||
$llmResult = (new LLMService())->checkReference($contentA, trim((string)$referText), true, $payload['doi_block']);
|
||||
$requestFailed = !empty($llmResult['request_failed']);
|
||||
$canSupport = $this->parseLlmCanSupport($llmResult);
|
||||
$confidence = floatval(isset($llmResult['confidence']) ? $llmResult['confidence'] : 0);
|
||||
$tag = '[Crossref复核' . (trim((string)$payload['doi_used']) !== '' ? (' ' . trim((string)$payload['doi_used'])) : '') . ']';
|
||||
$reason = $tag . ' ' . (isset($llmResult['reason']) ? $llmResult['reason'] : '');
|
||||
|
||||
if ($requestFailed) {
|
||||
$lastError = isset($llmResult['reason']) ? (string)$llmResult['reason'] : 'LLM request failed';
|
||||
if ($attempt < 1) {
|
||||
continue;
|
||||
}
|
||||
$this->updateCheckResult($checkId, [
|
||||
'confidence' => $confidence,
|
||||
'reason' => $reason,
|
||||
'status' => self::RECORD_FAILED,
|
||||
'error_msg' => $lastError,
|
||||
]);
|
||||
$amId = intval(isset($row['am_id']) ? $row['am_id'] : 0);
|
||||
if ($amId > 0) {
|
||||
$this->syncAmRefCheckStatus($amId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->updateCheckResult($checkId, [
|
||||
'can_support' => $canSupport ? 1 : 0,
|
||||
'is_match' => $canSupport ? 1 : 0,
|
||||
'confidence' => $confidence,
|
||||
'reason' => $reason,
|
||||
'status' => self::RECORD_COMPLETED,
|
||||
'error_msg' => '',
|
||||
]);
|
||||
$amId = intval(isset($row['am_id']) ? $row['am_id'] : 0);
|
||||
if ($amId > 0) {
|
||||
$this->syncAmRefCheckStatus($amId);
|
||||
}
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
$lastError = $e->getMessage();
|
||||
if ($attempt < 1) {
|
||||
continue;
|
||||
}
|
||||
$this->updateCheckResult($checkId, [
|
||||
'status' => self::RECORD_FAILED,
|
||||
'error_msg' => $lastError,
|
||||
]);
|
||||
$amId = intval(isset($row['am_id']) ? $row['am_id'] : 0);
|
||||
if ($amId > 0) {
|
||||
$this->syncAmRefCheckStatus($amId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{refer: array, p_article_id: int, p_refer_id: int, reference_no: int}
|
||||
*/
|
||||
@@ -2622,18 +2695,13 @@ class ReferenceCheckService
|
||||
}
|
||||
|
||||
/**
|
||||
* 第一轮 confidence<=0.65 且能抓到 DOI 真实内容时,延迟入队第二轮复核
|
||||
*
|
||||
* 跳过条件(避免无意义重跑得到相同结果):
|
||||
* - check_id 不合法 / 一次置信度高于阈值
|
||||
* - refer 行不存在
|
||||
* - refer_doi 为空或 Crossref 未返回摘要
|
||||
* 对已完成且低分的记录尝试同步 Crossref 二轮(供 enqueueSecondPassByArticle 等手工入口)
|
||||
*/
|
||||
public function maybeEnqueueSecondPass($checkId, $confidence)
|
||||
public function runSecondPassIfNeeded($checkId, $confidence)
|
||||
{
|
||||
$checkId = intval($checkId);
|
||||
$confidence = floatval($confidence);
|
||||
if ($checkId <= 0 || $confidence > 0.65) {
|
||||
if ($checkId <= 0 || $confidence > self::PASS_CONFIDENCE_THRESHOLD) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2658,9 +2726,13 @@ class ReferenceCheckService
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->clearReferenceCheckQueueLock($checkId);
|
||||
$this->pushJob2($checkId, 5);
|
||||
return true;
|
||||
$contentA = $this->resolveMainContentForJob($row);
|
||||
$referText = trim((string)$this->arrGet($row, 'refer_text', ''));
|
||||
if ($referText === '' && is_array($refer)) {
|
||||
$referText = $this->formatReferForLlm($refer);
|
||||
}
|
||||
|
||||
return $this->runSecondPassBlocking($checkId, $row, $contentA, $refer, $referText);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3047,72 +3119,93 @@ class ReferenceCheckService
|
||||
}
|
||||
|
||||
/**
|
||||
* 已入库记录按文献编号正序入队(同号按 am_id、正文位置稳定排序)
|
||||
* 批量记录已入库后创建文章批次并投递 RabbitMQ
|
||||
*
|
||||
* @param array $rows 元素含 check_id、reference_no,可选 am_id、text_start
|
||||
* @param array $rows 元素含 check_id
|
||||
* @param int $pArticleId
|
||||
* @param string $trigger enqueue|recheck_failed|manual
|
||||
* @return int[] check_id 列表
|
||||
*/
|
||||
private function pushJobsSortedByReferenceNo(array $rows)
|
||||
private function enqueueChecksSortedByReferenceNo(array $rows, $pArticleId = 0, $trigger = 'enqueue')
|
||||
{
|
||||
if (empty($rows)) {
|
||||
$checkIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$checkId = intval($row['check_id']);
|
||||
if ($checkId > 0) {
|
||||
$checkIds[] = $checkId;
|
||||
}
|
||||
}
|
||||
if (!empty($checkIds)) {
|
||||
$this->startArticleCheckQueue($checkIds, intval($pArticleId), $trigger);
|
||||
}
|
||||
return $checkIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文章批次;队首批次立即发 MQ,其余批次等待前序完成
|
||||
*
|
||||
* @param int[] $checkIds
|
||||
* @param int $pArticleId
|
||||
* @param string $trigger
|
||||
* @return int[]
|
||||
*/
|
||||
public function startArticleCheckQueue(array $checkIds, $pArticleId = 0, $trigger = 'enqueue')
|
||||
{
|
||||
$checkIds = array_values(array_filter(array_map('intval', $checkIds)));
|
||||
if (empty($checkIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
usort($rows, function ($a, $b) {
|
||||
if ($a['reference_no'] !== $b['reference_no']) {
|
||||
return $a['reference_no'] - $b['reference_no'];
|
||||
$pArticleId = intval($pArticleId);
|
||||
if ($pArticleId <= 0) {
|
||||
$firstRow = Db::name('article_reference_check_result')->where('id', $checkIds[0])->find();
|
||||
$pArticleId = empty($firstRow) ? 0 : intval($this->arrGet($firstRow, 'p_article_id', 0));
|
||||
}
|
||||
$amA = isset($a['am_id']) ? intval($a['am_id']) : 0;
|
||||
$amB = isset($b['am_id']) ? intval($b['am_id']) : 0;
|
||||
if ($amA !== $amB) {
|
||||
return $amA - $amB;
|
||||
if ($pArticleId <= 0) {
|
||||
throw new \RuntimeException('p_article_id is required for reference check queue');
|
||||
}
|
||||
$posA = isset($a['text_start']) ? intval($a['text_start']) : 0;
|
||||
$posB = isset($b['text_start']) ? intval($b['text_start']) : 0;
|
||||
return $posA - $posB;
|
||||
});
|
||||
|
||||
$checkIds = [];
|
||||
$delay = 0;
|
||||
foreach ($rows as $row) {
|
||||
$checkId = intval($row['check_id']);
|
||||
$checkIds[] = $checkId;
|
||||
$this->pushJob($checkId, $delay);
|
||||
$delay++;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$batchId = Db::name('article_reference_check_batch')->insertGetId([
|
||||
'p_article_id' => $pArticleId,
|
||||
'batch_status' => 0,
|
||||
'total_count' => count($checkIds),
|
||||
'done_count' => 0,
|
||||
'failed_count' => 0,
|
||||
'trigger' => (string)$trigger,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$shouldPublish = !$this->hasEarlierWaitingBatch($batchId) && !$this->hasRunningReferenceCheckBatch();
|
||||
if ($shouldPublish) {
|
||||
(new ReferenceCheckMqPublisher())->publishArticleStart($pArticleId, intval($batchId), $trigger);
|
||||
$this->log('startArticleCheckQueue publish p_article_id=' . $pArticleId . ' batch_id=' . $batchId);
|
||||
} else {
|
||||
$this->log('startArticleCheckQueue queued batch_id=' . $batchId . ' p_article_id=' . $pArticleId);
|
||||
}
|
||||
|
||||
return $checkIds;
|
||||
}
|
||||
|
||||
private function pushJob($checkId, $delaySeconds = 0)
|
||||
private function hasRunningReferenceCheckBatch()
|
||||
{
|
||||
$checkId = intval($checkId);
|
||||
$this->clearReferenceCheckQueueLock($checkId);
|
||||
$jobClass = 'app\api\job\ReferenceCheck@fire';
|
||||
$data = ['check_id' => $checkId];
|
||||
try {
|
||||
if ($delaySeconds > 0) {
|
||||
$jobId = Queue::later($delaySeconds, $jobClass, $data, self::QUEUE_NAME);
|
||||
} else {
|
||||
$jobId = Queue::push($jobClass, $data, self::QUEUE_NAME);
|
||||
return Db::name('article_reference_check_batch')
|
||||
->where('batch_status', 1)
|
||||
->count() > 0;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\think\Log::error('ReferenceCheck pushJob failed check_id=' . $checkId . ' ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
private function pushJob2($checkId, $delaySeconds = 0)
|
||||
|
||||
private function hasEarlierWaitingBatch($batchId)
|
||||
{
|
||||
$jobClass = 'app\api\job\ReferenceCheckTwo@fire';
|
||||
$data = ['check_id' => $checkId];
|
||||
try {
|
||||
if ($delaySeconds > 0) {
|
||||
$jobId = Queue::later($delaySeconds, $jobClass, $data, self::QUEUE_NAME);
|
||||
} else {
|
||||
$jobId = Queue::push($jobClass, $data, self::QUEUE_NAME);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\think\Log::error('ReferenceCheckTwo pushJob failed check_id=' . $checkId . ' ' . $e->getMessage());
|
||||
throw $e;
|
||||
return Db::name('article_reference_check_batch')
|
||||
->where('batch_status', 0)
|
||||
->where('id', '<', intval($batchId))
|
||||
->count() > 0;
|
||||
}
|
||||
|
||||
public function log($msg)
|
||||
{
|
||||
$line = date('Y-m-d H:i:s') . ' ' . $msg . PHP_EOL;
|
||||
@file_put_contents($this->logFile, $line, FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,15 +218,16 @@ class TurnitinService
|
||||
}
|
||||
|
||||
foreach ($candidates as $n) {
|
||||
if ($n > 0 && $n <= 1.0) {
|
||||
$scaled = round($n * 100, 2);
|
||||
if ($scaled > 1.0 || $n < 0.05) {
|
||||
return $scaled;
|
||||
if ($n < 0) {
|
||||
continue;
|
||||
}
|
||||
// Turnitin TCA 的 overall_match_percentage 是 0–100 整数,"1" 即代表 1%。
|
||||
// 仅当值是「严格小于 1 的非整数」(真正的 0–1 小数比例,如 0.12=12%)时才 ×100,
|
||||
// 避免把整数 1(1%)误判成 100%。
|
||||
if ($n > 0 && $n < 1.0) {
|
||||
return round(min($n * 100, 100), 2);
|
||||
}
|
||||
if ($n >= 0) {
|
||||
return round($n, 2);
|
||||
}
|
||||
return round(min($n, 100), 2);
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
|
||||
@@ -102,6 +102,7 @@ class UserFieldAiService
|
||||
throw new Exception('LLM returned empty field');
|
||||
}
|
||||
$this->updateFieldAi($userId, $fieldAi, self::STATUS_DONE, '');
|
||||
$this->syncLinkedExperts($userId);
|
||||
return ['ok' => true, 'field_ai' => $fieldAi];
|
||||
} catch (\Throwable $e) {
|
||||
$this->updateFieldAi($userId, '', self::STATUS_FAILED, mb_substr($e->getMessage(), 0, 500));
|
||||
@@ -460,4 +461,17 @@ class UserFieldAiService
|
||||
$line = date('Y-m-d H:i:s') . ' ' . $msg . PHP_EOL;
|
||||
@file_put_contents($this->logFile, $line, FILE_APPEND);
|
||||
}
|
||||
|
||||
/**
|
||||
* user.field_ai 更新后,同步到同邮箱 expert(方案 C 关联)。
|
||||
*/
|
||||
private function syncLinkedExperts($userId)
|
||||
{
|
||||
try {
|
||||
$svc = new ExpertFieldAiService();
|
||||
$svc->syncExpertsByUserId(intval($userId), true);
|
||||
} catch (\Throwable $e) {
|
||||
$this->log('[FieldAi] sync expert fail user_id=' . $userId . ' ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
application/common/mq/RabbitMqConfig.php
Normal file
24
application/common/mq/RabbitMqConfig.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\mq;
|
||||
|
||||
class RabbitMqConfig
|
||||
{
|
||||
public static function get($key = null, $default = null)
|
||||
{
|
||||
$cfg = config('rabbitmq');
|
||||
if (!is_array($cfg)) {
|
||||
$cfg = [];
|
||||
}
|
||||
if ($key === null) {
|
||||
return $cfg;
|
||||
}
|
||||
return isset($cfg[$key]) ? $cfg[$key] : $default;
|
||||
}
|
||||
|
||||
public static function referenceCheck()
|
||||
{
|
||||
$rc = self::get('reference_check', []);
|
||||
return is_array($rc) ? $rc : [];
|
||||
}
|
||||
}
|
||||
200
application/common/mq/ReferenceCheckArticleWorker.php
Normal file
200
application/common/mq/ReferenceCheckArticleWorker.php
Normal file
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\mq;
|
||||
|
||||
use think\Db;
|
||||
use app\common\ReferenceCheckService;
|
||||
|
||||
/**
|
||||
* RabbitMQ 消费:按文章串行,文章内 reference_no 升序逐条校对(含低分同步二轮)
|
||||
*/
|
||||
class ReferenceCheckArticleWorker
|
||||
{
|
||||
const BATCH_WAITING = 0;
|
||||
const BATCH_RUNNING = 1;
|
||||
const BATCH_DONE = 2;
|
||||
const BATCH_PARTIAL_FAILED = 3;
|
||||
|
||||
/** @var ReferenceCheckService */
|
||||
private $svc;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->svc = new ReferenceCheckService();
|
||||
}
|
||||
|
||||
public function handleMessage(array $payload)
|
||||
{
|
||||
$pArticleId = intval(isset($payload['p_article_id']) ? $payload['p_article_id'] : 0);
|
||||
$batchId = intval(isset($payload['batch_id']) ? $payload['batch_id'] : 0);
|
||||
if ($pArticleId <= 0 || $batchId <= 0) {
|
||||
$this->svc->log('ReferenceCheckArticleWorker invalid payload');
|
||||
return;
|
||||
}
|
||||
|
||||
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');
|
||||
sleep(3);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->claimBatch($batchId)) {
|
||||
$batch = $this->getBatch($batchId);
|
||||
if (empty($batch) || intval($batch['batch_status']) === self::BATCH_DONE) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->svc->log('ReferenceCheckArticleWorker start p_article_id=' . $pArticleId . ' batch_id=' . $batchId);
|
||||
|
||||
$done = 0;
|
||||
$failed = 0;
|
||||
while (true) {
|
||||
$row = $this->fetchNextPendingRow($pArticleId);
|
||||
if (empty($row)) {
|
||||
break;
|
||||
}
|
||||
$checkId = $this->svc->resolveCheckRowId($row);
|
||||
if ($checkId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$result = $this->processOneRow($checkId, $row);
|
||||
if ($result === 'ok') {
|
||||
$done++;
|
||||
} elseif ($result === 'failed') {
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->finalizeBatch($batchId, $done, $failed);
|
||||
$this->svc->log('ReferenceCheckArticleWorker done p_article_id=' . $pArticleId . ' batch_id=' . $batchId . ' done=' . $done . ' failed=' . $failed);
|
||||
|
||||
$this->publishNextWaitingBatch();
|
||||
}
|
||||
|
||||
private function canStartArticleWork($batchId)
|
||||
{
|
||||
$running = Db::name('article_reference_check_batch')
|
||||
->where('batch_status', self::BATCH_RUNNING)
|
||||
->where('id', '<>', intval($batchId))
|
||||
->count();
|
||||
return intval($running) === 0;
|
||||
}
|
||||
|
||||
private function claimBatch($batchId)
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$affected = Db::name('article_reference_check_batch')
|
||||
->where('id', intval($batchId))
|
||||
->whereIn('batch_status', [self::BATCH_WAITING, self::BATCH_RUNNING])
|
||||
->update([
|
||||
'batch_status' => self::BATCH_RUNNING,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
return intval($affected) > 0;
|
||||
}
|
||||
|
||||
private function getBatch($batchId)
|
||||
{
|
||||
return Db::name('article_reference_check_batch')->where('id', intval($batchId))->find();
|
||||
}
|
||||
|
||||
private function fetchNextPendingRow($pArticleId)
|
||||
{
|
||||
return Db::name('article_reference_check_result')
|
||||
->where('p_article_id', intval($pArticleId))
|
||||
->where('queue_status', ReferenceCheckService::QUEUE_PENDING)
|
||||
->where('status', ReferenceCheckService::RECORD_PENDING)
|
||||
->order('reference_no asc,am_id asc,text_start asc,id asc')
|
||||
->find();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string ok|failed|skip
|
||||
*/
|
||||
private function processOneRow($checkId, array $row)
|
||||
{
|
||||
$claimed = Db::name('article_reference_check_result')
|
||||
->where('id', intval($checkId))
|
||||
->where('queue_status', ReferenceCheckService::QUEUE_PENDING)
|
||||
->update(['queue_status' => ReferenceCheckService::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);
|
||||
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]));
|
||||
}
|
||||
try {
|
||||
$this->svc->updateCheckResult($checkId, [
|
||||
'status' => ReferenceCheckService::RECORD_FAILED,
|
||||
'error_msg' => $e->getMessage(),
|
||||
]);
|
||||
$this->svc->markQueueRuntime($checkId, ReferenceCheckService::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';
|
||||
}
|
||||
}
|
||||
|
||||
private function finalizeBatch($batchId, $done, $failed)
|
||||
{
|
||||
$batch = $this->getBatch($batchId);
|
||||
if (empty($batch)) {
|
||||
return;
|
||||
}
|
||||
$total = intval($batch['total_count']);
|
||||
$status = self::BATCH_DONE;
|
||||
if ($failed > 0) {
|
||||
$status = self::BATCH_PARTIAL_FAILED;
|
||||
}
|
||||
Db::name('article_reference_check_batch')->where('id', intval($batchId))->update([
|
||||
'batch_status' => $status,
|
||||
'done_count' => intval($done),
|
||||
'failed_count' => intval($failed),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
if ($total > 0 && ($done + $failed) < $total) {
|
||||
$this->svc->log('ReferenceCheckArticleWorker batch_id=' . $batchId . ' incomplete total=' . $total);
|
||||
}
|
||||
}
|
||||
|
||||
private function publishNextWaitingBatch()
|
||||
{
|
||||
$next = Db::name('article_reference_check_batch')
|
||||
->where('batch_status', self::BATCH_WAITING)
|
||||
->order('id asc')
|
||||
->find();
|
||||
if (empty($next)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
(new ReferenceCheckMqPublisher())->publishArticleStart(
|
||||
intval($next['p_article_id']),
|
||||
intval($next['id']),
|
||||
isset($next['trigger']) ? $next['trigger'] : 'enqueue'
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->svc->log('publishNextWaitingBatch failed: ' . $e->getMessage());
|
||||
\think\Log::error('publishNextWaitingBatch: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
73
application/common/mq/ReferenceCheckMqPublisher.php
Normal file
73
application/common/mq/ReferenceCheckMqPublisher.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\mq;
|
||||
|
||||
class ReferenceCheckMqPublisher
|
||||
{
|
||||
public function publishArticleStart($pArticleId, $batchId, $trigger = 'enqueue')
|
||||
{
|
||||
if (!class_exists('\\PhpAmqpLib\\Connection\\AMQPStreamConnection')) {
|
||||
throw new \RuntimeException('php-amqplib not installed. Run: php composer.phar require php-amqplib/php-amqplib:^2.12');
|
||||
}
|
||||
|
||||
$pArticleId = intval($pArticleId);
|
||||
$batchId = intval($batchId);
|
||||
if ($pArticleId <= 0 || $batchId <= 0) {
|
||||
throw new \InvalidArgumentException('invalid p_article_id or batch_id');
|
||||
}
|
||||
|
||||
$body = json_encode([
|
||||
'p_article_id' => $pArticleId,
|
||||
'batch_id' => $batchId,
|
||||
'trigger' => (string)$trigger,
|
||||
'ts' => time(),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$rc = RabbitMqConfig::referenceCheck();
|
||||
$exchange = isset($rc['exchange']) ? $rc['exchange'] : 'reference_check';
|
||||
$routeKey = isset($rc['route_key']) ? $rc['route_key'] : 'article.start';
|
||||
|
||||
$conn = $this->connect();
|
||||
try {
|
||||
$ch = $conn->channel();
|
||||
$this->declareTopology($ch, $rc);
|
||||
$msg = new \PhpAmqpLib\Message\AMQPMessage($body, [
|
||||
'content_type' => 'application/json',
|
||||
'delivery_mode' => \PhpAmqpLib\Message\AMQPMessage::DELIVERY_MODE_PERSISTENT,
|
||||
]);
|
||||
$ch->basic_publish($msg, $exchange, $routeKey);
|
||||
$ch->close();
|
||||
} finally {
|
||||
$conn->close();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function connect()
|
||||
{
|
||||
return new \PhpAmqpLib\Connection\AMQPStreamConnection(
|
||||
RabbitMqConfig::get('host', '127.0.0.1'),
|
||||
intval(RabbitMqConfig::get('port', 5672)),
|
||||
RabbitMqConfig::get('user', 'guest'),
|
||||
RabbitMqConfig::get('password', 'guest'),
|
||||
RabbitMqConfig::get('vhost', '/')
|
||||
);
|
||||
}
|
||||
|
||||
private function declareTopology($ch, array $rc)
|
||||
{
|
||||
$exchange = isset($rc['exchange']) ? $rc['exchange'] : 'reference_check';
|
||||
$queue = isset($rc['queue']) ? $rc['queue'] : 'ref_check.article';
|
||||
$dlq = isset($rc['dlq']) ? $rc['dlq'] : 'ref_check.article.dlq';
|
||||
$routeKey = isset($rc['route_key']) ? $rc['route_key'] : 'article.start';
|
||||
|
||||
$ch->exchange_declare($exchange, 'direct', false, true, false);
|
||||
$ch->queue_declare($dlq, false, true, false, false);
|
||||
$ch->queue_declare($queue, false, true, false, false, false, new \PhpAmqpLib\Wire\AMQPTable([
|
||||
'x-dead-letter-exchange' => '',
|
||||
'x-dead-letter-routing-key' => $dlq,
|
||||
]));
|
||||
$ch->queue_bind($queue, $exchange, $routeKey);
|
||||
}
|
||||
}
|
||||
1383
application/common/service/AuthorBackgroundService.php
Normal file
1383
application/common/service/AuthorBackgroundService.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ class LLMService
|
||||
public function checkReference($contextText, $referText, $isAgain = false, $doiBlock = null)
|
||||
{
|
||||
// request_failed=true 表示"LLM 通讯/解析层面的失败"(可重试,区别于业务上的"未命中");
|
||||
// 上游 runReferenceCheckOnce 会据此把 DB.status 置为 2(失败) 并抛异常触发队列重试
|
||||
// 上游 runReferenceCheckOnce 会据此把 DB.status 置为 3(失败) 并抛异常触发 MQ worker 重试
|
||||
$fallback = [
|
||||
'can_support' => false,
|
||||
'is_match' => false,
|
||||
@@ -120,6 +120,35 @@ class LLMService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用对话请求(与参考文献校对共用 postChat / [promotion] 配置)
|
||||
*
|
||||
* @param array $messages OpenAI messages
|
||||
* @param float $temperature
|
||||
* @return string|null 助手回复正文
|
||||
*/
|
||||
public function requestChat(array $messages, $temperature = 0)
|
||||
{
|
||||
if ($this->url === '' || $this->model === '') {
|
||||
\think\Log::warning('LLM requestChat: url or model not configured');
|
||||
return null;
|
||||
}
|
||||
$payload = [
|
||||
'model' => $this->model,
|
||||
'temperature' => $temperature,
|
||||
'messages' => $messages,
|
||||
];
|
||||
return $this->postChat($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型返回的 JSON 对象(去除 markdown 代码块等)
|
||||
*/
|
||||
public function parseJsonResponse($raw)
|
||||
{
|
||||
return $this->parseJson($raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 can_support;兼容 is_match 字段
|
||||
*/
|
||||
|
||||
219
application/common/service/LocalModelService.php
Normal file
219
application/common/service/LocalModelService.php
Normal file
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\Env;
|
||||
|
||||
/**
|
||||
* 本地模型服务:按上下文长度自动选择模型
|
||||
*
|
||||
* - 短上下文 -> 小模型(显存为大模型一半),对应 base.model_url1
|
||||
* - 长上下文 -> 大模型,对应 base.model_url
|
||||
*
|
||||
* 选择规则:上下文字符数 <= 阈值 用小模型;超过阈值 用大模型。
|
||||
* 两个端点模型名相同(base.model)。
|
||||
*
|
||||
* 用法:
|
||||
* $svc = new LocalModelService();
|
||||
* $res = $svc->chat([
|
||||
* ['role' => 'system', 'content' => '...'],
|
||||
* ['role' => 'user', 'content' => '...'],
|
||||
* ]);
|
||||
* // $res['ok'], $res['content'], $res['tier'](small|large), $res['context_len']
|
||||
*
|
||||
* // 只要文本结果:
|
||||
* $text = $svc->complete($systemPrompt, $userPrompt);
|
||||
*/
|
||||
class LocalModelService
|
||||
{
|
||||
/** 上下文长度阈值(字符数):<= 用小模型,> 用大模型 */
|
||||
const CONTEXT_THRESHOLD = 3000;
|
||||
|
||||
/** 请求超时(秒) */
|
||||
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';
|
||||
}
|
||||
}
|
||||
16
application/extra/rabbitmq.php
Normal file
16
application/extra/rabbitmq.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 5672,
|
||||
'user' => 'guest',//admin
|
||||
'password' => 'guest',//'751019'
|
||||
'vhost' => '/',
|
||||
|
||||
'reference_check' => [
|
||||
'exchange' => 'reference_check',
|
||||
'queue' => 'ref_check.article',
|
||||
'dlq' => 'ref_check.article.dlq',
|
||||
'route_key' => 'article.start',
|
||||
],
|
||||
];
|
||||
@@ -18,4 +18,14 @@ return [
|
||||
':name' => ['index/hello', ['method' => 'post']],
|
||||
],
|
||||
|
||||
// Author 背调(兼容 /api/author/ 与 /api/Author/)
|
||||
'api/author/index' => 'api/Author/index',
|
||||
'api/author/background_report' => 'api/Author/background_report',
|
||||
'api/author/due_diligence' => 'api/Author/due_diligence',
|
||||
'api/Author/index' => 'api/Author/index',
|
||||
'api/Author/background_report' => 'api/Author/background_report',
|
||||
'api/Author/backgroundReport' => 'api/Author/background_report',
|
||||
'api/Author/due_diligence' => 'api/Author/due_diligence',
|
||||
'api/Author/dueDiligence' => 'api/Author/due_diligence',
|
||||
|
||||
];
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"phpoffice/phpspreadsheet": "^1.12",
|
||||
"paypal/paypal-server-sdk": "^0.6.1",
|
||||
"guzzlehttp/guzzle": "^7.9",
|
||||
"php-amqplib/php-amqplib": "^2.12",
|
||||
"tectalic/openai": "^1.6"
|
||||
},
|
||||
"autoload": {
|
||||
@@ -42,6 +43,17 @@
|
||||
"allow-plugins": {
|
||||
"topthink/think-installer": true,
|
||||
"php-http/discovery": true
|
||||
},
|
||||
"secure-http": false,
|
||||
"audit": {
|
||||
"block-insecure": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"repositories": [{
|
||||
"name": "aliyun",
|
||||
"type": "composer",
|
||||
"url": "http://mirrors.aliyun.com/composer/"
|
||||
},{
|
||||
"packagist": false
|
||||
}]
|
||||
}
|
||||
|
||||
3
sql/add_field_ai_source_to_expert.sql
Normal file
3
sql/add_field_ai_source_to_expert.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- 若已执行过 add_field_ai_to_expert.sql 但缺少 field_ai_source,单独补这一列
|
||||
ALTER TABLE `t_expert`
|
||||
ADD COLUMN `field_ai_source` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源: user_link / ai' AFTER `field_ai_utime`;
|
||||
6
sql/add_field_ai_to_expert.sql
Normal file
6
sql/add_field_ai_to_expert.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Expert 主领域 AI 总结(方案 C:先邮箱关联 user.field_ai,后续可 AI 补全)
|
||||
ALTER TABLE `t_expert`
|
||||
ADD COLUMN `field_ai` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'AI/关联总结的主要研究领域(中文)' AFTER `affiliation`,
|
||||
ADD COLUMN `field_ai_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待处理 1已生成 2资料不足 3失败 4无user关联待AI' AFTER `field_ai`,
|
||||
ADD COLUMN `field_ai_utime` INT NOT NULL DEFAULT 0 COMMENT 'field_ai 更新时间' AFTER `field_ai_status`,
|
||||
ADD COLUMN `field_ai_source` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源: user_link / ai' AFTER `field_ai_utime`;
|
||||
46
sql/patch_expert_field_ai_columns.php
Normal file
46
sql/patch_expert_field_ai_columns.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
/**
|
||||
* 补全 t_expert 缺失的 field_ai 相关字段(可重复执行)
|
||||
* 用法: php sql/patch_expert_field_ai_columns.php
|
||||
*/
|
||||
$config = require __DIR__ . '/../application/database.php';
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
|
||||
$config['hostname'],
|
||||
$config['hostport'],
|
||||
$config['database'],
|
||||
$config['charset']
|
||||
);
|
||||
$pdo = new PDO($dsn, $config['username'], $config['password'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
]);
|
||||
|
||||
$table = $config['prefix'] . 'expert';
|
||||
$cols = $pdo->query("SHOW COLUMNS FROM `{$table}`")->fetchAll(PDO::FETCH_COLUMN, 0);
|
||||
$colSet = array_flip($cols);
|
||||
|
||||
$alters = [];
|
||||
if (!isset($colSet['field_ai'])) {
|
||||
$alters[] = "ADD COLUMN `field_ai` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'AI总结的主要研究领域(中文)' AFTER `affiliation`";
|
||||
}
|
||||
if (!isset($colSet['field_ai_status'])) {
|
||||
$after = isset($colSet['field_ai']) || !empty($alters) ? 'field_ai' : 'affiliation';
|
||||
$alters[] = "ADD COLUMN `field_ai_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待处理 1已生成 2资料不足 3失败 4无user待AI' AFTER `{$after}`";
|
||||
}
|
||||
if (!isset($colSet['field_ai_utime'])) {
|
||||
$alters[] = "ADD COLUMN `field_ai_utime` INT NOT NULL DEFAULT 0 COMMENT 'field_ai更新时间' AFTER `field_ai_status`";
|
||||
}
|
||||
if (!isset($colSet['field_ai_source'])) {
|
||||
$alters[] = "ADD COLUMN `field_ai_source` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源: user_link / ai' AFTER `field_ai_utime`";
|
||||
}
|
||||
|
||||
if (empty($alters)) {
|
||||
echo "OK: all field_ai columns exist on {$table}\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$sql = "ALTER TABLE `{$table}` " . implode(', ', $alters);
|
||||
echo "Running: {$sql}\n";
|
||||
$pdo->exec($sql);
|
||||
echo "Done.\n";
|
||||
2
vendor/autoload.php
vendored
2
vendor/autoload.php
vendored
@@ -19,4 +19,4 @@ if (PHP_VERSION_ID < 50600) {
|
||||
|
||||
require_once __DIR__ . '/composer/autoload_real.php';
|
||||
|
||||
return ComposerAutoloaderInit7020b987d316c2076c2a6f439a1140f9::getLoader();
|
||||
return ComposerAutoloaderInit2bc4f313dba415539e266f7ac2c87dcd::getLoader();
|
||||
|
||||
5
vendor/composer/autoload_classmap.php
vendored
5
vendor/composer/autoload_classmap.php
vendored
@@ -6,10 +6,5 @@ $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',
|
||||
);
|
||||
|
||||
4
vendor/composer/autoload_files.php
vendored
4
vendor/composer/autoload_files.php
vendored
@@ -7,12 +7,12 @@ $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',
|
||||
|
||||
8
vendor/composer/autoload_psr4.php
vendored
8
vendor/composer/autoload_psr4.php
vendored
@@ -9,26 +9,27 @@ return array(
|
||||
'think\\composer\\' => array($vendorDir . '/topthink/think-installer/src'),
|
||||
'think\\captcha\\' => array($vendorDir . '/topthink/think-captcha/src'),
|
||||
'think\\' => array($vendorDir . '/topthink/think-queue/src', $vendorDir . '/topthink/think-image/src', $vendorDir . '/topthink/think-helper/src', $baseDir . '/thinkphp/library/think'),
|
||||
'phpseclib3\\' => array($vendorDir . '/phpseclib/phpseclib/phpseclib'),
|
||||
'app\\' => array($baseDir . '/application'),
|
||||
'apimatic\\jsonmapper\\' => array($vendorDir . '/apimatic/jsonmapper/src'),
|
||||
'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/Psr/Log'),
|
||||
'Psr\\Log\\' => array($vendorDir . '/psr/log/src'),
|
||||
'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'),
|
||||
'PhpOffice\\PhpSpreadsheet\\' => array($vendorDir . '/phpoffice/phpspreadsheet/src/PhpSpreadsheet'),
|
||||
'PhpOffice\\Math\\' => array($vendorDir . '/phpoffice/math/src/Math'),
|
||||
'PhpAmqpLib\\' => array($vendorDir . '/php-amqplib/php-amqplib/PhpAmqpLib'),
|
||||
'PaypalServerSdkLib\\' => array($vendorDir . '/paypal/paypal-server-sdk/src'),
|
||||
'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'),
|
||||
@@ -38,6 +39,7 @@ 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'),
|
||||
);
|
||||
|
||||
12
vendor/composer/autoload_real.php
vendored
12
vendor/composer/autoload_real.php
vendored
@@ -2,7 +2,7 @@
|
||||
|
||||
// autoload_real.php @generated by Composer
|
||||
|
||||
class ComposerAutoloaderInit7020b987d316c2076c2a6f439a1140f9
|
||||
class ComposerAutoloaderInit2bc4f313dba415539e266f7ac2c87dcd
|
||||
{
|
||||
private static $loader;
|
||||
|
||||
@@ -22,18 +22,16 @@ class ComposerAutoloaderInit7020b987d316c2076c2a6f439a1140f9
|
||||
return self::$loader;
|
||||
}
|
||||
|
||||
require __DIR__ . '/platform_check.php';
|
||||
|
||||
spl_autoload_register(array('ComposerAutoloaderInit7020b987d316c2076c2a6f439a1140f9', 'loadClassLoader'), true, true);
|
||||
spl_autoload_register(array('ComposerAutoloaderInit2bc4f313dba415539e266f7ac2c87dcd', 'loadClassLoader'), true, true);
|
||||
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
|
||||
spl_autoload_unregister(array('ComposerAutoloaderInit7020b987d316c2076c2a6f439a1140f9', 'loadClassLoader'));
|
||||
spl_autoload_unregister(array('ComposerAutoloaderInit2bc4f313dba415539e266f7ac2c87dcd', 'loadClassLoader'));
|
||||
|
||||
require __DIR__ . '/autoload_static.php';
|
||||
call_user_func(\Composer\Autoload\ComposerStaticInit7020b987d316c2076c2a6f439a1140f9::getInitializer($loader));
|
||||
call_user_func(\Composer\Autoload\ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd::getInitializer($loader));
|
||||
|
||||
$loader->register(true);
|
||||
|
||||
$filesToLoad = \Composer\Autoload\ComposerStaticInit7020b987d316c2076c2a6f439a1140f9::$files;
|
||||
$filesToLoad = \Composer\Autoload\ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd::$files;
|
||||
$requireFile = \Closure::bind(static function ($fileIdentifier, $file) {
|
||||
if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
|
||||
$GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
|
||||
|
||||
54
vendor/composer/autoload_static.php
vendored
54
vendor/composer/autoload_static.php
vendored
@@ -4,16 +4,16 @@
|
||||
|
||||
namespace Composer\Autoload;
|
||||
|
||||
class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9
|
||||
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',
|
||||
@@ -27,6 +27,10 @@ class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9
|
||||
'think\\captcha\\' => 14,
|
||||
'think\\' => 6,
|
||||
),
|
||||
'p' =>
|
||||
array (
|
||||
'phpseclib3\\' => 11,
|
||||
),
|
||||
'a' =>
|
||||
array (
|
||||
'app\\' => 4,
|
||||
@@ -46,7 +50,6 @@ class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9
|
||||
),
|
||||
'S' =>
|
||||
array (
|
||||
'Symfony\\Polyfill\\Php80\\' => 23,
|
||||
'Symfony\\Polyfill\\Mbstring\\' => 26,
|
||||
'Symfony\\Component\\HttpFoundation\\' => 33,
|
||||
'Spatie\\DataTransferObject\\' => 26,
|
||||
@@ -60,7 +63,9 @@ class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9
|
||||
'PhpOffice\\PhpWord\\' => 18,
|
||||
'PhpOffice\\PhpSpreadsheet\\' => 25,
|
||||
'PhpOffice\\Math\\' => 15,
|
||||
'PhpAmqpLib\\' => 11,
|
||||
'PaypalServerSdkLib\\' => 19,
|
||||
'ParagonIE\\ConstantTime\\' => 23,
|
||||
'PHPMailer\\PHPMailer\\' => 20,
|
||||
),
|
||||
'N' =>
|
||||
@@ -69,7 +74,6 @@ class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9
|
||||
),
|
||||
'M' =>
|
||||
array (
|
||||
'MyCLabs\\Enum\\' => 13,
|
||||
'Matrix\\' => 7,
|
||||
),
|
||||
'H' =>
|
||||
@@ -88,6 +92,7 @@ class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9
|
||||
array (
|
||||
'Core\\' => 5,
|
||||
'CoreInterfaces\\' => 15,
|
||||
'Composer\\Pcre\\' => 14,
|
||||
'Complex\\' => 8,
|
||||
'Clue\\StreamFilter\\' => 18,
|
||||
),
|
||||
@@ -109,6 +114,10 @@ class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9
|
||||
2 => __DIR__ . '/..' . '/topthink/think-helper/src',
|
||||
3 => __DIR__ . '/../..' . '/thinkphp/library/think',
|
||||
),
|
||||
'phpseclib3\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/phpseclib/phpseclib/phpseclib',
|
||||
),
|
||||
'app\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/../..' . '/application',
|
||||
@@ -129,10 +138,6 @@ class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/tectalic/openai/src',
|
||||
),
|
||||
'Symfony\\Polyfill\\Php80\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/polyfill-php80',
|
||||
),
|
||||
'Symfony\\Polyfill\\Mbstring\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/polyfill-mbstring',
|
||||
@@ -151,7 +156,7 @@ class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9
|
||||
),
|
||||
'Psr\\Log\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/psr/log/Psr/Log',
|
||||
0 => __DIR__ . '/..' . '/psr/log/src',
|
||||
),
|
||||
'Psr\\Http\\Message\\' =>
|
||||
array (
|
||||
@@ -174,10 +179,18 @@ class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/phpoffice/math/src/Math',
|
||||
),
|
||||
'PhpAmqpLib\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/php-amqplib/php-amqplib/PhpAmqpLib',
|
||||
),
|
||||
'PaypalServerSdkLib\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/paypal/paypal-server-sdk/src',
|
||||
),
|
||||
'ParagonIE\\ConstantTime\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/paragonie/constant_time_encoding/src',
|
||||
),
|
||||
'PHPMailer\\PHPMailer\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/phpmailer/phpmailer/src',
|
||||
@@ -186,10 +199,6 @@ class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/nyholm/psr7/src',
|
||||
),
|
||||
'MyCLabs\\Enum\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/myclabs/php-enum/src',
|
||||
),
|
||||
'Matrix\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/markbaker/matrix/classes/src',
|
||||
@@ -226,6 +235,10 @@ class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/apimatic/core-interfaces/src',
|
||||
),
|
||||
'Composer\\Pcre\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/composer/pcre/src',
|
||||
),
|
||||
'Complex\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/markbaker/complex/classes/src',
|
||||
@@ -261,21 +274,16 @@ class ComposerStaticInit7020b987d316c2076c2a6f439a1140f9
|
||||
);
|
||||
|
||||
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)
|
||||
{
|
||||
return \Closure::bind(function () use ($loader) {
|
||||
$loader->prefixLengthsPsr4 = ComposerStaticInit7020b987d316c2076c2a6f439a1140f9::$prefixLengthsPsr4;
|
||||
$loader->prefixDirsPsr4 = ComposerStaticInit7020b987d316c2076c2a6f439a1140f9::$prefixDirsPsr4;
|
||||
$loader->prefixesPsr0 = ComposerStaticInit7020b987d316c2076c2a6f439a1140f9::$prefixesPsr0;
|
||||
$loader->classMap = ComposerStaticInit7020b987d316c2076c2a6f439a1140f9::$classMap;
|
||||
$loader->prefixLengthsPsr4 = ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd::$prefixLengthsPsr4;
|
||||
$loader->prefixDirsPsr4 = ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd::$prefixDirsPsr4;
|
||||
$loader->prefixesPsr0 = ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd::$prefixesPsr0;
|
||||
$loader->classMap = ComposerStaticInit2bc4f313dba415539e266f7ac2c87dcd::$classMap;
|
||||
|
||||
}, null, ClassLoader::class);
|
||||
}
|
||||
|
||||
1136
vendor/composer/installed.json
vendored
1136
vendor/composer/installed.json
vendored
File diff suppressed because it is too large
Load Diff
183
vendor/composer/installed.php
vendored
183
vendor/composer/installed.php
vendored
@@ -3,17 +3,17 @@
|
||||
'name' => 'topthink/think',
|
||||
'pretty_version' => 'dev-master',
|
||||
'version' => 'dev-master',
|
||||
'reference' => 'fa878334cd151a29627aac8f2e01d8ce27770606',
|
||||
'reference' => 'bbd690ca0f68c671ece05e82edc88ee7a68b82ed',
|
||||
'type' => 'project',
|
||||
'install_path' => __DIR__ . '/../../',
|
||||
'aliases' => array(),
|
||||
'dev' => false,
|
||||
'dev' => true,
|
||||
),
|
||||
'versions' => array(
|
||||
'apimatic/core' => array(
|
||||
'pretty_version' => '0.3.17',
|
||||
'version' => '0.3.17.0',
|
||||
'reference' => 'a48a583f686ee3786432b976c795a2817ec095b3',
|
||||
'pretty_version' => '0.3.16',
|
||||
'version' => '0.3.16.0',
|
||||
'reference' => 'ae4ab4ca26a41be41718f33c703d67b7a767c07b',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../apimatic/core',
|
||||
'aliases' => array(),
|
||||
@@ -55,6 +55,15 @@
|
||||
'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',
|
||||
@@ -83,18 +92,18 @@
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'guzzlehttp/psr7' => array(
|
||||
'pretty_version' => '2.9.0',
|
||||
'version' => '2.9.0.0',
|
||||
'reference' => '7d0ed42f28e42d61352a7a79de682e5e67fec884',
|
||||
'pretty_version' => '2.8.0',
|
||||
'version' => '2.8.0.0',
|
||||
'reference' => '21dc724a0583619cd1652f673303492272778051',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../guzzlehttp/psr7',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'maennchen/zipstream-php' => array(
|
||||
'pretty_version' => '2.1.0',
|
||||
'version' => '2.1.0.0',
|
||||
'reference' => 'c4c5803cc1f93df3d2448478ef79394a5981cc58',
|
||||
'pretty_version' => '3.1.2',
|
||||
'version' => '3.1.2.0',
|
||||
'reference' => 'aeadcf5c412332eb426c0f9b4485f6accba2a99f',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../maennchen/zipstream-php',
|
||||
'aliases' => array(),
|
||||
@@ -118,15 +127,6 @@
|
||||
'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',
|
||||
@@ -136,6 +136,24 @@
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'paragonie/constant_time_encoding' => array(
|
||||
'pretty_version' => 'v3.1.3',
|
||||
'version' => '3.1.3.0',
|
||||
'reference' => 'd5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../paragonie/constant_time_encoding',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'paragonie/random_compat' => array(
|
||||
'pretty_version' => 'v9.99.100',
|
||||
'version' => '9.99.100.0',
|
||||
'reference' => '996434e5492cb4c3edcb9168db6fbb1359ef965a',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../paragonie/random_compat',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'paypal/paypal-server-sdk' => array(
|
||||
'pretty_version' => '0.6.1',
|
||||
'version' => '0.6.1.0',
|
||||
@@ -145,6 +163,15 @@
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'php-amqplib/php-amqplib' => array(
|
||||
'pretty_version' => 'v2.12.3',
|
||||
'version' => '2.12.3.0',
|
||||
'reference' => 'f746eb44df6d8f838173729867dd1d20b0265faa',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../php-amqplib/php-amqplib',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'php-http/async-client-implementation' => array(
|
||||
'dev_requirement' => false,
|
||||
'provided' => array(
|
||||
@@ -167,9 +194,9 @@
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'php-http/message' => array(
|
||||
'pretty_version' => '1.16.2',
|
||||
'version' => '1.16.2.0',
|
||||
'reference' => '06dd5e8562f84e641bf929bfe699ee0f5ce8080a',
|
||||
'pretty_version' => '1.16.1',
|
||||
'version' => '1.16.1.0',
|
||||
'reference' => '5997f3289332c699fa2545c427826272498a2088',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../php-http/message',
|
||||
'aliases' => array(),
|
||||
@@ -182,9 +209,9 @@
|
||||
),
|
||||
),
|
||||
'php-http/multipart-stream-builder' => array(
|
||||
'pretty_version' => '1.4.2',
|
||||
'version' => '1.4.2.0',
|
||||
'reference' => '10086e6de6f53489cca5ecc45b6f468604d3460e',
|
||||
'pretty_version' => '1.3.1',
|
||||
'version' => '1.3.1.0',
|
||||
'reference' => 'ed56da23b95949ae4747378bed8a5b61a2fdae24',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../php-http/multipart-stream-builder',
|
||||
'aliases' => array(),
|
||||
@@ -200,18 +227,18 @@
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'phpmailer/phpmailer' => array(
|
||||
'pretty_version' => 'v6.12.0',
|
||||
'version' => '6.12.0.0',
|
||||
'reference' => 'd1ac35d784bf9f5e61b424901d5a014967f15b12',
|
||||
'pretty_version' => 'v6.11.1',
|
||||
'version' => '6.11.1.0',
|
||||
'reference' => 'd9e3b36b47f04b497a0164c5a20f92acb4593284',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../phpmailer/phpmailer',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'phpoffice/math' => array(
|
||||
'pretty_version' => '0.3.0',
|
||||
'version' => '0.3.0.0',
|
||||
'reference' => 'fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a',
|
||||
'pretty_version' => '0.2.0',
|
||||
'version' => '0.2.0.0',
|
||||
'reference' => 'fc2eb6d1a61b058d5dac77197059db30ee3c8329',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../phpoffice/math',
|
||||
'aliases' => array(),
|
||||
@@ -227,23 +254,32 @@
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'phpoffice/phpspreadsheet' => array(
|
||||
'pretty_version' => '1.25.2',
|
||||
'version' => '1.25.2.0',
|
||||
'reference' => 'a317a09e7def49852400a4b3eca4a4b0790ceeb5',
|
||||
'pretty_version' => '1.30.1',
|
||||
'version' => '1.30.1.0',
|
||||
'reference' => 'fa8257a579ec623473eabfe49731de5967306c4c',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../phpoffice/phpspreadsheet',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'phpoffice/phpword' => array(
|
||||
'pretty_version' => '1.4.0',
|
||||
'version' => '1.4.0.0',
|
||||
'reference' => '6d75328229bc93790b37e93741adf70646cea958',
|
||||
'pretty_version' => '1.3.0',
|
||||
'version' => '1.3.0.0',
|
||||
'reference' => '8392134ce4b5dba65130ba956231a1602b848b7f',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../phpoffice/phpword',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'phpseclib/phpseclib' => array(
|
||||
'pretty_version' => '3.0.48',
|
||||
'version' => '3.0.48.0',
|
||||
'reference' => '64065a5679c50acb886e82c07aa139b0f757bb89',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../phpseclib/phpseclib',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'psr/http-client' => array(
|
||||
'pretty_version' => '1.0.3',
|
||||
'version' => '1.0.3.0',
|
||||
@@ -261,9 +297,9 @@
|
||||
),
|
||||
),
|
||||
'psr/http-factory' => array(
|
||||
'pretty_version' => '1.1.0',
|
||||
'version' => '1.1.0.0',
|
||||
'reference' => '2b4765fddfe3b508ac62f829e852b1501d3f6e8a',
|
||||
'pretty_version' => '1.0.2',
|
||||
'version' => '1.0.2.0',
|
||||
'reference' => 'e616d01114759c4c489f93b099585439f795fe35',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../psr/http-factory',
|
||||
'aliases' => array(),
|
||||
@@ -277,9 +313,9 @@
|
||||
),
|
||||
),
|
||||
'psr/http-message' => array(
|
||||
'pretty_version' => '1.1',
|
||||
'version' => '1.1.0.0',
|
||||
'reference' => 'cb6ce4845ce34a8ad9e68117c10ee90a29919eba',
|
||||
'pretty_version' => '2.0',
|
||||
'version' => '2.0.0.0',
|
||||
'reference' => '402d35bcb92c70c026d1a6a9883f06b2ead23d71',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../psr/http-message',
|
||||
'aliases' => array(),
|
||||
@@ -293,18 +329,18 @@
|
||||
),
|
||||
),
|
||||
'psr/log' => array(
|
||||
'pretty_version' => '1.1.4',
|
||||
'version' => '1.1.4.0',
|
||||
'reference' => 'd49695b909c3b7628b6289db5479a1c204601f11',
|
||||
'pretty_version' => '3.0.1',
|
||||
'version' => '3.0.1.0',
|
||||
'reference' => '79dff0b268932c640297f5208d6298f71855c03e',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../psr/log',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'psr/simple-cache' => array(
|
||||
'pretty_version' => '1.0.1',
|
||||
'version' => '1.0.1.0',
|
||||
'reference' => '408d5eafb83c57f6365a3ca330ff23aa4a5fa39b',
|
||||
'pretty_version' => '3.0.0',
|
||||
'version' => '3.0.0.0',
|
||||
'reference' => '764e0b3939f5ca87cb904f570ef9be2d78a07865',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../psr/simple-cache',
|
||||
'aliases' => array(),
|
||||
@@ -329,41 +365,32 @@
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/deprecation-contracts' => array(
|
||||
'pretty_version' => 'v2.5.4',
|
||||
'version' => '2.5.4.0',
|
||||
'reference' => '605389f2a7e5625f273b53960dc46aeaf9c62918',
|
||||
'pretty_version' => 'v3.6.0',
|
||||
'version' => '3.6.0.0',
|
||||
'reference' => '63afe740e99a13ba87ec199bb07bbdee937a5b62',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/deprecation-contracts',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/http-foundation' => array(
|
||||
'pretty_version' => 'v5.4.50',
|
||||
'version' => '5.4.50.0',
|
||||
'reference' => '1a0706e8b8041046052ea2695eb8aeee04f97609',
|
||||
'pretty_version' => 'v8.0.1',
|
||||
'version' => '8.0.1.0',
|
||||
'reference' => '3690740e2e8b19d877f20d4f10b7a489cddf0fe2',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/http-foundation',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/polyfill-mbstring' => array(
|
||||
'pretty_version' => 'v1.37.0',
|
||||
'version' => '1.37.0.0',
|
||||
'reference' => '6a21eb99c6973357967f6ce3708cd55a6bec6315',
|
||||
'pretty_version' => 'v1.32.0',
|
||||
'version' => '1.32.0.0',
|
||||
'reference' => '6d857f4d76bd4b343eac26d6b539585d2bc56493',
|
||||
'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',
|
||||
@@ -385,7 +412,7 @@
|
||||
'topthink/think' => array(
|
||||
'pretty_version' => 'dev-master',
|
||||
'version' => 'dev-master',
|
||||
'reference' => 'fa878334cd151a29627aac8f2e01d8ce27770606',
|
||||
'reference' => 'bbd690ca0f68c671ece05e82edc88ee7a68b82ed',
|
||||
'type' => 'project',
|
||||
'install_path' => __DIR__ . '/../../',
|
||||
'aliases' => array(),
|
||||
@@ -401,18 +428,18 @@
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'topthink/think-helper' => array(
|
||||
'pretty_version' => 'v3.1.12',
|
||||
'version' => '3.1.12.0',
|
||||
'reference' => 'fe277121112a8f1c872e169a733ca80bb11c4acb',
|
||||
'pretty_version' => 'v3.1.11',
|
||||
'version' => '3.1.11.0',
|
||||
'reference' => '1d6ada9b9f3130046bf6922fe1bd159c8d88a33c',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../topthink/think-helper',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'topthink/think-image' => array(
|
||||
'pretty_version' => 'v1.0.8',
|
||||
'version' => '1.0.8.0',
|
||||
'reference' => 'd1d748cbb2fe2f29fca6138cf96cb8b5113892f1',
|
||||
'pretty_version' => 'v1.0.7',
|
||||
'version' => '1.0.7.0',
|
||||
'reference' => '8586cf47f117481c6d415b20f7dedf62e79d5512',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../topthink/think-image',
|
||||
'aliases' => array(),
|
||||
@@ -436,5 +463,11 @@
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'videlalvaro/php-amqplib' => array(
|
||||
'dev_requirement' => false,
|
||||
'replaced' => array(
|
||||
0 => 'v2.12.3',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
25
vendor/composer/platform_check.php
vendored
25
vendor/composer/platform_check.php
vendored
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
// platform_check.php @generated by Composer
|
||||
|
||||
$issues = array();
|
||||
|
||||
if (!(PHP_VERSION_ID >= 70300)) {
|
||||
$issues[] = 'Your Composer dependencies require a PHP version ">= 7.3.0". You are running ' . PHP_VERSION . '.';
|
||||
}
|
||||
|
||||
if ($issues) {
|
||||
if (!headers_sent()) {
|
||||
header('HTTP/1.1 500 Internal Server Error');
|
||||
}
|
||||
if (!ini_get('display_errors')) {
|
||||
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
|
||||
fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
|
||||
} elseif (!headers_sent()) {
|
||||
echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException(
|
||||
'Composer detected issues in your platform: ' . implode(' ', $issues)
|
||||
);
|
||||
}
|
||||
2
vendor/phpmailer/phpmailer/README.md
vendored
2
vendor/phpmailer/phpmailer/README.md
vendored
@@ -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.12.0"
|
||||
"phpmailer/phpmailer": "^6.11.1"
|
||||
```
|
||||
|
||||
or run
|
||||
|
||||
2
vendor/phpmailer/phpmailer/VERSION
vendored
2
vendor/phpmailer/phpmailer/VERSION
vendored
@@ -1 +1 @@
|
||||
6.12.0
|
||||
6.11.1
|
||||
|
||||
6
vendor/phpmailer/phpmailer/composer.json
vendored
6
vendor/phpmailer/phpmailer/composer.json
vendored
@@ -49,14 +49,15 @@
|
||||
},
|
||||
"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",
|
||||
"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)"
|
||||
"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"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -71,6 +72,7 @@
|
||||
"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": [
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
$PHPMAILER_LANG['authenticate'] = 'Error SMTP: Imposible autentificar.';
|
||||
$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['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['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(s) siguiente(s) direcciones de remitente fallaron: ';
|
||||
$PHPMAILER_LANG['from_failed'] = 'La siguiente dirección de remitente falló: ';
|
||||
$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,3 +34,5 @@ $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';
|
||||
|
||||
300
vendor/phpmailer/phpmailer/src/PHPMailer.php
vendored
300
vendor/phpmailer/phpmailer/src/PHPMailer.php
vendored
@@ -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 string
|
||||
* @var callable|callable-string
|
||||
*/
|
||||
public $action_function = '';
|
||||
|
||||
@@ -711,7 +711,7 @@ class PHPMailer
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $language = [];
|
||||
protected static $language = [];
|
||||
|
||||
/**
|
||||
* The number of errors encountered.
|
||||
@@ -768,7 +768,7 @@ class PHPMailer
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const VERSION = '6.12.0';
|
||||
const VERSION = '6.11.1';
|
||||
|
||||
/**
|
||||
* Error severity: message only, continue processing.
|
||||
@@ -1102,7 +1102,7 @@ class PHPMailer
|
||||
//At-sign is missing.
|
||||
$error_message = sprintf(
|
||||
'%s (%s): %s',
|
||||
$this->lang('invalid_address'),
|
||||
self::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',
|
||||
$this->lang('Invalid recipient kind'),
|
||||
self::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',
|
||||
$this->lang('invalid_address'),
|
||||
self::lang('invalid_address'),
|
||||
$kind,
|
||||
$address
|
||||
);
|
||||
@@ -1220,12 +1220,16 @@ class PHPMailer
|
||||
|
||||
return true;
|
||||
}
|
||||
} elseif (!array_key_exists(strtolower($address), $this->ReplyTo)) {
|
||||
$this->ReplyTo[strtolower($address)] = [$address, $name];
|
||||
} else {
|
||||
foreach ($this->ReplyTo as $replyTo) {
|
||||
if (0 === strcasecmp($replyTo[0], $address)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$this->ReplyTo[] = [$address, $name];
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1238,15 +1242,18 @@ 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 bool $useimap Whether to use the IMAP extension to parse the list
|
||||
* @param null $useimap Deprecated argument since 6.11.0.
|
||||
* @param string $charset The charset to use when decoding the address list string.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function parseAddresses($addrstr, $useimap = true, $charset = self::CHARSET_ISO88591)
|
||||
public static function parseAddresses($addrstr, $useimap = null, $charset = self::CHARSET_ISO88591)
|
||||
{
|
||||
if ($useimap !== null) {
|
||||
trigger_error(self::lang('deprecated_argument'), E_USER_DEPRECATED);
|
||||
}
|
||||
$addresses = [];
|
||||
if ($useimap && function_exists('imap_rfc822_parse_adrlist')) {
|
||||
if (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.
|
||||
@@ -1256,20 +1263,13 @@ class PHPMailer
|
||||
'.SYNTAX-ERROR.' !== $address->host &&
|
||||
static::validateAddress($address->mailbox . '@' . $address->host)
|
||||
) {
|
||||
//Decode the name part if it's present and encoded
|
||||
//Decode the name part if it's present and maybe encoded
|
||||
if (
|
||||
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)
|
||||
property_exists($address, 'personal')
|
||||
&& is_string($address->personal)
|
||||
&& $address->personal !== ''
|
||||
) {
|
||||
$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);
|
||||
$address->personal = static::decodeHeader($address->personal, $charset);
|
||||
}
|
||||
|
||||
$addresses[] = [
|
||||
@@ -1280,6 +1280,29 @@ 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);
|
||||
@@ -1293,21 +1316,10 @@ class PHPMailer
|
||||
];
|
||||
}
|
||||
} else {
|
||||
list($name, $email) = explode('<', $address);
|
||||
$email = trim(str_replace('>', '', $email));
|
||||
$name = trim($name);
|
||||
$parsed = static::parseEmailString($address);
|
||||
$email = $parsed['email'];
|
||||
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);
|
||||
}
|
||||
$name = static::decodeHeader($parsed['name'], $charset);
|
||||
$addresses[] = [
|
||||
//Remove any surrounding quotes and spaces from the name
|
||||
'name' => trim($name, '\'" '),
|
||||
@@ -1316,11 +1328,46 @@ 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.
|
||||
*
|
||||
@@ -1334,6 +1381,10 @@ 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().
|
||||
@@ -1345,7 +1396,7 @@ class PHPMailer
|
||||
) {
|
||||
$error_message = sprintf(
|
||||
'%s (From): %s',
|
||||
$this->lang('invalid_address'),
|
||||
self::lang('invalid_address'),
|
||||
$address
|
||||
);
|
||||
$this->setError($error_message);
|
||||
@@ -1601,7 +1652,7 @@ class PHPMailer
|
||||
&& ini_get('mail.add_x_header') === '1'
|
||||
&& stripos(PHP_OS, 'WIN') === 0
|
||||
) {
|
||||
trigger_error($this->lang('buggy_php'), E_USER_WARNING);
|
||||
trigger_error(self::lang('buggy_php'), E_USER_WARNING);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1631,7 +1682,7 @@ class PHPMailer
|
||||
call_user_func_array([$this, 'addAnAddress'], $params);
|
||||
}
|
||||
if (count($this->to) + count($this->cc) + count($this->bcc) < 1) {
|
||||
throw new Exception($this->lang('provide_address'), self::STOP_CRITICAL);
|
||||
throw new Exception(self::lang('provide_address'), self::STOP_CRITICAL);
|
||||
}
|
||||
|
||||
//Validate From, Sender, and ConfirmReadingTo addresses
|
||||
@@ -1648,7 +1699,7 @@ class PHPMailer
|
||||
if (!static::validateAddress($this->{$address_kind})) {
|
||||
$error_message = sprintf(
|
||||
'%s (%s): %s',
|
||||
$this->lang('invalid_address'),
|
||||
self::lang('invalid_address'),
|
||||
$address_kind,
|
||||
$this->{$address_kind}
|
||||
);
|
||||
@@ -1670,7 +1721,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($this->lang('empty_message'), self::STOP_CRITICAL);
|
||||
throw new Exception(self::lang('empty_message'), self::STOP_CRITICAL);
|
||||
}
|
||||
|
||||
//Trim subject consistently
|
||||
@@ -1809,8 +1860,10 @@ 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
|
||||
@@ -1828,17 +1881,18 @@ class PHPMailer
|
||||
foreach ($this->SingleToArray as $toAddr) {
|
||||
$mail = @popen($sendmail, 'w');
|
||||
if (!$mail) {
|
||||
throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL);
|
||||
throw new Exception(self::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, true, $this->CharSet);
|
||||
$addrinfo = static::parseAddresses($toAddr, null, $this->CharSet);
|
||||
foreach ($addrinfo as $addr) {
|
||||
$this->doCallback(
|
||||
($result === 0),
|
||||
[[$addrinfo['address'], $addrinfo['name']]],
|
||||
[[$addr['address'], $addr['name']]],
|
||||
$this->cc,
|
||||
$this->bcc,
|
||||
$this->Subject,
|
||||
@@ -1846,15 +1900,16 @@ class PHPMailer
|
||||
$this->From,
|
||||
[]
|
||||
);
|
||||
}
|
||||
$this->edebug("Result: " . ($result === 0 ? 'true' : 'false'));
|
||||
if (0 !== $result) {
|
||||
throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL);
|
||||
throw new Exception(self::lang('execute') . $this->Sendmail, self::STOP_CRITICAL);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$mail = @popen($sendmail, 'w');
|
||||
if (!$mail) {
|
||||
throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL);
|
||||
throw new Exception(self::lang('execute') . $this->Sendmail, self::STOP_CRITICAL);
|
||||
}
|
||||
fwrite($mail, $header);
|
||||
fwrite($mail, $body);
|
||||
@@ -1871,7 +1926,7 @@ class PHPMailer
|
||||
);
|
||||
$this->edebug("Result: " . ($result === 0 ? 'true' : 'false'));
|
||||
if (0 !== $result) {
|
||||
throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL);
|
||||
throw new Exception(self::lang('execute') . $this->Sendmail, self::STOP_CRITICAL);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2010,10 +2065,11 @@ 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, true, $this->CharSet);
|
||||
$addrinfo = static::parseAddresses($toAddr, null, $this->CharSet);
|
||||
foreach ($addrinfo as $addr) {
|
||||
$this->doCallback(
|
||||
$result,
|
||||
[[$addrinfo['address'], $addrinfo['name']]],
|
||||
[[$addr['address'], $addr['name']]],
|
||||
$this->cc,
|
||||
$this->bcc,
|
||||
$this->Subject,
|
||||
@@ -2022,6 +2078,7 @@ class PHPMailer
|
||||
[]
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$result = $this->mailPassthru($to, $this->Subject, $body, $header, $params);
|
||||
$this->doCallback($result, $this->to, $this->cc, $this->bcc, $this->Subject, $body, $this->From, []);
|
||||
@@ -2030,7 +2087,7 @@ class PHPMailer
|
||||
ini_set('sendmail_from', $old_from);
|
||||
}
|
||||
if (!$result) {
|
||||
throw new Exception($this->lang('instantiate'), self::STOP_CRITICAL);
|
||||
throw new Exception(self::lang('instantiate'), self::STOP_CRITICAL);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -2116,12 +2173,12 @@ class PHPMailer
|
||||
$header = static::stripTrailingWSP($header) . static::$LE . static::$LE;
|
||||
$bad_rcpt = [];
|
||||
if (!$this->smtpConnect($this->SMTPOptions)) {
|
||||
throw new Exception($this->lang('smtp_connect_failed'), self::STOP_CRITICAL);
|
||||
throw new Exception(self::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($this->lang('no_smtputf8'), self::STOP_CRITICAL);
|
||||
throw new Exception(self::lang('no_smtputf8'), self::STOP_CRITICAL);
|
||||
}
|
||||
//Sender already validated in preSend()
|
||||
if ('' === $this->Sender) {
|
||||
@@ -2133,7 +2190,7 @@ class PHPMailer
|
||||
$this->smtp->xclient($this->SMTPXClient);
|
||||
}
|
||||
if (!$this->smtp->mail($smtp_from)) {
|
||||
$this->setError($this->lang('from_failed') . $smtp_from . ' : ' . implode(',', $this->smtp->getError()));
|
||||
$this->setError(self::lang('from_failed') . $smtp_from . ' : ' . implode(',', $this->smtp->getError()));
|
||||
throw new Exception($this->ErrorInfo, self::STOP_CRITICAL);
|
||||
}
|
||||
|
||||
@@ -2155,7 +2212,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($this->lang('data_not_accepted'), self::STOP_CRITICAL);
|
||||
throw new Exception(self::lang('data_not_accepted'), self::STOP_CRITICAL);
|
||||
}
|
||||
|
||||
$smtp_transaction_id = $this->smtp->getLastTransactionID();
|
||||
@@ -2186,7 +2243,7 @@ class PHPMailer
|
||||
foreach ($bad_rcpt as $bad) {
|
||||
$errstr .= $bad['to'] . ': ' . $bad['error'];
|
||||
}
|
||||
throw new Exception($this->lang('recipients_failed') . $errstr, self::STOP_CONTINUE);
|
||||
throw new Exception(self::lang('recipients_failed') . $errstr, self::STOP_CONTINUE);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -2240,7 +2297,7 @@ class PHPMailer
|
||||
$hostinfo
|
||||
)
|
||||
) {
|
||||
$this->edebug($this->lang('invalid_hostentry') . ' ' . trim($hostentry));
|
||||
$this->edebug(self::lang('invalid_hostentry') . ' ' . trim($hostentry));
|
||||
//Not a valid host entry
|
||||
continue;
|
||||
}
|
||||
@@ -2252,7 +2309,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($this->lang('invalid_host') . ' ' . $hostinfo[2]);
|
||||
$this->edebug(self::lang('invalid_host') . ' ' . $hostinfo[2]);
|
||||
continue;
|
||||
}
|
||||
$prefix = '';
|
||||
@@ -2272,7 +2329,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($this->lang('extension_missing') . 'openssl', self::STOP_CRITICAL);
|
||||
throw new Exception(self::lang('extension_missing') . 'openssl', self::STOP_CRITICAL);
|
||||
}
|
||||
}
|
||||
$host = $hostinfo[2];
|
||||
@@ -2324,7 +2381,7 @@ class PHPMailer
|
||||
$this->oauth
|
||||
)
|
||||
) {
|
||||
throw new Exception($this->lang('authenticate'));
|
||||
throw new Exception(self::lang('authenticate'));
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -2374,7 +2431,7 @@ class PHPMailer
|
||||
*
|
||||
* @return bool Returns true if the requested language was loaded, false otherwise.
|
||||
*/
|
||||
public function setLanguage($langcode = 'en', $lang_path = '')
|
||||
public static function setLanguage($langcode = 'en', $lang_path = '')
|
||||
{
|
||||
//Backwards compatibility for renamed language codes
|
||||
$renamed_langcodes = [
|
||||
@@ -2423,6 +2480,9 @@ 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
|
||||
@@ -2489,7 +2549,7 @@ class PHPMailer
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->language = $PHPMAILER_LANG;
|
||||
self::$language = $PHPMAILER_LANG;
|
||||
|
||||
return $foundlang; //Returns false if language not found
|
||||
}
|
||||
@@ -2501,11 +2561,11 @@ class PHPMailer
|
||||
*/
|
||||
public function getTranslations()
|
||||
{
|
||||
if (empty($this->language)) {
|
||||
$this->setLanguage(); // Set the default language.
|
||||
if (empty(self::$language)) {
|
||||
self::setLanguage(); // Set the default language.
|
||||
}
|
||||
|
||||
return $this->language;
|
||||
return self::$language;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2928,10 +2988,6 @@ 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;
|
||||
@@ -2963,6 +3019,12 @@ 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) {
|
||||
@@ -3144,12 +3206,12 @@ class PHPMailer
|
||||
if ($this->isError()) {
|
||||
$body = '';
|
||||
if ($this->exceptions) {
|
||||
throw new Exception($this->lang('empty_message'), self::STOP_CRITICAL);
|
||||
throw new Exception(self::lang('empty_message'), self::STOP_CRITICAL);
|
||||
}
|
||||
} elseif ($this->sign_key_file) {
|
||||
try {
|
||||
if (!defined('PKCS7_TEXT')) {
|
||||
throw new Exception($this->lang('extension_missing') . 'openssl');
|
||||
throw new Exception(self::lang('extension_missing') . 'openssl');
|
||||
}
|
||||
|
||||
$file = tempnam(sys_get_temp_dir(), 'srcsign');
|
||||
@@ -3187,7 +3249,7 @@ class PHPMailer
|
||||
$body = $parts[1];
|
||||
} else {
|
||||
@unlink($signed);
|
||||
throw new Exception($this->lang('signing') . openssl_error_string());
|
||||
throw new Exception(self::lang('signing') . openssl_error_string());
|
||||
}
|
||||
} catch (Exception $exc) {
|
||||
$body = '';
|
||||
@@ -3332,7 +3394,7 @@ class PHPMailer
|
||||
) {
|
||||
try {
|
||||
if (!static::fileIsAccessible($path)) {
|
||||
throw new Exception($this->lang('file_access') . $path, self::STOP_CONTINUE);
|
||||
throw new Exception(self::lang('file_access') . $path, self::STOP_CONTINUE);
|
||||
}
|
||||
|
||||
//If a MIME type is not specified, try to work it out from the file name
|
||||
@@ -3345,7 +3407,7 @@ class PHPMailer
|
||||
$name = $filename;
|
||||
}
|
||||
if (!$this->validateEncoding($encoding)) {
|
||||
throw new Exception($this->lang('encoding') . $encoding);
|
||||
throw new Exception(self::lang('encoding') . $encoding);
|
||||
}
|
||||
|
||||
$this->attachment[] = [
|
||||
@@ -3506,11 +3568,11 @@ class PHPMailer
|
||||
{
|
||||
try {
|
||||
if (!static::fileIsAccessible($path)) {
|
||||
throw new Exception($this->lang('file_open') . $path, self::STOP_CONTINUE);
|
||||
throw new Exception(self::lang('file_open') . $path, self::STOP_CONTINUE);
|
||||
}
|
||||
$file_buffer = file_get_contents($path);
|
||||
if (false === $file_buffer) {
|
||||
throw new Exception($this->lang('file_open') . $path, self::STOP_CONTINUE);
|
||||
throw new Exception(self::lang('file_open') . $path, self::STOP_CONTINUE);
|
||||
}
|
||||
$file_buffer = $this->encodeString($file_buffer, $encoding);
|
||||
|
||||
@@ -3563,9 +3625,9 @@ class PHPMailer
|
||||
$encoded = $this->encodeQP($str);
|
||||
break;
|
||||
default:
|
||||
$this->setError($this->lang('encoding') . $encoding);
|
||||
$this->setError(self::lang('encoding') . $encoding);
|
||||
if ($this->exceptions) {
|
||||
throw new Exception($this->lang('encoding') . $encoding);
|
||||
throw new Exception(self::lang('encoding') . $encoding);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -3671,6 +3733,42 @@ 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.
|
||||
*
|
||||
@@ -3840,7 +3938,7 @@ class PHPMailer
|
||||
}
|
||||
|
||||
if (!$this->validateEncoding($encoding)) {
|
||||
throw new Exception($this->lang('encoding') . $encoding);
|
||||
throw new Exception(self::lang('encoding') . $encoding);
|
||||
}
|
||||
|
||||
//Append to $attachment array
|
||||
@@ -3899,7 +3997,7 @@ class PHPMailer
|
||||
) {
|
||||
try {
|
||||
if (!static::fileIsAccessible($path)) {
|
||||
throw new Exception($this->lang('file_access') . $path, self::STOP_CONTINUE);
|
||||
throw new Exception(self::lang('file_access') . $path, self::STOP_CONTINUE);
|
||||
}
|
||||
|
||||
//If a MIME type is not specified, try to work it out from the file name
|
||||
@@ -3908,7 +4006,7 @@ class PHPMailer
|
||||
}
|
||||
|
||||
if (!$this->validateEncoding($encoding)) {
|
||||
throw new Exception($this->lang('encoding') . $encoding);
|
||||
throw new Exception(self::lang('encoding') . $encoding);
|
||||
}
|
||||
|
||||
$filename = (string) static::mb_pathinfo($path, PATHINFO_BASENAME);
|
||||
@@ -3974,7 +4072,7 @@ class PHPMailer
|
||||
}
|
||||
|
||||
if (!$this->validateEncoding($encoding)) {
|
||||
throw new Exception($this->lang('encoding') . $encoding);
|
||||
throw new Exception(self::lang('encoding') . $encoding);
|
||||
}
|
||||
|
||||
//Append to $attachment array
|
||||
@@ -4231,7 +4329,7 @@ class PHPMailer
|
||||
}
|
||||
if (strpbrk($name . $value, "\r\n") !== false) {
|
||||
if ($this->exceptions) {
|
||||
throw new Exception($this->lang('invalid_header'));
|
||||
throw new Exception(self::lang('invalid_header'));
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -4255,15 +4353,15 @@ class PHPMailer
|
||||
if ('smtp' === $this->Mailer && null !== $this->smtp) {
|
||||
$lasterror = $this->smtp->getError();
|
||||
if (!empty($lasterror['error'])) {
|
||||
$msg .= ' ' . $this->lang('smtp_error') . $lasterror['error'];
|
||||
$msg .= ' ' . self::lang('smtp_error') . $lasterror['error'];
|
||||
if (!empty($lasterror['detail'])) {
|
||||
$msg .= ' ' . $this->lang('smtp_detail') . $lasterror['detail'];
|
||||
$msg .= ' ' . self::lang('smtp_detail') . $lasterror['detail'];
|
||||
}
|
||||
if (!empty($lasterror['smtp_code'])) {
|
||||
$msg .= ' ' . $this->lang('smtp_code') . $lasterror['smtp_code'];
|
||||
$msg .= ' ' . self::lang('smtp_code') . $lasterror['smtp_code'];
|
||||
}
|
||||
if (!empty($lasterror['smtp_code_ex'])) {
|
||||
$msg .= ' ' . $this->lang('smtp_code_ex') . $lasterror['smtp_code_ex'];
|
||||
$msg .= ' ' . self::lang('smtp_code_ex') . $lasterror['smtp_code_ex'];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4388,21 +4486,21 @@ class PHPMailer
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function lang($key)
|
||||
protected static function lang($key)
|
||||
{
|
||||
if (count($this->language) < 1) {
|
||||
$this->setLanguage(); //Set the default language
|
||||
if (count(self::$language) < 1) {
|
||||
self::setLanguage(); //Set the default language
|
||||
}
|
||||
|
||||
if (array_key_exists($key, $this->language)) {
|
||||
if (array_key_exists($key, self::$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 $this->language[$key] . ' https://github.com/PHPMailer/PHPMailer/wiki/Troubleshooting';
|
||||
return self::$language[$key] . ' https://github.com/PHPMailer/PHPMailer/wiki/Troubleshooting';
|
||||
}
|
||||
|
||||
return $this->language[$key];
|
||||
return self::$language[$key];
|
||||
}
|
||||
|
||||
//Return the key as a fallback
|
||||
@@ -4417,7 +4515,7 @@ class PHPMailer
|
||||
*/
|
||||
private function getSmtpErrorMessage($base_key)
|
||||
{
|
||||
$message = $this->lang($base_key);
|
||||
$message = self::lang($base_key);
|
||||
$error = $this->smtp->getError();
|
||||
if (!empty($error['error'])) {
|
||||
$message .= ' ' . $error['error'];
|
||||
@@ -4461,7 +4559,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($this->lang('invalid_header'));
|
||||
throw new Exception(self::lang('invalid_header'));
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -4854,7 +4952,7 @@ class PHPMailer
|
||||
|
||||
return true;
|
||||
}
|
||||
$this->setError($this->lang('variable_set') . $name);
|
||||
$this->setError(self::lang('variable_set') . $name);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -4992,7 +5090,7 @@ class PHPMailer
|
||||
{
|
||||
if (!defined('PKCS7_TEXT')) {
|
||||
if ($this->exceptions) {
|
||||
throw new Exception($this->lang('extension_missing') . 'openssl');
|
||||
throw new Exception(self::lang('extension_missing') . 'openssl');
|
||||
}
|
||||
|
||||
return '';
|
||||
|
||||
2
vendor/phpmailer/phpmailer/src/POP3.php
vendored
2
vendor/phpmailer/phpmailer/src/POP3.php
vendored
@@ -46,7 +46,7 @@ class POP3
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const VERSION = '6.12.0';
|
||||
const VERSION = '6.11.1';
|
||||
|
||||
/**
|
||||
* Default POP3 port number.
|
||||
|
||||
49
vendor/phpmailer/phpmailer/src/SMTP.php
vendored
49
vendor/phpmailer/phpmailer/src/SMTP.php
vendored
@@ -35,7 +35,7 @@ class SMTP
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const VERSION = '6.12.0';
|
||||
const VERSION = '6.11.1';
|
||||
|
||||
/**
|
||||
* SMTP line break constant.
|
||||
@@ -205,6 +205,7 @@ 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/',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -633,11 +634,42 @@ class SMTP
|
||||
return false;
|
||||
}
|
||||
$oauth = $OAuth->getOauth64();
|
||||
|
||||
//Start authentication
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
$this->setError("Authentication method \"$authtype\" is not supported");
|
||||
@@ -1309,7 +1341,16 @@ 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) {
|
||||
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
|
||||
)
|
||||
) {
|
||||
$this->edebug(
|
||||
'SMTP -> get_lines(): retrying stream_select',
|
||||
self::DEBUG_LOWLEVEL
|
||||
|
||||
@@ -45,7 +45,6 @@ jobs:
|
||||
- '8.1'
|
||||
- '8.2'
|
||||
- '8.3'
|
||||
- '8.4'
|
||||
steps:
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
@@ -76,7 +75,6 @@ jobs:
|
||||
- '8.1'
|
||||
- '8.2'
|
||||
- '8.3'
|
||||
- '8.4'
|
||||
steps:
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
|
||||
2
vendor/phpoffice/math/docs/index.md
vendored
2
vendor/phpoffice/math/docs/index.md
vendored
@@ -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 | :material-check: | |
|
||||
| | Semantics | | |
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
4
vendor/phpoffice/math/mkdocs.yml
vendored
4
vendor/phpoffice/math/mkdocs.yml
vendored
@@ -57,9 +57,7 @@ nav:
|
||||
- Writers: 'usage/writers.md'
|
||||
- Credits: 'credits.md'
|
||||
- Releases:
|
||||
- '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'
|
||||
- '0.1.0 (WIP)': 'changes/0.1.0.md'
|
||||
- Developers:
|
||||
- 'Coveralls': 'https://coveralls.io/github/PHPOffice/Math'
|
||||
- 'Code Coverage': 'coverage/index.html'
|
||||
|
||||
12
vendor/phpoffice/math/src/Math/Reader/MathML.php
vendored
12
vendor/phpoffice/math/src/Math/Reader/MathML.php
vendored
@@ -10,7 +10,6 @@ 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
|
||||
{
|
||||
@@ -23,17 +22,8 @@ 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(
|
||||
[
|
||||
'⁢',
|
||||
@@ -45,7 +35,7 @@ class MathML implements ReaderInterface
|
||||
);
|
||||
|
||||
$this->dom = new DOMDocument();
|
||||
$this->dom->loadXML($content);
|
||||
$this->dom->loadXML($content, LIBXML_DTDLOAD);
|
||||
|
||||
$this->math = new Math();
|
||||
$this->parseNode(null, $this->math);
|
||||
|
||||
23
vendor/phpoffice/math/src/Math/Writer/MathML.php
vendored
23
vendor/phpoffice/math/src/Math/Writer/MathML.php
vendored
@@ -40,26 +40,6 @@ 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);
|
||||
@@ -141,9 +121,6 @@ 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',
|
||||
|
||||
@@ -7,7 +7,6 @@ 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;
|
||||
@@ -295,15 +294,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,35 +80,6 @@ class MathMLTest extends WriterTestCase
|
||||
$this->assertIsSchemaMathMLValid($output);
|
||||
}
|
||||
|
||||
public function testWriteSemantics(): void
|
||||
{
|
||||
$opTimes = new Element\Operator('⁢');
|
||||
|
||||
$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);
|
||||
|
||||
2
vendor/phpoffice/phpword/LICENSE
vendored
2
vendor/phpoffice/phpword/LICENSE
vendored
@@ -1,6 +1,6 @@
|
||||
PHPWord, a pure PHP library for reading and writing word processing documents.
|
||||
|
||||
Copyright (c) 2010-2025 PHPWord.
|
||||
Copyright (c) 2010-2016 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
|
||||
|
||||
11
vendor/phpoffice/phpword/README.md
vendored
11
vendor/phpoffice/phpword/README.md
vendored
@@ -1,11 +1,11 @@
|
||||
# 
|
||||
|
||||
[](https://packagist.org/packages/phpoffice/phpword)
|
||||
[](https://packagist.org/packages/phpoffice/phpword)
|
||||
[](https://coveralls.io/github/PHPOffice/PHPWord?branch=master)
|
||||
[](https://packagist.org/packages/phpoffice/phpword)
|
||||
[](https://packagist.org/packages/phpoffice/phpword)
|
||||
|
||||
Branch Master : [](https://github.com/PHPOffice/PHPWord/actions/workflows/php.yml)
|
||||
[](https://packagist.org/packages/phpoffice/phpword)
|
||||
[](https://packagist.org/packages/phpoffice/phpword)
|
||||
[](https://github.com/PHPOffice/PHPWord/actions/workflows/ci.yml)
|
||||
[](https://gitter.im/PHPOffice/PHPWord)
|
||||
|
||||
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,6 +81,7 @@ 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();
|
||||
|
||||
87
vendor/phpoffice/phpword/composer.json
vendored
87
vendor/phpoffice/phpword/composer.json
vendored
@@ -8,7 +8,7 @@
|
||||
],
|
||||
"homepage": "https://phpoffice.github.io/PHPWord/",
|
||||
"type": "library",
|
||||
"license": "LGPL-3.0-only",
|
||||
"license": "LGPL-3.0",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mark Baker"
|
||||
@@ -36,67 +36,20 @@
|
||||
],
|
||||
"scripts": {
|
||||
"test": [
|
||||
"@php vendor/bin/phpunit --color=always"
|
||||
"phpunit --color=always"
|
||||
],
|
||||
"test-no-coverage": [
|
||||
"@php vendor/bin/phpunit --color=always --no-coverage"
|
||||
"phpunit --color=always --no-coverage"
|
||||
],
|
||||
"check": [
|
||||
"@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",
|
||||
"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",
|
||||
"@test-no-coverage",
|
||||
"@php vendor/bin/phpstan analyse --ansi"
|
||||
"phpstan analyse --ansi"
|
||||
],
|
||||
"fix": [
|
||||
"@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"
|
||||
"php-cs-fixer fix --ansi"
|
||||
]
|
||||
},
|
||||
"scripts-descriptions": {
|
||||
@@ -105,28 +58,34 @@
|
||||
"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.3"
|
||||
"phpoffice/math": "^0.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-zip": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-libxml": "*",
|
||||
"dompdf/dompdf": "^2.0 || ^3.0",
|
||||
"friendsofphp/php-cs-fixer": "^3.3",
|
||||
"mpdf/mpdf": "^7.0 || ^8.0",
|
||||
"dompdf/dompdf": "^2.0",
|
||||
"mpdf/mpdf": "^8.1",
|
||||
"phpmd/phpmd": "^2.13",
|
||||
"phpstan/phpstan": "^0.12.88 || ^1.0.0",
|
||||
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
||||
"phpunit/phpunit": ">=7.0",
|
||||
"tecnickcom/tcpdf": "^6.5",
|
||||
"symfony/process": "^4.4 || ^5.0",
|
||||
"tecnickcom/tcpdf": "^6.5"
|
||||
"friendsofphp/php-cs-fixer": "^3.3",
|
||||
"phpstan/phpstan-phpunit": "@stable"
|
||||
},
|
||||
"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"
|
||||
|
||||
5
vendor/phpoffice/phpword/mkdocs.yml
vendored
5
vendor/phpoffice/phpword/mkdocs.yml
vendored
@@ -63,7 +63,6 @@ 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'
|
||||
@@ -88,9 +87,7 @@ nav:
|
||||
- Credits: 'credits.md'
|
||||
- Releases:
|
||||
- '1.x':
|
||||
- '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.3.0 (WIP)': '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'
|
||||
|
||||
140
vendor/phpoffice/phpword/phpstan-baseline.neon
vendored
140
vendor/phpoffice/phpword/phpstan-baseline.neon
vendored
@@ -375,6 +375,11 @@ 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
|
||||
@@ -430,11 +435,6 @@ 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: 3
|
||||
count: 1
|
||||
path: src/PhpWord/Shared/Html.php
|
||||
|
||||
-
|
||||
@@ -685,6 +685,11 @@ 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
|
||||
@@ -755,6 +760,41 @@ 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
|
||||
@@ -775,6 +815,21 @@ 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
|
||||
@@ -785,6 +840,36 @@ 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
|
||||
@@ -1035,6 +1120,11 @@ 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
|
||||
@@ -1056,14 +1146,14 @@ parameters:
|
||||
path: src/PhpWord/Writer/ODText/Element/Table.php
|
||||
|
||||
-
|
||||
message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\ODText\\\\Element\\\\AbstractElement\\:\\:replaceTabs\\(\\) has parameter \\$text with no type specified\\.$#"
|
||||
message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\ODText\\\\Element\\\\Text\\:\\:replacetabs\\(\\) has parameter \\$text with no type specified\\.$#"
|
||||
count: 1
|
||||
path: src/PhpWord/Writer/ODText/Element/AbstractElement.php
|
||||
path: src/PhpWord/Writer/ODText/Element/Text.php
|
||||
|
||||
-
|
||||
message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\ODText\\\\Element\\\\AbstractElement\\:\\:replaceTabs\\(\\) has parameter \\$xmlWriter with no type specified\\.$#"
|
||||
message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\ODText\\\\Element\\\\Text\\:\\:replacetabs\\(\\) has parameter \\$xmlWriter with no type specified\\.$#"
|
||||
count: 1
|
||||
path: src/PhpWord/Writer/ODText/Element/AbstractElement.php
|
||||
path: src/PhpWord/Writer/ODText/Element/Text.php
|
||||
|
||||
-
|
||||
message: "#^Method PhpOffice\\\\PhpWord\\\\Writer\\\\ODText\\\\Element\\\\Text\\:\\:writeChangeInsertion\\(\\) has parameter \\$start with no type specified\\.$#"
|
||||
@@ -1225,6 +1315,11 @@ 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
|
||||
@@ -1331,29 +1426,29 @@ parameters:
|
||||
path: tests/PhpWordTests/AbstractTestReader.php
|
||||
|
||||
-
|
||||
message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbedded\\:\\:getBaseUrl\\(\\) has no return type specified\\.$#"
|
||||
message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbeddedTest\\:\\:getBaseUrl\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: tests/PhpWordTests/AbstractWebServerEmbedded.php
|
||||
path: tests/PhpWordTests/AbstractWebServerEmbeddedTest.php
|
||||
|
||||
-
|
||||
message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbedded\\:\\:getRemoteBmpImageUrl\\(\\) has no return type specified\\.$#"
|
||||
message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbeddedTest\\:\\:getRemoteBmpImageUrl\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: tests/PhpWordTests/AbstractWebServerEmbedded.php
|
||||
path: tests/PhpWordTests/AbstractWebServerEmbeddedTest.php
|
||||
|
||||
-
|
||||
message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbedded\\:\\:getRemoteGifImageUrl\\(\\) has no return type specified\\.$#"
|
||||
message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbeddedTest\\:\\:getRemoteGifImageUrl\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: tests/PhpWordTests/AbstractWebServerEmbedded.php
|
||||
path: tests/PhpWordTests/AbstractWebServerEmbeddedTest.php
|
||||
|
||||
-
|
||||
message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbedded\\:\\:getRemoteImageUrl\\(\\) has no return type specified\\.$#"
|
||||
message: "#^Method PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbeddedTest\\:\\:getRemoteImageUrl\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: tests/PhpWordTests/AbstractWebServerEmbedded.php
|
||||
path: tests/PhpWordTests/AbstractWebServerEmbeddedTest.php
|
||||
|
||||
-
|
||||
message: "#^Property PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbedded\\:\\:\\$httpServer has no type specified\\.$#"
|
||||
message: "#^Property PhpOffice\\\\PhpWordTests\\\\AbstractWebServerEmbeddedTest\\:\\:\\$httpServer has no type specified\\.$#"
|
||||
count: 1
|
||||
path: tests/PhpWordTests/AbstractWebServerEmbedded.php
|
||||
path: tests/PhpWordTests/AbstractWebServerEmbeddedTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$width of class PhpOffice\\\\PhpWord\\\\Element\\\\Cell constructor expects int\\|null, string given\\.$#"
|
||||
@@ -1695,11 +1790,6 @@ parameters:
|
||||
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\\.$#"
|
||||
count: 11
|
||||
|
||||
1
vendor/phpoffice/phpword/phpword.ini.dist
vendored
1
vendor/phpoffice/phpword/phpword.ini.dist
vendored
@@ -14,7 +14,6 @@ outputEscapingEnabled = false
|
||||
|
||||
defaultFontName = Arial
|
||||
defaultFontSize = 10
|
||||
defaultFontColor = 000000
|
||||
|
||||
[Paper]
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
@@ -22,7 +21,6 @@ namespace PhpOffice\PhpWord\Collection;
|
||||
* Collection abstract class.
|
||||
*
|
||||
* @since 0.10.0
|
||||
*
|
||||
* @template T
|
||||
*/
|
||||
abstract class AbstractCollection
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
@@ -24,7 +23,6 @@ use PhpOffice\PhpWord\Element\Bookmark;
|
||||
* Bookmarks collection.
|
||||
*
|
||||
* @since 0.12.0
|
||||
*
|
||||
* @extends AbstractCollection<Bookmark>
|
||||
*/
|
||||
class Bookmarks extends AbstractCollection
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
@@ -24,7 +23,6 @@ use PhpOffice\PhpWord\Element\Chart;
|
||||
* Charts collection.
|
||||
*
|
||||
* @since 0.12.0
|
||||
*
|
||||
* @extends AbstractCollection<Chart>
|
||||
*/
|
||||
class Charts extends AbstractCollection
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
@@ -24,7 +23,6 @@ use PhpOffice\PhpWord\Element\Comment;
|
||||
* Comments collection.
|
||||
*
|
||||
* @since 0.12.0
|
||||
*
|
||||
* @extends AbstractCollection<Comment>
|
||||
*/
|
||||
class Comments extends AbstractCollection
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
@@ -24,7 +23,6 @@ use PhpOffice\PhpWord\Element\Endnote;
|
||||
* Endnotes collection.
|
||||
*
|
||||
* @since 0.10.0
|
||||
*
|
||||
* @extends AbstractCollection<Endnote>
|
||||
*/
|
||||
class Endnotes extends AbstractCollection
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
@@ -24,7 +23,6 @@ use PhpOffice\PhpWord\Element\Footnote;
|
||||
* Footnotes collection.
|
||||
*
|
||||
* @since 0.10.0
|
||||
*
|
||||
* @extends AbstractCollection<Footnote>
|
||||
*/
|
||||
class Footnotes extends AbstractCollection
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
@@ -24,7 +23,6 @@ use PhpOffice\PhpWord\Element\Title;
|
||||
* Titles collection.
|
||||
*
|
||||
* @since 0.10.0
|
||||
*
|
||||
* @extends AbstractCollection<Title>
|
||||
*/
|
||||
class Titles extends AbstractCollection
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
@@ -50,7 +49,6 @@ 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
|
||||
@@ -60,7 +58,7 @@ abstract class AbstractContainer extends AbstractElement
|
||||
/**
|
||||
* Elements collection.
|
||||
*
|
||||
* @var AbstractElement[]
|
||||
* @var \PhpOffice\PhpWord\Element\AbstractElement[]
|
||||
*/
|
||||
protected $elements = [];
|
||||
|
||||
@@ -82,7 +80,7 @@ abstract class AbstractContainer extends AbstractElement
|
||||
* @param mixed $function
|
||||
* @param mixed $args
|
||||
*
|
||||
* @return AbstractElement
|
||||
* @return \PhpOffice\PhpWord\Element\AbstractElement
|
||||
*/
|
||||
public function __call($function, $args)
|
||||
{
|
||||
@@ -92,7 +90,7 @@ abstract class AbstractContainer extends AbstractElement
|
||||
'Footnote', 'Endnote', 'CheckBox', 'TextBox', 'Field',
|
||||
'Line', 'Shape', 'Title', 'TOC', 'PageBreak',
|
||||
'Chart', 'FormField', 'SDT', 'Comment',
|
||||
'Formula', 'Ruby',
|
||||
'Formula',
|
||||
];
|
||||
$functions = [];
|
||||
foreach ($elements as $element) {
|
||||
@@ -132,7 +130,7 @@ abstract class AbstractContainer extends AbstractElement
|
||||
*
|
||||
* @param string $elementName
|
||||
*
|
||||
* @return AbstractElement
|
||||
* @return \PhpOffice\PhpWord\Element\AbstractElement
|
||||
*/
|
||||
protected function addElement($elementName)
|
||||
{
|
||||
@@ -151,7 +149,7 @@ abstract class AbstractContainer extends AbstractElement
|
||||
$elementArgs = $args;
|
||||
array_shift($elementArgs); // Shift the $elementName off the beginning of array
|
||||
|
||||
/** @var AbstractElement $element Type hint */
|
||||
/** @var \PhpOffice\PhpWord\Element\AbstractElement $element Type hint */
|
||||
$element = $reflection->newInstanceArgs($elementArgs);
|
||||
|
||||
// Set parent container
|
||||
@@ -167,7 +165,7 @@ abstract class AbstractContainer extends AbstractElement
|
||||
/**
|
||||
* Get all elements.
|
||||
*
|
||||
* @return AbstractElement[]
|
||||
* @return \PhpOffice\PhpWord\Element\AbstractElement[]
|
||||
*/
|
||||
public function getElements()
|
||||
{
|
||||
@@ -179,7 +177,7 @@ abstract class AbstractContainer extends AbstractElement
|
||||
*
|
||||
* @param int $index
|
||||
*
|
||||
* @return null|AbstractElement
|
||||
* @return null|\PhpOffice\PhpWord\Element\AbstractElement
|
||||
*/
|
||||
public function getElement($index)
|
||||
{
|
||||
@@ -193,13 +191,13 @@ abstract class AbstractContainer extends AbstractElement
|
||||
/**
|
||||
* Removes the element at requested index.
|
||||
*
|
||||
* @param AbstractElement|int $toRemove
|
||||
* @param int|\PhpOffice\PhpWord\Element\AbstractElement $toRemove
|
||||
*/
|
||||
public function removeElement($toRemove): void
|
||||
{
|
||||
if (is_int($toRemove) && array_key_exists($toRemove, $this->elements)) {
|
||||
unset($this->elements[$toRemove]);
|
||||
} elseif ($toRemove instanceof AbstractElement) {
|
||||
} elseif ($toRemove instanceof \PhpOffice\PhpWord\Element\AbstractElement) {
|
||||
foreach ($this->elements as $key => $element) {
|
||||
if ($element->getElementId() === $toRemove->getElementId()) {
|
||||
unset($this->elements[$key]);
|
||||
@@ -255,7 +253,7 @@ abstract class AbstractContainer extends AbstractElement
|
||||
'Footnote' => ['Section', 'TextRun', 'Cell', 'ListItemRun'],
|
||||
'Endnote' => ['Section', 'TextRun', 'Cell'],
|
||||
'PreserveText' => ['Section', 'Header', 'Footer', 'Cell'],
|
||||
'Title' => ['Section', 'Cell', 'Header'],
|
||||
'Title' => ['Section', 'Cell'],
|
||||
'TOC' => ['Section'],
|
||||
'PageBreak' => ['Section'],
|
||||
'Chart' => ['Section', 'Cell'],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
@@ -149,6 +148,8 @@ abstract class AbstractElement
|
||||
|
||||
/**
|
||||
* Get PhpWord.
|
||||
*
|
||||
* @return ?PhpWord
|
||||
*/
|
||||
public function getPhpWord(): ?PhpWord
|
||||
{
|
||||
@@ -255,7 +256,7 @@ abstract class AbstractElement
|
||||
*/
|
||||
public function setElementId(): void
|
||||
{
|
||||
$this->elementId = substr(md5((string) mt_rand()), 0, 6);
|
||||
$this->elementId = substr(md5(mt_rand()), 0, 6);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,6 +291,8 @@ abstract class AbstractElement
|
||||
|
||||
/**
|
||||
* Get comments start.
|
||||
*
|
||||
* @return Comments
|
||||
*/
|
||||
public function getCommentsRangeStart(): ?Comments
|
||||
{
|
||||
@@ -298,6 +301,8 @@ abstract class AbstractElement
|
||||
|
||||
/**
|
||||
* Get comment start.
|
||||
*
|
||||
* @return Comment
|
||||
*/
|
||||
public function getCommentRangeStart(): ?Comment
|
||||
{
|
||||
@@ -334,6 +339,8 @@ abstract class AbstractElement
|
||||
|
||||
/**
|
||||
* Get comments end.
|
||||
*
|
||||
* @return Comments
|
||||
*/
|
||||
public function getCommentsRangeEnd(): ?Comments
|
||||
{
|
||||
@@ -342,6 +349,8 @@ abstract class AbstractElement
|
||||
|
||||
/**
|
||||
* Get comment end.
|
||||
*
|
||||
* @return Comment
|
||||
*/
|
||||
public function getCommentRangeEnd(): ?Comment
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
@@ -40,7 +39,7 @@ class Cell extends AbstractContainer
|
||||
/**
|
||||
* Cell style.
|
||||
*
|
||||
* @var ?CellStyle
|
||||
* @var ?\PhpOffice\PhpWord\Style\Cell
|
||||
*/
|
||||
private $style;
|
||||
|
||||
@@ -48,7 +47,7 @@ class Cell extends AbstractContainer
|
||||
* Create new instance.
|
||||
*
|
||||
* @param null|int $width
|
||||
* @param array|CellStyle $style
|
||||
* @param array|\PhpOffice\PhpWord\Style\Cell $style
|
||||
*/
|
||||
public function __construct($width = null, $style = null)
|
||||
{
|
||||
@@ -59,7 +58,7 @@ class Cell extends AbstractContainer
|
||||
/**
|
||||
* Get cell style.
|
||||
*
|
||||
* @return ?CellStyle
|
||||
* @return ?\PhpOffice\PhpWord\Style\Cell
|
||||
*/
|
||||
public function getStyle()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
@@ -51,7 +50,7 @@ class Chart extends AbstractElement
|
||||
/**
|
||||
* Chart style.
|
||||
*
|
||||
* @var ?ChartStyle
|
||||
* @var ?\PhpOffice\PhpWord\Style\Chart
|
||||
*/
|
||||
private $style;
|
||||
|
||||
@@ -121,7 +120,7 @@ class Chart extends AbstractElement
|
||||
/**
|
||||
* Get chart style.
|
||||
*
|
||||
* @return ?ChartStyle
|
||||
* @return ?\PhpOffice\PhpWord\Style\Chart
|
||||
*/
|
||||
public function getStyle()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
@@ -90,7 +89,7 @@ class Comment extends TrackChange
|
||||
/**
|
||||
* Get the element where this comment starts.
|
||||
*
|
||||
* @return AbstractElement
|
||||
* @return \PhpOffice\PhpWord\Element\AbstractElement
|
||||
*/
|
||||
public function getStartElement()
|
||||
{
|
||||
@@ -109,7 +108,7 @@ class Comment extends TrackChange
|
||||
/**
|
||||
* Get the element where this comment ends.
|
||||
*
|
||||
* @return AbstractElement
|
||||
* @return \PhpOffice\PhpWord\Element\AbstractElement
|
||||
*/
|
||||
public function getEndElement()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
@@ -129,16 +128,16 @@ class Field extends AbstractElement
|
||||
/**
|
||||
* Font style.
|
||||
*
|
||||
* @var Font|string
|
||||
* @var \PhpOffice\PhpWord\Style\Font|string
|
||||
*/
|
||||
protected $fontStyle;
|
||||
|
||||
/**
|
||||
* Set Font style.
|
||||
*
|
||||
* @param array|Font|string $style
|
||||
* @param array|\PhpOffice\PhpWord\Style\Font|string $style
|
||||
*
|
||||
* @return Font|string
|
||||
* @return \PhpOffice\PhpWord\Style\Font|string
|
||||
*/
|
||||
public function setFontStyle($style = null)
|
||||
{
|
||||
@@ -159,7 +158,7 @@ class Field extends AbstractElement
|
||||
/**
|
||||
* Get Font style.
|
||||
*
|
||||
* @return Font|string
|
||||
* @return \PhpOffice\PhpWord\Style\Font|string
|
||||
*/
|
||||
public function getFontStyle()
|
||||
{
|
||||
@@ -173,7 +172,7 @@ class Field extends AbstractElement
|
||||
* @param array $properties
|
||||
* @param array $options
|
||||
* @param null|string|TextRun $text
|
||||
* @param array|Font|string $fontStyle
|
||||
* @param array|\PhpOffice\PhpWord\Style\Font|string $fontStyle
|
||||
*/
|
||||
public function __construct($type = null, $properties = [], $options = [], $text = null, $fontStyle = null)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
@@ -30,7 +29,7 @@ class Footnote extends AbstractContainer
|
||||
/**
|
||||
* Paragraph style.
|
||||
*
|
||||
* @var null|Paragraph|string
|
||||
* @var null|\PhpOffice\PhpWord\Style\Paragraph|string
|
||||
*/
|
||||
protected $paragraphStyle;
|
||||
|
||||
@@ -44,7 +43,7 @@ class Footnote extends AbstractContainer
|
||||
/**
|
||||
* Create new instance.
|
||||
*
|
||||
* @param array|Paragraph|string $paragraphStyle
|
||||
* @param array|\PhpOffice\PhpWord\Style\Paragraph|string $paragraphStyle
|
||||
*/
|
||||
public function __construct($paragraphStyle = null)
|
||||
{
|
||||
@@ -55,7 +54,7 @@ class Footnote extends AbstractContainer
|
||||
/**
|
||||
* Get paragraph style.
|
||||
*
|
||||
* @return null|Paragraph|string
|
||||
* @return null|\PhpOffice\PhpWord\Style\Paragraph|string
|
||||
*/
|
||||
public function getParagraphStyle()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
@@ -28,7 +27,7 @@ class Line extends AbstractElement
|
||||
/**
|
||||
* Line style.
|
||||
*
|
||||
* @var ?LineStyle
|
||||
* @var ?\PhpOffice\PhpWord\Style\Line
|
||||
*/
|
||||
private $style;
|
||||
|
||||
@@ -45,7 +44,7 @@ class Line extends AbstractElement
|
||||
/**
|
||||
* Get line style.
|
||||
*
|
||||
* @return ?LineStyle
|
||||
* @return ?\PhpOffice\PhpWord\Style\Line
|
||||
*/
|
||||
public function getStyle()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of PHPWord - A pure PHP library for reading and writing
|
||||
* word processing documents.
|
||||
@@ -44,14 +43,14 @@ class Link extends AbstractElement
|
||||
/**
|
||||
* Font style.
|
||||
*
|
||||
* @var null|Font|string
|
||||
* @var null|\PhpOffice\PhpWord\Style\Font|string
|
||||
*/
|
||||
private $fontStyle;
|
||||
|
||||
/**
|
||||
* Paragraph style.
|
||||
*
|
||||
* @var null|Paragraph|string
|
||||
* @var null|\PhpOffice\PhpWord\Style\Paragraph|string
|
||||
*/
|
||||
private $paragraphStyle;
|
||||
|
||||
@@ -99,8 +98,10 @@ class Link extends AbstractElement
|
||||
|
||||
/**
|
||||
* Get link text.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getText(): string
|
||||
public function getText()
|
||||
{
|
||||
return $this->text;
|
||||
}
|
||||
@@ -108,7 +109,7 @@ class Link extends AbstractElement
|
||||
/**
|
||||
* Get Text style.
|
||||
*
|
||||
* @return null|Font|string
|
||||
* @return null|\PhpOffice\PhpWord\Style\Font|string
|
||||
*/
|
||||
public function getFontStyle()
|
||||
{
|
||||
@@ -118,7 +119,7 @@ class Link extends AbstractElement
|
||||
/**
|
||||
* Get Paragraph style.
|
||||
*
|
||||
* @return null|Paragraph|string
|
||||
* @return null|\PhpOffice\PhpWord\Style\Paragraph|string
|
||||
*/
|
||||
public function getParagraphStyle()
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user