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

View File

@@ -0,0 +1,705 @@
<?php
namespace app\common;
/**
* 背景调查公共服务
* 封装 OpenAlex / CrossRef / Retraction Watch 数据查询
*/
class BackgroundCheckService
{
private $openAlexBase = 'https://api.openalex.org';
private $crossRefBase = 'https://api.crossref.org';
private $mailto = 'publisher@tmrjournals.com';
// ===================== OpenAlex =====================
public function openAlexGet($path, $query = [])
{
$query['mailto'] = $this->mailto;
$url = $this->openAlexBase . $path . '?' . http_build_query($query);
$result = $this->httpGet($url, [
'Accept: application/json',
'User-Agent: TMRJournals-BackgroundCheck/1.0 (mailto:' . $this->mailto . ')',
]);
if (!$result['success']) {
return $result;
}
$data = json_decode($result['body'], true);
if (!is_array($data)) {
return ['success' => false, 'error' => 'OpenAlex返回数据格式异常'];
}
return ['success' => true, 'data' => $data];
}
public function resolveAuthor($params)
{
if (!empty($params['openalex_id'])) {
$id = preg_replace('/^https?:\/\/openalex\.org\//', '', $params['openalex_id']);
$res = $this->openAlexGet('/authors/' . urlencode($id));
if (!$res['success']) {
return ['success' => false, 'error' => $res['error']];
}
return ['success' => true, 'data' => $res['data']];
}
if (!empty($params['orcid'])) {
$orcid = $this->cleanOrcid($params['orcid']);
$res = $this->openAlexGet('/authors/https://orcid.org/' . $orcid);
if (!$res['success']) {
return ['success' => false, 'error' => '未在 OpenAlex 找到该 ORCID 对应学者'];
}
return ['success' => true, 'data' => $res['data']];
}
if (empty($params['name'])) {
return ['success' => false, 'error' => '请提供 openalex_id、orcid 或 name'];
}
$filter = 'display_name.search:' . $params['name'];
if (!empty($params['affiliation'])) {
$filter .= ',last_known_institutions.display_name.search:' . $params['affiliation'];
}
$res = $this->openAlexGet('/authors', [
'search' => $params['name'],
'filter' => $filter,
'sort' => 'cited_by_count:desc',
'per-page' => 1,
]);
if (!$res['success']) {
return ['success' => false, 'error' => $res['error']];
}
$results = $res['data']['results'] ?? [];
if (empty($results)) {
return ['success' => false, 'error' => '未找到匹配学者,请补充 affiliation 或使用 orcid'];
}
return ['success' => true, 'data' => $results[0]];
}
public function fetchRetractedWorksOpenAlex($openAlexId)
{
$res = $this->openAlexGet('/works', [
'filter' => 'authorships.author.id:' . $openAlexId . ',is_retracted:true',
'sort' => 'publication_date:desc',
'per-page' => 25,
]);
if (!$res['success']) {
return ['count' => 0, 'list' => [], 'error' => $res['error']];
}
$list = [];
foreach ($res['data']['results'] ?? [] as $work) {
$list[] = $this->formatOpenAlexWork($work);
}
return ['count' => count($list), 'list' => $list, 'source' => 'openalex'];
}
public function fetchRecentWorks($openAlexId, $limit = 5)
{
$res = $this->openAlexGet('/works', [
'filter' => 'authorships.author.id:' . $openAlexId,
'sort' => 'publication_date:desc',
'per-page' => $limit,
]);
if (!$res['success']) {
return [];
}
$list = [];
foreach ($res['data']['results'] ?? [] as $work) {
$item = $this->formatOpenAlexWork($work);
$item['is_retracted'] = !empty($work['is_retracted']);
$list[] = $item;
}
return $list;
}
/**
* 按领域/关键词批量搜索学者OpenAlex
*/
public function searchAuthorsByField($keyword, $options = [])
{
$minHIndex = intval($options['min_h_index'] ?? 5);
$limit = min(max(intval($options['limit'] ?? 10), 1), 30);
$page = max(intval($options['page'] ?? 1), 1);
$topicId = $this->resolveTopicId($keyword);
$filters = [];
if ($topicId !== '') {
$filters[] = 'topics.id:' . $topicId;
}
if ($minHIndex > 0) {
$filters[] = 'summary_stats.h_index:>' . $minHIndex;
}
$query = [
'sort' => 'cited_by_count:desc',
'per-page' => $limit,
'page' => $page,
];
if (!empty($filters)) {
$query['filter'] = implode(',', $filters);
$query['search'] = $keyword;
} else {
$query['search'] = $keyword;
}
$res = $this->openAlexGet('/authors', $query);
if (!$res['success']) {
return ['success' => false, 'error' => $res['error']];
}
$authors = [];
foreach ($res['data']['results'] ?? [] as $author) {
$authors[] = $this->formatAuthorBrief($author);
}
return [
'success' => true,
'data' => [
'keyword' => $keyword,
'topic_id' => $topicId,
'page' => $page,
'limit' => $limit,
'total' => $res['data']['meta']['count'] ?? count($authors),
'list' => $authors,
],
];
}
private function resolveTopicId($keyword)
{
$res = $this->openAlexGet('/topics', [
'search' => $keyword,
'sort' => 'works_count:desc',
'per-page' => 1,
]);
if (!$res['success']) {
return '';
}
$results = $res['data']['results'] ?? [];
if (empty($results)) {
return '';
}
return $this->extractOpenAlexId($results[0]['id'] ?? '');
}
// ===================== CrossRef =====================
public function cleanDoi($doi)
{
$doi = trim($doi);
$doi = preg_replace('/^https?:\/\/doi\.org\//', '', $doi);
$doi = preg_replace('/^doi:\s*/i', '', $doi);
return trim($doi);
}
public function fetchCrossRefWork($doi)
{
$doi = $this->cleanDoi($doi);
if ($doi === '') {
return ['success' => false, 'error' => 'DOI为空'];
}
$url = $this->crossRefBase . '/works/' . urlencode($doi);
$result = $this->httpGet($url, [
'Accept: application/json',
'User-Agent: TMRJournals-BackgroundCheck/1.0 (mailto:' . $this->mailto . ')',
]);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error']];
}
if ($result['http_code'] == 404) {
return ['success' => false, 'error' => 'DOI在CrossRef中未找到'];
}
if ($result['http_code'] != 200) {
return ['success' => false, 'error' => 'CrossRef返回 HTTP ' . $result['http_code']];
}
$data = json_decode($result['body'], true);
if (!isset($data['message'])) {
return ['success' => false, 'error' => 'CrossRef返回数据格式异常'];
}
return ['success' => true, 'message' => $data['message']];
}
public function parseCrossRefRetractionDetail($doi, $message)
{
$retraction = $this->detectCrossRefRetraction($message);
return [
'doi' => $this->cleanDoi($doi),
'title' => isset($message['title'][0]) ? $message['title'][0] : '',
'is_retracted' => $retraction['is_retracted'],
'retraction_detail' => $retraction['retraction_detail'],
'journal' => isset($message['container-title'][0]) ? $message['container-title'][0] : '',
'publisher' => $message['publisher'] ?? '',
'published_date' => isset($message['published-print']) ? $this->parseDateParts($message['published-print']) : '',
'authors' => $this->parseCrossRefAuthors($message['author'] ?? []),
'url' => $message['URL'] ?? ('https://doi.org/' . $this->cleanDoi($doi)),
];
}
public function enrichRetractionsWithCrossRef($retractionList)
{
$enriched = [];
foreach ($retractionList as $item) {
$doi = $this->cleanDoi($item['doi'] ?? '');
if ($doi === '') {
$item['crossref'] = ['success' => false, 'error' => '无DOI'];
$enriched[] = $item;
continue;
}
$res = $this->fetchCrossRefWork($doi);
if (!$res['success']) {
$item['crossref'] = ['success' => false, 'error' => $res['error']];
} else {
$item['crossref'] = [
'success' => true,
'data' => $this->parseCrossRefRetractionDetail($doi, $res['message']),
];
}
$enriched[] = $item;
usleep(200000);
}
return $enriched;
}
private function detectCrossRefRetraction($message)
{
$isRetracted = false;
$retractionDetail = [
'sources' => [],
'retraction_notices' => [],
'record_ids' => [],
];
foreach (['updated-by', 'update-to'] as $field) {
if (!isset($message[$field]) || !is_array($message[$field])) {
continue;
}
foreach ($message[$field] as $update) {
$updateType = strtolower($update['type'] ?? '');
$updateLabel = strtolower($update['label'] ?? '');
if (strpos($updateType, 'retract') === false && strpos($updateLabel, 'retract') === false) {
continue;
}
$isRetracted = true;
$source = $update['source'] ?? 'publisher';
$retractionDetail['sources'][] = $source;
$notice = [
'type' => $update['type'] ?? '',
'label' => $update['label'] ?? '',
'source' => $source,
'notice_doi'=> $update['DOI'] ?? '',
'date' => isset($update['updated']) ? $this->parseDateParts($update['updated']) : '',
'record_id' => $update['record-id'] ?? '',
];
$retractionDetail['retraction_notices'][] = $notice;
if (!empty($notice['record_id'])) {
$retractionDetail['record_ids'][] = $notice['record_id'];
}
}
}
$type = strtolower($message['type'] ?? '');
$subtype = strtolower($message['subtype'] ?? '');
if (strpos($type, 'retract') !== false || strpos($subtype, 'retract') !== false) {
$isRetracted = true;
$retractionDetail['is_retraction_notice'] = true;
}
if (isset($message['relation']) && is_array($message['relation'])) {
foreach ($message['relation'] as $relType => $relations) {
if (strpos(strtolower($relType), 'retract') !== false) {
$isRetracted = true;
$retractionDetail['relation'] = [$relType => $relations];
break;
}
}
}
$retractionDetail['sources'] = array_values(array_unique($retractionDetail['sources']));
$retractionDetail['record_ids'] = array_values(array_unique($retractionDetail['record_ids']));
return ['is_retracted' => $isRetracted, 'retraction_detail' => $retractionDetail];
}
// ===================== Retraction Watch (via CrossRef) =====================
/**
* 通过 CrossRef 检索 Retraction Watch 来源的撤稿记录(按作者姓名)
*/
public function fetchRetractionWatchByAuthor($authorName)
{
$url = $this->crossRefBase . '/works?' . http_build_query([
'query.author' => $authorName,
'filter' => 'update-type:retraction',
'rows' => 25,
'mailto' => $this->mailto,
]);
$result = $this->httpGet($url, [
'Accept: application/json',
'User-Agent: TMRJournals-BackgroundCheck/1.0 (mailto:' . $this->mailto . ')',
]);
if (!$result['success']) {
return ['count' => 0, 'list' => [], 'error' => $result['error']];
}
if ($result['http_code'] != 200) {
return ['count' => 0, 'list' => [], 'error' => 'CrossRef返回 HTTP ' . $result['http_code']];
}
$data = json_decode($result['body'], true);
$items = $data['message']['items'] ?? [];
$list = [];
foreach ($items as $message) {
$parsed = $this->parseCrossRefRetractionDetail($message['DOI'] ?? '', $message);
if (!$parsed['is_retracted']) {
continue;
}
$rwSources = array_filter($parsed['retraction_detail']['sources'] ?? [], function ($s) {
return stripos($s, 'retraction-watch') !== false || stripos($s, 'retraction_watch') !== false;
});
$list[] = [
'title' => $parsed['title'],
'doi' => $parsed['doi'],
'journal' => $parsed['journal'],
'publisher' => $parsed['publisher'],
'published_date' => $parsed['published_date'],
'is_retracted' => true,
'retraction_detail' => $parsed['retraction_detail'],
'from_retraction_watch' => !empty($rwSources) || !empty($parsed['retraction_detail']['record_ids']),
'source' => 'retraction_watch',
];
}
return [
'count' => count($list),
'list' => $list,
'source' => 'retraction_watch',
];
}
/**
* 合并 OpenAlex + Retraction Watch 撤稿记录(按 DOI 去重)
*/
public function mergeRetractionRecords($openAlexRetractions, $rwRetractions, $withCrossRefDetail = false)
{
$merged = [];
$doiMap = [];
foreach ([$openAlexRetractions, $rwRetractions] as $sourceData) {
foreach ($sourceData['list'] ?? [] as $item) {
$doi = $this->cleanDoi($item['doi'] ?? '');
$key = $doi !== '' ? strtolower($doi) : md5(json_encode($item));
if (!isset($doiMap[$key])) {
$doiMap[$key] = [
'title' => $item['title'] ?? '',
'doi' => $doi,
'journal' => $item['journal'] ?? '',
'publication_date' => $item['publication_date'] ?? ($item['published_date'] ?? ''),
'sources' => [],
'retraction_detail'=> $item['retraction_detail'] ?? [],
'from_retraction_watch' => !empty($item['from_retraction_watch']),
];
}
$src = $item['source'] ?? 'unknown';
if (!in_array($src, $doiMap[$key]['sources'])) {
$doiMap[$key]['sources'][] = $src;
}
if (!empty($item['from_retraction_watch'])) {
$doiMap[$key]['from_retraction_watch'] = true;
}
if (!empty($item['retraction_detail']) && empty($doiMap[$key]['retraction_detail'])) {
$doiMap[$key]['retraction_detail'] = $item['retraction_detail'];
}
}
}
$merged = array_values($doiMap);
if ($withCrossRefDetail) {
$merged = $this->enrichRetractionsWithCrossRef($merged);
}
$rwOnlyCount = 0;
foreach ($merged as $row) {
if (!empty($row['from_retraction_watch']) && count($row['sources'] ?? []) <= 1) {
$rwOnlyCount++;
}
}
return [
'count' => count($merged),
'openalex_count' => intval($openAlexRetractions['count'] ?? 0),
'rw_count' => intval($rwRetractions['count'] ?? 0),
'rw_only_count' => $rwOnlyCount,
'list' => $merged,
];
}
// ===================== 格式化 =====================
public function formatAuthorBrief($author)
{
$institutions = [];
foreach ($author['last_known_institutions'] ?? [] as $inst) {
$institutions[] = [
'name' => $inst['display_name'] ?? '',
'country' => $inst['country_code'] ?? '',
];
}
return [
'openalex_id' => $this->extractOpenAlexId($author['id'] ?? ''),
'name' => $author['display_name'] ?? '',
'orcid' => $this->extractOrcid($author['orcid'] ?? ''),
'works_count' => intval($author['works_count'] ?? 0),
'cited_by_count' => intval($author['cited_by_count'] ?? 0),
'h_index' => intval($author['summary_stats']['h_index'] ?? 0),
'institutions' => $institutions,
'openalex_url' => $author['id'] ?? '',
];
}
public function parseAuthorMetrics($author)
{
$stats = $author['summary_stats'] ?? [];
return [
'works_count' => intval($author['works_count'] ?? 0),
'cited_by_count' => intval($author['cited_by_count'] ?? 0),
'h_index' => intval($stats['h_index'] ?? 0),
'i10_index' => intval($stats['i10_index'] ?? 0),
'two_year_mean_cited' => round(floatval($stats['2yr_mean_citedness'] ?? 0), 2),
'level_label' => $this->getAcademicLevelLabel($stats),
];
}
public function parseResearchTopics($author)
{
$topics = [];
foreach ($author['x_concepts'] ?? [] as $concept) {
if (empty($concept['display_name'])) {
continue;
}
$topics[] = [
'name' => $concept['display_name'],
'score' => round(floatval($concept['score'] ?? 0), 3),
];
}
if (empty($topics)) {
foreach ($author['topics'] ?? [] as $topic) {
if (empty($topic['display_name'])) {
continue;
}
$topics[] = [
'name' => $topic['display_name'],
'score' => round(floatval($topic['score'] ?? 0), 3),
];
}
}
return array_slice($topics, 0, 8);
}
public function assessRisk($metrics, $retractions)
{
$retractionCount = intval($retractions['count'] ?? 0);
$rwOnlyCount = intval($retractions['rw_only_count'] ?? 0);
$level = 'low';
$score = 0;
$reasons = [];
if ($retractionCount === 0) {
$level = 'low';
$score = 10;
$reasons[] = 'OpenAlex 与 Retraction Watch 均未发现撤稿记录';
} elseif ($retractionCount === 1) {
$level = 'medium';
$score = 50;
$reasons[] = '发现 1 篇撤稿论文,建议人工核实撤稿原因';
} else {
$level = 'high';
$score = 80 + min($retractionCount * 5, 20);
$reasons[] = '发现 ' . $retractionCount . ' 篇撤稿论文,存在较高学术风险';
}
if ($rwOnlyCount > 0) {
$reasons[] = 'Retraction Watch 额外发现 ' . $rwOnlyCount . ' 条 OpenAlex 未收录的撤稿记录';
if ($level === 'low') {
$level = 'medium';
$score = max($score, 45);
}
}
$worksCount = max(intval($metrics['works_count'] ?? 0), 1);
$retractionRate = round($retractionCount / $worksCount * 100, 2);
if ($retractionCount > 0 && $retractionRate >= 5) {
$reasons[] = '撤稿率 ' . $retractionRate . '%,比例偏高';
if ($level === 'medium') {
$level = 'high';
$score = max($score, 70);
}
}
return [
'level' => $level,
'level_label' => $this->getRiskLevelLabel($level),
'score' => min($score, 100),
'retraction_count' => $retractionCount,
'retraction_rate' => $retractionRate . '%',
'rw_only_count' => $rwOnlyCount,
'reasons' => $reasons,
];
}
// ===================== 内部工具 =====================
private function formatOpenAlexWork($work)
{
return [
'title' => $work['display_name'] ?? '',
'doi' => $this->extractDoi($work),
'publication_date' => $work['publication_date'] ?? '',
'journal' => $work['primary_location']['source']['display_name'] ?? '',
'cited_by_count' => intval($work['cited_by_count'] ?? 0),
'openalex_url' => $work['id'] ?? '',
'source' => 'openalex',
];
}
private function parseCrossRefAuthors($authorList)
{
if (empty($authorList) || !is_array($authorList)) {
return [];
}
$result = [];
foreach ($authorList as $a) {
$result[] = [
'given' => $a['given'] ?? '',
'family' => $a['family'] ?? '',
'name' => isset($a['name']) ? $a['name'] : trim(($a['given'] ?? '') . ' ' . ($a['family'] ?? '')),
'orcid' => $a['ORCID'] ?? '',
];
}
return $result;
}
private function parseDateParts($dateObj)
{
if (!isset($dateObj['date-parts'][0])) {
return '';
}
$parts = $dateObj['date-parts'][0];
$y = isset($parts[0]) ? $parts[0] : '';
$m = isset($parts[1]) ? sprintf('%02d', $parts[1]) : '';
$d = isset($parts[2]) ? sprintf('%02d', $parts[2]) : '';
if ($y && $m && $d) {
return "{$y}-{$m}-{$d}";
}
if ($y && $m) {
return "{$y}-{$m}";
}
return (string)$y;
}
private function getAcademicLevelLabel($stats)
{
$h = intval($stats['h_index'] ?? 0);
if ($h >= 50) return '国际顶尖学者';
if ($h >= 30) return '资深专家';
if ($h >= 15) return '活跃研究者';
if ($h >= 5) return '青年学者';
if ($h > 0) return '初入领域';
return '暂无足够公开数据';
}
private function getRiskLevelLabel($level)
{
$map = ['low' => '低风险', 'medium' => '中风险', 'high' => '高风险'];
return $map[$level] ?? '未知';
}
public function extractOpenAlexId($id)
{
return preg_replace('/^https?:\/\/openalex\.org\//', '', $id);
}
public function extractOrcid($orcid)
{
if ($orcid === '') return '';
return preg_replace('/^https?:\/\/orcid\.org\//', '', $orcid);
}
public function cleanOrcid($orcid)
{
$orcid = trim($orcid);
$orcid = preg_replace('/^https?:\/\/orcid\.org\//', '', $orcid);
return trim($orcid);
}
private function extractDoi($work)
{
$doi = $work['doi'] ?? '';
return preg_replace('/^https?:\/\/doi\.org\//', '', $doi);
}
private function httpGet($url, $headers = [])
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$body = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
return ['success' => false, 'error' => 'HTTP请求失败: ' . $error];
}
curl_close($ch);
return ['success' => true, 'body' => $body, 'http_code' => $httpCode];
}
}

View File

@@ -0,0 +1,321 @@
<?php
namespace app\common;
use think\Db;
use think\Queue;
/**
* Expert 领域总结(方案 C
* 阶段1通过 email 关联 t_user / t_user_reviewer_info复用 user.field_ai
* 阶段2后续对 field_ai_status=4 的记录走 LLM 总结
*/
class ExpertFieldAiService
{
const QUEUE_NAME = 'ExpertFieldAi';
const STATUS_PENDING = 0;
const STATUS_DONE = 1;
const STATUS_INSUFFICIENT = 2;
const STATUS_FAILED = 3;
const STATUS_NO_USER_LINK = 4;
const SOURCE_USER_LINK = 'user_link';
const SOURCE_AI = 'ai';
private $logFile;
public function __construct()
{
$this->logFile = ROOT_PATH . 'runtime' . DS . 'expert_field_ai.log';
}
/**
* 启动链式关联(从 expert_id=0 之后找下一位待处理专家)。
*/
public function startLinkChain($force = false, $delay = 1, $queue = '')
{
return $this->enqueueNextLink($delay, $queue, 0, $force);
}
/**
* 链式:处理 expert_id > $afterExpertId 的下一位。
*/
public function enqueueNextLink($delay = 1, $queue = '', $afterExpertId = 0, $force = false)
{
if ($queue === '') {
$queue = self::QUEUE_NAME;
}
$afterExpertId = intval($afterExpertId);
$expertId = $this->findNextLinkExpertId($afterExpertId, $force);
if ($expertId <= 0) {
$this->log('[ExpertFieldAi] link chain finished after expert_id=' . $afterExpertId);
return false;
}
$data = [
'expert_id' => $expertId,
'queue' => $queue,
'force' => $force ? 1 : 0,
'mode' => 'link',
];
$jobClass = 'app\\api\\job\\ExpertFieldAiFill@fire';
if ($delay > 0) {
Queue::later($delay, $jobClass, $data, $queue);
} else {
Queue::push($jobClass, $data, $queue);
}
$this->log('[ExpertFieldAi] enqueued expert_id=' . $expertId . ' queue=' . $queue);
return true;
}
/**
* 单个 expert尝试从 user 邮箱关联 field_ai。
*
* @return array{ok:bool, linked?:bool, skipped?:bool, field_ai?:string, user_id?:int, error?:string}
*/
public function linkFromUser($expertId, $force = false)
{
$expertId = intval($expertId);
if ($expertId <= 0) {
return ['ok' => false, 'error' => 'invalid expert_id'];
}
$expert = Db::name('expert')->where('expert_id', $expertId)->find();
if (!$expert) {
return ['ok' => false, 'error' => 'expert not found'];
}
if (!$force
&& intval($expert['field_ai_status']) === self::STATUS_DONE
&& trim((string)$expert['field_ai']) !== '') {
return [
'ok' => true,
'skipped' => true,
'field_ai' => (string)$expert['field_ai'],
'source' => (string)($expert['field_ai_source'] ?? ''),
];
}
$email = strtolower(trim((string)($expert['email'] ?? '')));
if ($email === '') {
$this->updateFieldAi($expertId, '', self::STATUS_NO_USER_LINK, '', 'no email');
return ['ok' => true, 'linked' => false, 'reason' => 'empty email'];
}
$user = Db::name('user')
->where('email', $email)
->where('state', 0)
->field('user_id,email,realname')
->find();
if (!$user) {
$this->updateFieldAi($expertId, '', self::STATUS_NO_USER_LINK, '', 'no matching user');
return ['ok' => true, 'linked' => false, 'reason' => 'user not found'];
}
$uri = Db::name('user_reviewer_info')
->where('reviewer_id', intval($user['user_id']))
->where('state', 0)
->find();
$fieldAi = $uri ? trim((string)($uri['field_ai'] ?? '')) : '';
$userStatus = $uri ? intval($uri['field_ai_status']) : 0;
if ($fieldAi === '' || $userStatus !== UserFieldAiService::STATUS_DONE) {
$this->updateFieldAi($expertId, '', self::STATUS_NO_USER_LINK, '', 'user field_ai not ready');
return [
'ok' => true,
'linked' => false,
'user_id' => intval($user['user_id']),
'reason' => 'user has no field_ai',
];
}
$this->updateFieldAi($expertId, $fieldAi, self::STATUS_DONE, self::SOURCE_USER_LINK, 'linked from user_id=' . $user['user_id']);
return [
'ok' => true,
'linked' => true,
'field_ai' => $fieldAi,
'user_id' => intval($user['user_id']),
'source' => self::SOURCE_USER_LINK,
];
}
/**
* 批量同步(同步执行,适合小批量调试)。
*/
public function batchLinkFromUser(array $expertIds, $force = false)
{
$linked = 0;
$skipped = 0;
$noLink = 0;
$failed = 0;
$details = [];
foreach ($expertIds as $expertId) {
$expertId = intval($expertId);
if ($expertId <= 0) {
continue;
}
$result = $this->linkFromUser($expertId, $force);
if (empty($result['ok'])) {
$failed++;
} elseif (!empty($result['skipped'])) {
$skipped++;
} elseif (!empty($result['linked'])) {
$linked++;
} else {
$noLink++;
}
$details[] = array_merge(['expert_id' => $expertId], $result);
}
return [
'total' => count($details),
'linked' => $linked,
'skipped' => $skipped,
'no_link' => $noLink,
'failed' => $failed,
'details' => $details,
];
}
/**
* 预览expert 是否可关联到 user.field_ai。
*/
public function previewLink($expertId)
{
$expertId = intval($expertId);
$expert = Db::name('expert')->where('expert_id', $expertId)->find();
if (!$expert) {
return ['ok' => false, 'error' => 'expert not found'];
}
$email = strtolower(trim((string)($expert['email'] ?? '')));
$user = null;
$uri = null;
if ($email !== '') {
$user = Db::name('user')->where('email', $email)->where('state', 0)->field('user_id,email,realname')->find();
if ($user) {
$uri = Db::name('user_reviewer_info')
->where('reviewer_id', intval($user['user_id']))
->where('state', 0)
->find();
}
}
$canLink = $user && $uri
&& trim((string)($uri['field_ai'] ?? '')) !== ''
&& intval($uri['field_ai_status']) === UserFieldAiService::STATUS_DONE;
return [
'ok' => true,
'expert_id' => $expertId,
'expert_email' => $email,
'expert_field_ai' => (string)($expert['field_ai'] ?? ''),
'expert_field_ai_status'=> intval($expert['field_ai_status'] ?? 0),
'matched_user_id' => $user ? intval($user['user_id']) : 0,
'matched_user_name' => $user ? (string)$user['realname'] : '',
'user_field_ai' => $uri ? (string)($uri['field_ai'] ?? '') : '',
'user_field_ai_status' => $uri ? intval($uri['field_ai_status']) : 0,
'can_link' => $canLink,
];
}
/**
* user 生成 field_ai 后,反向同步到同邮箱 expert可选调用
*/
public function syncExpertsByUserId($userId, $force = false)
{
$userId = intval($userId);
$user = Db::name('user')->where('user_id', $userId)->where('state', 0)->field('user_id,email')->find();
if (!$user || trim((string)$user['email']) === '') {
return ['ok' => false, 'error' => 'user not found'];
}
$email = strtolower(trim((string)$user['email']));
$expertIds = Db::name('expert')
->where('email', $email)
->where('state', '<>', 5)
->column('expert_id');
if (empty($expertIds)) {
return ['ok' => true, 'synced' => 0, 'msg' => 'no expert with same email'];
}
return array_merge(['ok' => true], $this->batchLinkFromUser($expertIds, $force));
}
private function findNextLinkExpertId($afterExpertId, $force)
{
$batch = 50;
$cursor = intval($afterExpertId);
while (true) {
$query = Db::name('expert')
->where('expert_id', '>', $cursor)
->where('state', '<>', 5);
if (!$force) {
$query->where(function ($q) {
$q->where('field_ai_status', self::STATUS_PENDING)
->whereOr('field_ai_status', self::STATUS_FAILED);
});
}
$ids = $query->order('expert_id asc')->limit($batch)->column('expert_id');
if (empty($ids)) {
return 0;
}
foreach ($ids as $expertId) {
$expertId = intval($expertId);
$cursor = $expertId;
if (!$force) {
$row = Db::name('expert')->where('expert_id', $expertId)->field('field_ai,field_ai_status')->find();
if ($row
&& intval($row['field_ai_status']) === self::STATUS_DONE
&& trim((string)$row['field_ai']) !== '') {
continue;
}
}
return $expertId;
}
}
}
private function updateFieldAi($expertId, $fieldAi, $status, $source, $note)
{
$data = [
'field_ai' => mb_substr(trim((string)$fieldAi), 0, 512),
'field_ai_status' => intval($status),
'field_ai_utime' => time(),
'field_ai_source' => mb_substr(trim((string)$source), 0, 32),
];
Db::name('expert')->where('expert_id', intval($expertId))->update($data);
if ($note !== '') {
$this->log('[ExpertFieldAi] expert_id=' . $expertId . ' status=' . $status . ' note=' . $note);
}
}
public function statusLabel($status)
{
$map = [
self::STATUS_PENDING => 'pending',
self::STATUS_DONE => 'done',
self::STATUS_INSUFFICIENT => 'insufficient',
self::STATUS_FAILED => 'failed',
self::STATUS_NO_USER_LINK => 'no_user_link',
];
return isset($map[$status]) ? $map[$status] : 'unknown';
}
public function log($msg)
{
$line = date('Y-m-d H:i:s') . ' ' . $msg . PHP_EOL;
@file_put_contents($this->logFile, $line, FILE_APPEND);
}
}

View File

@@ -509,47 +509,66 @@ class PromotionService
}
}
// 一次 LLM 调用生成两段内容(description + advised_topics
$llmResult = [
'description' => '',
'description_status' => 0,
'advised_topics' => '',
'advised_topics_status' => 0,
];
try {
$llm = new PromotionLlmService();
$llmResult = $llm->generateEmailContent(
$expert,
$journal ?: [],
$overlapFields,
$taskFields,
$fieldSet
);
} catch (\Exception $e) {
$fbDesc = '';
$fbAdvised = '';
try {
$fbSvc = isset($llm) ? $llm : new PromotionLlmService();
$fbDesc = $fbSvc->getFallback();
$fbAdvised = $fbSvc->getAdvisedFallback();
} catch (\Exception $ignore) {
}
$llmResult = [
'description' => $fbDesc,
'description_status' => 2,
'advised_topics' => $fbAdvised,
'advised_topics_status' => 2,
];
$this->log("prepareSingleEmail log_id={$logId} llm_exception=" . $e->getMessage());
}
$llmText = (string)$llmResult['description'];
$llmStatus = intval($llmResult['description_status']);
$advisedText = (string)$llmResult['advised_topics'];
$advisedStatus = intval($llmResult['advised_topics_status']);
// 仅当模板真正引用了 LLM 占位符llm_description / ai_content_analysis /
// ai_advised_topics / llm_advised_topics时才调用 LLM避免无谓的请求与开销。
$llmNeed = $this->detectLlmTemplateNeed(
$task['template_id'],
$task['journal_id'],
intval($task['style_id'] ?? 0)
);
$expert['llm_description'] = $llmText;
$expert['ai_advised_topics'] = $advisedText;
$expert['role'] = $this->mapExpertTypeRole($expertType);
if (!$llmNeed['need']) {
$llmText = '';
$llmStatus = 0;
$advisedText = '';
$advisedStatus = 0;
$expert['llm_description'] = '';
$expert['ai_advised_topics'] = '';
$expert['role'] = $this->mapExpertTypeRole($expertType);
$this->log("prepareSingleEmail log_id={$logId} skip_llm (template has no llm placeholders)");
} else {
// 一次 LLM 调用生成两段内容description + advised_topics
$llmResult = [
'description' => '',
'description_status' => 0,
'advised_topics' => '',
'advised_topics_status' => 0,
];
try {
$llm = new PromotionLlmService();
$llmResult = $llm->generateEmailContent(
$expert,
$journal ?: [],
$overlapFields,
$taskFields,
$fieldSet
);
} catch (\Exception $e) {
$fbDesc = '';
$fbAdvised = '';
try {
$fbSvc = isset($llm) ? $llm : new PromotionLlmService();
$fbDesc = $fbSvc->getFallback();
$fbAdvised = $fbSvc->getAdvisedFallback();
} catch (\Exception $ignore) {
}
$llmResult = [
'description' => $fbDesc,
'description_status' => 2,
'advised_topics' => $fbAdvised,
'advised_topics_status' => 2,
];
$this->log("prepareSingleEmail log_id={$logId} llm_exception=" . $e->getMessage());
}
$llmText = (string)$llmResult['description'];
$llmStatus = intval($llmResult['description_status']);
$advisedText = (string)$llmResult['advised_topics'];
$advisedStatus = intval($llmResult['advised_topics_status']);
$expert['llm_description'] = $llmText;
$expert['ai_advised_topics'] = $advisedText;
$expert['role'] = $this->mapExpertTypeRole($expertType);
}
}
$expertVars = $this->buildExpertVars($expert);
@@ -935,6 +954,73 @@ class PromotionService
// ==================== Template Rendering ====================
/**
* 检测邮件模板(含 style 头尾)是否包含需要 LLM 生成的占位符。
*
* @return array{need:bool,need_description:bool,need_advised_topics:bool,tags:array<int,string>}
*/
public function detectLlmTemplateNeed($templateId, $journalId, $styleId = 0)
{
$descriptionTags = ['llm_description', 'ai_content_analysis'];
$advisedTags = ['ai_advised_topics', 'llm_advised_topics'];
$allTags = array_merge($descriptionTags, $advisedTags);
$parts = [];
$tpl = Db::name('mail_template')
->where('template_id', $templateId)
->where('journal_id', $journalId)
->where('state', 0)
->find();
if ($tpl) {
$parts[] = (string)($tpl['subject'] ?? '');
$parts[] = (string)($tpl['body_html'] ?? '');
}
$styleId = intval($styleId);
if ($styleId > 0) {
$style = Db::name('mail_style')->where('style_id', $styleId)->where('state', 0)->find();
if ($style) {
$parts[] = (string)($style['header_html'] ?? '');
$parts[] = (string)($style['footer_html'] ?? '');
}
}
$haystack = implode("\n", $parts);
$found = [];
foreach ($allTags as $tag) {
if ($this->templateContainsVar($haystack, $tag)) {
$found[] = $tag;
}
}
$needDesc = (bool) array_intersect($found, $descriptionTags);
$needAdvised = (bool) array_intersect($found, $advisedTags);
return [
'need' => !empty($found),
'need_description' => $needDesc,
'need_advised_topics' => $needAdvised,
'tags' => $found,
];
}
/**
* 模板是否包含某变量占位符(支持 {{ var }} 与 {var})。
*/
protected function templateContainsVar($haystack, $varName)
{
if (!is_string($haystack) || $haystack === '' || $varName === '') {
return false;
}
$quoted = preg_quote($varName, '/');
if (preg_match('/\{\{\s*' . $quoted . '\s*\}\}/', $haystack)) {
return true;
}
if (strpos($haystack, '{' . $varName . '}') !== false) {
return true;
}
return false;
}
public function renderFromTemplate($templateId, $journalId, $varsJson, $styleId = 0)
{
$tpl = Db::name('mail_template')->where('template_id', $templateId)->where('journal_id', $journalId)->where('state', 0)->find();

View File

@@ -218,15 +218,16 @@ class TurnitinService
}
foreach ($candidates as $n) {
if ($n > 0 && $n <= 1.0) {
$scaled = round($n * 100, 2);
if ($scaled > 1.0 || $n < 0.05) {
return $scaled;
}
if ($n < 0) {
continue;
}
if ($n >= 0) {
return round($n, 2);
// Turnitin TCA 的 overall_match_percentage 是 0100 整数,"1" 即代表 1%。
// 仅当值是「严格小于 1 的非整数」(真正的 01 小数比例,如 0.12=12%)时才 ×100
// 避免把整数 11%)误判成 100%。
if ($n > 0 && $n < 1.0) {
return round(min($n * 100, 100), 2);
}
return round(min($n, 100), 2);
}
return 0.0;

View File

@@ -102,6 +102,7 @@ class UserFieldAiService
throw new Exception('LLM returned empty field');
}
$this->updateFieldAi($userId, $fieldAi, self::STATUS_DONE, '');
$this->syncLinkedExperts($userId);
return ['ok' => true, 'field_ai' => $fieldAi];
} catch (\Throwable $e) {
$this->updateFieldAi($userId, '', self::STATUS_FAILED, mb_substr($e->getMessage(), 0, 500));
@@ -460,4 +461,17 @@ class UserFieldAiService
$line = date('Y-m-d H:i:s') . ' ' . $msg . PHP_EOL;
@file_put_contents($this->logFile, $line, FILE_APPEND);
}
/**
* user.field_ai 更新后,同步到同邮箱 expert方案 C 关联)。
*/
private function syncLinkedExperts($userId)
{
try {
$svc = new ExpertFieldAiService();
$svc->syncExpertsByUserId(intval($userId), true);
} catch (\Throwable $e) {
$this->log('[FieldAi] sync expert fail user_id=' . $userId . ' ' . $e->getMessage());
}
}
}