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 */ 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 */ 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 */ 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 */ 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 */ 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); } }