Files
tougao/application/api/controller/BackgroundCheck.php
2026-06-04 13:33:13 +08:00

387 lines
14 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\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,
];
}
}