作者堆叠 专刊
This commit is contained in:
@@ -20,20 +20,28 @@ class ArticleParserService
|
||||
if (!file_exists($filePath)) {
|
||||
return json_encode(['status' => 5, 'msg' => '"文档不存在:{$filePath}"']);
|
||||
}
|
||||
$processedFilePath = null;
|
||||
try {
|
||||
// 含 OMML 公式时先展平:PhpWord 单独 saveXML(m:oMath) 会丢掉 xmlns:m,触发 loadXML 告警
|
||||
$loadPath = $filePath;
|
||||
if ($this->docxContainsOfficeMath($filePath)) {
|
||||
$processedFilePath = $this->removeEmfFromDocx($filePath);
|
||||
$loadPath = $processedFilePath;
|
||||
}
|
||||
|
||||
// 关键配置:关闭“仅读数据”,保留完整节结构
|
||||
$reader = IOFactory::createReader();
|
||||
$reader->setReadDataOnly(false);
|
||||
Settings::setCompatibility(false);
|
||||
Settings::setOutputEscapingEnabled(true); // 避免XML转义冲突
|
||||
|
||||
$doc = $reader->load($filePath);
|
||||
$sectionCount = count($doc->getSections());
|
||||
// $this->log("✅ 文档直接加载成功,节数量:{$sectionCount}");
|
||||
$this->phpWord = $reader->load($filePath);
|
||||
$this->phpWord = $reader->load($loadPath);
|
||||
$this->sections = $this->phpWord->getSections();
|
||||
} catch (\Throwable $e) {
|
||||
// 预处理:移除 EMF、表格内分页符等 PhpWord 不兼容内容后重试
|
||||
// 预处理:移除 EMF、表格内分页符、OMML 公式等 PhpWord 不兼容内容后重试
|
||||
if ($processedFilePath && is_file($processedFilePath)) {
|
||||
@unlink($processedFilePath);
|
||||
}
|
||||
$processedFilePath = $this->removeEmfFromDocx($filePath);
|
||||
$reader = IOFactory::createReader();
|
||||
$reader->setReadDataOnly(false);
|
||||
@@ -42,8 +50,8 @@ class ArticleParserService
|
||||
|
||||
$this->phpWord = $reader->load($processedFilePath);
|
||||
$this->sections = $this->phpWord->getSections();
|
||||
|
||||
if (is_file($processedFilePath)) {
|
||||
} finally {
|
||||
if ($processedFilePath && is_file($processedFilePath)) {
|
||||
@unlink($processedFilePath);
|
||||
}
|
||||
}
|
||||
@@ -77,7 +85,7 @@ class ArticleParserService
|
||||
}
|
||||
}
|
||||
|
||||
// 3.1 清理表格单元格内分页符(PhpWord 无法解析,会抛 Cannot add PageBreak in Cell)
|
||||
// 3.1 清理表格单元格内分页符、展平 OMML 公式(避免 PhpWord OfficeMathML 缺命名空间告警)
|
||||
$this->sanitizePageBreaksInDocxXmlFiles($tempDir);
|
||||
|
||||
// 4. 重新打包为 DOCX
|
||||
@@ -98,7 +106,24 @@ class ArticleParserService
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 word/*.xml 中表格单元格里的分页符,避免 PhpWord 读取失败
|
||||
* 文档是否含 Word OMML 公式(m:oMath / m:oMathPara)
|
||||
*/
|
||||
private function docxContainsOfficeMath($docxPath): bool
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($docxPath) !== true) {
|
||||
return false;
|
||||
}
|
||||
$xml = $zip->getFromName('word/document.xml');
|
||||
$zip->close();
|
||||
if ($xml === false || $xml === '') {
|
||||
return false;
|
||||
}
|
||||
return stripos($xml, 'oMath') !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 word/*.xml:表格内分页符 + OMML 公式展平为纯文本
|
||||
*/
|
||||
private function sanitizePageBreaksInDocxXmlFiles($tempDir)
|
||||
{
|
||||
@@ -113,14 +138,14 @@ class ArticleParserService
|
||||
}
|
||||
|
||||
foreach ($xmlFiles as $xmlPath) {
|
||||
$this->sanitizePageBreaksInWordXmlFile($xmlPath);
|
||||
$this->sanitizeWordXmlFile($xmlPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $xmlPath
|
||||
*/
|
||||
private function sanitizePageBreaksInWordXmlFile($xmlPath)
|
||||
private function sanitizeWordXmlFile($xmlPath)
|
||||
{
|
||||
if (!is_file($xmlPath) || !is_readable($xmlPath)) {
|
||||
return;
|
||||
@@ -131,15 +156,72 @@ class ArticleParserService
|
||||
return;
|
||||
}
|
||||
|
||||
// 缺 xmlns:m 时先补到根节点,便于 DOM 解析
|
||||
if (stripos($xml, 'oMath') !== false && stripos($xml, 'xmlns:m=') === false) {
|
||||
$xml = preg_replace(
|
||||
'/<(w:document|w:hdr|w:ftr|w:footnotes|w:endnotes)\b([^>]*)>/',
|
||||
'<$1$2 xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">',
|
||||
$xml,
|
||||
1
|
||||
);
|
||||
if (!is_string($xml)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$prev = libxml_use_internal_errors(true);
|
||||
$dom = new DOMDocument();
|
||||
$dom->preserveWhiteSpace = true;
|
||||
$dom->formatOutput = false;
|
||||
if (@$dom->loadXML($xml) === false) {
|
||||
$ok = $dom->loadXML($xml);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($prev);
|
||||
if ($ok === false) {
|
||||
// DOM 失败时用正则展平公式,避免 PhpWord 再踩 oMath 命名空间问题
|
||||
$flattened = $this->flattenOfficeMathByRegex($xml);
|
||||
if ($flattened !== $xml) {
|
||||
file_put_contents($xmlPath, $flattened);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$xpath = new DOMXPath($dom);
|
||||
$xpath->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main');
|
||||
$wNs = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||||
$mNs = 'http://schemas.openxmlformats.org/officeDocument/2006/math';
|
||||
$xpath->registerNamespace('w', $wNs);
|
||||
$xpath->registerNamespace('m', $mNs);
|
||||
|
||||
$changed = false;
|
||||
|
||||
// 先处理 oMathPara,再处理剩余 oMath,保留 m:t 可见文本
|
||||
foreach (['//m:oMathPara', '//m:oMath'] as $query) {
|
||||
$nodes = $xpath->query($query);
|
||||
if (!$nodes || $nodes->length === 0) {
|
||||
continue;
|
||||
}
|
||||
for ($i = $nodes->length - 1; $i >= 0; $i--) {
|
||||
$mathNode = $nodes->item($i);
|
||||
if (!$mathNode || !$mathNode->parentNode) {
|
||||
continue;
|
||||
}
|
||||
$text = '';
|
||||
$tNodes = $xpath->query('.//m:t', $mathNode);
|
||||
if ($tNodes) {
|
||||
foreach ($tNodes as $tNode) {
|
||||
$text .= $tNode->textContent;
|
||||
}
|
||||
}
|
||||
$run = $dom->createElementNS($wNs, 'w:r');
|
||||
$tEl = $dom->createElementNS($wNs, 'w:t');
|
||||
$tEl->appendChild($dom->createTextNode($text));
|
||||
if ($text !== '' && preg_match('/^\s|\s$/u', $text)) {
|
||||
$tEl->setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve');
|
||||
}
|
||||
$run->appendChild($tEl);
|
||||
$mathNode->parentNode->replaceChild($run, $mathNode);
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
$queries = [
|
||||
'//w:tc//w:br[@w:type="page"]',
|
||||
@@ -147,7 +229,6 @@ class ArticleParserService
|
||||
'//w:tc//w:pPr/w:pageBreakBefore',
|
||||
];
|
||||
|
||||
$removed = false;
|
||||
foreach ($queries as $query) {
|
||||
$nodes = $xpath->query($query);
|
||||
if (!$nodes || $nodes->length === 0) {
|
||||
@@ -157,16 +238,55 @@ class ArticleParserService
|
||||
$node = $nodes->item($i);
|
||||
if ($node && $node->parentNode) {
|
||||
$node->parentNode->removeChild($node);
|
||||
$removed = true;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($removed) {
|
||||
if ($changed) {
|
||||
file_put_contents($xmlPath, $dom->saveXML());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 正则展平 OMML:保留 m:t 文本,去掉 oMath/oMathPara(DOM 不可用时兜底)
|
||||
*/
|
||||
private function flattenOfficeMathByRegex(string $xml): string
|
||||
{
|
||||
if (stripos($xml, 'oMath') === false) {
|
||||
return $xml;
|
||||
}
|
||||
|
||||
$toRun = function (string $inner): string {
|
||||
$text = '';
|
||||
if (preg_match_all('/<m:t\b[^>]*>([\s\S]*?)<\/m:t>/i', $inner, $m)) {
|
||||
foreach ($m[1] as $part) {
|
||||
$text .= html_entity_decode(strip_tags($part), ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
}
|
||||
}
|
||||
$safe = htmlspecialchars($text, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
$space = ($text !== '' && preg_match('/^\s|\s$/u', $text)) ? ' xml:space="preserve"' : '';
|
||||
return '<w:r><w:t' . $space . '>' . $safe . '</w:t></w:r>';
|
||||
};
|
||||
|
||||
$xml = preg_replace_callback(
|
||||
'/<m:oMathPara\b[^>]*>([\s\S]*?)<\/m:oMathPara>/i',
|
||||
function ($m) use ($toRun) {
|
||||
return $toRun($m[1]);
|
||||
},
|
||||
$xml
|
||||
);
|
||||
$xml = preg_replace_callback(
|
||||
'/<m:oMath\b[^>]*>([\s\S]*?)<\/m:oMath>/i',
|
||||
function ($m) use ($toRun) {
|
||||
return $toRun($m[1]);
|
||||
},
|
||||
is_string($xml) ? $xml : ''
|
||||
);
|
||||
|
||||
return is_string($xml) ? $xml : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归添加目录文件到 ZipArchive
|
||||
* @param string $dir 目录路径
|
||||
|
||||
@@ -246,6 +246,131 @@ class ReferenceReferAuthorService
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条参考文献:外网拉取作者并入库,再返回明细
|
||||
* 有 DOI → Crossref + OpenAlex;失败或无 DOI → 解析 refer.author
|
||||
*
|
||||
* @return array{
|
||||
* p_article_id:int,
|
||||
* p_refer_id:int,
|
||||
* reference_no:int,
|
||||
* doi:string,
|
||||
* synced:int,
|
||||
* refer:array|null,
|
||||
* author_count:int,
|
||||
* authors:array
|
||||
* }
|
||||
*/
|
||||
public function fetchAuthorsByPReferId($pArticleId, $pReferId)
|
||||
{
|
||||
$pArticleId = intval($pArticleId);
|
||||
$pReferId = intval($pReferId);
|
||||
if ($pArticleId <= 0 || $pReferId <= 0) {
|
||||
throw new \InvalidArgumentException('p_article_id and p_refer_id are required');
|
||||
}
|
||||
|
||||
$refer = Db::name('production_article_refer')
|
||||
->where('p_refer_id', $pReferId)
|
||||
->where('p_article_id', $pArticleId)
|
||||
->where('state', 0)
|
||||
->find();
|
||||
|
||||
if (empty($refer)) {
|
||||
return [
|
||||
'p_article_id' => $pArticleId,
|
||||
'p_refer_id' => $pReferId,
|
||||
'reference_no' => 0,
|
||||
'doi' => '',
|
||||
'synced' => 0,
|
||||
'refer' => null,
|
||||
'author_count' => 0,
|
||||
'authors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$refUtil = new ReferenceCheckService();
|
||||
$doi = $refUtil->extractDoiFromRefer($refer);
|
||||
$syncedCount = $this->syncOneRefer($pReferId, $pArticleId, $refer);
|
||||
$result = $this->getAuthorsByPReferId($pArticleId, $pReferId);
|
||||
$result['doi'] = $doi;
|
||||
$result['synced'] = $syncedCount > 0 ? 1 : 0;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 p_article_id + p_refer_id 读取作者明细(只读本地库)
|
||||
*
|
||||
* @return array{p_article_id:int,p_refer_id:int,reference_no:int,refer:array|null,author_count:int,authors:array}
|
||||
*/
|
||||
public function getAuthorsByPReferId($pArticleId, $pReferId)
|
||||
{
|
||||
$pArticleId = intval($pArticleId);
|
||||
$pReferId = intval($pReferId);
|
||||
if ($pArticleId <= 0 || $pReferId <= 0) {
|
||||
throw new \InvalidArgumentException('p_article_id and p_refer_id are required');
|
||||
}
|
||||
|
||||
$refer = Db::name('production_article_refer')
|
||||
->field('p_refer_id,p_article_id,index,author,title,joura,refer_doi,doilink,refer_type')
|
||||
->where('p_refer_id', $pReferId)
|
||||
->where('p_article_id', $pArticleId)
|
||||
->where('state', 0)
|
||||
->find();
|
||||
|
||||
if (empty($refer)) {
|
||||
return [
|
||||
'p_article_id' => $pArticleId,
|
||||
'p_refer_id' => $pReferId,
|
||||
'reference_no' => 0,
|
||||
'refer' => null,
|
||||
'author_count' => 0,
|
||||
'authors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$rows = Db::name('production_article_refer_author')
|
||||
->where('p_article_id', $pArticleId)
|
||||
->where('p_refer_id', $pReferId)
|
||||
->order('author_seq asc, id asc')
|
||||
->field('author_seq,author_position,is_first_author,family,given,display_name,citation_name,orcid,openalex_id,identity_source')
|
||||
->select();
|
||||
|
||||
$authors = [];
|
||||
foreach ($rows as $row) {
|
||||
$authors[] = [
|
||||
'author_seq' => intval($row['author_seq'] ?? 0),
|
||||
'author_position' => (string)($row['author_position'] ?? ''),
|
||||
'is_first_author' => intval($row['is_first_author'] ?? 0),
|
||||
'family' => (string)($row['family'] ?? ''),
|
||||
'given' => (string)($row['given'] ?? ''),
|
||||
'display_name' => (string)($row['display_name'] ?? ''),
|
||||
'citation_name' => (string)($row['citation_name'] ?? ''),
|
||||
'orcid' => (string)($row['orcid'] ?? ''),
|
||||
'openalex_id' => (string)($row['openalex_id'] ?? ''),
|
||||
'identity_source' => (string)($row['identity_source'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'p_article_id' => $pArticleId,
|
||||
'p_refer_id' => $pReferId,
|
||||
'reference_no' => intval($refer['index']) + 1,
|
||||
'refer' => [
|
||||
'p_refer_id' => $pReferId,
|
||||
'reference_no' => intval($refer['index']) + 1,
|
||||
'author' => (string)($refer['author'] ?? ''),
|
||||
'title' => (string)($refer['title'] ?? ''),
|
||||
'joura' => (string)($refer['joura'] ?? ''),
|
||||
'refer_doi' => (string)($refer['refer_doi'] ?? ''),
|
||||
'doilink' => (string)($refer['doilink'] ?? ''),
|
||||
'refer_type' => (string)($refer['refer_type'] ?? ''),
|
||||
],
|
||||
'author_count' => count($authors),
|
||||
'authors' => $authors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取已入库的作者身份(与 ReferenceAuthorIdentityService 结构兼容)
|
||||
*
|
||||
|
||||
@@ -6,33 +6,16 @@ use think\Db;
|
||||
|
||||
/**
|
||||
* 参考文献引用堆叠统计:同刊、同作者、自引(实时计算,不入库)
|
||||
* 仅读本地库/字段,不请求 Crossref/OpenAlex,避免网关超时。
|
||||
*/
|
||||
class ReferenceStackingStatsService
|
||||
{
|
||||
const DETAIL_JOURNAL = 'journal';
|
||||
const DETAIL_AUTHOR = 'author';
|
||||
|
||||
const THRESHOLD_SAME_AUTHOR = 0.15;
|
||||
const THRESHOLD_SAME_JOURNAL = 0.20;
|
||||
const THRESHOLD_SELF_CITATION = 0.10;
|
||||
|
||||
/** @var ReferenceCheckService */
|
||||
private $refUtil;
|
||||
|
||||
/** @var CrossrefService */
|
||||
private $crossref;
|
||||
|
||||
/** @var ReferenceAuthorIdentityService */
|
||||
private $identity;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->refUtil = new ReferenceCheckService();
|
||||
$this->crossref = new CrossrefService([
|
||||
'mailto' => trim((string)\think\Env::get('crossref_mailto', '')),
|
||||
]);
|
||||
$this->identity = new ReferenceAuthorIdentityService();
|
||||
}
|
||||
const THRESHOLD_SAME_AUTHOR = 0.15;//相同作者;
|
||||
const THRESHOLD_SAME_JOURNAL = 0.20;//同一期刊;
|
||||
const THRESHOLD_SELF_CITATION = 0.10;//作者自引;
|
||||
|
||||
/**
|
||||
* 按阈值规则实时统计:同作者(>15%)、同刊(>20%)、自引(>10%)
|
||||
@@ -50,6 +33,44 @@ class ReferenceStackingStatsService
|
||||
return $this->formatThresholdStackingReport($this->compute($pArticleId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 堆叠摘要(展示用):同作者名称+比例、同刊名称+比例、自引比例
|
||||
*
|
||||
* @param int $pArticleId
|
||||
* @return array
|
||||
*/
|
||||
public function getStackingSummaryByPArticleId($pArticleId)
|
||||
{
|
||||
$full = $this->getThresholdStackingByPArticleId($pArticleId);
|
||||
|
||||
$authors = [];
|
||||
foreach ((array)(($full['same_author_stacking']['items'] ?? [])) as $item) {
|
||||
$authors[] = [
|
||||
'name' => (string)($item['author_name'] ?? ''),
|
||||
'ratio' => round(floatval($item['cite_ratio'] ?? 0), 4),
|
||||
];
|
||||
}
|
||||
|
||||
$journals = [];
|
||||
foreach ((array)(($full['same_journal_stacking']['items'] ?? [])) as $item) {
|
||||
$journals[] = [
|
||||
'name' => (string)($item['journal_name'] ?? ''),
|
||||
'ratio' => round(floatval($item['cite_ratio'] ?? 0), 4),
|
||||
];
|
||||
}
|
||||
|
||||
$self = (array)($full['self_citation'] ?? []);
|
||||
|
||||
return [
|
||||
'p_article_id' => intval($full['p_article_id'] ?? 0),
|
||||
'total_references' => intval($full['total_references'] ?? 0),
|
||||
'same_author' => $authors,
|
||||
'same_journal' => $journals,
|
||||
'self_citation_ratio' => round(floatval($self['cite_ratio'] ?? 0), 4),
|
||||
'computed_at' => (string)($full['computed_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $full compute/analyze 或 getStored 的完整结果
|
||||
*/
|
||||
@@ -118,6 +139,11 @@ class ReferenceStackingStatsService
|
||||
];
|
||||
}
|
||||
|
||||
$authorDataIssues = $this->formatAuthorDataIssues(
|
||||
(array)($full['author_data_issues'] ?? []),
|
||||
$referMap
|
||||
);
|
||||
|
||||
return [
|
||||
'p_article_id' => intval($full['p_article_id'] ?? 0),
|
||||
'article_id' => intval($full['article_id'] ?? 0),
|
||||
@@ -141,6 +167,7 @@ class ReferenceStackingStatsService
|
||||
'items' => $selfItems,
|
||||
'note' => (string)($full['author_identity_note'] ?? ''),
|
||||
],
|
||||
'author_data_issues' => $authorDataIssues,
|
||||
'computed_at' => (string)($full['computed_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
@@ -167,21 +194,22 @@ class ReferenceStackingStatsService
|
||||
->order('index asc')
|
||||
->select();
|
||||
|
||||
$manuscriptAuthors = $this->identity->resolveManuscriptAuthors($pArticleId);
|
||||
$manuscriptAuthors = $this->loadManuscriptAuthorsLocal($pArticleId);
|
||||
$ambiguousManuscriptNameKeys = $this->buildAmbiguousManuscriptNameKeys($manuscriptAuthors);
|
||||
$doiCache = [];
|
||||
$referAuthorRowsMap = $this->loadReferAuthorRowsByPArticleId($pArticleId);
|
||||
$referMap = [];
|
||||
|
||||
$journalBuckets = [];
|
||||
$authorBuckets = [];
|
||||
$selfCitationDetails = [];
|
||||
$authorDataIssues = [];
|
||||
|
||||
foreach ($refers as $refer) {
|
||||
$refNo = intval($refer['index']) + 1;
|
||||
$pReferId = intval($refer['p_refer_id']);
|
||||
$referMap[$pReferId] = $refer;
|
||||
|
||||
$meta = $this->resolveReferMeta($refer, $doiCache);
|
||||
$meta = $this->resolveReferMetaLocal($refer);
|
||||
$joura = (string)$meta['joura'];
|
||||
$journalKey = $this->normalizeJournalKey($joura);
|
||||
if ($journalKey !== '') {
|
||||
@@ -199,7 +227,14 @@ class ReferenceStackingStatsService
|
||||
$journalBuckets[$journalKey]['p_refer_ids'][] = $pReferId;
|
||||
}
|
||||
|
||||
$referAuthors = $this->resolveReferAuthorsWithMeta($pReferId, $meta);
|
||||
$dbRows = isset($referAuthorRowsMap[$pReferId]) ? $referAuthorRowsMap[$pReferId] : [];
|
||||
$resolved = $this->resolveReferAuthorsWithMetaDetailed($pReferId, $meta, $dbRows);
|
||||
$referAuthors = $resolved['authors'];
|
||||
$authorIssue = $this->buildAuthorDataIssue($refNo, $pReferId, $meta, $resolved);
|
||||
if ($authorIssue !== null) {
|
||||
$authorDataIssues[] = $authorIssue;
|
||||
}
|
||||
|
||||
$this->accumulateAuthorBuckets($authorBuckets, $referAuthors, $refNo, $pReferId);
|
||||
|
||||
$matchedManuscript = $this->matchManuscriptAuthorForSelfCitation(
|
||||
@@ -238,12 +273,61 @@ class ReferenceStackingStatsService
|
||||
'journal_details' => $journalDetails,
|
||||
'author_details' => $authorDetails,
|
||||
'self_citation_details' => $selfDetails,
|
||||
'author_data_issues' => $authorDataIssues,
|
||||
'refer_map' => $referMap,
|
||||
'author_identity_note' => '同作者堆叠按 citation_name(空则 display_name)姓名精确匹配,同名即同人;本文出现重名作者(如两位 Lin)时跳过该姓名的自引判定。优先读 t_production_article_refer_author,否则解析 refer.author。',
|
||||
'author_identity_note' => '同作者堆叠按 citation_name(空则 display_name)姓名精确匹配,同名即同人;本文出现重名作者(如两位 Lin)时跳过该姓名的自引判定。优先读 t_production_article_refer_author,否则解析 refer.author。统计仅用本地数据,不实时请求 Crossref/OpenAlex。',
|
||||
'computed_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 本文作者(仅本地库,不打 OpenAlex)
|
||||
*
|
||||
* @return array<int, array{display_name:string,orcid:string}>
|
||||
*/
|
||||
private function loadManuscriptAuthorsLocal($pArticleId)
|
||||
{
|
||||
$rows = Db::name('production_article_author')
|
||||
->field('first_name,last_name,author_name,orcid')
|
||||
->where('p_article_id', intval($pArticleId))
|
||||
->where('state', 0)
|
||||
->select();
|
||||
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$first = trim((string)($row['first_name'] ?? ''));
|
||||
$last = trim((string)($row['last_name'] ?? ''));
|
||||
$displayName = ($first !== '' && $last !== '')
|
||||
? trim($first . ' ' . $last)
|
||||
: trim((string)($row['author_name'] ?? ''));
|
||||
$list[] = [
|
||||
'display_name' => $displayName,
|
||||
'orcid' => $this->cleanOrcid($row['orcid'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array>
|
||||
*/
|
||||
private function loadReferAuthorRowsByPArticleId($pArticleId)
|
||||
{
|
||||
$rows = Db::name('production_article_refer_author')
|
||||
->where('p_article_id', intval($pArticleId))
|
||||
->order('p_refer_id asc, author_seq asc, id asc')
|
||||
->field('p_refer_id,display_name,citation_name,orcid')
|
||||
->select();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[intval($row['p_refer_id'])][] = $row;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function formatAuthorBucketDetails(array $buckets, $detailType)
|
||||
{
|
||||
$list = [];
|
||||
@@ -280,23 +364,47 @@ class ReferenceStackingStatsService
|
||||
/**
|
||||
* @return array<int, array{name:string,orcid:string}>
|
||||
*/
|
||||
private function resolveReferAuthorsWithMeta($pReferId, array $meta)
|
||||
private function resolveReferAuthorsWithMeta($pReferId, array $meta, array $dbRows = null)
|
||||
{
|
||||
return $this->resolveReferAuthorsWithMetaDetailed($pReferId, $meta, $dbRows)['authors'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array|null $dbRows 预加载的 production_article_refer_author 行;null 则按 p_refer_id 查询
|
||||
* @return array{
|
||||
* authors:array<int, array{name:string,orcid:string}>,
|
||||
* source:string,
|
||||
* db_row_count:int,
|
||||
* db_usable_count:int,
|
||||
* db_blank_name_count:int,
|
||||
* has_et_al:bool
|
||||
* }
|
||||
*/
|
||||
private function resolveReferAuthorsWithMetaDetailed($pReferId, array $meta, array $dbRows = null)
|
||||
{
|
||||
$pReferId = intval($pReferId);
|
||||
$list = [];
|
||||
$dbRowCount = 0;
|
||||
$dbBlankNameCount = 0;
|
||||
$authorString = (string)($meta['author'] ?? '');
|
||||
$hasEtAl = (bool)preg_match('/\bet\s+al\.?\b/iu', $authorString);
|
||||
|
||||
if ($pReferId > 0) {
|
||||
$rows = Db::name('production_article_refer_author')
|
||||
->where('p_refer_id', $pReferId)
|
||||
->order('author_seq asc, id asc')
|
||||
->field('display_name,citation_name,orcid')
|
||||
->select();
|
||||
foreach ($rows as $row) {
|
||||
if ($dbRows === null) {
|
||||
$dbRows = Db::name('production_article_refer_author')
|
||||
->where('p_refer_id', $pReferId)
|
||||
->order('author_seq asc, id asc')
|
||||
->field('display_name,citation_name,orcid')
|
||||
->select();
|
||||
}
|
||||
$dbRowCount = count($dbRows);
|
||||
foreach ($dbRows as $row) {
|
||||
$name = trim((string)($row['citation_name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = trim((string)($row['display_name'] ?? ''));
|
||||
}
|
||||
if ($name === '') {
|
||||
$dbBlankNameCount++;
|
||||
continue;
|
||||
}
|
||||
$list[] = [
|
||||
@@ -307,17 +415,148 @@ class ReferenceStackingStatsService
|
||||
}
|
||||
|
||||
if (!empty($list)) {
|
||||
return $list;
|
||||
return [
|
||||
'authors' => $list,
|
||||
'source' => 'refer_author_table',
|
||||
'db_row_count' => $dbRowCount,
|
||||
'db_usable_count' => count($list),
|
||||
'db_blank_name_count' => $dbBlankNameCount,
|
||||
'has_et_al' => $hasEtAl,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($this->parseAuthorStringParts((string)($meta['author'] ?? '')) as $name) {
|
||||
foreach ($this->parseAuthorStringParts($authorString) as $name) {
|
||||
$list[] = [
|
||||
'name' => $name,
|
||||
'orcid' => '',
|
||||
];
|
||||
}
|
||||
|
||||
return $list;
|
||||
return [
|
||||
'authors' => $list,
|
||||
'source' => empty($list) ? 'none' : 'author_string',
|
||||
'db_row_count' => $dbRowCount,
|
||||
'db_usable_count' => 0,
|
||||
'db_blank_name_count' => $dbBlankNameCount,
|
||||
'has_et_al' => $hasEtAl,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* authors:array,
|
||||
* source:string,
|
||||
* db_row_count:int,
|
||||
* db_usable_count:int,
|
||||
* db_blank_name_count:int,
|
||||
* has_et_al:bool
|
||||
* } $resolved
|
||||
* @return array|null
|
||||
*/
|
||||
private function buildAuthorDataIssue($refNo, $pReferId, array $meta, array $resolved)
|
||||
{
|
||||
$authors = (array)($resolved['authors'] ?? []);
|
||||
$authorCount = count($authors);
|
||||
$source = (string)($resolved['source'] ?? 'none');
|
||||
$dbRowCount = intval($resolved['db_row_count'] ?? 0);
|
||||
$dbBlankNameCount = intval($resolved['db_blank_name_count'] ?? 0);
|
||||
$hasEtAl = !empty($resolved['has_et_al']);
|
||||
$rawAuthor = trim((string)($meta['author'] ?? ''));
|
||||
|
||||
if ($authorCount <= 0) {
|
||||
$reason = '未获取到作者';
|
||||
if ($dbRowCount > 0 && $dbBlankNameCount > 0) {
|
||||
$reason = '作者明细表有记录但姓名均为空';
|
||||
} elseif ($rawAuthor !== '') {
|
||||
$reason = '作者字段无法解析出有效姓名';
|
||||
}
|
||||
|
||||
return [
|
||||
'issue_type' => 'missing',
|
||||
'reason' => $reason,
|
||||
'reference_no' => intval($refNo),
|
||||
'p_refer_id' => intval($pReferId),
|
||||
'author_source' => $source,
|
||||
'author_count' => 0,
|
||||
'raw_author' => $rawAuthor,
|
||||
'meta_source' => (string)($meta['meta_source'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$reasons = [];
|
||||
if ($dbRowCount > 0 && $dbBlankNameCount > 0) {
|
||||
$reasons[] = '部分作者姓名为空(空姓名 ' . $dbBlankNameCount . ' 条)';
|
||||
}
|
||||
// refer.author 常因引用格式截断带 et al.;明细表已有完整作者时不再判为不全
|
||||
if ($hasEtAl && $source !== 'refer_author_table') {
|
||||
$reasons[] = '作者列表含 et al.,可能不全';
|
||||
}
|
||||
|
||||
if (empty($reasons)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'issue_type' => 'incomplete',
|
||||
'reason' => implode(';', $reasons),
|
||||
'reference_no' => intval($refNo),
|
||||
'p_refer_id' => intval($pReferId),
|
||||
'author_source' => $source,
|
||||
'author_count' => $authorCount,
|
||||
'raw_author' => $rawAuthor,
|
||||
'meta_source' => (string)($meta['meta_source'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $issues
|
||||
* @param array<int,array> $referMap
|
||||
* @return array{
|
||||
* missing_count:int,
|
||||
* incomplete_count:int,
|
||||
* missing_reference_nos:int[],
|
||||
* incomplete_reference_nos:int[],
|
||||
* items:array
|
||||
* }
|
||||
*/
|
||||
private function formatAuthorDataIssues(array $issues, array $referMap)
|
||||
{
|
||||
$missing = [];
|
||||
$incomplete = [];
|
||||
$items = [];
|
||||
|
||||
usort($issues, function ($a, $b) {
|
||||
return intval($a['reference_no'] ?? 0) <=> intval($b['reference_no'] ?? 0);
|
||||
});
|
||||
|
||||
foreach ($issues as $issue) {
|
||||
$pReferId = intval($issue['p_refer_id'] ?? 0);
|
||||
$item = [
|
||||
'issue_type' => (string)($issue['issue_type'] ?? ''),
|
||||
'reason' => (string)($issue['reason'] ?? ''),
|
||||
'reference_no' => intval($issue['reference_no'] ?? 0),
|
||||
'p_refer_id' => $pReferId,
|
||||
'author_source' => (string)($issue['author_source'] ?? ''),
|
||||
'author_count' => intval($issue['author_count'] ?? 0),
|
||||
'raw_author' => (string)($issue['raw_author'] ?? ''),
|
||||
'meta_source' => (string)($issue['meta_source'] ?? ''),
|
||||
'reference' => $this->buildReferBriefs([$pReferId], $referMap)[0] ?? null,
|
||||
];
|
||||
$items[] = $item;
|
||||
if ($item['issue_type'] === 'missing') {
|
||||
$missing[] = $item['reference_no'];
|
||||
} elseif ($item['issue_type'] === 'incomplete') {
|
||||
$incomplete[] = $item['reference_no'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'missing_count' => count($missing),
|
||||
'incomplete_count' => count($incomplete),
|
||||
'missing_reference_nos' => array_values($missing),
|
||||
'incomplete_reference_nos' => array_values($incomplete),
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -462,7 +701,7 @@ class ReferenceStackingStatsService
|
||||
/**
|
||||
* @param int[] $pReferIds
|
||||
* @param array<int,array> $referMap
|
||||
* @return array<int,array{p_refer_id:int,reference_no:int,refer_text:string}>
|
||||
* @return array<int,array{p_refer_id:int,reference_no:int,refer_text:string,doi:string,url:string}>
|
||||
*/
|
||||
private function buildReferBriefs(array $pReferIds, array $referMap)
|
||||
{
|
||||
@@ -473,16 +712,62 @@ class ReferenceStackingStatsService
|
||||
continue;
|
||||
}
|
||||
$refer = $referMap[$pReferId];
|
||||
$doi = $this->extractReferDoi($refer);
|
||||
$list[] = [
|
||||
'p_refer_id' => $pReferId,
|
||||
'reference_no' => intval($refer['index'] ?? 0) + 1,
|
||||
'refer_text' => $this->referSnippet($refer),
|
||||
'doi' => $doi,
|
||||
'url' => $this->buildReferOpenUrl($refer, $doi),
|
||||
];
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从参考文献行提取 DOI(去前缀)
|
||||
*/
|
||||
private function extractReferDoi(array $refer)
|
||||
{
|
||||
foreach (['refer_doi', 'doilink'] as $field) {
|
||||
$raw = trim((string)($refer[$field] ?? ''));
|
||||
if ($raw === '') {
|
||||
continue;
|
||||
}
|
||||
$raw = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', $raw);
|
||||
$raw = trim($raw, " \t\n\r\0\x0B/");
|
||||
if ($raw !== '' && stripos($raw, '10.') !== false) {
|
||||
if (preg_match('#(10\.\d{4,9}/[^\s]+)#i', $raw, $m)) {
|
||||
return rtrim($m[1], '.,;');
|
||||
}
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 可跳转打开的文献链接:优先 DOI,其次 doilink 若已是 URL
|
||||
*/
|
||||
private function buildReferOpenUrl(array $refer, $doi = '')
|
||||
{
|
||||
$doi = trim((string)$doi);
|
||||
if ($doi === '') {
|
||||
$doi = $this->extractReferDoi($refer);
|
||||
}
|
||||
if ($doi !== '') {
|
||||
return 'https://doi.org/' . $doi;
|
||||
}
|
||||
|
||||
$doilink = trim((string)($refer['doilink'] ?? ''));
|
||||
if ($doilink !== '' && preg_match('#^https?://#i', $doilink)) {
|
||||
return $doilink;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function loadReferMapByPArticleId($pArticleId)
|
||||
{
|
||||
$pArticleId = intval($pArticleId);
|
||||
@@ -540,7 +825,7 @@ class ReferenceStackingStatsService
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 refer 行已有字段、Crossref、refer_frag/refer_content 解析结果
|
||||
* 仅用本地字段 + refer_frag/refer_content 解析,不请求 Crossref
|
||||
*
|
||||
* @return array{
|
||||
* author:string,
|
||||
@@ -551,7 +836,7 @@ class ReferenceStackingStatsService
|
||||
* resolved:bool
|
||||
* }
|
||||
*/
|
||||
private function resolveReferMeta(array $refer, array &$doiCache)
|
||||
private function resolveReferMetaLocal(array $refer)
|
||||
{
|
||||
$author = trim(trim((string)($refer['author'] ?? '')), '.');
|
||||
$joura = trim(trim((string)($refer['joura'] ?? '')), '.');
|
||||
@@ -563,38 +848,6 @@ class ReferenceStackingStatsService
|
||||
$authorKeys = $this->extractAuthorKeysFromAuthorString($author);
|
||||
}
|
||||
|
||||
$doi = $this->refUtil->extractDoiFromRefer($refer);
|
||||
$summary = null;
|
||||
if ($doi !== '') {
|
||||
if (!array_key_exists($doi, $doiCache)) {
|
||||
try {
|
||||
$doiCache[$doi] = $this->crossref->fetchWorkSummary($doi);
|
||||
} catch (\Throwable $e) {
|
||||
$doiCache[$doi] = null;
|
||||
}
|
||||
}
|
||||
$summary = $doiCache[$doi];
|
||||
}
|
||||
|
||||
if (is_array($summary)) {
|
||||
$sources[] = 'crossref';
|
||||
if ($joura === '' && trim((string)($summary['joura'] ?? '')) !== '') {
|
||||
$joura = trim((string)$summary['joura']);
|
||||
}
|
||||
$crossrefKeys = $this->authorKeysFromCrossrefMessage($summary['raw'] ?? []);
|
||||
if ($author === '') {
|
||||
$citationAuthor = $this->crossref->getAuthorsCitation($summary['raw'] ?? [], 3);
|
||||
if ($citationAuthor !== '') {
|
||||
$author = $citationAuthor;
|
||||
}
|
||||
$authorKeys = !empty($crossrefKeys)
|
||||
? $crossrefKeys
|
||||
: $this->extractAuthorKeysFromAuthorString($author);
|
||||
} elseif (!empty($crossrefKeys)) {
|
||||
$authorKeys = array_values(array_unique(array_merge($authorKeys, $crossrefKeys)));
|
||||
}
|
||||
}
|
||||
|
||||
if ($joura === '' || $author === '') {
|
||||
$fragParsed = $this->parseStructuredReferText($refer);
|
||||
if (is_array($fragParsed)) {
|
||||
@@ -668,37 +921,6 @@ class ReferenceStackingStatsService
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function authorKeysFromCrossrefMessage(array $message)
|
||||
{
|
||||
$keys = [];
|
||||
if (empty($message['author']) || !is_array($message['author'])) {
|
||||
return $keys;
|
||||
}
|
||||
|
||||
foreach ($message['author'] as $author) {
|
||||
if (!is_array($author)) {
|
||||
continue;
|
||||
}
|
||||
$family = trim((string)($author['family'] ?? ''));
|
||||
$given = trim((string)($author['given'] ?? ''));
|
||||
if ($family === '' && $given === '') {
|
||||
$org = trim((string)($author['name'] ?? ''));
|
||||
if ($org !== '') {
|
||||
$keys[] = $this->authorKeyFromCitationPart($org);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($family !== '') {
|
||||
$keys[] = mb_strtoupper($family) . '|' . $this->givenToInitials($given);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($keys)));
|
||||
}
|
||||
|
||||
private function referSnippet(array $refer)
|
||||
{
|
||||
foreach (['refer_content', 'refer_frag'] as $field) {
|
||||
@@ -805,23 +1027,6 @@ class ReferenceStackingStatsService
|
||||
return $key;
|
||||
}
|
||||
|
||||
private function givenToInitials($given)
|
||||
{
|
||||
$given = trim((string)$given);
|
||||
if ($given === '') {
|
||||
return '';
|
||||
}
|
||||
$parts = preg_split('/[\s\-\.]+/u', $given, -1, PREG_SPLIT_NO_EMPTY);
|
||||
$initials = '';
|
||||
foreach ($parts as $part) {
|
||||
$first = mb_substr($part, 0, 1);
|
||||
if ($first !== '') {
|
||||
$initials .= mb_strtoupper($first);
|
||||
}
|
||||
}
|
||||
return $initials;
|
||||
}
|
||||
|
||||
private function cleanOrcid($orcid)
|
||||
{
|
||||
$orcid = trim((string)$orcid);
|
||||
|
||||
@@ -26,6 +26,7 @@ class UserActLog
|
||||
return ['status' => 2, 'msg' => '非法操作'];
|
||||
}
|
||||
$aInsert['create_time'] = time();
|
||||
$aInsert['update_time'] = time();
|
||||
$result = Db::name('user_act_log')->insertGetId($aInsert);
|
||||
if(empty($result)){
|
||||
return ['status' => 3, 'msg' => '数据插入失败'.Db::getLastSql()."\n数据内容:",'data' => $aParam];
|
||||
|
||||
@@ -65,6 +65,8 @@ class ReferenceCheckArticleWorker
|
||||
}
|
||||
$this->svc->log('ReferenceCheckArticleWorker start p_article_id=' . $pArticleId . ' batch_id=' . $batchId);
|
||||
|
||||
// 快照本批待处理 id:联合引用组长一次会整组落库,循环计数会小于 total_count,收尾按快照回填
|
||||
$trackedIds = $this->listPendingCheckIds($pArticleId);
|
||||
$done = 0;
|
||||
$failed = 0;
|
||||
while (true) {
|
||||
@@ -84,12 +86,58 @@ class ReferenceCheckArticleWorker
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($trackedIds)) {
|
||||
$stats = $this->summarizeTrackedCheckIds($trackedIds);
|
||||
$done = intval($stats['done']);
|
||||
$failed = intval($stats['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 listPendingCheckIds($pArticleId)
|
||||
{
|
||||
$rows = Db::name('article_reference_relevance_check_result')
|
||||
->where('p_article_id', intval($pArticleId))
|
||||
->where('queue_status', ReferenceRelevanceCheckService::QUEUE_PENDING)
|
||||
->where('status', ReferenceRelevanceCheckService::RECORD_PENDING)
|
||||
->field('id')
|
||||
->select();
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$id = intval(isset($row['id']) ? $row['id'] : 0);
|
||||
if ($id > 0) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private function summarizeTrackedCheckIds(array $checkIds)
|
||||
{
|
||||
$checkIds = array_values(array_filter(array_map('intval', $checkIds)));
|
||||
if (empty($checkIds)) {
|
||||
return ['done' => 0, 'failed' => 0];
|
||||
}
|
||||
$rows = Db::name('article_reference_relevance_check_result')
|
||||
->whereIn('id', $checkIds)
|
||||
->field('id,status')
|
||||
->select();
|
||||
$done = 0;
|
||||
$failed = 0;
|
||||
foreach ($rows as $row) {
|
||||
$st = intval(isset($row['status']) ? $row['status'] : -1);
|
||||
if ($st === ReferenceRelevanceCheckService::RECORD_COMPLETED) {
|
||||
$done++;
|
||||
} elseif ($st === ReferenceRelevanceCheckService::RECORD_FAILED) {
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
return ['done' => $done, 'failed' => $failed];
|
||||
}
|
||||
|
||||
private function canStartArticleWork($batchId)
|
||||
{
|
||||
$running = Db::name('article_reference_relevance_check_batch')
|
||||
@@ -182,18 +230,25 @@ class ReferenceCheckArticleWorker
|
||||
return;
|
||||
}
|
||||
$total = intval($batch['total_count']);
|
||||
$done = intval($done);
|
||||
$failed = intval($failed);
|
||||
// 快照回填后若实际终态条数多于入队 total,抬升 total 保持一致
|
||||
if (($done + $failed) > $total) {
|
||||
$total = $done + $failed;
|
||||
}
|
||||
$status = self::BATCH_DONE;
|
||||
if ($failed > 0) {
|
||||
$status = self::BATCH_PARTIAL_FAILED;
|
||||
}
|
||||
Db::name('article_reference_relevance_check_batch')->where('id', intval($batchId))->update([
|
||||
'batch_status' => $status,
|
||||
'done_count' => intval($done),
|
||||
'failed_count' => intval($failed),
|
||||
'total_count' => $total,
|
||||
'done_count' => $done,
|
||||
'failed_count' => $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);
|
||||
$this->svc->log('ReferenceCheckArticleWorker batch_id=' . $batchId . ' incomplete total=' . $total . ' done=' . $done . ' failed=' . $failed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user