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, ]; } }