Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -4,6 +4,7 @@ namespace app\api\controller;
|
||||
|
||||
use app\common\service\AuthorBackgroundService;
|
||||
use think\Controller;
|
||||
use think\Db;
|
||||
|
||||
/**
|
||||
* 作者背调:HTML 报告页 + JSON API
|
||||
@@ -15,6 +16,9 @@ class Author extends Controller
|
||||
/** @var AuthorBackgroundService */
|
||||
private $bgService;
|
||||
|
||||
/** @var string */
|
||||
private $articleLookupError = '';
|
||||
|
||||
public function __construct(\think\Request $request = null)
|
||||
{
|
||||
parent::__construct($request);
|
||||
@@ -25,7 +29,8 @@ class Author extends Controller
|
||||
* 作者背调 HTML 页面入口
|
||||
*
|
||||
* 1. 传了 ORCID → 直接生成报告
|
||||
* 2. 未传 ORCID + 姓氏(机构选填)→ 仅按姓名搜 ORCID;1 条直接报告,多条显示选择列表
|
||||
* 2. 传了 articleId(稿件作者 ID,即 art_aut_id)→ 从 t_article_author 补全 ORCID / 姓名 / 机构
|
||||
* 3. 未传 ORCID + 姓氏(机构选填)→ 仅按姓名搜 ORCID;1 条直接报告,多条显示选择列表
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
@@ -33,6 +38,18 @@ class Author extends Controller
|
||||
|
||||
$formAction = $this->resolveFormAction();
|
||||
$params = $this->resolveBackgroundParams();
|
||||
|
||||
if ($this->articleLookupError !== '') {
|
||||
$this->assign([
|
||||
'form_action' => $formAction,
|
||||
'error_msg' => $this->articleLookupError,
|
||||
'last_name' => $params['last_name'],
|
||||
'first_name' => $params['first_name'],
|
||||
'institution' => $params['institution'],
|
||||
]);
|
||||
return $this->fetch('author/index');
|
||||
}
|
||||
|
||||
$orcidNorm = $this->bgService->normalizeOrcid($params['orcid']);
|
||||
|
||||
if ($orcidNorm === ''
|
||||
@@ -129,6 +146,8 @@ class Author extends Controller
|
||||
*/
|
||||
private function resolveBackgroundParams()
|
||||
{
|
||||
$this->articleLookupError = '';
|
||||
|
||||
$pick = function (...$keys) {
|
||||
foreach ($keys as $k) {
|
||||
$v = trim((string) input('param.' . $k, ''));
|
||||
@@ -145,17 +164,146 @@ class Author extends Controller
|
||||
return '';
|
||||
};
|
||||
|
||||
$orcid = $pick('orcid', 'orcid_id');
|
||||
$lastName = $pick('lastName', 'last_name', 'lastname', 'surname');
|
||||
$firstName = $pick('firstName', 'first_name', 'firstname', 'given_name');
|
||||
$institution = $pick('institution', 'affiliation', 'affil', 'org');
|
||||
$realname = $pick('realname', 'real_name');
|
||||
$artAutId = $pick( 'art_aut_id', 'artAutId');
|
||||
|
||||
if ($artAutId !== '') {
|
||||
$fromAuthor = $this->loadAuthorByArtAutId($artAutId);
|
||||
if ($fromAuthor === null) {
|
||||
$this->articleLookupError = '未找到该作者信息';
|
||||
} else {
|
||||
if ($orcid === '') {
|
||||
$orcid = $fromAuthor['orcid'];
|
||||
}
|
||||
if ($lastName === '') {
|
||||
$lastName = $fromAuthor['last_name'];
|
||||
}
|
||||
if ($firstName === '') {
|
||||
$firstName = $fromAuthor['first_name'];
|
||||
}
|
||||
if ($institution === '') {
|
||||
$institution = $fromAuthor['institution'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($realname !== '' && ($lastName === '' || $firstName === '')) {
|
||||
$parsed = $this->parseRealname($realname);
|
||||
if ($lastName === '') {
|
||||
$lastName = $parsed['last_name'];
|
||||
}
|
||||
if ($firstName === '') {
|
||||
$firstName = $parsed['first_name'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'orcid' => $pick('orcid', 'orcid_id'),
|
||||
'last_name' => $pick('lastName', 'last_name', 'lastname', 'surname'),
|
||||
'first_name' => $pick('firstName', 'first_name', 'firstname', 'given_name'),
|
||||
'institution' => $pick('institution', 'affiliation', 'affil', 'org'),
|
||||
'orcid' => $orcid,
|
||||
'last_name' => $lastName,
|
||||
'first_name' => $firstName,
|
||||
'institution' => $institution,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 art_aut_id 从 t_article_author 读取作者信息
|
||||
*/
|
||||
private function loadAuthorByArtAutId($artAutId)
|
||||
{
|
||||
$artAutId = (int) $artAutId;
|
||||
if ($artAutId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = Db::name('article_author')
|
||||
->field('orcid,firstname,lastname,company')
|
||||
->where(['art_aut_id' => $artAutId, 'state' => 0])
|
||||
->find();
|
||||
|
||||
if (empty($row)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'orcid' => trim((string) ($row['orcid'] ?? '')),
|
||||
'first_name' => trim((string) ($row['firstname'] ?? '')),
|
||||
'last_name' => trim((string) ($row['lastname'] ?? '')),
|
||||
'institution' => $this->extractInstitutionFromCompany($row['company'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 company 字段提取机构名(去掉序号前缀,支持 ; 和 , 分隔)
|
||||
*/
|
||||
private function extractInstitutionFromCompany($company)
|
||||
{
|
||||
$company = trim((string) $company);
|
||||
if ($company === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$company = str_replace(',', ',', $company);
|
||||
$parts = preg_split('/[;,]/u', $company);
|
||||
$institutions = [];
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$part = trim($part);
|
||||
if ($part === '') {
|
||||
continue;
|
||||
}
|
||||
$part = preg_replace('/^\d+\s*/', '', $part);
|
||||
$part = trim($part);
|
||||
if ($part !== '' && !in_array($part, $institutions, true)) {
|
||||
$institutions[] = $part;
|
||||
}
|
||||
}
|
||||
|
||||
return implode(';', $institutions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将整段姓名拆成名+姓(如 Chuanying ZHANG → first=Chuanying, last=ZHANG)
|
||||
*/
|
||||
private function parseRealname($realname)
|
||||
{
|
||||
$realname = trim((string) $realname);
|
||||
if ($realname === '') {
|
||||
return ['first_name' => '', 'last_name' => ''];
|
||||
}
|
||||
|
||||
if (strpos($realname, ',') !== false) {
|
||||
$parts = array_map('trim', explode(',', $realname, 2));
|
||||
$family = $parts[0] ?? '';
|
||||
$given = $parts[1] ?? '';
|
||||
if ($family !== '' && $given !== '') {
|
||||
return ['first_name' => $given, 'last_name' => $family];
|
||||
}
|
||||
}
|
||||
|
||||
$tokens = preg_split('/\s+/u', $realname);
|
||||
$tokens = array_values(array_filter($tokens, function ($t) {
|
||||
return $t !== '';
|
||||
}));
|
||||
if (count($tokens) === 0) {
|
||||
return ['first_name' => '', 'last_name' => ''];
|
||||
}
|
||||
if (count($tokens) === 1) {
|
||||
return ['first_name' => '', 'last_name' => $tokens[0]];
|
||||
}
|
||||
|
||||
$lastName = array_pop($tokens);
|
||||
$firstName = implode(' ', $tokens);
|
||||
return ['first_name' => $firstName, 'last_name' => $lastName];
|
||||
}
|
||||
|
||||
private function resolveFormAction()
|
||||
{
|
||||
return rtrim($this->request->root(), '/') . '/api/author/index';
|
||||
// 生产环境未配置伪静态时需带 index.php,如 /public/index.php/api/author/index
|
||||
return rtrim($this->request->baseFile(), '/') . '/api/author/index';
|
||||
}
|
||||
|
||||
private function renderReportPage(array $params, $formAction)
|
||||
|
||||
@@ -2130,6 +2130,7 @@ class EmailClient extends Base
|
||||
$state = $this->request->param('state', '-1');
|
||||
$page = max(1, intval($this->request->param('page', 1)));
|
||||
$perPage = max(1, min(intval($this->request->param('per_page', 50)), 200));
|
||||
$keyword = trim($this->request->param('keyword', ''));
|
||||
|
||||
if (!$taskId) {
|
||||
return jsonError('task_id is required');
|
||||
@@ -2139,8 +2140,13 @@ class EmailClient extends Base
|
||||
if ($state !== '-1' && $state !== '') {
|
||||
$where['l.state'] = intval($state);
|
||||
}
|
||||
|
||||
$total = Db::name('promotion_email_log')->alias('l')->where($where)->count();
|
||||
if ($keyword !== '') {
|
||||
$where['e.email|e.name'] = ['like', '%' . $keyword . '%'];
|
||||
}
|
||||
$total = Db::name('promotion_email_log')->alias('l')
|
||||
->join('t_expert e', 'l.expert_id = e.expert_id', 'LEFT')
|
||||
->where($where)
|
||||
->count('l.log_id');
|
||||
$list = Db::name('promotion_email_log')->alias('l')
|
||||
->join('t_expert e', 'l.expert_id = e.expert_id', 'LEFT')
|
||||
->where($where)
|
||||
|
||||
@@ -39,6 +39,7 @@ class ExpertManage extends Base
|
||||
$field = trim(isset($data['field']) ? $data['field'] : '');
|
||||
$state = isset($data['state']) ? $data['state'] : '-1';
|
||||
$source = trim(isset($data['source']) ? $data['source'] : '');
|
||||
$country = trim(isset($data['country']) ? $data['country'] : '');
|
||||
$page = max(1, intval(isset($data['pageIndex']) ? $data['pageIndex'] : 1));
|
||||
$pageSize = max(1, intval(isset($data['pageSize']) ? $data['pageSize'] : 20));
|
||||
|
||||
@@ -74,6 +75,10 @@ class ExpertManage extends Base
|
||||
$query->where('e.source', $source);
|
||||
$countQuery->where('e.source', $source);
|
||||
}
|
||||
if ($country !== '') {
|
||||
$query->where('e.country', $country);
|
||||
$countQuery->where('e.country', $country);
|
||||
}
|
||||
|
||||
// $countQuery = clone $query;
|
||||
// $total = $countQuery->distinct('e.expert_id')->count();
|
||||
|
||||
@@ -3311,4 +3311,63 @@ class User extends Base
|
||||
|
||||
return jsonSuccess([]);
|
||||
}
|
||||
/**
|
||||
* 根据 user_id 查 user_cv 简历,调用大模型解析用户基本信息。
|
||||
*
|
||||
* POST/GET user_id=用户ID(也支持 userId)
|
||||
* 简历地址:https://submission.tmrjournals.com/public/reviewer/{cv}
|
||||
* 同机部署时优先读 public/reviewer/ 本地文件。
|
||||
*/
|
||||
public function getUserInfoByFile()
|
||||
{
|
||||
@set_time_limit(180);
|
||||
|
||||
$userId = intval($this->request->param('user_id', $this->request->param('userId', 0)));
|
||||
if ($userId <= 0) {
|
||||
return jsonError('请提供 user_id');
|
||||
}
|
||||
|
||||
try {
|
||||
$service = new \app\common\UserInfoFromFileService();
|
||||
$result = $service->parseFromUserId($userId);
|
||||
|
||||
\think\Log::info('[getUserInfoByFile] user_id=' . $userId . ' ' . json_encode($result['user_info'], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return jsonSuccess([
|
||||
'message' => '解析完成',
|
||||
'user_id' => $result['user_id'],
|
||||
'cv' => $result['cv'],
|
||||
'cv_url' => $result['cv_url'],
|
||||
'file' => $result['file'],
|
||||
'text_length' => $result['text_length'],
|
||||
'text_preview' => $result['text_preview'],
|
||||
'user_info' => $result['user_info'],
|
||||
'print' => $this->formatUserInfoForPrint($result['user_info']),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return jsonError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function formatUserInfoForPrint(array $info)
|
||||
{
|
||||
$labels = [
|
||||
'realname' => '姓名',
|
||||
'email' => '邮箱',
|
||||
'phone' => '电话',
|
||||
'orcid' => 'ORCID',
|
||||
'technical' => '职称',
|
||||
'field' => '研究领域',
|
||||
'company' => 'Institution',
|
||||
'department' => '科室',
|
||||
'country' => '国家',
|
||||
'introduction' => '简介',
|
||||
];
|
||||
$lines = [];
|
||||
foreach ($labels as $key => $label) {
|
||||
$val = trim((string) ($info[$key] ?? ''));
|
||||
$lines[] = $label . ':' . ($val !== '' ? $val : '(未识别)');
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +289,57 @@ a.ext:hover { text-decoration: underline; }
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.report-rules {
|
||||
margin-top: 32px;
|
||||
padding: 18px 22px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.report-rules-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.report-rules-sub {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 14px 0 8px;
|
||||
}
|
||||
.rules-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.rules-table th,
|
||||
.rules-table td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.rules-table th {
|
||||
background: #edf2f7;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
width: 22%;
|
||||
}
|
||||
.rules-table td strong {
|
||||
color: var(--text);
|
||||
}
|
||||
.report-rules ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.report-rules li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.report-foot {
|
||||
margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border);
|
||||
font-size: 12px; color: var(--text-muted); text-align: center; line-height: 1.7;
|
||||
|
||||
@@ -26,19 +26,19 @@
|
||||
<form method="get" action="{$form_action}">
|
||||
<div class="form-group">
|
||||
<label class="form-label">ORCID <span class="opt">— 填写后直接出报告</span></label>
|
||||
<input class="form-input" type="text" name="orcid" placeholder="0000-0002-6388-7847" value="{$orcid|default=''|htmlspecialchars}">
|
||||
<input class="form-input" type="text" name="orcid" value="{$orcid|default=''|htmlspecialchars}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">姓 Last Name <span class="opt">— 未填 ORCID 时必填</span></label>
|
||||
<input class="form-input" type="text" name="lastName" placeholder="PENG" value="{$last_name|default=''|htmlspecialchars}">
|
||||
<input class="form-input" type="text" name="lastName" value="{$last_name|default=''|htmlspecialchars}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">名 First Name <span class="opt">— 选填</span></label>
|
||||
<input class="form-input" type="text" name="firstName" placeholder="Sijing" value="{$first_name|default=''|htmlspecialchars}">
|
||||
<input class="form-input" type="text" name="firstName" value="{$first_name|default=''|htmlspecialchars}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">机构 Institution <span class="opt">— 选填,用于候选列表排序</span></label>
|
||||
<input class="form-input" type="text" name="institution" placeholder="University of Ibadan" value="{$institution|default=''|htmlspecialchars}">
|
||||
<input class="form-input" type="text" name="institution" value="{$institution|default=''|htmlspecialchars}">
|
||||
</div>
|
||||
<button class="btn-primary" type="submit">生成背调报告</button>
|
||||
</form>
|
||||
|
||||
@@ -239,6 +239,32 @@
|
||||
</div>
|
||||
{/notempty}
|
||||
|
||||
<div class="report-rules">
|
||||
<p class="report-rules-title">学术指标说明</p>
|
||||
<table class="rules-table">
|
||||
<tr><th>论文总数</th><td>取 <strong>OpenAlex、ORCID、PubMed、Scopus</strong> 四项中的<strong>最大值</strong>(非相加)。OpenAlex 无档案时,可由 ORCID / PubMed 等补全。</td></tr>
|
||||
<tr><th>总被引</th><td>仅来自 <strong>OpenAlex</strong>;未匹配到作者档案时为 0。</td></tr>
|
||||
<tr><th>H 指数</th><td>仅来自 <strong>OpenAlex</strong>;未匹配时为 0。</td></tr>
|
||||
<tr><th>i10 指数</th><td>仅来自 <strong>OpenAlex</strong>(至少 10 次被引的论文数);未匹配时为 0。</td></tr>
|
||||
<tr><th>PubMed</th><td>独立统计,与论文总数<strong>口径不同</strong>。检索顺序:ORCID → 姓名+机构 → 姓名,采用第一个有结果的检索式;列表最多显示最近 10 篇。</td></tr>
|
||||
<tr><th>研究方向</th><td>来自 OpenAlex 前 5 个主题;无 OpenAlex 档案时不显示。</td></tr>
|
||||
</table>
|
||||
<p class="report-rules-sub">风险评级</p>
|
||||
<ul>
|
||||
<li><strong>高风险</strong>:Retraction Watch 存在学术不端相关记录</li>
|
||||
<li><strong>中风险</strong>:存在撤稿 / 关注声明</li>
|
||||
<li><strong>待核实</strong>:论文总数为 0</li>
|
||||
<li><strong>低风险</strong>:H ≥ 10 或论文总数 ≥ 20,且无上述不良记录</li>
|
||||
<li><strong>一般</strong>:其余情况(青年学者常见产出区间)</li>
|
||||
</ul>
|
||||
<p class="report-rules-sub">其他说明</p>
|
||||
<ul>
|
||||
<li>OpenAlex 匹配:优先 ORCID 直连;有 ORCID 时不使用同名他人数据。</li>
|
||||
<li>诚信记录:有 DOI 的 ORCID 作品按 DOI 精确比对;无 DOI 时回退姓名+题目匹配(同名需人工核实)。</li>
|
||||
<li>论文总数有值、总被引/H 为 0:通常表示 OpenAlex 未收录该作者,不代表无学术产出。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="report-foot">
|
||||
数据来源:OpenAlex · ORCID · PubMed · Scopus · Retraction Watch<br>
|
||||
适用于青年编委 / 特约审稿人 / 作者资质初审
|
||||
|
||||
@@ -989,9 +989,14 @@ class AuthorBackgroundService
|
||||
}
|
||||
$papers = $this->pubmedFetchSummaries($ids);
|
||||
}
|
||||
$urlTerm = $usedTerm;
|
||||
if (preg_match('/^(.+)\[ORCID\]$/i', $usedTerm, $m)) {
|
||||
$urlTerm = $m[1];
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $total, 'papers' => $papers, 'query' => $usedTerm,
|
||||
'pubmed_url' => 'https://pubmed.ncbi.nlm.nih.gov/?term=' . urlencode($usedTerm),
|
||||
'pubmed_url' => 'https://pubmed.ncbi.nlm.nih.gov/?term=' . urlencode($urlTerm),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,35 @@ class LLMService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用对话请求(与参考文献校对共用 postChat / [promotion] 配置)
|
||||
*
|
||||
* @param array $messages OpenAI messages
|
||||
* @param float $temperature
|
||||
* @return string|null 助手回复正文
|
||||
*/
|
||||
public function requestChat(array $messages, $temperature = 0)
|
||||
{
|
||||
if ($this->url === '' || $this->model === '') {
|
||||
\think\Log::warning('LLM requestChat: url or model not configured');
|
||||
return null;
|
||||
}
|
||||
$payload = [
|
||||
'model' => $this->model,
|
||||
'temperature' => $temperature,
|
||||
'messages' => $messages,
|
||||
];
|
||||
return $this->postChat($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型返回的 JSON 对象(去除 markdown 代码块等)
|
||||
*/
|
||||
public function parseJsonResponse($raw)
|
||||
{
|
||||
return $this->parseJson($raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 can_support;兼容 is_match 字段
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user