This commit is contained in:
wyn
2026-06-05 11:20:26 +08:00
369 changed files with 3463 additions and 3177 deletions

View File

@@ -0,0 +1,386 @@
<?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,
];
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace app\api\controller;
use think\Db;
use app\common\ExpertFieldAiService;
/**
* Expert 领域总结(方案 C - 阶段1邮箱关联 user.field_ai
*
* POST startLinkChain 启动链式队列,批量关联
* POST linkOne 同步关联单个 expert_id
* POST linkBatch 同步批量关联 expert_ids
* POST syncByUser user 有 field_ai 后,同步到同邮箱 expert
* GET preview 预览是否可关联
* GET statistics 统计 field_ai 覆盖情况
*/
class ExpertFieldAi extends Base
{
/**
* 启动链式关联队列
* Worker: php think queue:work --queue ExpertFieldAi
*/
public function startLinkChain()
{
$force = intval($this->request->param('force', 0)) === 1;
$delay = max(0, intval($this->request->param('delay', 1)));
$svc = new ExpertFieldAiService();
$started = $svc->startLinkChain($force, $delay);
return jsonSuccess([
'started' => $started,
'queue' => ExpertFieldAiService::QUEUE_NAME,
'force' => $force,
'msg' => $started ? 'link chain enqueued' : 'no pending experts',
]);
}
/**
* 同步关联单个 expert
*/
public function linkOne()
{
$expertId = intval($this->request->param('expert_id', 0));
$force = intval($this->request->param('force', 0)) === 1;
if ($expertId <= 0) {
return jsonError('expert_id required');
}
$svc = new ExpertFieldAiService();
$result = $svc->linkFromUser($expertId, $force);
if (empty($result['ok'])) {
return jsonError(isset($result['error']) ? $result['error'] : 'failed');
}
return jsonSuccess($result);
}
/**
* 同步批量关联
* expert_ids: 逗号分隔,或传 limit 扫描待处理前 N 条
*/
public function linkBatch()
{
$force = intval($this->request->param('force', 0)) === 1;
$idsRaw = trim((string)$this->request->param('expert_ids', ''));
$limit = min(max(intval($this->request->param('limit', 0)), 0), 200);
$ids = [];
if ($idsRaw !== '') {
$ids = array_filter(array_map('intval', explode(',', $idsRaw)));
} elseif ($limit > 0) {
$ids = Db::name('expert')
->where('state', '<>', 5)
->where(function ($q) {
$q->where('field_ai_status', ExpertFieldAiService::STATUS_PENDING)
->whereOr('field_ai_status', ExpertFieldAiService::STATUS_FAILED);
})
->order('expert_id asc')
->limit($limit)
->column('expert_id');
}
if (empty($ids)) {
return jsonError('expert_ids 或 limit 必填');
}
$svc = new ExpertFieldAiService();
$result = $svc->batchLinkFromUser($ids, $force);
return jsonSuccess($result);
}
/**
* user 更新 field_ai 后,同步到同邮箱 expert
*/
public function syncByUser()
{
$userId = intval($this->request->param('user_id', 0));
$force = intval($this->request->param('force', 0)) === 1;
if ($userId <= 0) {
return jsonError('user_id required');
}
$svc = new ExpertFieldAiService();
$result = $svc->syncExpertsByUserId($userId, $force);
if (empty($result['ok'])) {
return jsonError(isset($result['error']) ? $result['error'] : 'failed');
}
return jsonSuccess($result);
}
/**
* 预览是否可关联
*/
public function preview()
{
$expertId = intval($this->request->param('expert_id', 0));
if ($expertId <= 0) {
return jsonError('expert_id required');
}
$svc = new ExpertFieldAiService();
$result = $svc->previewLink($expertId);
if (empty($result['ok'])) {
return jsonError(isset($result['error']) ? $result['error'] : 'failed');
}
$result['field_ai_status_text'] = $svc->statusLabel(intval($result['expert_field_ai_status']));
return jsonSuccess($result);
}
/**
* 统计 field_ai 覆盖
*/
public function statistics()
{
$total = Db::name('expert')->where('state', '<>', 5)->count();
$done = Db::name('expert')->where('state', '<>', 5)->where('field_ai_status', ExpertFieldAiService::STATUS_DONE)->count();
$userLink = Db::name('expert')
->where('state', '<>', 5)
->where('field_ai_source', ExpertFieldAiService::SOURCE_USER_LINK)
->count();
$noUserLink = Db::name('expert')
->where('state', '<>', 5)
->where('field_ai_status', ExpertFieldAiService::STATUS_NO_USER_LINK)
->count();
$pending = Db::name('expert')
->where('state', '<>', 5)
->where('field_ai_status', ExpertFieldAiService::STATUS_PENDING)
->count();
return jsonSuccess([
'total' => $total,
'done' => $done,
'user_link' => $userLink,
'no_user_link' => $noUserLink,
'pending' => $pending,
'coverage_rate' => $total > 0 ? round($done / $total * 100, 2) . '%' : '0%',
]);
}
}

View File

@@ -44,17 +44,22 @@ class ExpertManage extends Base
$query = Db::name('expert')->alias('e');
$countQuery = Db::name('expert')->alias('e');
$needJoin = ($field !== '');
if ($needJoin) {
$query->join('t_expert_field ef', 'ef.expert_id = e.expert_id AND ef.state = 0', 'inner');
$countQuery->join('t_expert_field ef', 'ef.expert_id = e.expert_id AND ef.state = 0', 'inner');
if ($field !== '') {
$query->where('ef.field', 'like', '%' . $field . '%');
$countQuery->where('ef.field', 'like', '%' . $field . '%');
}
$query->group('e.expert_id');
$countQuery->group('e.expert_id');
if ($field !== '') {
$fieldExpertIds = Db::name('expert_field')
->where('state', 0)
->where('field', 'like', '%' . $field . '%')
->column('expert_id');
$fieldExpertIds = array_values(array_unique(array_filter(array_map('intval', $fieldExpertIds))));
$fieldWhere = function ($q) use ($field, $fieldExpertIds) {
$q->where('e.field_ai', 'like', '%' . $field . '%');
if (!empty($fieldExpertIds)) {
$q->whereOr('e.expert_id', 'in', $fieldExpertIds);
}
};
$query->where($fieldWhere);
$countQuery->where($fieldWhere);
}
if ($state !== '-1' && $state !== '') {
@@ -62,8 +67,8 @@ class ExpertManage extends Base
$countQuery->where('e.state', intval($state));
}
if ($keyword !== '') {
$query->where('e.name|e.email|e.affiliation', 'like', '%' . $keyword . '%');
$countQuery->where('e.name|e.email|e.affiliation', 'like', '%' . $keyword . '%');
$query->where('e.name|e.email|e.affiliation|e.field_ai', 'like', '%' . $keyword . '%');
$countQuery->where('e.name|e.email|e.affiliation|e.field_ai', 'like', '%' . $keyword . '%');
}
if ($source !== '') {
$query->where('e.source', $source);
@@ -72,7 +77,7 @@ class ExpertManage extends Base
// $countQuery = clone $query;
// $total = $countQuery->distinct('e.expert_id')->count();
$total = $needJoin ? count($countQuery->group('e.expert_id')->column('e.expert_id')) : $countQuery->count();
$total = $countQuery->count();
$list = $query
->field('e.*')

View File

@@ -2269,7 +2269,8 @@ class Reviewer extends Base
$where['t_user.email'] = ['like',"%" . $data["email"] . "%"];
}
if(isset($data['field'])&&$data['field']!=''){
$where['t_user_reviewer_info.field'] = ['like',"%" . $data["field"] . "%"];
// field 参数同时匹配原始 field 与 AI 总结 field_ai
$where['t_user_reviewer_info.field|t_user_reviewer_info.field_ai'] = ['like',"%" . $data["field"] . "%"];
}
if (isset($data['major_id'])&&$data['major_id']!=0){
$where['t_user_reviewer_info.major'] = ['in',$this->majorids($data['major_id'])];
@@ -2306,7 +2307,7 @@ class Reviewer extends Base
$list = $this->reviewer_to_journal_obj
->join("t_user", "t_user.user_id = t_reviewer_to_journal.reviewer_id", "left")
->join("t_user_reviewer_info", "t_user_reviewer_info.reviewer_id = t_reviewer_to_journal.reviewer_id", "left")
->field('t_user.account,t_user.email,t_user.realname,t_user_reviewer_info.company,t_user_reviewer_info.field,t_user_reviewer_info.last_invite_time,t_user.user_id,t_user.rs_num')
->field('t_user.account,t_user.email,t_user.realname,t_user_reviewer_info.company,t_user_reviewer_info.field,t_user_reviewer_info.field_ai,t_user_reviewer_info.last_invite_time,t_user.user_id,t_user.rs_num')
->where($where)->where(function($query) use ($iTeenDaysLater) {
$query->where('t_user_reviewer_info.last_invite_time', '<', $iTeenDaysLater)
->whereOr('t_user_reviewer_info.last_invite_time', '=', 0);

View File

@@ -0,0 +1,38 @@
<?php
namespace app\api\job;
use think\queue\Job;
use app\common\ExpertFieldAiService;
/**
* Expert field_ai 链式任务阶段1邮箱关联 user.field_ai
*
* data:
* - expert_id
* - queue 队列名,默认 ExpertFieldAi
* - force 1=强制重算
* - mode link默认
*
* Worker: php think queue:work --queue ExpertFieldAi
*/
class ExpertFieldAiFill
{
public function fire(Job $job, $data)
{
$expertId = isset($data['expert_id']) ? intval($data['expert_id']) : 0;
$queue = isset($data['queue']) ? (string)$data['queue'] : ExpertFieldAiService::QUEUE_NAME;
$force = !empty($data['force']);
$mode = isset($data['mode']) ? (string)$data['mode'] : 'link';
$svc = new ExpertFieldAiService();
if ($expertId > 0 && $mode === 'link') {
$svc->linkFromUser($expertId, $force);
}
$job->delete();
$delay = max(0, (int)(isset($data['delay']) ? $data['delay'] : 1));
$svc->enqueueNextLink($delay, $queue, $expertId, $force);
}
}

View File

@@ -30,6 +30,6 @@ class UserFieldAiFill
$job->delete();
$delay = max(0, (int) (isset($data['delay']) ? $data['delay'] : 1));
// $svc->enqueueNextFieldAi($delay, $queue, $userId, $force);
$svc->enqueueNextFieldAi($delay, $queue, $userId, $force);
}
}