Files
tougao/application/common/ReferenceReferAuthorService.php
2026-07-29 17:05:42 +08:00

646 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\common;
use think\Db;
/**
* 参考文献作者明细入库/读取(供引用堆叠等同作者精准统计)
*/
class ReferenceReferAuthorService
{
/** @var BackgroundCheckService */
private $bgCheck;
/** @var CrossrefService */
private $crossref;
public function __construct()
{
$this->bgCheck = new BackgroundCheckService();
$this->crossref = new CrossrefService([
'mailto' => trim((string)\think\Env::get('crossref_mailto', '')),
]);
}
/**
* Crossref enrichment 成功后:解析并覆盖写入作者明细
*
* @return int 写入作者条数
*/
public function syncFromWorkSummary($pReferId, $pArticleId, $doi, array $summary)
{
$pReferId = intval($pReferId);
$pArticleId = intval($pArticleId);
if ($pReferId <= 0) {
return 0;
}
$rows = $this->buildAuthorRows($doi, $summary);
Db::name('production_article_refer_author')->where('p_refer_id', $pReferId)->delete();
if (empty($rows)) {
return 0;
}
$now = date('Y-m-d H:i:s');
$insertRows = [];
foreach ($rows as $row) {
$insertRows[] = [
'p_article_id' => $pArticleId,
'p_refer_id' => $pReferId,
'author_seq' => intval($row['author_seq']),
'author_position' => $this->clipField((string)$row['author_position'], 16),
'is_first_author' => intval($row['is_first_author']),
'family' => $this->clipField((string)$row['family'], 128),
'given' => $this->clipField((string)$row['given'], 128),
'display_name' => $this->clipField((string)$row['display_name'], 256),
'citation_name' => $this->clipField((string)$row['citation_name'], 128),
'orcid' => $this->clipField((string)$row['orcid'], 32),
'openalex_id' => $this->clipField((string)$row['openalex_id'], 32),
'identity_source' => $this->clipField((string)$row['identity_source'], 32),
'created_at' => $now,
'updated_at' => $now,
];
}
Db::name('production_article_refer_author')->insertAll($insertRows);
return count($insertRows);
}
/**
* 从 refer 表已有 author 字段解析并入库Crossref/OpenAlex 均不可用时的兜底)
*
* @return int
*/
public function syncFromReferAuthorField($pReferId, $pArticleId, $authorString)
{
$pReferId = intval($pReferId);
$pArticleId = intval($pArticleId);
if ($pReferId <= 0) {
return 0;
}
$rows = $this->parseReferAuthorString($authorString);
Db::name('production_article_refer_author')->where('p_refer_id', $pReferId)->delete();
if (empty($rows)) {
return 0;
}
$now = date('Y-m-d H:i:s');
$insertRows = [];
foreach ($rows as $row) {
$insertRows[] = [
'p_article_id' => $pArticleId,
'p_refer_id' => $pReferId,
'author_seq' => intval($row['author_seq']),
'author_position' => $this->clipField((string)$row['author_position'], 16),
'is_first_author' => intval($row['is_first_author']),
'family' => '',
'given' => '',
'display_name' => $this->clipField((string)$row['display_name'], 256),
'citation_name' => $this->clipField((string)$row['citation_name'], 128),
'orcid' => '',
'openalex_id' => '',
'identity_source' => 'refer_author',
'created_at' => $now,
'updated_at' => $now,
];
}
Db::name('production_article_refer_author')->insertAll($insertRows);
return count($insertRows);
}
/**
* 单条参考文献同步作者明细:有 DOI 走 Crossref+OpenAlex否则解析 refer.author
*
* @param int $pReferId
* @param int $pArticleId
* @param array $refer 可传 production_article_refer 行;为空则从库读取
* @return int 写入作者条数
*/
public function syncOneRefer($pReferId, $pArticleId, array $refer = [])
{
$pReferId = intval($pReferId);
$pArticleId = intval($pArticleId);
if ($pReferId <= 0 || $pArticleId <= 0) {
return 0;
}
if (empty($refer)) {
$refer = Db::name('production_article_refer')
->where('p_refer_id', $pReferId)
->where('p_article_id', $pArticleId)
->where('state', 0)
->find();
if (empty($refer)) {
return 0;
}
}
$refUtil = new ReferenceCheckService();
$doi = $refUtil->extractDoiFromRefer($refer);
$count = 0;
if ($doi !== '') {
$summary = $this->crossref->fetchWorkSummary($doi);
if ($summary === null || empty($summary['doi'])) {
$summary = ['doi' => $doi, 'raw' => []];
}
$count = $this->syncFromWorkSummary($pReferId, $pArticleId, $doi, $summary);
}
if ($count <= 0) {
$count = $this->syncFromReferAuthorField(
$pReferId,
$pArticleId,
(string)($refer['author'] ?? '')
);
}
return $count;
}
/**
* 按篇批量同步参考文献作者明细(有 DOI 则 Crossref + OpenAlex
*
* @return array{total:int,synced:int,authors:int,skipped_no_doi:int,failed:int,errors:array}
*/
public function syncByPArticleId($pArticleId, array $options = [])
{
$pArticleId = intval($pArticleId);
if ($pArticleId <= 0) {
throw new \InvalidArgumentException('p_article_id is required');
}
$sleepMs = max(0, intval($options['sleep_ms'] ?? 100));
$refUtil = new ReferenceCheckService();
$refers = Db::name('production_article_refer')
->field('p_refer_id,p_article_id,index,refer_doi,doilink,refer_content,refer_frag,author')
->where('p_article_id', $pArticleId)
->where('state', 0)
->order('index asc')
->select();
$result = [
'p_article_id' => $pArticleId,
'total' => count($refers),
'synced' => 0,
'authors' => 0,
'skipped_no_doi' => 0,
'failed' => 0,
'errors' => [],
];
foreach ($refers as $refer) {
$pReferId = intval($refer['p_refer_id']);
$doi = $refUtil->extractDoiFromRefer($refer);
if ($doi === '') {
$result['skipped_no_doi']++;
continue;
}
try {
$summary = $this->crossref->fetchWorkSummary($doi);
if ($summary === null || empty($summary['doi'])) {
$summary = ['doi' => $doi, 'raw' => []];
}
$count = $this->syncFromWorkSummary($pReferId, $pArticleId, $doi, $summary);
if ($count <= 0) {
$count = $this->syncFromReferAuthorField(
$pReferId,
$pArticleId,
(string)($refer['author'] ?? '')
);
}
if ($count > 0) {
$result['synced']++;
$result['authors'] += $count;
} else {
$result['failed']++;
$result['errors'][] = [
'p_refer_id' => $pReferId,
'reference_no' => intval($refer['index']) + 1,
'doi' => $doi,
'msg' => 'no author rows from Crossref/OpenAlex/refer.author',
];
}
} catch (\Throwable $e) {
$result['failed']++;
$result['errors'][] = [
'p_refer_id' => $pReferId,
'reference_no' => intval($refer['index']) + 1,
'doi' => $doi,
'msg' => $e->getMessage(),
];
}
if ($sleepMs > 0) {
usleep($sleepMs * 1000);
}
}
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 结构兼容)
*
* @return array<int, array{openalex_id:string,orcid:string,display_name:string,author_position:string,is_corresponding:bool,identity_keys:string[]}>
*/
public function loadAuthorshipsByPReferId($pReferId)
{
$pReferId = intval($pReferId);
if ($pReferId <= 0) {
return [];
}
$rows = Db::name('production_article_refer_author')
->where('p_refer_id', $pReferId)
->order('author_seq asc, id asc')
->select();
$list = [];
foreach ($rows as $row) {
$openalexId = trim((string)($row['openalex_id'] ?? ''));
$orcid = trim((string)($row['orcid'] ?? ''));
$identityKeys = [];
if ($openalexId !== '') {
$identityKeys[] = 'openalex:' . $openalexId;
}
if ($orcid !== '') {
$identityKeys[] = 'orcid:' . $orcid;
}
if (empty($identityKeys)) {
continue;
}
$position = trim((string)($row['author_position'] ?? ''));
if ($position === '' && intval($row['is_first_author'] ?? 0) === 1) {
$position = 'first';
}
$list[] = [
'openalex_id' => $openalexId,
'orcid' => $orcid,
'display_name' => trim((string)($row['display_name'] ?? '')),
'author_position' => $position,
'is_corresponding' => false,
'identity_keys' => $identityKeys,
];
}
return $list;
}
/**
* @return array<int, array>
*/
private function buildAuthorRows($doi, array $summary)
{
$doi = trim((string)$doi);
$raw = is_array($summary['raw'] ?? null) ? $summary['raw'] : [];
$crossrefAuthors = $this->parseCrossrefAuthorList($raw['author'] ?? []);
$openalexList = $doi !== '' ? $this->fetchOpenAlexAuthorships($doi) : [];
$max = max(count($crossrefAuthors), count($openalexList));
if ($max <= 0) {
return [];
}
$rows = [];
for ($i = 0; $i < $max; $i++) {
$cr = $crossrefAuthors[$i] ?? null;
$oa = $openalexList[$i] ?? null;
if (is_array($oa) && is_array($cr) && trim((string)($cr['orcid'] ?? '')) !== '' && trim((string)($oa['orcid'] ?? '')) === '') {
$oa['orcid'] = trim((string)$cr['orcid']);
}
if (is_array($oa) && is_array($cr) && trim((string)($oa['display_name'] ?? '')) === '' && trim((string)($cr['display_name'] ?? '')) !== '') {
$oa['display_name'] = trim((string)$cr['display_name']);
}
$row = $this->mergeOneAuthorRow($i, $cr, $oa);
if ($row !== null) {
$rows[] = $row;
}
}
return $rows;
}
private function mergeOneAuthorRow($seq, $crossrefAuthor, $openalexAuthor)
{
$family = '';
$given = '';
$displayName = '';
$citationName = '';
$orcid = '';
$openalexId = '';
$position = '';
$sources = [];
if (is_array($crossrefAuthor)) {
$family = trim((string)($crossrefAuthor['family'] ?? ''));
$given = trim((string)($crossrefAuthor['given'] ?? ''));
$displayName = trim((string)($crossrefAuthor['display_name'] ?? ''));
$citationName = trim((string)($crossrefAuthor['citation_name'] ?? ''));
$orcid = trim((string)($crossrefAuthor['orcid'] ?? ''));
$position = trim((string)($crossrefAuthor['author_position'] ?? ''));
$sources[] = 'crossref';
}
if (is_array($openalexAuthor)) {
$openalexId = trim((string)($openalexAuthor['openalex_id'] ?? ''));
if (trim((string)($openalexAuthor['orcid'] ?? '')) !== '') {
$orcid = trim((string)$openalexAuthor['orcid']);
}
if (trim((string)($openalexAuthor['display_name'] ?? '')) !== '') {
$displayName = trim((string)$openalexAuthor['display_name']);
}
if (trim((string)($openalexAuthor['author_position'] ?? '')) !== '') {
$position = trim((string)$openalexAuthor['author_position']);
}
$sources[] = 'openalex';
}
if ($displayName === '' && ($family !== '' || $given !== '')) {
$displayName = trim($given . ' ' . $family);
}
if ($citationName === '' && is_array($crossrefAuthor) && !empty($crossrefAuthor['raw_author'])) {
$citationName = $this->crossref->getAuthorsCitation(['author' => [$crossrefAuthor['raw_author']]], 1);
}
if ($citationName === '' && $displayName !== '') {
$citationName = $displayName;
}
if ($displayName === '' && $citationName === '' && $orcid === '' && $openalexId === '') {
return null;
}
$sources = array_values(array_unique($sources));
$isFirst = ($position === 'first') || ($seq === 0 && $position === '');
return [
'author_seq' => intval($seq),
'author_position' => $position,
'is_first_author' => $isFirst ? 1 : 0,
'family' => $family,
'given' => $given,
'display_name' => $displayName,
'citation_name' => $citationName,
'orcid' => $orcid,
'openalex_id' => $openalexId,
'identity_source' => implode('+', $sources),
];
}
/**
* @return array<int, array>
*/
private function parseCrossrefAuthorList($authorList)
{
if (empty($authorList) || !is_array($authorList)) {
return [];
}
$parsed = $this->bgCheck->parseCrossRefAuthors($authorList);
$rows = [];
foreach ($parsed as $i => $item) {
$rawAuthor = $authorList[$i] ?? [];
if (!is_array($rawAuthor)) {
$rawAuthor = [];
}
$family = trim((string)($item['family'] ?? ''));
$given = trim((string)($item['given'] ?? ''));
$displayName = trim((string)($item['name'] ?? ''));
$position = trim((string)($rawAuthor['sequence'] ?? ''));
if ($position === '' && $i === 0) {
$position = 'first';
}
$rows[] = [
'family' => $family,
'given' => $given,
'display_name' => $displayName,
'orcid' => trim((string)($item['orcid'] ?? '')),
'author_position' => $position,
'citation_name' => $this->crossref->getAuthorsCitation(['author' => [$rawAuthor]], 1),
'raw_author' => $rawAuthor,
];
}
return $rows;
}
/**
* @return array<int, array{openalex_id:string,orcid:string,display_name:string,author_position:string}>
*/
private function fetchOpenAlexAuthorships($doi)
{
$res = $this->bgCheck->fetchOpenAlexWorkByDoi($doi);
if (empty($res['success']) || empty($res['work']) || !is_array($res['work'])) {
return [];
}
$list = [];
foreach ($res['work']['authorships'] ?? [] as $auth) {
if (!is_array($auth)) {
continue;
}
$author = is_array($auth['author'] ?? null) ? $auth['author'] : [];
$openalexId = $this->bgCheck->extractOpenAlexId($author['id'] ?? '');
$orcid = $this->bgCheck->cleanOrcid($author['orcid'] ?? '');
$displayName = trim((string)($author['display_name'] ?? ''));
if ($openalexId === '' && $orcid === '' && $displayName === '') {
continue;
}
$list[] = [
'openalex_id' => $openalexId,
'orcid' => $orcid,
'display_name' => $displayName,
'author_position' => (string)($auth['author_position'] ?? ''),
'is_corresponding' => !empty($auth['is_corresponding']),
];
}
return $list;
}
/**
* @return array<int, array{author_seq:int,author_position:string,is_first_author:int,display_name:string,citation_name:string}>
*/
private function parseReferAuthorString($authorString)
{
$authorString = trim((string)$authorString);
if ($authorString === '') {
return [];
}
$authorString = preg_replace('/\s+et\s+al\.?\s*$/iu', '', $authorString);
$parts = preg_split('/\s*,\s*/u', $authorString);
if (!is_array($parts)) {
return [];
}
$rows = [];
foreach ($parts as $i => $part) {
$name = trim((string)$part);
if ($name === '' || preg_match('/^et\s+al\.?$/iu', $name)) {
continue;
}
$seq = count($rows);
$rows[] = [
'author_seq' => $seq,
'author_position' => $seq === 0 ? 'first' : 'additional',
'is_first_author' => $seq === 0 ? 1 : 0,
'display_name' => $name,
'citation_name' => $name,
];
}
return $rows;
}
private function clipField($value, $maxLen)
{
$value = trim((string)$value);
if ($value === '' || $maxLen <= 0) {
return $value;
}
if (mb_strlen($value) <= $maxLen) {
return $value;
}
return mb_substr($value, 0, $maxLen);
}
}