Merge remote-tracking branch 'origin/master'

This commit is contained in:
wangjinlei
2026-06-08 17:19:04 +08:00
9 changed files with 342 additions and 13 deletions

View File

@@ -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 + 姓氏(机构选填)→ 仅按姓名搜 ORCID1 条直接报告,多条显示选择列表
* 2. 传了 articleId稿件作者 ID即 art_aut_id→ 从 t_article_author 补全 ORCID / 姓名 / 机构
* 3. 未传 ORCID + 姓氏(机构选填)→ 仅按姓名搜 ORCID1 条直接报告,多条显示选择列表
*/
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)

View File

@@ -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)

View File

@@ -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();

View File

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

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>
适用于青年编委 / 特约审稿人 / 作者资质初审

View File

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

View File

@@ -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 字段
*/