Compare commits
51 Commits
7d23e9c777
...
checkrefer
| Author | SHA1 | Date | |
|---|---|---|---|
| c1107780a7 | |||
| 68cf1867d8 | |||
| 44f3383887 | |||
| f118a799c2 | |||
| d9c3243053 | |||
| 8cd033a56d | |||
| 3663dd4ea6 | |||
| 6f76c483ec | |||
| 867621232b | |||
| 74383d24ea | |||
| 7e5a087a4e | |||
| 4aab7f5b7e | |||
|
|
fa878334cd | ||
|
|
c36eba77b1 | ||
|
|
336fa08a18 | ||
|
|
b1e978ed73 | ||
|
|
a68742d2c2 | ||
|
|
9a9a69333b | ||
|
|
b6bb0396f9 | ||
|
|
f2fabac740 | ||
|
|
9d864b6803 | ||
|
|
02242b1f08 | ||
|
|
085bf5365c | ||
|
|
5645b87669 | ||
|
|
1ff6bef655 | ||
|
|
0bac3b5d3f | ||
|
|
a955316ec3 | ||
|
|
e50decedcd | ||
|
|
d21c33fcd0 | ||
|
|
13fd3af816 | ||
|
|
7a204961de | ||
|
|
e1bf8c0189 | ||
|
|
48b2335063 | ||
|
|
5f724217c0 | ||
|
|
f1fad67f76 | ||
|
|
d2a6c3ff30 | ||
|
|
4417a7ea28 | ||
|
|
e9a354c663 | ||
|
|
87d815dc17 | ||
|
|
ae221e6be6 | ||
|
|
a2338340f6 | ||
|
|
717c662d81 | ||
|
|
b0f0d6461f | ||
|
|
d92df3e103 | ||
|
|
a802b2e923 | ||
|
|
22947a56a4 | ||
|
|
20a68ddc8a | ||
|
|
d1e0f43992 | ||
|
|
e3ec1b0ca1 | ||
|
|
e7bb34e11d | ||
|
|
65ba338a7d |
16
.env
16
.env
@@ -19,6 +19,22 @@ client_id = 616562
|
||||
client_secret = CfMDrllyqBTFKrUkO2XaE7OmWTYqP3yd
|
||||
hmac = 8aU8WnITYhwaGTXH
|
||||
|
||||
[promotion]
|
||||
PROMOTION_LLM_URL=http://chat.taimed.cn/v1/chat/completions
|
||||
PROMOTION_LLM_MODEL=your-model-name
|
||||
PROMOTION_LLM_TIMEOUT=30
|
||||
PROMOTION_LLM_FALLBACK="We would like to cordially invite you to consider submitting a manuscript to {{journal_name}}."
|
||||
PROMOTION_LLM_ADVISED_FALLBACK=""
|
||||
|
||||
[unsubscribe]
|
||||
UNSUBSCRIBE_SECRET="TMR Unsubscribe Secret create on 20260427"
|
||||
UNSUBSCRIBE_BASE_URL=https://submission.tmrjournals.com/api/Unsubscribe/index
|
||||
|
||||
[yboard]
|
||||
APPLY_URL="https://submission.tmrjournals.com/youthBoardRegister"
|
||||
|
||||
[plagiarism]
|
||||
static_root="/home/wwwroot/api.tmrjournals.com/public"
|
||||
|
||||
[journal]
|
||||
;官网服务器地址
|
||||
|
||||
@@ -8,6 +8,8 @@ use think\Queue;
|
||||
use think\Validate;
|
||||
use PhpOffice\PhpWord\IOFactory;
|
||||
use app\common\OpenAi;
|
||||
use app\common\CrossrefService;
|
||||
use app\common\PubmedService;
|
||||
|
||||
/**
|
||||
* @title 文章接口
|
||||
@@ -17,9 +19,17 @@ class Article extends Base
|
||||
{
|
||||
|
||||
|
||||
/**
|
||||
* @var CrossrefService
|
||||
*/
|
||||
private $crossService;
|
||||
private $pubmedService;
|
||||
|
||||
public function __construct(\think\Request $request = null)
|
||||
{
|
||||
parent::__construct($request);
|
||||
$this->crossService = new CrossrefService();
|
||||
$this->pubmedService = new PubmedService();
|
||||
}
|
||||
|
||||
|
||||
@@ -169,16 +179,6 @@ class Article extends Base
|
||||
return jsonSuccess($re);
|
||||
}
|
||||
|
||||
|
||||
public function myttt()
|
||||
{
|
||||
$res = $this->addProductionEx("3689");
|
||||
echo "<pre>";
|
||||
var_dump($res);
|
||||
echo "</pre>";
|
||||
die;
|
||||
}
|
||||
|
||||
/**获取预接收内容状态
|
||||
* @return void
|
||||
*/
|
||||
@@ -1053,6 +1053,36 @@ class Article extends Base
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function testCheckArticleCitation()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
"article_id"=>"require"
|
||||
]);
|
||||
if(!$rule->check($data)){
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
$mains = $this->article_main_obj->where("article_id",$data['article_id'])->where("state",0)->select();
|
||||
$production = $this->production_article_obj->where("article_id",$data['article_id'])->find();
|
||||
$refers = $this->production_article_refer_obj->where("p_article_id",$production['p_article_id'])->where("state",0)->order("index asc")->select();
|
||||
$res = $this->crossService->qcArticleCitations($mains,$refers);
|
||||
return jsonSuccess(['res'=>$res]);
|
||||
}
|
||||
|
||||
public function testCheckArticlePubmed()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
"doi"=>"require"
|
||||
]);
|
||||
if(!$rule->check($data)){
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
$res = $this->pubmedService->fetchByDoi($data['doi']);
|
||||
return jsonSuccess(['res'=>$res]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @title 发送留言板消息
|
||||
* @description 发送留言板消息
|
||||
@@ -3940,7 +3970,14 @@ class Article extends Base
|
||||
|
||||
public function ffff(){
|
||||
$data = $this->request->post();
|
||||
$this->ai_scor($data['article_id']);
|
||||
$rule = new Validate([
|
||||
"doi"=>"require"
|
||||
]);
|
||||
if(!$rule->check($data)){
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
$res = $this->crossService->fetchWorkSummary($data['doi']);
|
||||
return jsonSuccess($res);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -271,6 +271,14 @@ class Base extends Controller
|
||||
}
|
||||
$this->production_article_refer_obj->where('p_article_id', $refer_info['p_article_id'])->where('index', ">", $refer_info['index'])->where('state', 0)->setDec('index');
|
||||
$this->production_article_refer_obj->where('p_refer_id', $p_refer_id)->update(['state' => 1]);
|
||||
|
||||
// 文献集合已变更,原校对结果的 reference_no 已全部错位,整篇标记为未校对
|
||||
try {
|
||||
(new \app\common\ReferenceCheckService())
|
||||
->clearArticleChecksByPArticleId(intval($refer_info['p_article_id']));
|
||||
} catch (\Exception $e) {
|
||||
\think\Log::error('delOneRefer clearArticleChecksByPArticleId p_refer_id=' . $p_refer_id . ' ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1047,6 +1055,162 @@ class Base extends Controller
|
||||
return $this->production_article_obj->insertGetId($insert);
|
||||
}
|
||||
|
||||
public function markUnusedReferencesForArticle(int $articleId)
|
||||
{
|
||||
if ($articleId <= 0) return;
|
||||
|
||||
$production = Db::name('production_article')
|
||||
->where('article_id', $articleId)
|
||||
->where('state', 0)
|
||||
->field('p_article_id')
|
||||
->find();
|
||||
$pArticleId = intval($production['p_article_id'] ?? 0);
|
||||
if ($pArticleId <= 0) return;
|
||||
|
||||
// 1) 收集已使用的 p_refer_id(与 convertArticleMainCitationsToMycite 一致:正文 + 表格 + 图)
|
||||
$usedIds = [];
|
||||
|
||||
// t_article_main:type=0 等为正文;type=2 时引用在 article_main_table,content 可能为空,仍扫描不增加成本
|
||||
$mains = Db::name('article_main')
|
||||
->where('article_id', $articleId)
|
||||
->whereIn('state', [0, 2])
|
||||
->field('content')
|
||||
->select();
|
||||
foreach ($mains as $row) {
|
||||
$usedIds = array_merge($usedIds, $this->extractMyciteIds((string)($row['content'] ?? '')));
|
||||
}
|
||||
|
||||
// t_article_main_table:type=2 对应表格数据(含 url)
|
||||
$tables = Db::name('article_main_table')
|
||||
->where('article_id', $articleId)
|
||||
->where('state', 0)
|
||||
->field('table_data,html_data,title,note,url')
|
||||
->select();
|
||||
foreach ($tables as $row) {
|
||||
$usedIds = array_merge($usedIds, $this->extractMyciteIdsFromTableDataJson((string)($row['table_data'] ?? '')));
|
||||
$usedIds = array_merge($usedIds, $this->extractMyciteIds((string)($row['html_data'] ?? '')));
|
||||
$usedIds = array_merge($usedIds, $this->extractMyciteIds((string)($row['title'] ?? '')));
|
||||
$usedIds = array_merge($usedIds, $this->extractMyciteIds((string)($row['note'] ?? '')));
|
||||
$usedIds = array_merge($usedIds, $this->extractMyciteIds((string)($row['url'] ?? '')));
|
||||
}
|
||||
|
||||
// t_article_main_image:type=1 图注等
|
||||
$images = Db::name('article_main_image')
|
||||
->where('article_id', $articleId)
|
||||
->where('state', 0)
|
||||
->field('title,note,url')
|
||||
->select();
|
||||
foreach ($images as $row) {
|
||||
$usedIds = array_merge($usedIds, $this->extractMyciteIds((string)($row['title'] ?? '')));
|
||||
$usedIds = array_merge($usedIds, $this->extractMyciteIds((string)($row['note'] ?? '')));
|
||||
$usedIds = array_merge($usedIds, $this->extractMyciteIds((string)($row['url'] ?? '')));
|
||||
}
|
||||
|
||||
$usedIds = array_values(array_unique($usedIds));
|
||||
|
||||
// 2) 标记:先全置 0,再把用到的置 1
|
||||
try {
|
||||
Db::name('production_article_refer')
|
||||
->where('p_article_id', $pArticleId)
|
||||
->where('state', 0)
|
||||
->update(['is_used' => 0, 'update_time' => time()]);
|
||||
|
||||
if (!empty($usedIds)) {
|
||||
Db::name('production_article_refer')
|
||||
->where('p_article_id', $pArticleId)
|
||||
->where('state', 0)
|
||||
->whereIn('p_refer_id', $usedIds)
|
||||
->update(['is_used' => 1, 'update_time' => time()]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 工具方法:不影响主流程,忽略异常(可按需改为记录日志)
|
||||
}
|
||||
}
|
||||
public function extractMyciteIds(string $text): array
|
||||
{
|
||||
if ($text === '') return [];
|
||||
$ids = [];
|
||||
if (preg_match_all('/<\s*mycite\b[^>]*\bdata-id\s*=\s*(["\'])(.*?)\1[^>]*>/iu', $text, $m)) {
|
||||
foreach ($m[2] as $raw) {
|
||||
$raw = trim((string)$raw);
|
||||
if ($raw === '') continue;
|
||||
$parts = preg_split('/\s*,\s*/', $raw);
|
||||
foreach ($parts as $p) {
|
||||
$p = trim((string)$p);
|
||||
if ($p === '') continue;
|
||||
$v = intval($p);
|
||||
if ($v > 0) $ids[] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
$ids = array_values(array_unique($ids));
|
||||
sort($ids);
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* table_data:二维数组 JSON [[{text,colspan,rowspan},...],...];支持双重 JSON 字符串编码。
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
protected function decodeTableDataJsonToArray(string $raw): ?array
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
if (preg_match('/^\xEF\xBB\xBF/', $raw)) {
|
||||
$raw = substr($raw, 3);
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return null;
|
||||
}
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
if (is_string($decoded)) {
|
||||
$decoded2 = json_decode($decoded, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded2)) {
|
||||
return $decoded2;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* table_data 为 JSON:递归收集所有字符串里的 <mycite>;非合法 JSON 时按整串解析。
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
protected function extractMyciteIdsFromTableDataJson(string $tableDataJson): array
|
||||
{
|
||||
$tableDataJson = trim($tableDataJson);
|
||||
if ($tableDataJson === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = $this->decodeTableDataJsonToArray($tableDataJson);
|
||||
if ($decoded === null) {
|
||||
return $this->extractMyciteIds($tableDataJson);
|
||||
}
|
||||
$ids = [];
|
||||
$walk = function ($node) use (&$walk, &$ids) {
|
||||
if (is_string($node)) {
|
||||
$ids = array_merge($ids, $this->extractMyciteIds($node));
|
||||
return;
|
||||
}
|
||||
if (is_array($node)) {
|
||||
foreach ($node as $v) {
|
||||
$walk($v);
|
||||
}
|
||||
}
|
||||
};
|
||||
$walk($decoded);
|
||||
$ids = array_values(array_unique($ids));
|
||||
sort($ids);
|
||||
return $ids;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use app\api\controller\Base;
|
||||
use think\Db;
|
||||
use PhpOffice\PhpWord\IOFactory;
|
||||
use think\Exception;
|
||||
use think\Validate;
|
||||
use \app\common\ArticleParserService;
|
||||
/**
|
||||
* @title 自动投稿控制器
|
||||
@@ -102,6 +103,23 @@ class Contribute extends Base
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function myTestArticle(){
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
'article_id'=>"require"
|
||||
]);
|
||||
if(!$rule->check($data)){
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
$files = $this->article_file_obj->where("article_id",$data['article_id'])->where("type_name","manuscirpt")->order("file_id desc")->limit(1)->select();
|
||||
$sFileUrl =$files[0]['file_url'];
|
||||
$sFileUrl = rtrim(ROOT_PATH,'/').'/public/'.ltrim(ltrim($sFileUrl,'/'),'public');
|
||||
$res = ArticleParserService::getReferencesFromWord($sFileUrl);
|
||||
return jsonSuccess($res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装数据插入相关数据表
|
||||
* @param array $aParam
|
||||
|
||||
250
application/api/controller/Country.php
Normal file
250
application/api/controller/Country.php
Normal file
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use think\Db;
|
||||
|
||||
class Country extends Base
|
||||
{
|
||||
public function __construct(\think\Request $request = null)
|
||||
{
|
||||
parent::__construct($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 国家列表(支持筛选 + 分页)
|
||||
*
|
||||
* 参数:
|
||||
* keyword - 搜索中文名/英文名/code
|
||||
* is_hot - 0/1 筛选热门
|
||||
* partition - 分区 1/2/3
|
||||
* page - 页码(默认1)
|
||||
* per_page - 每页条数(默认20,最大100)
|
||||
*/
|
||||
public function getList()
|
||||
{
|
||||
$keyword = trim($this->request->param('keyword', ''));
|
||||
$isHot = $this->request->param('is_hot', '');
|
||||
$partition = $this->request->param('partition', '');
|
||||
$page = max(1, intval($this->request->param('page', 1)));
|
||||
$perPage = max(1, min(intval($this->request->param('per_page', 20)), 100));
|
||||
|
||||
$where = [];
|
||||
if ($keyword !== '') {
|
||||
$where[] = ['zh_name|en_name|code', 'like', '%' . $keyword . '%'];
|
||||
}
|
||||
if ($isHot !== '' && $isHot !== '-1') {
|
||||
$where[] = ['is_hot', '=', intval($isHot)];
|
||||
}
|
||||
if ($partition !== '' && $partition !== '-1') {
|
||||
$where[] = ['partition', '=', intval($partition)];
|
||||
}
|
||||
|
||||
$countQuery = Db::name('country');
|
||||
foreach ($where as $w) {
|
||||
$countQuery->where($w[0], $w[1], $w[2]);
|
||||
}
|
||||
$total = $countQuery->count();
|
||||
|
||||
$listQuery = Db::name('country');
|
||||
foreach ($where as $w) {
|
||||
$listQuery->where($w[0], $w[1], $w[2]);
|
||||
}
|
||||
$list = $listQuery
|
||||
->order('is_hot desc, zh_name asc')
|
||||
->page($page, $perPage)
|
||||
->select();
|
||||
|
||||
return jsonSuccess([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total_pages' => $total > 0 ? ceil($total / $perPage) : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 国家下拉选项(不分页,用于筛选器)
|
||||
*
|
||||
* 参数:
|
||||
* hot_only - 1 则只返回热门国家
|
||||
* partition - 按分区筛选
|
||||
*/
|
||||
public function getOptions()
|
||||
{
|
||||
$hotOnly = intval($this->request->param('hot_only', 0));
|
||||
$partition = $this->request->param('partition', '');
|
||||
|
||||
$query = Db::name('country');
|
||||
|
||||
if ($hotOnly) {
|
||||
$query->where('is_hot', 1);
|
||||
}
|
||||
if ($partition !== '' && $partition !== '-1') {
|
||||
$query->where('partition', intval($partition));
|
||||
}
|
||||
|
||||
$list = $query
|
||||
->field('country_id, zh_name, en_name, code, is_hot, partition')
|
||||
->order('is_hot desc, zh_name asc')
|
||||
->select();
|
||||
|
||||
return jsonSuccess($list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 国家详情
|
||||
*/
|
||||
public function getDetail()
|
||||
{
|
||||
$id = intval($this->request->param('country_id', 0));
|
||||
if (!$id) {
|
||||
return jsonError('country_id is required');
|
||||
}
|
||||
|
||||
$row = Db::name('country')->where('country_id', $id)->find();
|
||||
if (!$row) {
|
||||
return jsonError('Country not found');
|
||||
}
|
||||
|
||||
return jsonSuccess($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增国家
|
||||
*/
|
||||
public function add()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
|
||||
$zhName = trim(isset($data['zh_name']) ? $data['zh_name'] : '');
|
||||
$enName = trim(isset($data['en_name']) ? $data['en_name'] : '');
|
||||
$code = strtoupper(trim(isset($data['code']) ? $data['code'] : ''));
|
||||
|
||||
if ($zhName === '' && $enName === '') {
|
||||
return jsonError('zh_name 或 en_name 至少填一个');
|
||||
}
|
||||
|
||||
if ($code !== '') {
|
||||
$exists = Db::name('country')->where('code', $code)->find();
|
||||
if ($exists) {
|
||||
return jsonError('code 已存在: ' . $code);
|
||||
}
|
||||
}
|
||||
|
||||
$id = Db::name('country')->insertGetId([
|
||||
'zh_name' => mb_substr($zhName, 0, 32),
|
||||
'en_name' => mb_substr($enName, 0, 256),
|
||||
'code' => mb_substr($code, 0, 32),
|
||||
'is_hot' => intval(isset($data['is_hot']) ? $data['is_hot'] : 0),
|
||||
'partition' => intval(isset($data['partition']) ? $data['partition'] : 2),
|
||||
]);
|
||||
|
||||
return jsonSuccess(['country_id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑国家
|
||||
*/
|
||||
public function edit()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$id = intval(isset($data['country_id']) ? $data['country_id'] : 0);
|
||||
if (!$id) {
|
||||
return jsonError('country_id is required');
|
||||
}
|
||||
|
||||
$row = Db::name('country')->where('country_id', $id)->find();
|
||||
if (!$row) {
|
||||
return jsonError('Country not found');
|
||||
}
|
||||
|
||||
$update = [];
|
||||
|
||||
if (isset($data['zh_name'])) {
|
||||
$update['zh_name'] = mb_substr(trim($data['zh_name']), 0, 32);
|
||||
}
|
||||
if (isset($data['en_name'])) {
|
||||
$update['en_name'] = mb_substr(trim($data['en_name']), 0, 256);
|
||||
}
|
||||
if (isset($data['code'])) {
|
||||
$code = strtoupper(trim($data['code']));
|
||||
$dup = Db::name('country')->where('code', $code)->where('country_id', '<>', $id)->find();
|
||||
if ($dup) {
|
||||
return jsonError('code 已被其他国家使用: ' . $code);
|
||||
}
|
||||
$update['code'] = mb_substr($code, 0, 32);
|
||||
}
|
||||
if (isset($data['is_hot'])) {
|
||||
$update['is_hot'] = intval($data['is_hot']);
|
||||
}
|
||||
if (isset($data['partition'])) {
|
||||
$update['partition'] = intval($data['partition']);
|
||||
}
|
||||
|
||||
if (empty($update)) {
|
||||
return jsonError('没有可更新的字段');
|
||||
}
|
||||
|
||||
Db::name('country')->where('country_id', $id)->update($update);
|
||||
|
||||
return jsonSuccess([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除国家
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
$id = intval($this->request->param('country_id', 0));
|
||||
if (!$id) {
|
||||
return jsonError('country_id is required');
|
||||
}
|
||||
|
||||
$used = Db::name('expert')->where('country_id', $id)->count();
|
||||
if ($used > 0) {
|
||||
return jsonError('该国家下有 ' . $used . ' 位专家关联,无法删除');
|
||||
}
|
||||
|
||||
Db::name('country')->where('country_id', $id)->delete();
|
||||
|
||||
return jsonSuccess([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置热门
|
||||
*/
|
||||
public function setHot()
|
||||
{
|
||||
$ids = $this->request->param('country_ids', '');
|
||||
$isHot = intval($this->request->param('is_hot', 1));
|
||||
|
||||
if (empty($ids)) {
|
||||
return jsonError('country_ids is required');
|
||||
}
|
||||
|
||||
$idArr = array_map('intval', explode(',', $ids));
|
||||
$count = Db::name('country')->where('country_id', 'in', $idArr)->update(['is_hot' => $isHot]);
|
||||
|
||||
return jsonSuccess(['affected' => $count]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置分区
|
||||
*/
|
||||
public function setPartition()
|
||||
{
|
||||
$ids = $this->request->param('country_ids', '');
|
||||
$partition = intval($this->request->param('partition', 2));
|
||||
|
||||
if (empty($ids)) {
|
||||
return jsonError('country_ids is required');
|
||||
}
|
||||
|
||||
$idArr = array_map('intval', explode(',', $ids));
|
||||
$count = Db::name('country')->where('country_id', 'in', $idArr)->update(['partition' => $partition]);
|
||||
|
||||
return jsonSuccess(['affected' => $count]);
|
||||
}
|
||||
}
|
||||
326
application/api/controller/Crossrefdoi.php
Normal file
326
application/api/controller/Crossrefdoi.php
Normal file
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
namespace app\api\controller;
|
||||
use app\api\controller\Base;
|
||||
use think\Db;
|
||||
use think\Validate;
|
||||
class Crossrefdoi extends Base{
|
||||
|
||||
public function __construct(\think\Request $request = null) {
|
||||
parent::__construct($request);
|
||||
}
|
||||
// 配置项
|
||||
private $mailto; // 邮箱(提升优先级)
|
||||
private $timeout = 15; // 请求超时(秒)
|
||||
private $maxRetry = 2; // 单个DOI最大重试次数
|
||||
private $crossrefUrl = "https://api.crossref.org/works/"; //接口地址
|
||||
/**
|
||||
* 批量查询DOI信息(核心方法)
|
||||
* @param array $dois DOI列表
|
||||
* @return array 包含所有DOI的查询结果
|
||||
*/
|
||||
public function get($aParam = []){
|
||||
|
||||
//获取参数
|
||||
$aParam = empty($aParam) ? $this->request->post() : $aParam;
|
||||
|
||||
$iPReferId = empty($aParam['p_refer_id']) ? 0 : $aParam['p_refer_id'];
|
||||
if(empty($iPReferId)){
|
||||
return json_encode(['status' => 2,'msg' => 'Please select the reference to be queried']);
|
||||
}
|
||||
$aWhere = ['p_refer_id' => $iPReferId,'state' => ['in', [0,2]]];
|
||||
$aRefer = Db::name('production_article_refer')->field('title,joura,author,refer_doi,doilink,cs')->where($aWhere)->find();
|
||||
if(empty($aRefer['refer_doi'])){
|
||||
return json_encode(['status' => 3,'msg' => 'The doi of the reference is empty']);
|
||||
}
|
||||
|
||||
$finalResult = [];
|
||||
$sDoi = empty($aRefer['refer_doi']) ? '' : $aRefer['refer_doi'];
|
||||
$sCheckDoi = $this->filterValidDoi($sDoi); // 过滤非法DOI
|
||||
if(empty($sCheckDoi)){
|
||||
return json_encode(['status' => 4,'msg' => 'Doi does not comply with the rules']);
|
||||
}
|
||||
|
||||
// 调用单DOI查询(带重试)
|
||||
$aDoiInfo = $this->fetchSingleDoiWithRetry($sCheckDoi);
|
||||
if (!$aDoiInfo) {
|
||||
$result['status'] = 'fail';
|
||||
$result['fail_reason'] = "请求失败(重试{$this->maxRetry}次后仍失败)";
|
||||
}
|
||||
// 提取核心字段
|
||||
$sTitle = $this->getTitle($aDoiInfo);
|
||||
//期刊信息
|
||||
$aPublisher = $this->getPublisher($aDoiInfo);
|
||||
$sJoura = empty($aPublisher['title']) ? $aPublisher['short_title'] : $aPublisher['title'];
|
||||
//作者信息
|
||||
$aAuthor = $this->getAuthors($aDoiInfo);
|
||||
$sAuthor = empty($aAuthor) ? '' : implode(',', $aAuthor);
|
||||
$sDateno = $this->getVolumeIssuePages($aDoiInfo);
|
||||
|
||||
// 识别撤稿状态
|
||||
$aRetractInfo = $this->checkRetracted($aDoiInfo);
|
||||
$bIsRetracted = 2;
|
||||
if($aRetractInfo['is_retracted'] == true){
|
||||
$bIsRetracted = 1;
|
||||
}
|
||||
$sRetractReason = empty($aRetractInfo['reason']) ? '' : $aRetractInfo['reason'];
|
||||
//获取dolink
|
||||
$sDolink = $this->getDolink($aDoiInfo);
|
||||
$sDolink = empty($sDolink) ? 'https://doi.org/' . $sCheckDoi : $sDolink;
|
||||
|
||||
//数据处理更新数据库
|
||||
$aUpdate = [];
|
||||
if(!empty($sTitle) && empty($aRefer['title'])){
|
||||
$aUpdate['title'] = $sTitle;
|
||||
}
|
||||
if(!empty($sJoura) && empty($aRefer['joura'])){
|
||||
$aUpdate['joura'] = $sJoura;
|
||||
}
|
||||
if(!empty($sAuthor) && empty($aRefer['author'])){
|
||||
$aUpdate['author'] = $sAuthor;
|
||||
}
|
||||
if(!empty($sDateno)){
|
||||
$aUpdate['dateno'] = $sDateno;
|
||||
}
|
||||
if($bIsRetracted == 1){
|
||||
$aUpdate['is_retracted'] = 1;
|
||||
}
|
||||
if(!empty($sDolink) && empty($aRefer['doilink'])){
|
||||
$aUpdate['doilink'] = $sDolink;
|
||||
}
|
||||
if(empty($aUpdate)){
|
||||
return json_encode(['status' => 5,'msg' => 'No update information available']);
|
||||
}
|
||||
$aUpdate['update_time'] = time();
|
||||
$aUpdate['cs'] = 1;
|
||||
$aWhere = ['p_refer_id' => $iPReferId];
|
||||
$result = Db::name('production_article_refer')->where($aWhere)->limit(1)->update($aUpdate);
|
||||
if($result === false){
|
||||
return json_encode(['status' => 6,'msg' => 'Update failed-Cs']);
|
||||
}
|
||||
return json_encode(['status' => 1,'msg' => 'Update successful']);
|
||||
}
|
||||
|
||||
|
||||
public function getOneDoi(){
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
"doi"=>"require"
|
||||
]);
|
||||
if(!$rule->check($data)){
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
$sCheckDoi = $this->fetchSingleDoiWithRetry($this->filterValidDoi($data['doi'])); // 过滤非法DOI
|
||||
return jsonSuccess($sCheckDoi);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 过滤非法DOI(仅保留10.xxxx/xxx格式)
|
||||
*/
|
||||
private function filterValidDoi($doi = ''){
|
||||
$doi = trim($doi);
|
||||
if (empty($doi)) return '';
|
||||
// 正则匹配:10. + 至少4位数字 + / + 任意字符
|
||||
if (preg_match('/^10\.\d{4,}\/.+/', $doi)) {
|
||||
return $doi;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 单DOI查询
|
||||
*/
|
||||
private function fetchSingleDoiWithRetry($doi){
|
||||
$retryCount = 0;
|
||||
while ($retryCount < $this->maxRetry) {
|
||||
$url = $this->crossrefUrl. rawurlencode($doi) . "?mailto=" . rawurlencode($this->mailto);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
"User-Agent: DOI-Fetcher/1.0 (mailto:{$this->mailto})"
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
// 成功返回
|
||||
if ($httpCode == 200) {
|
||||
$data = json_decode($response, true);
|
||||
return $data['status'] == 'ok' ? $data['message'] : null;
|
||||
}
|
||||
|
||||
// 429速率限制:延长等待后重试
|
||||
if ($httpCode == 429) {
|
||||
sleep(5);
|
||||
$retryCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$retryCount++;
|
||||
sleep(1); // 普通失败,1秒后重试
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取标题
|
||||
*/
|
||||
private function getTitle($aDoiInfo = []){
|
||||
return $aDoiInfo['title'][0] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取出版社名
|
||||
*/
|
||||
private function getPublisher($aDoiInfo = []){
|
||||
$aJournal = [
|
||||
'title' => isset($aDoiInfo['container-title'][0]) ? $aDoiInfo['container-title'][0] : '',
|
||||
'short_title'=> isset($aDoiInfo['short-container-title'][0]) ? $aDoiInfo['short-container-title'][0] : '',
|
||||
'ISSN' => $aDoiInfo['ISSN'] ?? [],
|
||||
'publisher' => $aDoiInfo['publisher'] ?? '',
|
||||
];
|
||||
return $aJournal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取作者
|
||||
*/
|
||||
private function getAuthors($aDoiInfo = []){
|
||||
$authors = [];
|
||||
if (!empty($aDoiInfo['author'])) {
|
||||
foreach ($aDoiInfo['author'] as $author) {
|
||||
$name = $author['family'] ?? '';
|
||||
if (!empty($author['given'])) {
|
||||
$name = $author['given'] . ' ' . $name;
|
||||
}
|
||||
if (!empty($name)) {
|
||||
$authors[] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $authors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取发表年份
|
||||
*/
|
||||
private function getPublishYear($aDoiInfo = []){
|
||||
if (!empty($aDoiInfo['issued']['date-parts'][0][0])) {
|
||||
return (string)$aDoiInfo['issued']['date-parts'][0][0];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取卷(期):起始页-终止页(格式:2024;10(2):100-120)
|
||||
*/
|
||||
private function getVolumeIssuePages($aDoiInfo = []){
|
||||
$parts = [];
|
||||
// 年
|
||||
$year = $this->getPublishYear($aDoiInfo);
|
||||
if ($year) $parts[] = $year;
|
||||
|
||||
// 卷(期)
|
||||
$volume = $aDoiInfo['volume'] ?? '';
|
||||
$issue = $aDoiInfo['issue'] ?? '';
|
||||
if ($volume) {
|
||||
$volumeIssue = $volume . ($issue ? "({$issue})" : '');
|
||||
$parts[] = $volumeIssue;
|
||||
}
|
||||
|
||||
// 起始页-终止页
|
||||
$pageStart = $aDoiInfo['page']['start'] ?? ($aDoiInfo['first-page'] ?? '');
|
||||
$pageEnd = $aDoiInfo['page']['end'] ?? ($aDoiInfo['last-page'] ?? '');
|
||||
$pages = '';
|
||||
if ($pageStart) {
|
||||
$pages = $pageStart . ($pageEnd ? "-{$pageEnd}" : '');
|
||||
}else{
|
||||
$pages = $aDoiInfo['page'] ?? '';
|
||||
}
|
||||
if ($pages) $parts[] = $pages;
|
||||
|
||||
return implode(':', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别撤稿文章
|
||||
*/
|
||||
private function checkRetracted($aDoiInfo = []){
|
||||
$isRetracted = false;
|
||||
$reason = "未撤稿";
|
||||
|
||||
// 1. 文章类型为撤稿声明type/subtype
|
||||
$sType = strtolower($aDoiInfo['type'] ?? '');
|
||||
$sSubtype = strtolower($aDoiInfo['subtype'] ?? '');
|
||||
if (isset($sType) && in_array($sType, ['retraction', 'correction'])) {
|
||||
$isRetracted = true;
|
||||
$reason = "文章类型为{$sType}(撤稿/更正声明)";
|
||||
}
|
||||
|
||||
if (isset($sSubtype) && in_array($sSubtype, ['retraction', 'correction'])) {
|
||||
$isRetracted = true;
|
||||
$reason = "文章类型为{$sSubtype}(撤稿/更正声明)";
|
||||
}
|
||||
// 2. update-type包含撤稿
|
||||
if (isset($aDoiInfo['update-type']) && in_array('retraction', $aDoiInfo['update-type'])) {
|
||||
$isRetracted = true;
|
||||
$reason = "官方标记为撤稿(update-type: retraction)";
|
||||
}
|
||||
|
||||
// 3. 关联撤稿文章
|
||||
if (isset($aDoiInfo['relation']) && !empty($aDoiInfo['relation'])) {
|
||||
foreach ($aDoiInfo['relation'] as $relType => $relItems) {
|
||||
if (in_array($relType, ['is-retraction-of', 'corrects'])) {
|
||||
$isRetracted = true;
|
||||
$relatedDoi = $relItems[0]['id'] ?? '未知';
|
||||
$reason = "关联撤稿文章{$relatedDoi}(关系:{$relType})";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. update-to 字段
|
||||
if (isset($aDoiInfo['update-to']) && is_array($aDoiInfo['update-to'])) {
|
||||
foreach ($aDoiInfo['update-to'] as $update) {
|
||||
$updateType = strtolower($update['type'] ?? '');
|
||||
$updateLabel = strtolower($update['label'] ?? '');
|
||||
if (strpos($updateType, 'retract') !== false || strpos($updateLabel, 'retract') !== false) {
|
||||
$isRetracted = true;
|
||||
$retractionDetail['retraction_notice'] = [
|
||||
'type' => $update['type'] ?? '',
|
||||
'label' => $update['label'] ?? '',
|
||||
'DOI' => $update['DOI'] ?? '',
|
||||
'date' => isset($update['updated']) ? $this->parseDateParts($update['updated']) : '',
|
||||
];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
//5.title 关键词
|
||||
$aTitles = $aDoiInfo['title'] ?? [];
|
||||
foreach ($aTitles as $value) {
|
||||
$sTitleLower = strtolower($value);
|
||||
if (strpos($sTitleLower, 'retraction') !== false || strpos($sTitleLower, 'retracted') !== false
|
||||
|| strpos($sTitleLower, 'withdrawal') !== false || strpos($sTitleLower, 'withdrawn') !== false) {
|
||||
$isRetracted = true;
|
||||
$retractionDetail['title_keyword'] = $value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [
|
||||
'is_retracted' => $isRetracted,
|
||||
'reason' => $reason
|
||||
];
|
||||
}
|
||||
/**
|
||||
* 识别doi链接
|
||||
*/
|
||||
private function getDolink($aDoiInfo = []){
|
||||
return $aDoiInfo['URL'] ?? '';
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,31 +4,22 @@ namespace app\api\controller;
|
||||
|
||||
use think\Cache;
|
||||
use think\Db;
|
||||
use GuzzleHttp\Client;
|
||||
use think\Queue;
|
||||
use think\Validate;
|
||||
use app\common\ExpertFinderService;
|
||||
|
||||
class ExpertFinder extends Base
|
||||
{
|
||||
private $httpClient;
|
||||
private $ncbiBaseUrl = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/';
|
||||
private $service;
|
||||
|
||||
public function __construct(\think\Request $request = null)
|
||||
{
|
||||
parent::__construct($request);
|
||||
$this->httpClient = new Client([
|
||||
'timeout' => 180,
|
||||
'connect_timeout' => 15,
|
||||
'verify' => false,
|
||||
]);
|
||||
$this->service = new ExpertFinderService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main search endpoint
|
||||
* Params:
|
||||
* keyword - search term (e.g. "biomedical engineering")
|
||||
* page - page number, default 1
|
||||
* per_page - articles per page, default 100, max 100
|
||||
* min_year - earliest publication year, default current-3
|
||||
* source - "pubmed" (fast, email from affiliation) or "pmc" (slower, structured email)
|
||||
*/
|
||||
public function search()
|
||||
{
|
||||
@@ -49,16 +40,12 @@ class ExpertFinder extends Base
|
||||
}
|
||||
|
||||
try {
|
||||
if ($source === 'pmc') {
|
||||
$result = $this->searchViaPMC($keyword, $perPage, $minYear, $page);
|
||||
} else {
|
||||
$result = $this->searchViaPubMed($keyword, $perPage, $minYear, $page);
|
||||
}
|
||||
$result = $this->service->searchExperts($keyword, $perPage, $minYear, $page, $source);
|
||||
} catch (\Exception $e) {
|
||||
return jsonError('Search failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$saveResult = $this->saveExperts($result['experts'], $keyword, $source);
|
||||
$saveResult = $this->service->saveExperts($result['experts'], $keyword, $source);
|
||||
$result['saved_new'] = $saveResult['inserted'];
|
||||
$result['saved_exist'] = $saveResult['existing'];
|
||||
|
||||
@@ -67,23 +54,65 @@ class ExpertFinder extends Base
|
||||
return jsonSuccess($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试单个专家的国家解析(同步执行,立刻返回结果)
|
||||
*/
|
||||
public function cityTest(){
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
"expert_id"=>"require",
|
||||
"aff"=>"require"
|
||||
]);
|
||||
if(!$rule->check($data)){
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
$data1 = [
|
||||
'expert_id' => intval($data['expert_id']),
|
||||
'affiliation' => trim((string)$data['aff']),
|
||||
];
|
||||
|
||||
// Queue::push('app\api\job\FillExpertCountry@fire', $data1, 'FetchExpertCity');
|
||||
|
||||
|
||||
|
||||
$service = new ExpertFinderService();
|
||||
$service->fillExpertCountry($data['expert_id'], $data['aff']);
|
||||
$expert = Db::name('expert')->where('expert_id', intval($data['expert_id']))->find();
|
||||
return jsonSuccess([
|
||||
'country_id' => isset($expert['country_id']) ? $expert['country_id'] : null,
|
||||
'country' => isset($expert['country']) ? $expert['country'] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动国家解析:同时启动两条链,分别用不同模型并行处理。
|
||||
* 只需调一次,两条链各自链式执行直到全部处理完。
|
||||
*/
|
||||
public function batchFillCountry(){
|
||||
$service = new ExpertFinderService();
|
||||
|
||||
// $chain1 = $service->enqueueNextCountryFill(0, 'FetchExpertCity', '');
|
||||
$chain2 = $service->enqueueNextCountryFill(0, 'FetchExpertCityOne', 'http://125.39.141.154:10002/v1/chat/completions');
|
||||
|
||||
$pending = Db::name('expert')
|
||||
->where('affiliation', '<>', '')
|
||||
->where('country_id', 0)
|
||||
->where('state', '<>', 5)
|
||||
->count();
|
||||
|
||||
return jsonSuccess([
|
||||
// 'chain1_started' => $chain1,
|
||||
'chain2_started' => $chain2,
|
||||
'pending' => $pending,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get experts from local database
|
||||
* Params:
|
||||
* field - filter by field keyword (searches t_expert_field)
|
||||
* major_id - filter by major_id (searches t_expert_field)
|
||||
* state - filter by state (0-5), -1 for all
|
||||
* keyword - search name/email/affiliation
|
||||
* no_recent - if 1, exclude experts promoted within last N days (default 30)
|
||||
* recent_days - days threshold for no_recent filter, default 30
|
||||
* page - page number, default 1
|
||||
* per_page - items per page, default 20
|
||||
* min_experts - auto-fetch threshold, default 50
|
||||
*/
|
||||
public function getList()
|
||||
{
|
||||
$field = trim($this->request->param('field', ''));
|
||||
$majorId = intval($this->request->param('major_id', 0));
|
||||
$state = $this->request->param('state', '-1');
|
||||
$keyword = trim($this->request->param('keyword', ''));
|
||||
$noRecent = intval($this->request->param('no_recent', 0));
|
||||
@@ -93,16 +122,13 @@ class ExpertFinder extends Base
|
||||
$minExperts = max(0, intval($this->request->param('min_experts', 50)));
|
||||
|
||||
$query = Db::name('expert')->alias('e');
|
||||
$needJoin = ($field !== '' || $majorId > 0);
|
||||
$needJoin = ($field !== '');
|
||||
|
||||
if ($needJoin) {
|
||||
$query->join('t_expert_field ef', 'ef.expert_id = e.expert_id AND ef.state = 0', 'inner');
|
||||
if ($field !== '') {
|
||||
$query->where('ef.field', 'like', '%' . $field . '%');
|
||||
}
|
||||
if ($majorId > 0) {
|
||||
$query->where('ef.major_id', $majorId);
|
||||
}
|
||||
$query->group('e.expert_id');
|
||||
}
|
||||
|
||||
@@ -132,6 +158,7 @@ class ExpertFinder extends Base
|
||||
$item['fields'] = Db::name('expert_field')
|
||||
->where('expert_id', $item['expert_id'])
|
||||
->where('state', 0)
|
||||
->group('field')
|
||||
->column('field');
|
||||
}
|
||||
|
||||
@@ -171,9 +198,6 @@ class ExpertFinder extends Base
|
||||
|
||||
/**
|
||||
* Update expert state
|
||||
* Params:
|
||||
* expert_id - single id or comma-separated ids
|
||||
* state - new state (0-5)
|
||||
*/
|
||||
public function updateState()
|
||||
{
|
||||
@@ -195,9 +219,6 @@ class ExpertFinder extends Base
|
||||
|
||||
/**
|
||||
* Delete expert (soft: set state=5 blacklist, or hard delete)
|
||||
* Params:
|
||||
* expert_id - single id or comma-separated ids
|
||||
* hard - 1 for hard delete, default 0 (blacklist)
|
||||
*/
|
||||
public function deleteExpert()
|
||||
{
|
||||
@@ -221,7 +242,6 @@ class ExpertFinder extends Base
|
||||
|
||||
/**
|
||||
* Export search results to Excel
|
||||
* Same params as search(), exports current page results
|
||||
*/
|
||||
public function export()
|
||||
{
|
||||
@@ -240,11 +260,7 @@ class ExpertFinder extends Base
|
||||
|
||||
if (!$cached) {
|
||||
try {
|
||||
if ($source === 'pmc') {
|
||||
$cached = $this->searchViaPMC($keyword, $perPage, $minYear, $page);
|
||||
} else {
|
||||
$cached = $this->searchViaPubMed($keyword, $perPage, $minYear, $page);
|
||||
}
|
||||
$cached = $this->service->searchExperts($keyword, $perPage, $minYear, $page, $source);
|
||||
Cache::set($cacheKey, $cached, 3600);
|
||||
} catch (\Exception $e) {
|
||||
return jsonError('Search failed: ' . $e->getMessage());
|
||||
@@ -276,16 +292,66 @@ class ExpertFinder extends Base
|
||||
|
||||
// ==================== Cron / Auto Fetch ====================
|
||||
|
||||
/**
|
||||
* Daily cron: auto-fetch experts for all active fields in t_expert_fetch via queue.
|
||||
* No longer tied to journals; t_expert_fetch is the sole source of crawl targets.
|
||||
*/
|
||||
public function dailyFetchAll()
|
||||
{
|
||||
$perPage = max(10, intval($this->request->param('per_page', 10)));
|
||||
$source = $this->request->param('source', 'pubmed');
|
||||
$minYear = intval($this->request->param('min_year', date('Y') - 3));
|
||||
|
||||
$fetchList = Db::name('expert_fetch')
|
||||
->where('state', 0)
|
||||
->select();
|
||||
|
||||
if (empty($fetchList)) {
|
||||
return jsonSuccess(['msg' => 'No active fetch fields found', 'queued' => 0]);
|
||||
}
|
||||
|
||||
$queued = 0;
|
||||
$skipped = 0;
|
||||
$details = [];
|
||||
$todayStart = strtotime(date('Y-m-d'));
|
||||
|
||||
foreach ($fetchList as $item) {
|
||||
$keyword = trim($item['field']);
|
||||
$itemSource = trim($item['source'] ?: $source);
|
||||
if ($keyword === '') continue;
|
||||
|
||||
$fetchLog = $this->service->getFetchLog($keyword, $itemSource);
|
||||
if ($fetchLog['last_time'] >= $todayStart) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$delay = $queued * 10;
|
||||
\think\Queue::later($delay, 'app\api\job\FetchExperts@fire', [
|
||||
'field' => $keyword,
|
||||
'source' => $itemSource,
|
||||
'per_page' => $perPage,
|
||||
'min_year' => $minYear,
|
||||
], 'FetchExperts');
|
||||
|
||||
$queued++;
|
||||
$details[] = [
|
||||
'expert_fetch_id' => $item['expert_fetch_id'],
|
||||
'field' => $keyword,
|
||||
'source' => $itemSource,
|
||||
'delay_s' => $delay,
|
||||
];
|
||||
}
|
||||
|
||||
return jsonSuccess([
|
||||
'queued' => $queued,
|
||||
'skipped' => $skipped,
|
||||
'details' => $details,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron job: daily fetch experts for given keywords
|
||||
* Params:
|
||||
* keywords - comma-separated keywords (e.g. "biomedical engineering,tissue engineering")
|
||||
* source - pubmed or pmc, default pubmed
|
||||
* per_page - articles per page, default 100
|
||||
* min_year - default current-3
|
||||
*
|
||||
* Uses cache to remember which page was last fetched per keyword,
|
||||
* so each cron run fetches the next page automatically.
|
||||
*/
|
||||
public function cronFetch()
|
||||
{
|
||||
@@ -303,39 +369,14 @@ class ExpertFinder extends Base
|
||||
$report = [];
|
||||
|
||||
foreach ($keywords as $kw) {
|
||||
if (empty($kw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fetchLog = $this->getFetchLog($kw, $source);
|
||||
$page = $fetchLog['last_page'] + 1;
|
||||
if (empty($kw)) continue;
|
||||
|
||||
try {
|
||||
if ($source === 'pmc') {
|
||||
$result = $this->searchViaPMC($kw, $perPage, $minYear, $page);
|
||||
} else {
|
||||
$result = $this->searchViaPubMed($kw, $perPage, $minYear, $page);
|
||||
}
|
||||
|
||||
$saveResult = $this->saveExperts($result['experts'], $kw, $source);
|
||||
|
||||
$nextPage = $result['has_more'] ? $page : 0;
|
||||
$totalPages = isset($result['total_pages']) ? $result['total_pages'] : 0;
|
||||
$this->updateFetchLog($kw, $source, $nextPage, $totalPages);
|
||||
|
||||
$report[] = [
|
||||
'keyword' => $kw,
|
||||
'page' => $page,
|
||||
'experts_found' => $result['total'],
|
||||
'saved_new' => $saveResult['inserted'],
|
||||
'saved_exist' => $saveResult['existing'],
|
||||
'field_enriched' => $saveResult['field_enriched'],
|
||||
'has_more' => $result['has_more'],
|
||||
];
|
||||
$result = $this->service->doFetchForField($kw, $source, $perPage, $minYear);
|
||||
$report[] = $result;
|
||||
} catch (\Exception $e) {
|
||||
$report[] = [
|
||||
'keyword' => $kw,
|
||||
'page' => $page,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
@@ -357,7 +398,7 @@ class ExpertFinder extends Base
|
||||
}
|
||||
Cache::set($lockKey, 1, 300);
|
||||
|
||||
\think\Queue::push('app\api\job\FetchExperts', [
|
||||
\think\Queue::push('app\api\job\FetchExperts@fire', [
|
||||
'field' => $field,
|
||||
'source' => 'pubmed',
|
||||
'per_page' => 100,
|
||||
@@ -365,459 +406,17 @@ class ExpertFinder extends Base
|
||||
], 'FetchExperts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method: run a fetch for a single keyword (used by both cron and queue job)
|
||||
*/
|
||||
public function doFetchForField($field, $source = 'pubmed', $perPage = 100, $minYear = null)
|
||||
public function fetchOneField()
|
||||
{
|
||||
if ($minYear === null) {
|
||||
$minYear = date('Y') - 3;
|
||||
}
|
||||
|
||||
$fetchLog = $this->getFetchLog($field, $source);
|
||||
$page = $fetchLog['last_page'] + 1;
|
||||
|
||||
if ($source === 'pmc') {
|
||||
$result = $this->searchViaPMC($field, $perPage, $minYear, $page);
|
||||
} else {
|
||||
$result = $this->searchViaPubMed($field, $perPage, $minYear, $page);
|
||||
}
|
||||
|
||||
$saveResult = $this->saveExperts($result['experts'], $field, $source);
|
||||
|
||||
$nextPage = $result['has_more'] ? $page : 0;
|
||||
$totalPages = isset($result['total_pages']) ? $result['total_pages'] : 0;
|
||||
$this->updateFetchLog($field, $source, $nextPage, $totalPages);
|
||||
|
||||
return [
|
||||
'keyword' => $field,
|
||||
'page' => $page,
|
||||
'experts_found' => $result['total'],
|
||||
'saved_new' => $saveResult['inserted'],
|
||||
'saved_exist' => $saveResult['existing'],
|
||||
'field_enriched' => $saveResult['field_enriched'],
|
||||
'has_more' => $result['has_more'],
|
||||
];
|
||||
}
|
||||
|
||||
// ==================== PubMed Search ====================
|
||||
|
||||
private function searchViaPubMed($keyword, $perPage, $minYear, $page = 1)
|
||||
{
|
||||
set_time_limit(600);
|
||||
|
||||
$searchResult = $this->esearch('pubmed', $keyword, $perPage, $minYear, $page);
|
||||
$ids = $searchResult['ids'];
|
||||
$totalArticles = $searchResult['total'];
|
||||
|
||||
if (empty($ids)) {
|
||||
return $this->buildPagedResult([], 0, 0, $totalArticles, $page, $perPage, 'pubmed');
|
||||
}
|
||||
|
||||
$allAuthors = [];
|
||||
$batches = array_chunk($ids, 50);
|
||||
foreach ($batches as $batch) {
|
||||
$xml = $this->efetchWithRetry('pubmed', $batch);
|
||||
if ($xml) {
|
||||
$authors = $this->parsePubMedXml($xml);
|
||||
$allAuthors = array_merge($allAuthors, $authors);
|
||||
}
|
||||
usleep(400000);
|
||||
}
|
||||
|
||||
$experts = $this->aggregateExperts($allAuthors);
|
||||
|
||||
return $this->buildPagedResult($experts, count($experts), count($ids), $totalArticles, $page, $perPage, 'pubmed');
|
||||
}
|
||||
|
||||
// ==================== PMC Search ====================
|
||||
|
||||
private function searchViaPMC($keyword, $perPage, $minYear, $page = 1)
|
||||
{
|
||||
set_time_limit(600);
|
||||
|
||||
$searchResult = $this->esearch('pmc', $keyword, $perPage, $minYear, $page);
|
||||
$ids = $searchResult['ids'];
|
||||
$totalArticles = $searchResult['total'];
|
||||
|
||||
if (empty($ids)) {
|
||||
return $this->buildPagedResult([], 0, 0, $totalArticles, $page, $perPage, 'pmc');
|
||||
}
|
||||
|
||||
$allAuthors = [];
|
||||
$batches = array_chunk($ids, 5);
|
||||
foreach ($batches as $batch) {
|
||||
$xml = $this->efetchWithRetry('pmc', $batch);
|
||||
if ($xml) {
|
||||
$authors = $this->parsePMCXml($xml);
|
||||
$allAuthors = array_merge($allAuthors, $authors);
|
||||
}
|
||||
usleep(500000);
|
||||
}
|
||||
|
||||
$experts = $this->aggregateExperts($allAuthors);
|
||||
|
||||
return $this->buildPagedResult($experts, count($experts), count($ids), $totalArticles, $page, $perPage, 'pmc');
|
||||
}
|
||||
|
||||
// ==================== NCBI API Calls ====================
|
||||
|
||||
private function esearch($db, $keyword, $perPage, $minYear, $page = 1)
|
||||
{
|
||||
$term = $keyword . ' AND ' . $minYear . ':' . date('Y') . '[pdat]';
|
||||
$retstart = ($page - 1) * $perPage;
|
||||
|
||||
$response = $this->httpClient->get($this->ncbiBaseUrl . 'esearch.fcgi', [
|
||||
'query' => [
|
||||
'db' => $db,
|
||||
'term' => $term,
|
||||
'retstart' => $retstart,
|
||||
'retmax' => $perPage,
|
||||
'retmode' => 'json',
|
||||
'sort' => 'relevance',
|
||||
],
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
"field" => "require"
|
||||
]);
|
||||
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
$ids = $data['esearchresult']['idlist'] ?? [];
|
||||
$total = intval($data['esearchresult']['count'] ?? 0);
|
||||
|
||||
return ['ids' => $ids, 'total' => $total];
|
||||
}
|
||||
|
||||
private function efetch($db, $ids)
|
||||
{
|
||||
$response = $this->httpClient->post($this->ncbiBaseUrl . 'efetch.fcgi', [
|
||||
'form_params' => [
|
||||
'db' => $db,
|
||||
'id' => implode(',', $ids),
|
||||
'retmode' => 'xml',
|
||||
],
|
||||
]);
|
||||
|
||||
return $response->getBody()->getContents();
|
||||
}
|
||||
|
||||
private function efetchWithRetry($db, $ids, $maxRetries = 3)
|
||||
{
|
||||
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
|
||||
try {
|
||||
return $this->efetch($db, $ids);
|
||||
} catch (\Exception $e) {
|
||||
if ($attempt === $maxRetries) {
|
||||
if (count($ids) > 1) {
|
||||
$half = ceil(count($ids) / 2);
|
||||
$firstHalf = array_slice($ids, 0, $half);
|
||||
$secondHalf = array_slice($ids, $half);
|
||||
$xml1 = $this->efetchWithRetry($db, $firstHalf, 2);
|
||||
$xml2 = $this->efetchWithRetry($db, $secondHalf, 2);
|
||||
return $this->mergeXml($xml1, $xml2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
sleep($attempt * 2);
|
||||
}
|
||||
if (!$rule->check($data)) {
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function mergeXml($xml1, $xml2)
|
||||
{
|
||||
if (empty($xml1)) return $xml2;
|
||||
if (empty($xml2)) return $xml1;
|
||||
return $xml1 . "\n" . $xml2;
|
||||
}
|
||||
|
||||
// ==================== PubMed XML Parsing ====================
|
||||
|
||||
private function parsePubMedXml($xmlString)
|
||||
{
|
||||
$results = [];
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$xml = simplexml_load_string($xmlString);
|
||||
if ($xml === false) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
foreach ($xml->PubmedArticle as $article) {
|
||||
$citation = $article->MedlineCitation;
|
||||
$articleData = $citation->Article;
|
||||
|
||||
$title = $this->xmlNodeToString($articleData->ArticleTitle);
|
||||
$pmid = (string) $citation->PMID;
|
||||
|
||||
$journal = '';
|
||||
if (isset($articleData->Journal->Title)) {
|
||||
$journal = (string) $articleData->Journal->Title;
|
||||
}
|
||||
|
||||
if (!isset($articleData->AuthorList->Author)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($articleData->AuthorList->Author as $author) {
|
||||
$lastName = (string) ($author->LastName ?? '');
|
||||
$foreName = (string) ($author->ForeName ?? '');
|
||||
$fullName = trim($foreName . ' ' . $lastName);
|
||||
|
||||
if (empty($fullName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$email = '';
|
||||
$affiliation = '';
|
||||
|
||||
if (isset($author->AffiliationInfo)) {
|
||||
foreach ($author->AffiliationInfo as $affInfo) {
|
||||
$affText = (string) $affInfo->Affiliation;
|
||||
if (empty($affiliation)) {
|
||||
$affiliation = $affText;
|
||||
}
|
||||
if (empty($email)) {
|
||||
$email = $this->extractEmailFromText($affText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($email)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'name' => $fullName,
|
||||
'email' => strtolower($email),
|
||||
'affiliation' => $this->cleanAffiliation($affiliation),
|
||||
'article_title' => $title,
|
||||
'article_id' => $pmid,
|
||||
'journal' => $journal,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
// ==================== PMC XML Parsing ====================
|
||||
|
||||
private function parsePMCXml($xmlString)
|
||||
{
|
||||
$results = [];
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$xml = simplexml_load_string($xmlString);
|
||||
if ($xml === false) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
$articles = $xml->article ?? $xml->children();
|
||||
|
||||
foreach ($articles as $article) {
|
||||
if ($article->getName() !== 'article') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$front = $article->front;
|
||||
if (!$front) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$articleMeta = $front->{'article-meta'};
|
||||
if (!$articleMeta) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = $this->xmlNodeToString($articleMeta->{'title-group'}->{'article-title'} ?? null);
|
||||
$pmcId = '';
|
||||
if (isset($articleMeta->{'article-id'})) {
|
||||
foreach ($articleMeta->{'article-id'} as $idNode) {
|
||||
if ((string) $idNode['pub-id-type'] === 'pmc') {
|
||||
$pmcId = (string) $idNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$journal = '';
|
||||
if (isset($front->{'journal-meta'}->{'journal-title'})) {
|
||||
$journal = (string) $front->{'journal-meta'}->{'journal-title'};
|
||||
} elseif (isset($front->{'journal-meta'}->{'journal-title-group'}->{'journal-title'})) {
|
||||
$journal = (string) $front->{'journal-meta'}->{'journal-title-group'}->{'journal-title'};
|
||||
}
|
||||
|
||||
$correspEmails = [];
|
||||
if (isset($articleMeta->{'author-notes'})) {
|
||||
$this->extractEmailsFromNode($articleMeta->{'author-notes'}, $correspEmails);
|
||||
}
|
||||
|
||||
$affiliationMap = [];
|
||||
if (isset($articleMeta->{'contrib-group'})) {
|
||||
foreach ($articleMeta->{'contrib-group'}->children() as $child) {
|
||||
if ($child->getName() === 'aff') {
|
||||
$affId = (string) ($child['id'] ?? '');
|
||||
$affText = $this->xmlNodeToString($child);
|
||||
if ($affId) {
|
||||
$affiliationMap[$affId] = $affText;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($front->{'article-meta'}->{'aff'})) {
|
||||
foreach ($front->{'article-meta'}->{'aff'} as $aff) {
|
||||
$affId = (string) ($aff['id'] ?? '');
|
||||
$affText = $this->xmlNodeToString($aff);
|
||||
if ($affId) {
|
||||
$affiliationMap[$affId] = $affText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($articleMeta->{'contrib-group'})) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($articleMeta->{'contrib-group'}->contrib as $contrib) {
|
||||
$contribType = (string) ($contrib['contrib-type'] ?? '');
|
||||
if ($contribType !== 'author') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$nameNode = $contrib->name;
|
||||
if (!$nameNode) {
|
||||
continue;
|
||||
}
|
||||
$surname = (string) ($nameNode->surname ?? '');
|
||||
$givenNames = (string) ($nameNode->{'given-names'} ?? '');
|
||||
$fullName = trim($givenNames . ' ' . $surname);
|
||||
|
||||
if (empty($fullName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$email = '';
|
||||
if (isset($contrib->email)) {
|
||||
$email = strtolower(trim((string) $contrib->email));
|
||||
}
|
||||
|
||||
$affiliation = '';
|
||||
if (isset($contrib->xref)) {
|
||||
foreach ($contrib->xref as $xref) {
|
||||
if ((string) $xref['ref-type'] === 'aff') {
|
||||
$rid = (string) $xref['rid'];
|
||||
if (isset($affiliationMap[$rid])) {
|
||||
$affiliation = $affiliationMap[$rid];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (empty($affiliation) && isset($contrib->aff)) {
|
||||
$affiliation = $this->xmlNodeToString($contrib->aff);
|
||||
}
|
||||
|
||||
$isCorresponding = false;
|
||||
if (isset($contrib->xref)) {
|
||||
foreach ($contrib->xref as $xref) {
|
||||
if ((string) $xref['ref-type'] === 'corresp') {
|
||||
$isCorresponding = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((string) ($contrib['corresp'] ?? '') === 'yes') {
|
||||
$isCorresponding = true;
|
||||
}
|
||||
|
||||
if (empty($email) && $isCorresponding && !empty($correspEmails)) {
|
||||
$email = $correspEmails[0];
|
||||
}
|
||||
|
||||
if (empty($email)) {
|
||||
$extracted = $this->extractEmailFromText($affiliation);
|
||||
if ($extracted) {
|
||||
$email = $extracted;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($email)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'name' => $fullName,
|
||||
'email' => strtolower($email),
|
||||
'affiliation' => $this->cleanAffiliation($affiliation),
|
||||
'article_title' => $title,
|
||||
'article_id' => $pmcId,
|
||||
'journal' => $journal,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
// ==================== Pagination ====================
|
||||
|
||||
private function buildPagedResult($experts, $expertCount, $articlesScanned, $totalArticles, $page, $perPage, $source)
|
||||
{
|
||||
$totalPages = $totalArticles > 0 ? ceil($totalArticles / $perPage) : 0;
|
||||
|
||||
return [
|
||||
'experts' => $experts,
|
||||
'total' => $expertCount,
|
||||
'articles_scanned' => $articlesScanned,
|
||||
'total_articles' => $totalArticles,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total_pages' => $totalPages,
|
||||
'has_more' => $page < $totalPages,
|
||||
'source' => $source,
|
||||
];
|
||||
}
|
||||
|
||||
// ==================== Aggregation ====================
|
||||
|
||||
private function aggregateExperts($authorRecords)
|
||||
{
|
||||
$map = [];
|
||||
|
||||
foreach ($authorRecords as $record) {
|
||||
$key = strtolower(trim($record['email']));
|
||||
if (empty($key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($map[$key])) {
|
||||
$map[$key] = [
|
||||
'name' => $record['name'],
|
||||
'email' => $record['email'],
|
||||
'affiliation' => $record['affiliation'],
|
||||
'paper_count' => 0,
|
||||
'papers' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$map[$key]['paper_count']++;
|
||||
|
||||
if (count($map[$key]['papers']) < 10) {
|
||||
$map[$key]['papers'][] = [
|
||||
'title' => $record['article_title'],
|
||||
'article_id' => $record['article_id'],
|
||||
'journal' => $record['journal'],
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($map[$key]['affiliation']) && !empty($record['affiliation'])) {
|
||||
$map[$key]['affiliation'] = $record['affiliation'];
|
||||
}
|
||||
}
|
||||
|
||||
$experts = array_values($map);
|
||||
|
||||
usort($experts, function ($a, $b) {
|
||||
return $b['paper_count'] - $a['paper_count'];
|
||||
});
|
||||
|
||||
return $experts;
|
||||
$res = $this->service->doFetchForField($data['field'], "pubmed", 100, date('Y') - 3);
|
||||
return jsonSuccess($res);
|
||||
}
|
||||
|
||||
// ==================== Excel Export ====================
|
||||
@@ -880,188 +479,4 @@ class ExpertFinder extends Base
|
||||
'count' => count($experts),
|
||||
]);
|
||||
}
|
||||
|
||||
// ==================== Database Storage ====================
|
||||
|
||||
private function saveExperts($experts, $field, $source)
|
||||
{
|
||||
$inserted = 0;
|
||||
$existing = 0;
|
||||
$fieldEnrich = 0;
|
||||
|
||||
foreach ($experts as $expert) {
|
||||
$email = strtolower(trim($expert['email']));
|
||||
if (empty($email)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$exists = Db::name('expert')->where('email', $email)->find();
|
||||
|
||||
if ($exists) {
|
||||
$existing++;
|
||||
$fieldEnrich += $this->enrichExpertField($exists['expert_id'], $field);
|
||||
continue;
|
||||
}
|
||||
|
||||
$insert = [
|
||||
'name' => mb_substr($expert['name'], 0, 255),
|
||||
'email' => mb_substr($email, 0, 128),
|
||||
'affiliation' => mb_substr($expert['affiliation'], 0, 128),
|
||||
'source' => mb_substr($source, 0, 128),
|
||||
'ctime' => time(),
|
||||
'ltime' => 0,
|
||||
'state' => 0,
|
||||
];
|
||||
|
||||
try {
|
||||
$expertId = Db::name('expert')->insertGetId($insert);
|
||||
$this->enrichExpertField($expertId, $field);
|
||||
$inserted++;
|
||||
} catch (\Exception $e) {
|
||||
$existing++;
|
||||
}
|
||||
}
|
||||
|
||||
return ['inserted' => $inserted, 'existing' => $existing, 'field_enriched' => $fieldEnrich];
|
||||
}
|
||||
|
||||
// ==================== Fetch Log (t_expert_fetch) ====================
|
||||
|
||||
private function getFetchLog($field, $source)
|
||||
{
|
||||
$log = Db::name('expert_fetch')
|
||||
->where('field', $field)
|
||||
->where('source', $source)
|
||||
->find();
|
||||
|
||||
if (!$log) {
|
||||
return ['last_page' => 0, 'total_pages' => 0, 'last_time' => 0];
|
||||
}
|
||||
|
||||
return $log;
|
||||
}
|
||||
|
||||
private function updateFetchLog($field, $source, $lastPage, $totalPages)
|
||||
{
|
||||
$exists = Db::name('expert_fetch')
|
||||
->where('field', $field)
|
||||
->where('source', $source)
|
||||
->find();
|
||||
|
||||
if ($exists) {
|
||||
Db::name('expert_fetch')
|
||||
->where('expert_fetch_id', $exists['expert_fetch_id'])
|
||||
->update([
|
||||
'last_page' => $lastPage,
|
||||
'total_pages' => $totalPages,
|
||||
'last_time' => time(),
|
||||
]);
|
||||
} else {
|
||||
Db::name('expert_fetch')->insert([
|
||||
'field' => mb_substr($field, 0, 128),
|
||||
'source' => mb_substr($source, 0, 128),
|
||||
'last_page' => $lastPage,
|
||||
'total_pages' => $totalPages,
|
||||
'last_time' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function enrichExpertField($expertId, $field)
|
||||
{
|
||||
$field = trim($field);
|
||||
if (empty($field)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$exists = Db::name('expert_field')
|
||||
->where('expert_id', $expertId)
|
||||
->where('field', $field)
|
||||
->where('state', 0)
|
||||
->find();
|
||||
|
||||
if ($exists) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
Db::name('expert_field')->insert([
|
||||
'expert_id' => $expertId,
|
||||
'major_id' => 0,
|
||||
'field' => mb_substr($field, 0, 128),
|
||||
'state' => 0,
|
||||
]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
private function extractEmailFromText($text)
|
||||
{
|
||||
if (empty($text)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (preg_match('/[Ee]lectronic address:\s*([^\s;,]+@[^\s;,]+)/', $text, $m)) {
|
||||
return strtolower(trim($m[1], '.'));
|
||||
}
|
||||
|
||||
if (preg_match('/[Ee]-?mail:\s*([^\s;,]+@[^\s;,]+)/', $text, $m)) {
|
||||
return strtolower(trim($m[1], '.'));
|
||||
}
|
||||
|
||||
if (preg_match('/\b([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})\b/', $text, $m)) {
|
||||
return strtolower(trim($m[1], '.'));
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function extractEmailsFromNode($node, &$emails)
|
||||
{
|
||||
if ($node === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($node->children() as $child) {
|
||||
if ($child->getName() === 'email') {
|
||||
$email = strtolower(trim((string) $child));
|
||||
if (!empty($email) && !in_array($email, $emails)) {
|
||||
$emails[] = $email;
|
||||
}
|
||||
}
|
||||
$this->extractEmailsFromNode($child, $emails);
|
||||
}
|
||||
|
||||
$text = (string) $node;
|
||||
if (preg_match_all('/\b([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})\b/', $text, $matches)) {
|
||||
foreach ($matches[1] as $email) {
|
||||
$email = strtolower(trim($email, '.'));
|
||||
if (!in_array($email, $emails)) {
|
||||
$emails[] = $email;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanAffiliation($text)
|
||||
{
|
||||
$text = preg_replace('/\s*[Ee]lectronic address:\s*[^\s;,]+@[^\s;,]+/', '', $text);
|
||||
$text = preg_replace('/\s*[Ee]-?mail:\s*[^\s;,]+@[^\s;,]+/', '', $text);
|
||||
$text = preg_replace('/\s*\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b/', '', $text);
|
||||
$text = trim($text, " \t\n\r\0\x0B.,;");
|
||||
return $text;
|
||||
}
|
||||
|
||||
private function xmlNodeToString($node)
|
||||
{
|
||||
if ($node === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$xml = $node->asXML();
|
||||
$text = strip_tags($xml);
|
||||
$text = html_entity_decode($text, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
return trim(preg_replace('/\s+/', ' ', $text));
|
||||
}
|
||||
}
|
||||
|
||||
710
application/api/controller/ExpertManage.php
Normal file
710
application/api/controller/ExpertManage.php
Normal file
@@ -0,0 +1,710 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use think\Db;
|
||||
|
||||
class ExpertManage extends Base
|
||||
{
|
||||
private $stateMap = [
|
||||
0 => '待联系',
|
||||
1 => '已发邮件',
|
||||
2 => '已回复',
|
||||
3 => '已投稿',
|
||||
4 => '退信/无效',
|
||||
5 => '黑名单(退订)',
|
||||
];
|
||||
|
||||
public function __construct(\think\Request $request = null)
|
||||
{
|
||||
parent::__construct($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 专家列表(支持多条件筛选 + 分页)
|
||||
*
|
||||
* 参数:
|
||||
* keyword - 搜索姓名/邮箱/单位
|
||||
* field - 按领域关键词筛选
|
||||
* major_id - 按学科ID筛选
|
||||
* state - 状态筛选 (0-5, 传-1或不传则不过滤)
|
||||
* source - 来源筛选
|
||||
* pageIndex - 页码 (默认1)
|
||||
* pageSize - 每页条数 (默认20)
|
||||
*/
|
||||
public function getList()
|
||||
{
|
||||
$data = $this->request->param();
|
||||
$keyword = trim(isset($data['keyword']) ? $data['keyword'] : '');
|
||||
$field = trim(isset($data['field']) ? $data['field'] : '');
|
||||
$state = isset($data['state']) ? $data['state'] : '-1';
|
||||
$source = trim(isset($data['source']) ? $data['source'] : '');
|
||||
$page = max(1, intval(isset($data['pageIndex']) ? $data['pageIndex'] : 1));
|
||||
$pageSize = max(1, intval(isset($data['pageSize']) ? $data['pageSize'] : 20));
|
||||
|
||||
$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 ($state !== '-1' && $state !== '') {
|
||||
$query->where('e.state', intval($state));
|
||||
$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 . '%');
|
||||
}
|
||||
if ($source !== '') {
|
||||
$query->where('e.source', $source);
|
||||
$countQuery->where('e.source', $source);
|
||||
}
|
||||
|
||||
// $countQuery = clone $query;
|
||||
// $total = $countQuery->distinct('e.expert_id')->count();
|
||||
$total = $needJoin ? count($countQuery->group('e.expert_id')->column('e.expert_id')) : $countQuery->count();
|
||||
|
||||
$list = $query
|
||||
->field('e.*')
|
||||
->order('e.ctime desc')
|
||||
->page($page, $pageSize)
|
||||
->select();
|
||||
|
||||
foreach ($list as &$item) {
|
||||
// $item['fields'] = Db::name('expert_field')
|
||||
// ->where('expert_id', $item['expert_id'])
|
||||
// ->where('state', 0)
|
||||
// ->select();
|
||||
$item['state_text'] = isset($this->stateMap[$item['state']]) ? $this->stateMap[$item['state']] : '未知';
|
||||
$item['ctime_text'] = $item['ctime'] ? date('Y-m-d H:i:s', $item['ctime']) : '';
|
||||
$item['ltime_text'] = $item['ltime'] ? date('Y-m-d H:i:s', $item['ltime']) : '';
|
||||
}
|
||||
|
||||
return jsonSuccess([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'pageIndex' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
'totalPages' => $total > 0 ? ceil($total / $pageSize) : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取专家详情(含所有领域)
|
||||
*/
|
||||
public function getDetail()
|
||||
{
|
||||
$expertId = intval($this->request->param('expert_id', 0));
|
||||
if (!$expertId) {
|
||||
return jsonError('expert_id is required');
|
||||
}
|
||||
|
||||
$expert = Db::name('expert')->where('expert_id', $expertId)->find();
|
||||
if (!$expert) {
|
||||
return jsonError('专家不存在');
|
||||
}
|
||||
|
||||
$expert['fields'] = Db::name('expert_field')
|
||||
->where('expert_id', $expertId)
|
||||
->where('state', 0)
|
||||
->select();
|
||||
$expert['state_text'] = isset($this->stateMap[$expert['state']]) ? $this->stateMap[$expert['state']] : '未知';
|
||||
$expert['ctime_text'] = $expert['ctime'] ? date('Y-m-d H:i:s', $expert['ctime']) : '';
|
||||
$expert['ltime_text'] = $expert['ltime'] ? date('Y-m-d H:i:s', $expert['ltime']) : '';
|
||||
|
||||
return jsonSuccess($expert);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加专家
|
||||
*/
|
||||
public function addExpert()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$name = trim(isset($data['name']) ? $data['name'] : '');
|
||||
$email = trim(isset($data['email']) ? $data['email'] : '');
|
||||
|
||||
if ($name === '' || $email === '') {
|
||||
return jsonError('name和email不能为空');
|
||||
}
|
||||
|
||||
$exists = Db::name('expert')->where('email', $email)->find();
|
||||
if ($exists) {
|
||||
return jsonError('该邮箱已存在,expert_id=' . $exists['expert_id']);
|
||||
}
|
||||
|
||||
$insert = [
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'affiliation' => trim(isset($data['affiliation']) ? $data['affiliation'] : ''),
|
||||
'source' => trim(isset($data['source']) ? $data['source'] : 'manual'),
|
||||
'ctime' => time(),
|
||||
'ltime' => 0,
|
||||
'state' => 0,
|
||||
];
|
||||
|
||||
$expertId = Db::name('expert')->insertGetId($insert);
|
||||
|
||||
if (!empty($data['fields'])) {
|
||||
$this->saveExpertFields($expertId, $data['fields']);
|
||||
}
|
||||
|
||||
return jsonSuccess(['expert_id' => $expertId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑专家
|
||||
*/
|
||||
public function editExpert()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$expertId = intval(isset($data['expert_id']) ? $data['expert_id'] : 0);
|
||||
if (!$expertId) {
|
||||
return jsonError('expert_id is required');
|
||||
}
|
||||
|
||||
$expert = Db::name('expert')->where('expert_id', $expertId)->find();
|
||||
if (!$expert) {
|
||||
return jsonError('专家不存在');
|
||||
}
|
||||
|
||||
$update = [];
|
||||
if (isset($data['name'])) $update['name'] = trim($data['name']);
|
||||
if (isset($data['email'])) $update['email'] = trim($data['email']);
|
||||
if (isset($data['affiliation'])) $update['affiliation'] = trim($data['affiliation']);
|
||||
if (isset($data['source'])) $update['source'] = trim($data['source']);
|
||||
if (isset($data['state'])) $update['state'] = intval($data['state']);
|
||||
|
||||
if (!empty($update)) {
|
||||
Db::name('expert')->where('expert_id', $expertId)->update($update);
|
||||
}
|
||||
|
||||
if (isset($data['fields'])) {
|
||||
Db::name('expert_field')->where('expert_id', $expertId)->where('state', 0)->update(['state' => 1]);
|
||||
if (!empty($data['fields'])) {
|
||||
$this->saveExpertFields($expertId, $data['fields']);
|
||||
}
|
||||
}
|
||||
|
||||
return jsonSuccess(['expert_id' => $expertId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量修改状态
|
||||
*
|
||||
* 参数:
|
||||
* expert_ids - 逗号分隔的ID列表 "1,2,3"
|
||||
* state - 目标状态 0-5
|
||||
*/
|
||||
public function updateState()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$expertIds = isset($data['expert_ids']) ? $data['expert_ids'] : '';
|
||||
$state = intval(isset($data['state']) ? $data['state'] : -1);
|
||||
|
||||
if (empty($expertIds)) {
|
||||
return jsonError('expert_ids is required');
|
||||
}
|
||||
if ($state < 0 || $state > 5) {
|
||||
return jsonError('state取值范围0-5');
|
||||
}
|
||||
|
||||
$ids = array_map('intval', explode(',', $expertIds));
|
||||
$count = Db::name('expert')->where('expert_id', 'in', $ids)->update(['state' => $state]);
|
||||
|
||||
return jsonSuccess(['updated' => $count]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除专家(软删除,设为黑名单状态5)
|
||||
*
|
||||
* 参数:
|
||||
* expert_ids - 逗号分隔的ID列表
|
||||
* hard - 传1则物理删除
|
||||
*/
|
||||
public function deleteExpert()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$expertIds = isset($data['expert_ids']) ? $data['expert_ids'] : '';
|
||||
$hard = intval(isset($data['hard']) ? $data['hard'] : 0);
|
||||
|
||||
if (empty($expertIds)) {
|
||||
return jsonError('expert_ids is required');
|
||||
}
|
||||
|
||||
$ids = array_map('intval', explode(',', $expertIds));
|
||||
|
||||
if ($hard) {
|
||||
Db::name('expert_field')->where('expert_id', 'in', $ids)->delete();
|
||||
$count = Db::name('expert')->where('expert_id', 'in', $ids)->delete();
|
||||
} else {
|
||||
$count = Db::name('expert')->where('expert_id', 'in', $ids)->update(['state' => 5]);
|
||||
}
|
||||
|
||||
return jsonSuccess(['affected' => $count]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 给专家添加领域
|
||||
*/
|
||||
public function addField()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$expertId = intval(isset($data['expert_id']) ? $data['expert_id'] : 0);
|
||||
$field = trim(isset($data['field']) ? $data['field'] : '');
|
||||
|
||||
if (!$expertId || $field === '') {
|
||||
return jsonError('expert_id和field不能为空');
|
||||
}
|
||||
|
||||
$exists = Db::name('expert_field')
|
||||
->where('expert_id', $expertId)
|
||||
->where('field', $field)
|
||||
->where('state', 0)
|
||||
->find();
|
||||
if ($exists) {
|
||||
return jsonError('该领域已存在');
|
||||
}
|
||||
|
||||
$id = Db::name('expert_field')->insertGetId([
|
||||
'expert_id' => $expertId,
|
||||
'field' => $field,
|
||||
'state' => 0,
|
||||
]);
|
||||
|
||||
return jsonSuccess(['expert_field_id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除领域(软删除)
|
||||
*/
|
||||
public function removeField()
|
||||
{
|
||||
$efId = intval($this->request->param('expert_field_id', 0));
|
||||
if (!$efId) {
|
||||
return jsonError('expert_field_id is required');
|
||||
}
|
||||
|
||||
// 若 t_expert_field 同时存“领域行 + 论文行”,删除领域时应同时软删该领域下所有论文行
|
||||
$row = Db::name('expert_field')->where('expert_field_id', $efId)->find();
|
||||
if ($row) {
|
||||
Db::name('expert_field')
|
||||
->where('expert_id', intval($row['expert_id']))
|
||||
->where('field', (string)$row['field'])
|
||||
->update(['state' => 1]);
|
||||
} else {
|
||||
Db::name('expert_field')->where('expert_field_id', $efId)->update(['state' => 1]);
|
||||
}
|
||||
|
||||
return jsonSuccess([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有不重复的领域列表(用于筛选下拉框)
|
||||
*/
|
||||
public function getFieldOptions()
|
||||
{
|
||||
$list = Db::name('expert_field')
|
||||
->where('state', 0)
|
||||
->group('field')
|
||||
->column('field');
|
||||
|
||||
return jsonSuccess($list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有来源列表(用于筛选下拉框)
|
||||
*/
|
||||
public function getSourceOptions()
|
||||
{
|
||||
$list = Db::name('expert')
|
||||
->where('source', '<>', '')
|
||||
->group('source')
|
||||
->column('source');
|
||||
|
||||
return jsonSuccess($list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出某个领域的专家为Excel
|
||||
*
|
||||
* 参数:
|
||||
* field - 领域关键词(必填)
|
||||
* major_id - 学科ID(可选)
|
||||
* state - 状态筛选(可选,默认不过滤)
|
||||
* keyword - 搜索姓名/邮箱/单位
|
||||
* source - 来源筛选
|
||||
*/
|
||||
public function exportExcel()
|
||||
{
|
||||
$data = $this->request->param();
|
||||
$field = trim(isset($data['field']) ? $data['field'] : '');
|
||||
$state = isset($data['state']) ? $data['state'] : '-1';
|
||||
$keyword = trim(isset($data['keyword']) ? $data['keyword'] : '');
|
||||
$source = trim(isset($data['source']) ? $data['source'] : '');
|
||||
|
||||
$query = Db::name('expert')->alias('e');
|
||||
|
||||
if ($field !== '') {
|
||||
$query->join('t_expert_field ef', 'ef.expert_id = e.expert_id AND ef.state = 0', 'inner');
|
||||
if ($field !== '') {
|
||||
$query->where('ef.field', 'like', '%' . $field . '%');
|
||||
}
|
||||
$query->group('e.expert_id');
|
||||
}
|
||||
|
||||
if ($state !== '-1' && $state !== '') {
|
||||
$query->where('e.state', intval($state));
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
$query->where('e.name|e.email|e.affiliation', 'like', '%' . $keyword . '%');
|
||||
}
|
||||
if ($source !== '') {
|
||||
$query->where('e.source', $source);
|
||||
}
|
||||
|
||||
$list = $query->field('e.*')->order('e.ctime desc')->select();
|
||||
|
||||
if (empty($list)) {
|
||||
return jsonError('没有符合条件的数据可导出');
|
||||
}
|
||||
|
||||
$expertIds = array_column($list, 'expert_id');
|
||||
$allFields = Db::name('expert_field')
|
||||
->where('expert_id', 'in', $expertIds)
|
||||
->where('state', 0)
|
||||
->select();
|
||||
|
||||
$fieldMap = [];
|
||||
foreach ($allFields as $f) {
|
||||
$fieldMap[$f['expert_id']][] = $f['field'];
|
||||
}
|
||||
|
||||
vendor("PHPExcel.PHPExcel");
|
||||
|
||||
$objPHPExcel = new \PHPExcel();
|
||||
$sheet = $objPHPExcel->getActiveSheet();
|
||||
$sheet->setTitle('Expert List');
|
||||
|
||||
$headers = [
|
||||
'A' => '#',
|
||||
'B' => 'Name',
|
||||
'C' => 'Email',
|
||||
'D' => 'Affiliation',
|
||||
'E' => 'Source',
|
||||
'F' => 'Fields',
|
||||
'G' => 'State',
|
||||
'H' => 'Add Time',
|
||||
'I' => 'Last Promotion',
|
||||
];
|
||||
foreach ($headers as $col => $header) {
|
||||
$sheet->setCellValue($col . '1', $header);
|
||||
}
|
||||
|
||||
$headerStyle = [
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => ['type' => \PHPExcel_Style_Fill::FILL_SOLID, 'startcolor' => ['rgb' => '4472C4']],
|
||||
'alignment' => ['horizontal' => \PHPExcel_Style_Alignment::HORIZONTAL_CENTER],
|
||||
];
|
||||
$sheet->getStyle('A1:I1')->applyFromArray($headerStyle);
|
||||
|
||||
foreach ($list as $i => $item) {
|
||||
$row = $i + 2;
|
||||
$fields = isset($fieldMap[$item['expert_id']]) ? implode(', ', $fieldMap[$item['expert_id']]) : '';
|
||||
$stateText = isset($this->stateMap[$item['state']]) ? $this->stateMap[$item['state']] : '未知';
|
||||
|
||||
$sheet->setCellValue('A' . $row, $i + 1);
|
||||
$sheet->setCellValue('B' . $row, $item['name']);
|
||||
$sheet->setCellValueExplicit('C' . $row, $item['email'], \PHPExcel_Cell_DataType::TYPE_STRING);
|
||||
$sheet->setCellValue('D' . $row, $item['affiliation']);
|
||||
$sheet->setCellValue('E' . $row, $item['source']);
|
||||
$sheet->setCellValue('F' . $row, $fields);
|
||||
$sheet->setCellValue('G' . $row, $stateText);
|
||||
$sheet->setCellValue('H' . $row, $item['ctime'] ? date('Y-m-d H:i:s', $item['ctime']) : '');
|
||||
$sheet->setCellValue('I' . $row, $item['ltime'] ? date('Y-m-d H:i:s', $item['ltime']) : '');
|
||||
}
|
||||
|
||||
$sheet->getColumnDimension('A')->setWidth(6);
|
||||
$sheet->getColumnDimension('B')->setWidth(25);
|
||||
$sheet->getColumnDimension('C')->setWidth(35);
|
||||
$sheet->getColumnDimension('D')->setWidth(40);
|
||||
$sheet->getColumnDimension('E')->setWidth(15);
|
||||
$sheet->getColumnDimension('F')->setWidth(50);
|
||||
$sheet->getColumnDimension('G')->setWidth(15);
|
||||
$sheet->getColumnDimension('H')->setWidth(20);
|
||||
$sheet->getColumnDimension('I')->setWidth(20);
|
||||
|
||||
$label = $field !== '' ? preg_replace('/[^a-zA-Z0-9_\x{4e00}-\x{9fa5}]/u', '_', $field) : 'all';
|
||||
$filename = 'expert_' . $label . '_' . date('Ymd_His') . '.xlsx';
|
||||
|
||||
$dir = ROOT_PATH . 'public' . DS . 'exports';
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0777, true);
|
||||
}
|
||||
|
||||
$filepath = $dir . DS . $filename;
|
||||
$writer = \PHPExcel_IOFactory::createWriter($objPHPExcel, 'Excel2007');
|
||||
$writer->save($filepath);
|
||||
|
||||
return jsonSuccess([
|
||||
'file_url' => '/exports/' . $filename,
|
||||
'file_name' => $filename,
|
||||
'count' => count($list),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量保存专家领域
|
||||
* @param int $expertId
|
||||
* @param array $fields [{"field":"xxx"}, ...]
|
||||
*/
|
||||
private function saveExpertFields($expertId, $fields)
|
||||
{
|
||||
if (is_string($fields)) {
|
||||
$fields = json_decode($fields, true);
|
||||
}
|
||||
if (!is_array($fields)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($fields as $f) {
|
||||
$fieldName = trim(isset($f['field']) ? $f['field'] : '');
|
||||
if ($fieldName === '') continue;
|
||||
|
||||
$exists = Db::name('expert_field')
|
||||
->where('expert_id', $expertId)
|
||||
->where('field', $fieldName)
|
||||
->where('state', 0)
|
||||
->find();
|
||||
if ($exists) continue;
|
||||
|
||||
Db::name('expert_field')->insert([
|
||||
'expert_id' => $expertId,
|
||||
'field' => $fieldName,
|
||||
'state' => 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Expert Fetch Field Management ====================
|
||||
|
||||
/**
|
||||
* 获取抓取领域列表
|
||||
* 参数: state(-1不过滤), keyword(搜索field), pageIndex, pageSize
|
||||
*/
|
||||
public function getFetchList()
|
||||
{
|
||||
$data = $this->request->param();
|
||||
$state = isset($data['state']) ? $data['state'] : '-1';
|
||||
$keyword = trim(isset($data['keyword']) ? $data['keyword'] : '');
|
||||
$page = max(1, intval(isset($data['pageIndex']) ? $data['pageIndex'] : 1));
|
||||
$pageSize = max(1, intval(isset($data['pageSize']) ? $data['pageSize'] : 20));
|
||||
|
||||
$query = Db::name('expert_fetch');
|
||||
|
||||
if ($state !== '-1' && $state !== '') {
|
||||
$query->where('state', intval($state));
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
$query->where('field', 'like', '%' . $keyword . '%');
|
||||
}
|
||||
|
||||
$countQuery = Db::name('expert_fetch');
|
||||
if ($state !== '-1' && $state !== '') {
|
||||
$countQuery->where('state', intval($state));
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
$countQuery->where('field', 'like', '%' . $keyword . '%');
|
||||
}
|
||||
|
||||
$total = $countQuery->count();
|
||||
$list = $query->order('expert_fetch_id desc')->page($page, $pageSize)->select();
|
||||
|
||||
foreach ($list as &$item) {
|
||||
$item['last_time_text'] = $item['last_time'] ? date('Y-m-d H:i:s', $item['last_time']) : '';
|
||||
$item['ctime_text'] = $item['ctime'] ? date('Y-m-d H:i:s', $item['ctime']) : '';
|
||||
$fieldName = trim($item['field']);
|
||||
|
||||
$item['journal_count'] = Db::name('journal_promotion_field')
|
||||
->where('expert_fetch_id', $item['expert_fetch_id'])
|
||||
->where('state', 0)
|
||||
->count();
|
||||
|
||||
// 与推广选人一致:按字段关键词匹配,统计可用专家(去重)
|
||||
$item['expert_count'] = Db::name('expert_field')->alias('ef')
|
||||
->join('t_expert e', 'e.expert_id = ef.expert_id', 'inner')
|
||||
->where('ef.state', 0)
|
||||
->where('e.state', 0)
|
||||
->where('ef.field', 'like', '%' . $fieldName . '%')
|
||||
->count('distinct ef.expert_id');
|
||||
|
||||
|
||||
}
|
||||
|
||||
return jsonSuccess([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'pageIndex' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
'totalPages' => $total > 0 ? ceil($total / $pageSize) : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增抓取领域
|
||||
* 参数: field(必填), source(选填,默认pubmed)
|
||||
*/
|
||||
public function addFetchField()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$field = trim(isset($data['field']) ? $data['field'] : '');
|
||||
$source = trim(isset($data['source']) ? $data['source'] : 'pubmed');
|
||||
|
||||
if ($field === '') {
|
||||
return jsonError('field不能为空');
|
||||
}
|
||||
|
||||
$exists = Db::name('expert_fetch')
|
||||
->where('field', $field)
|
||||
->where('source', $source)
|
||||
->find();
|
||||
if ($exists) {
|
||||
if ($exists['state'] == 1) {
|
||||
Db::name('expert_fetch')
|
||||
->where('expert_fetch_id', $exists['expert_fetch_id'])
|
||||
->update(['state' => 0]);
|
||||
return jsonSuccess(['expert_fetch_id' => $exists['expert_fetch_id'], 'msg' => 'reactivated']);
|
||||
}
|
||||
return jsonError('该领域已存在 (expert_fetch_id=' . $exists['expert_fetch_id'] . ')');
|
||||
}
|
||||
|
||||
$id = Db::name('expert_fetch')->insertGetId([
|
||||
'field' => mb_substr($field, 0, 128),
|
||||
'source' => mb_substr($source, 0, 128),
|
||||
'last_page' => 0,
|
||||
'total_pages' => 0,
|
||||
'last_time' => 0,
|
||||
'state' => 0,
|
||||
'ctime' => time(),
|
||||
]);
|
||||
|
||||
return jsonSuccess(['expert_fetch_id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑抓取领域
|
||||
* 参数: expert_fetch_id(必填), field, source, state
|
||||
*/
|
||||
public function editFetchField()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$id = intval(isset($data['expert_fetch_id']) ? $data['expert_fetch_id'] : 0);
|
||||
if (!$id) {
|
||||
return jsonError('expert_fetch_id is required');
|
||||
}
|
||||
|
||||
$record = Db::name('expert_fetch')->where('expert_fetch_id', $id)->find();
|
||||
if (!$record) {
|
||||
return jsonError('记录不存在');
|
||||
}
|
||||
|
||||
$update = [];
|
||||
if (isset($data['field'])) $update['field'] = mb_substr(trim($data['field']), 0, 128);
|
||||
if (isset($data['source'])) $update['source'] = mb_substr(trim($data['source']), 0, 128);
|
||||
if (isset($data['state'])) $update['state'] = intval($data['state']);
|
||||
|
||||
if (!empty($update)) {
|
||||
Db::name('expert_fetch')->where('expert_fetch_id', $id)->update($update);
|
||||
}
|
||||
|
||||
return jsonSuccess(['expert_fetch_id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除/停用抓取领域(软删除 state=1)
|
||||
* 参数: expert_fetch_id(必填), hard(传1物理删除)
|
||||
*/
|
||||
public function deleteFetchField()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$id = intval(isset($data['expert_fetch_id']) ? $data['expert_fetch_id'] : 0);
|
||||
$hard = intval(isset($data['hard']) ? $data['hard'] : 0);
|
||||
|
||||
if (!$id) {
|
||||
return jsonError('expert_fetch_id is required');
|
||||
}
|
||||
|
||||
if ($hard) {
|
||||
Db::name('journal_promotion_field')->where('expert_fetch_id', $id)->delete();
|
||||
Db::name('expert_fetch')->where('expert_fetch_id', $id)->delete();
|
||||
} else {
|
||||
Db::name('expert_fetch')->where('expert_fetch_id', $id)->update(['state' => 1]);
|
||||
}
|
||||
|
||||
return jsonSuccess([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台手工将专家标记为退订
|
||||
* 参数: expert_id(必填) 或 expert_ids(逗号分隔批量)
|
||||
*/
|
||||
public function unsubscribe()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$idsRaw = trim((string)(isset($data['expert_ids']) ? $data['expert_ids'] : (isset($data['expert_id']) ? $data['expert_id'] : '')));
|
||||
if ($idsRaw === '') {
|
||||
return jsonError('expert_id 或 expert_ids 必填');
|
||||
}
|
||||
$ids = array_filter(array_map('intval', explode(',', $idsRaw)));
|
||||
if (empty($ids)) {
|
||||
return jsonError('expert_id 无效');
|
||||
}
|
||||
|
||||
$affected = Db::name('expert')
|
||||
->where('expert_id', 'in', $ids)
|
||||
->where('unsubscribed', 0)
|
||||
->update(['unsubscribed' => 1]);
|
||||
|
||||
return jsonSuccess([
|
||||
'requested' => count($ids),
|
||||
'affected' => intval($affected),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台手工恢复专家订阅状态
|
||||
* 参数: expert_id(必填) 或 expert_ids(逗号分隔批量)
|
||||
*/
|
||||
public function resubscribe()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$idsRaw = trim((string)(isset($data['expert_ids']) ? $data['expert_ids'] : (isset($data['expert_id']) ? $data['expert_id'] : '')));
|
||||
if ($idsRaw === '') {
|
||||
return jsonError('expert_id 或 expert_ids 必填');
|
||||
}
|
||||
$ids = array_filter(array_map('intval', explode(',', $idsRaw)));
|
||||
if (empty($ids)) {
|
||||
return jsonError('expert_id 无效');
|
||||
}
|
||||
|
||||
$affected = Db::name('expert')
|
||||
->where('expert_id', 'in', $ids)
|
||||
->where('unsubscribed', 1)
|
||||
->update(['unsubscribed' => 0]);
|
||||
|
||||
return jsonSuccess([
|
||||
'requested' => count($ids),
|
||||
'affected' => intval($affected),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -333,6 +333,17 @@ class Journal extends Base {
|
||||
$update['wechat_app_secret'] = $data['wechat_app_secret'];
|
||||
$aJournalUpdate['wechat_app_secret'] = $update['wechat_app_secret'];
|
||||
}
|
||||
if (isset($data['wechat_yboard_qrcode'])){
|
||||
$update['wechat_yboard_qrcode'] = $data['wechat_yboard_qrcode'];
|
||||
}
|
||||
|
||||
if(isset($data['editor_name'])&&$data['editor_name']!=''){
|
||||
$update['editor_name'] = $data['editor_name'];
|
||||
}
|
||||
|
||||
if(isset($data['databases'])&&$data['databases']!=''){
|
||||
$update['databases'] = $data['databases'];
|
||||
}
|
||||
|
||||
if(!empty($aJournalUpdate)){
|
||||
$aJournalUpdate['issn'] = $journal_info['issn'];
|
||||
@@ -440,6 +451,19 @@ class Journal extends Base {
|
||||
}
|
||||
}
|
||||
|
||||
public function uploadYboardQrcode()
|
||||
{
|
||||
$file = request()->file('qrcode_url');
|
||||
if ($file) {
|
||||
$info = $file->move(ROOT_PATH . 'public' . DS . 'journalyboardqrcode');
|
||||
if ($info) {
|
||||
return json(['code' => 0, 'upurl' => str_replace("\\", "/", $info->getSaveName())]);
|
||||
} else {
|
||||
return json(['code' => 1, 'msg' => $file->getError()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取微信公众号相关数量
|
||||
*/
|
||||
|
||||
261
application/api/controller/MailTemplate.php
Normal file
261
application/api/controller/MailTemplate.php
Normal file
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use think\Db;
|
||||
use think\Validate;
|
||||
|
||||
class MailTemplate extends Base
|
||||
{
|
||||
/**
|
||||
* Create or update a mail template (V2)
|
||||
* POST fields:
|
||||
* - template_id (optional)
|
||||
* - journal_id, scene, language, title, subject, body_html, body_text, variables_json, version, is_active
|
||||
*/
|
||||
public function saveTemplate()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
|
||||
$rule = new Validate([
|
||||
'journal_id' => 'require|number',
|
||||
'scene' => 'require',
|
||||
'language' => 'require',
|
||||
'title' => 'require',
|
||||
'subject' => 'require',
|
||||
]);
|
||||
if (!$rule->check($data)) {
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'journal_id' => intval($data['journal_id']),
|
||||
'scene' => trim($data['scene']),
|
||||
'language' => trim($data['language']),
|
||||
'title' => trim($data['title']),
|
||||
'subject' => trim($data['subject']),
|
||||
'body_html' => $data['body_html'] ?? '',
|
||||
'body_text' => $data['body_text'] ?? '',
|
||||
'variables_json' => $data['variables_json'] ?? '',
|
||||
'version' => intval($data['version'] ?? 1),
|
||||
'is_active' => intval($data['is_active'] ?? 1),
|
||||
'utime' => time(),
|
||||
];
|
||||
|
||||
$templateId = intval($data['template_id'] ?? 0);
|
||||
if ($templateId) {
|
||||
$exists = Db::name('mail_template')->where('template_id', $templateId)->where('state', 0)->find();
|
||||
if (!$exists) {
|
||||
return jsonError('Template not found');
|
||||
}
|
||||
Db::name('mail_template')->where('template_id', $templateId)->update($payload);
|
||||
return jsonSuccess(['template_id' => $templateId]);
|
||||
}
|
||||
|
||||
$payload['ctime'] = time();
|
||||
$payload['state'] = 0;
|
||||
$newId = Db::name('mail_template')->insertGetId($payload);
|
||||
return jsonSuccess(['template_id' => $newId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a template
|
||||
* Params: template_id
|
||||
*/
|
||||
public function deleteTemplate()
|
||||
{
|
||||
$templateId = intval($this->request->param('template_id', 0));
|
||||
if (!$templateId) {
|
||||
return jsonError('template_id is required');
|
||||
}
|
||||
|
||||
Db::name('mail_template')->where('template_id', $templateId)->update(['state' => 1, 'utime' => time()]);
|
||||
return jsonSuccess([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get template detail
|
||||
*/
|
||||
public function getTemplate()
|
||||
{
|
||||
$templateId = intval($this->request->param('template_id', 0));
|
||||
if (!$templateId) {
|
||||
return jsonError('template_id is required');
|
||||
}
|
||||
|
||||
$tpl = Db::name('mail_template')->where('template_id', $templateId)->where('state', 0)->find();
|
||||
if (!$tpl) {
|
||||
return jsonError('Template not found');
|
||||
}
|
||||
return jsonSuccess(['template' => $tpl]);
|
||||
}
|
||||
|
||||
/**
|
||||
* List templates
|
||||
* Params: journal_id, scene(optional), language(optional), is_active(optional)
|
||||
*/
|
||||
public function listTemplates()
|
||||
{
|
||||
$journalId = intval($this->request->param('journal_id', 0));
|
||||
if (!$journalId) {
|
||||
return jsonError('journal_id is required');
|
||||
}
|
||||
|
||||
$scene = trim($this->request->param('scene', ''));
|
||||
$language = trim($this->request->param('language', ''));
|
||||
$isActive = $this->request->param('is_active', '');
|
||||
|
||||
$where = ['journal_id' => $journalId, 'state' => 0];
|
||||
if ($scene !== '') $where['scene'] = $scene;
|
||||
if ($language !== '') $where['language'] = $language;
|
||||
if ($isActive !== '') $where['is_active'] = intval($isActive);
|
||||
|
||||
$list = Db::name('mail_template')
|
||||
->where($where)
|
||||
->order('is_active desc, utime desc, template_id desc')
|
||||
->select();
|
||||
|
||||
return jsonSuccess(['list' => $list]);
|
||||
}
|
||||
|
||||
public function listTemplatesAll(){
|
||||
$list = Db::name('mail_template')
|
||||
->where('state', 0)
|
||||
->order('is_active desc, utime desc, template_id desc')
|
||||
->select();
|
||||
return jsonSuccess(['list'=>$list]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a global mail style
|
||||
* 当前 style 表字段:
|
||||
* - style_id, name, description, header_html, footer_html, state, ctime
|
||||
*/
|
||||
public function saveStyle()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
'name' => 'require',
|
||||
]);
|
||||
if (!$rule->check($data)) {
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'name' => trim($data['name']),
|
||||
'description' => trim($data['description'] ?? ''),
|
||||
'header_html' => $data['header_html'] ?? '',
|
||||
'footer_html' => $data['footer_html'] ?? '',
|
||||
'state' => 0,
|
||||
];
|
||||
|
||||
$styleId = intval($data['style_id'] ?? 0);
|
||||
if ($styleId) {
|
||||
$exists = Db::name('mail_style')->where('style_id', $styleId)->where('state', 0)->find();
|
||||
if (!$exists) {
|
||||
return jsonError('Style not found');
|
||||
}
|
||||
Db::name('mail_style')->where('style_id', $styleId)->update($payload);
|
||||
return jsonSuccess(['style_id' => $styleId]);
|
||||
}
|
||||
|
||||
$payload['ctime'] = time();
|
||||
$newId = Db::name('mail_style')->insertGetId($payload);
|
||||
return jsonSuccess(['style_id' => $newId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a global mail style (soft delete)
|
||||
*/
|
||||
public function deleteStyle()
|
||||
{
|
||||
$styleId = intval($this->request->param('style_id', 0));
|
||||
if (!$styleId) {
|
||||
return jsonError('style_id is required');
|
||||
}
|
||||
|
||||
Db::name('mail_style')->where('style_id', $styleId)->update(['state' => 1]);
|
||||
return jsonSuccess([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get style detail
|
||||
*/
|
||||
public function getStyle()
|
||||
{
|
||||
$styleId = intval($this->request->param('style_id', 0));
|
||||
if (!$styleId) {
|
||||
return jsonError('style_id is required');
|
||||
}
|
||||
|
||||
$style = Db::name('mail_style')->where('style_id', $styleId)->where('state', 0)->find();
|
||||
if (!$style) {
|
||||
return jsonError('Style not found');
|
||||
}
|
||||
return jsonSuccess(['style' => $style]);
|
||||
}
|
||||
|
||||
/**
|
||||
* List styles
|
||||
* 现在样式不再按 scene / language 区分,只返回全部正常状态的样式
|
||||
*/
|
||||
public function listStyles()
|
||||
{
|
||||
$where = ['state' => 0];
|
||||
|
||||
$list = Db::name('mail_style')
|
||||
->where($where)
|
||||
->order('style_id desc')
|
||||
->select();
|
||||
|
||||
return jsonSuccess(['list' => $list]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render preview for a template预览效果,没啥用
|
||||
* Params: template_id, vars (json string)
|
||||
*/
|
||||
public function preview()
|
||||
{
|
||||
$templateId = intval($this->request->param('template_id', 0));
|
||||
$varsJson = $this->request->param('vars', '');
|
||||
if (!$templateId) {
|
||||
return jsonError('template_id is required');
|
||||
}
|
||||
|
||||
$tpl = Db::name('mail_template')->where('template_id', $templateId)->where('state', 0)->find();
|
||||
if (!$tpl) {
|
||||
return jsonError('Template not found');
|
||||
}
|
||||
|
||||
$vars = [];
|
||||
if ($varsJson) {
|
||||
$decoded = json_decode($varsJson, true);
|
||||
if (is_array($decoded)) $vars = $decoded;
|
||||
}
|
||||
|
||||
$subject = $this->render($tpl['subject'], $vars);
|
||||
$body = $this->render($tpl['body_html'], $vars);
|
||||
|
||||
// For preview we do not enforce a style; caller can combine with style if needed
|
||||
return jsonSuccess(['subject' => $subject, 'body' => $body]);
|
||||
}
|
||||
|
||||
private function render($tpl, $vars)
|
||||
{
|
||||
if (!is_string($tpl) || empty($tpl)) return '';
|
||||
if (!is_array($vars) || empty($vars)) return $tpl;
|
||||
|
||||
$replace = [];
|
||||
foreach ($vars as $k => $v) {
|
||||
$key = trim((string)$k);
|
||||
if ($key === '') continue;
|
||||
$replace['{{' . $key . '}}'] = (string)$v;
|
||||
// backward compatible placeholders
|
||||
$replace['{' . $key . '}'] = (string)$v;
|
||||
}
|
||||
|
||||
return str_replace(array_keys($replace), array_values($replace), $tpl);
|
||||
}
|
||||
}
|
||||
|
||||
226
application/api/controller/Plagiarism.php
Normal file
226
application/api/controller/Plagiarism.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use think\Db;
|
||||
use think\Response;
|
||||
use app\common\PlagiarismService;
|
||||
|
||||
/**
|
||||
* 论文查重(Turnitin / Crossref Similarity Check)控制器。
|
||||
*
|
||||
* 触发方式:纯手工(编辑后台点"查重"按钮)。
|
||||
* 报告策略:在线 viewer URL 临时签名 + PDF 永久落盘 runtime/plagiarism/。
|
||||
*
|
||||
* 主要接口:
|
||||
* POST submit 触发查重
|
||||
* GET getStatus 轮询单条查重状态(前端 ajax)
|
||||
* GET getList 列出某 article 的全部查重记录
|
||||
* GET getReportUrl 获取/刷新在线查看 URL
|
||||
* GET downloadReport 下载本地 PDF
|
||||
* POST retry 重新触发(创建新行)
|
||||
* GET features 探活(开发调试用)
|
||||
*/
|
||||
class Plagiarism extends Base
|
||||
{
|
||||
public function __construct(\think\Request $request = null)
|
||||
{
|
||||
parent::__construct($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发查重
|
||||
*
|
||||
* 入参:
|
||||
* article_id 必填
|
||||
* file_url 选填;不传则按 article_id 在 t_article_file 找 manuscirpt
|
||||
* editor_id 选填;触发人 user_id(前端拿不到也可以传 0)
|
||||
*/
|
||||
public function submit()
|
||||
{
|
||||
$articleId = intval($this->request->param('article_id', 0));
|
||||
$fileUrl = trim($this->request->param('file_url', ''));
|
||||
$editorId = intval($this->request->param('editor_id', 0));
|
||||
|
||||
if ($articleId <= 0) {
|
||||
return jsonError('article_id required');
|
||||
}
|
||||
|
||||
try {
|
||||
$svc = new PlagiarismService();
|
||||
$localPath = $fileUrl !== ''
|
||||
? $svc->resolveFileUrlToLocal($fileUrl)
|
||||
: $svc->locateArticleManuscript($articleId);
|
||||
echo $localPath;
|
||||
$checkId = $svc->submit($articleId, $localPath, $editorId, 'manual');
|
||||
return jsonSuccess(['check_id' => $checkId]);
|
||||
} catch (\Throwable $e) {
|
||||
return jsonError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function testccone(){
|
||||
$svc = new PlagiarismService();
|
||||
$checkId = 9;
|
||||
$filePath = "/home/wwwroot/api.tmrjournals.com/public/manuscirpt/20260509/6832a56e8ace38fe99df390ab5221deb.docx";
|
||||
$svc->runUploadAndTrigger($checkId,$filePath);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试 = 提交一次新查重(保留历史)
|
||||
*/
|
||||
public function retry()
|
||||
{
|
||||
return $this->submit();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取单条查重状态
|
||||
*/
|
||||
public function getStatus()
|
||||
{
|
||||
$checkId = intval($this->request->param('check_id', 0));
|
||||
if ($checkId <= 0) {
|
||||
return jsonError('check_id required');
|
||||
}
|
||||
$row = Db::name('plagiarism_check')->where('check_id', $checkId)->find();
|
||||
if (!$row) {
|
||||
return jsonError('not found');
|
||||
}
|
||||
return jsonSuccess($this->formatRow($row));
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出某 article 的全部查重记录(按时间倒序)
|
||||
*/
|
||||
public function getList()
|
||||
{
|
||||
$articleId = intval($this->request->param('article_id', 0));
|
||||
if ($articleId <= 0) {
|
||||
return jsonError('article_id required');
|
||||
}
|
||||
$rows = Db::name('plagiarism_check')
|
||||
->where('article_id', $articleId)
|
||||
->order('check_id desc')
|
||||
->select();
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$out[] = $this->formatRow($r);
|
||||
}
|
||||
return jsonSuccess(['list' => $out]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取在线查看 URL;过期则自动刷新
|
||||
*/
|
||||
public function getReportUrl()
|
||||
{
|
||||
$checkId = intval($this->request->param('check_id', 0));
|
||||
if ($checkId <= 0) {
|
||||
return jsonError('check_id required');
|
||||
}
|
||||
try {
|
||||
$row = Db::name('plagiarism_check')->where('check_id', $checkId)->find();
|
||||
if (!$row) {
|
||||
return jsonError('not found');
|
||||
}
|
||||
if ($row['state'] != 3) {
|
||||
return jsonError('check not completed yet, state=' . $row['state']);
|
||||
}
|
||||
$needRefresh = empty($row['view_only_url'])
|
||||
|| intval($row['view_only_url_expire']) < time() + 60;
|
||||
|
||||
if ($needRefresh) {
|
||||
$svc = new PlagiarismService();
|
||||
$info = $svc->refreshViewerUrlFor($checkId);
|
||||
return jsonSuccess([
|
||||
'view_only_url' => $info['url'],
|
||||
'expire' => $info['expire'],
|
||||
]);
|
||||
}
|
||||
return jsonSuccess([
|
||||
'view_only_url' => $row['view_only_url'],
|
||||
'expire' => intval($row['view_only_url_expire']),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return jsonError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接吐 PDF 二进制流给浏览器下载
|
||||
*/
|
||||
public function downloadReport()
|
||||
{
|
||||
$checkId = intval($this->request->param('check_id', 0));
|
||||
if ($checkId <= 0) {
|
||||
return jsonError('check_id required');
|
||||
}
|
||||
$row = Db::name('plagiarism_check')->where('check_id', $checkId)->find();
|
||||
if (!$row || empty($row['pdf_local_path'])) {
|
||||
return jsonError('report not available');
|
||||
}
|
||||
$rootDir = ROOT_PATH ?: dirname(dirname(dirname(__DIR__)));
|
||||
$abs = rtrim($rootDir, '/\\') . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $row['pdf_local_path']);
|
||||
if (!is_file($abs)) {
|
||||
return jsonError('pdf file missing on disk: ' . $row['pdf_local_path']);
|
||||
}
|
||||
$filename = sprintf('plagiarism_check_%d_article_%d.pdf', $row['check_id'], $row['article_id']);
|
||||
return Response::create(file_get_contents($abs), 'html', 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
'Content-Length' => (string)filesize($abs),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turnitin 探活(开发调试用)
|
||||
*/
|
||||
public function features()
|
||||
{
|
||||
try {
|
||||
$tii = new \app\common\TurnitinService();
|
||||
return jsonSuccess($tii->featuresEnabled());
|
||||
} catch (\Throwable $e) {
|
||||
return jsonError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 内部 ----------
|
||||
|
||||
private function formatRow($r)
|
||||
{
|
||||
return [
|
||||
'check_id' => intval($r['check_id']),
|
||||
'article_id' => intval($r['article_id']),
|
||||
'journal_id' => intval($r['journal_id']),
|
||||
'state' => intval($r['state']),
|
||||
'state_label' => $this->stateLabel($r['state']),
|
||||
'similarity_score' => floatval($r['similarity_score']),
|
||||
'tii_report_status' => (string)$r['tii_report_status'],
|
||||
'has_pdf' => !empty($r['pdf_local_path']),
|
||||
'has_viewer_url' => !empty($r['view_only_url']) && intval($r['view_only_url_expire']) > time(),
|
||||
'attempts' => intval($r['attempts']),
|
||||
'error_msg' => (string)$r['error_msg'],
|
||||
'source_file_name' => (string)$r['source_file_name'],
|
||||
'trigger_source' => (string)$r['trigger_source'],
|
||||
'triggered_by' => intval($r['triggered_by']),
|
||||
'ctime' => intval($r['ctime']),
|
||||
'utime' => intval($r['utime']),
|
||||
];
|
||||
}
|
||||
|
||||
private function stateLabel($state)
|
||||
{
|
||||
$map = [
|
||||
0 => '待上传',
|
||||
1 => '上传中',
|
||||
2 => '比对中',
|
||||
3 => '完成',
|
||||
4 => '失败',
|
||||
];
|
||||
return isset($map[$state]) ? $map[$state] : 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ use think\Db;
|
||||
use think\Env;
|
||||
use think\Queue;
|
||||
use think\Validate;
|
||||
use app\common\CrossrefService;
|
||||
use app\common\ReferenceCheckService;
|
||||
|
||||
class Preaccept extends Base
|
||||
{
|
||||
@@ -14,6 +16,26 @@ class Preaccept extends Base
|
||||
parent::__construct($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增/修改导致文献集合改变后,清空整篇校对明细,使文章状态回到"未校对"。
|
||||
* 失败仅记日志,不阻塞主流程。
|
||||
*/
|
||||
private function resetArticleChecksOnReferChange($pArticleId, $sourceTag = '')
|
||||
{
|
||||
$pArticleId = intval($pArticleId);
|
||||
if ($pArticleId <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
(new ReferenceCheckService())->clearArticleChecksByPArticleId($pArticleId);
|
||||
} catch (\Exception $e) {
|
||||
\think\Log::error(
|
||||
'resetArticleChecksOnReferChange[' . $sourceTag . '] p_article_id='
|
||||
. $pArticleId . ' ' . $e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**获取文章参考文献列表
|
||||
* @return \think\response\Json
|
||||
@@ -91,6 +113,7 @@ class Preaccept extends Base
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
$this->production_article_refer_obj->where('p_article_id',$data['p_article_id'])->update(["state"=>1]);
|
||||
$this->resetArticleChecksOnReferChange(intval($data['p_article_id']), 'discardRefersByParticleid');
|
||||
return jsonSuccess([]);
|
||||
}
|
||||
|
||||
@@ -141,6 +164,7 @@ class Preaccept extends Base
|
||||
}
|
||||
$adId= $this->production_article_refer_obj->insertGetId($insert);
|
||||
$this->production_article_refer_obj->where('p_article_id', $p_info['p_article_id'])->where("p_refer_id", "<>", $adId)->where("index", ">", $pre_refer['index'])->where('state', 0)->setInc('index');
|
||||
$this->resetArticleChecksOnReferChange(intval($p_info['p_article_id']), 'addRefer');
|
||||
return jsonSuccess([]);
|
||||
|
||||
|
||||
@@ -197,6 +221,7 @@ class Preaccept extends Base
|
||||
}
|
||||
$adId= $this->production_article_refer_obj->insertGetId($insert);
|
||||
$this->production_article_refer_obj->where('p_article_id', $p_info['p_article_id'])->where("p_refer_id", "<>", $adId)->where("index", ">", $pre_refer['index'])->where('state', 0)->setInc('index');
|
||||
$this->resetArticleChecksOnReferChange(intval($p_info['p_article_id']), 'addReferByParticleid');
|
||||
return jsonSuccess([]);
|
||||
}
|
||||
|
||||
@@ -232,6 +257,7 @@ class Preaccept extends Base
|
||||
$insert['cs'] = 1;
|
||||
$adId = $this->production_article_refer_obj->insertGetId($insert);
|
||||
$this->production_article_refer_obj->where('p_article_id', $p_info['p_article_id'])->where("p_refer_id", "<>", $adId)->where("index", ">", $pre_refer['index'])->where('state', 0)->setInc('index');
|
||||
$this->resetArticleChecksOnReferChange(intval($p_info['p_article_id']), 'addReferNotdoi');
|
||||
return jsonSuccess([]);
|
||||
}
|
||||
|
||||
@@ -461,6 +487,17 @@ class Preaccept extends Base
|
||||
// }
|
||||
// $this->production_article_refer_obj->where('p_refer_id', $data['p_refer_id'])->update(['refer_doi' => $data['doi']]);
|
||||
// my_doiToFrag2($this->production_article_refer_obj->where('p_refer_id', $data['p_refer_id'])->find());
|
||||
|
||||
//文献内容更新成功后异步重检该文献对应的全部校对明细(失败不阻塞主流程)
|
||||
try {
|
||||
(new ReferenceCheckService())->enqueueRecheckByPReferId(
|
||||
intval($data['p_refer_id']),
|
||||
intval($old_refer_info['p_article_id'])
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
\think\Log::error('editRefer enqueueRecheckByPReferId p_refer_id=' . $data['p_refer_id'] . ' ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return jsonSuccess([]);
|
||||
}
|
||||
|
||||
@@ -708,36 +745,66 @@ class Preaccept extends Base
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 通过 DOI 获取文献元数据(Crossref REST API)。
|
||||
*
|
||||
* POST 参数:
|
||||
* doi 必填,可为纯 DOI(10.xxxx/...)或 https://doi.org/10.xxxx/...
|
||||
*
|
||||
* 返回 data.formate 与旧版字段兼容: author, title, joura, dateno, doilink
|
||||
* 另附 data.crossref: 原始摘要字段(不含 raw message,避免体积过大)
|
||||
*/
|
||||
public function searchDoi()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
"doi" => "require"
|
||||
'doi' => 'require',
|
||||
]);
|
||||
if (!$rule->check($data)) {
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
$doi = str_replace('/', '%2F', $data['doi']);
|
||||
// $url = "https://citation.crosscite.org/format?doi=$doi&style=cancer-translational-medicine&lang=en-US";
|
||||
$url = "https://citation.doi.org/format?doi=$doi&style=cancer-translational-medicine&lang=en-US";
|
||||
$res = myGet($url);
|
||||
$frag = trim(substr($res, strpos($res, '.') + 1));
|
||||
if ($frag == "") {
|
||||
return jsonError("not find");
|
||||
|
||||
$doiInput = trim((string)$data['doi']);
|
||||
if ($doiInput === '') {
|
||||
return jsonError('doi empty');
|
||||
}
|
||||
if (mb_substr_count($frag, '.') != 3) {
|
||||
return jsonError("formate fail");
|
||||
// 去掉 URL 前缀,得到裸 DOI
|
||||
$doiNorm = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', $doiInput);
|
||||
$doiNorm = trim($doiNorm, " \t\n\r\0\x0B/");
|
||||
|
||||
$svc = new CrossrefService([
|
||||
'mailto' => trim((string)Env::get('crossref_mailto', '')),
|
||||
]);
|
||||
$summary = $svc->fetchWorkSummary($doiNorm);
|
||||
if ($summary === null || empty($summary['doi'])) {
|
||||
return jsonError('DOI not found or invalid (Crossref)');
|
||||
}
|
||||
$res = explode('.', $frag);
|
||||
$f['author'] = prgeAuthor($res[0]);
|
||||
$f['title'] = trim($res[1]);
|
||||
$bj = bekjournal($res[2]);
|
||||
$joura = formateJournal(trim($bj[0]));
|
||||
$f['joura'] = $joura;
|
||||
$f['dateno'] = str_replace(' ', '', str_replace('-', '–', trim($bj[1])));
|
||||
$f['doilink'] = strpos($data['doi'], "http") === false ? "http://doi.org/" . $data['doi'] : $data['doi'];
|
||||
$re['formate'] = $f;
|
||||
return jsonSuccess($re);
|
||||
|
||||
$title = trim((string)($summary['title'] ?? ''));
|
||||
$jouraRaw = trim((string)($summary['joura'] ?? ''));
|
||||
$authorStr = trim((string)($summary['author_str'] ?? ''));
|
||||
$dateno = trim((string)($summary['dateno'] ?? ''));
|
||||
$doilink = trim((string)($summary['doilink'] ?? ''));
|
||||
if ($doilink === '') {
|
||||
$doilink = 'https://doi.org/' . $summary['doi'];
|
||||
}
|
||||
|
||||
$f = [
|
||||
'author' => $authorStr !== '' ? prgeAuthor($authorStr) : '',
|
||||
'title' => $title,
|
||||
'joura' => $jouraRaw !== '' ? formateJournal($jouraRaw) : '',
|
||||
'dateno' => str_replace(' ', '', str_replace('-', '–', $dateno)),
|
||||
'doilink' => $doilink,
|
||||
];
|
||||
|
||||
$crossrefOut = $summary;
|
||||
unset($crossrefOut['raw']);
|
||||
|
||||
return jsonSuccess([
|
||||
'formate' => $f,
|
||||
'crossref' => $crossrefOut,
|
||||
'doi' => $summary['doi'],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -843,14 +910,14 @@ class Preaccept extends Base
|
||||
}
|
||||
$am_info = $this->article_main_obj->where("am_id",$data['am_id'])->find();
|
||||
//上一行,空行
|
||||
$p_list = $this->article_main_obj->where("article_id",$am_info['article_id'])->where("sort","<",$am_info['sort'])->whereIn("state",[0,2])->order("sort desc")->limit(1)->select();
|
||||
if($p_list&&($p_list[0]['type']>0||$p_list[0]['content']!="")){
|
||||
$this->addBRow($am_info['article_id'],$p_list[0]['am_id']);
|
||||
}
|
||||
$n_list = $this->article_main_obj->where("article_id",$am_info['article_id'])->where("sort",">",$am_info['sort'])->whereIn("state",[0,2])->order("sort asc")->limit(1)->select();
|
||||
if($n_list[0]['type']>0||$n_list[0]['content']!=""){
|
||||
$this->addBRow($am_info['article_id'],$data['am_id']);
|
||||
}
|
||||
// $p_list = $this->article_main_obj->where("article_id",$am_info['article_id'])->where("sort","<",$am_info['sort'])->whereIn("state",[0,2])->order("sort desc")->limit(1)->select();
|
||||
// if($p_list&&($p_list[0]['type']>0||$p_list[0]['content']!="")){
|
||||
// $this->addBRow($am_info['article_id'],$p_list[0]['am_id']);
|
||||
// }
|
||||
// $n_list = $this->article_main_obj->where("article_id",$am_info['article_id'])->where("sort",">",$am_info['sort'])->whereIn("state",[0,2])->order("sort asc")->limit(1)->select();
|
||||
// if($n_list[0]['type']>0||$n_list[0]['content']!=""){
|
||||
// $this->addBRow($am_info['article_id'],$data['am_id']);
|
||||
// }
|
||||
$this->article_main_obj->where("am_id",$data['am_id'])->update(["is_h1"=>1,"is_h2"=>0,"is_h3"=>0]);
|
||||
// return jsonSuccess([]);
|
||||
//返回数据 20260119 start
|
||||
@@ -872,10 +939,10 @@ class Preaccept extends Base
|
||||
}
|
||||
$am_info = $this->article_main_obj->where("am_id",$data['am_id'])->find();
|
||||
//上一行,空行
|
||||
$p_list = $this->article_main_obj->where("article_id",$am_info['article_id'])->where("sort","<",$am_info['sort'])->whereIn("state",[0,2])->order("sort desc")->limit(1)->select();
|
||||
if($p_list&&($p_list[0]['type']>0||$p_list[0]['content']!="")){
|
||||
$this->addBRow($am_info['article_id'],$p_list[0]['am_id']);
|
||||
}
|
||||
// $p_list = $this->article_main_obj->where("article_id",$am_info['article_id'])->where("sort","<",$am_info['sort'])->whereIn("state",[0,2])->order("sort desc")->limit(1)->select();
|
||||
// if($p_list&&($p_list[0]['type']>0||$p_list[0]['content']!="")){
|
||||
// $this->addBRow($am_info['article_id'],$p_list[0]['am_id']);
|
||||
// }
|
||||
$this->article_main_obj->where("am_id",$data['am_id'])->update(["is_h1"=>0,"is_h2"=>1,"is_h3"=>0]);
|
||||
// return jsonSuccess([]);
|
||||
//返回数据 20260119 start
|
||||
@@ -1131,6 +1198,9 @@ class Preaccept extends Base
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
$am_info = $this->article_main_obj->where("am_id",$data['am_id'])->find();
|
||||
$old_content = $am_info['content'];
|
||||
$new_raw_content = isset($data['content']) ? (string)$data['content'] : '';
|
||||
|
||||
$insert['article_id'] = $am_info['article_id'];
|
||||
$insert['am_id'] = $data['am_id'];
|
||||
$insert['type'] = 0;
|
||||
@@ -1140,11 +1210,21 @@ class Preaccept extends Base
|
||||
$insert['ctime'] = time();
|
||||
$this->article_main_log_obj->insert($insert);
|
||||
|
||||
// 判断是否存在“引用删除”(新 content 相对旧 content 缺少 <mycite>)
|
||||
$hasCitationDeletion = $this->hasMyciteDeletion($old_content, $new_raw_content);
|
||||
|
||||
$update['content'] = $this->formatMain($new_raw_content);
|
||||
|
||||
|
||||
|
||||
|
||||
$update['content'] = $this->formatMain($data['content']);
|
||||
$update['state'] = 0;
|
||||
$this->article_main_obj->where("am_id",$data['am_id'])->update($update);
|
||||
|
||||
// 若检测到引用删除,则进行全文扫描并标记未被引用条目的 is_used=0(含 table 内容)
|
||||
if ($hasCitationDeletion) {
|
||||
$this->markUnusedReferencesForArticle(intval($am_info['article_id']));
|
||||
}
|
||||
// return jsonSuccess([]);
|
||||
//返回更新数据 20260119 start
|
||||
$update = empty($update) ? [] : array_merge($update,['am_id' => empty($data['am_id']) ? 0 : $data['am_id']]);
|
||||
@@ -1155,6 +1235,46 @@ class Preaccept extends Base
|
||||
//返回更新数据 20260119 end
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否发生 <mycite> 删除(new 相对 old 少了任意引用 id)
|
||||
*/
|
||||
private function hasMyciteDeletion(string $oldContent, string $newContent): bool
|
||||
{
|
||||
$oldIds = $this->extractMyciteIds($oldContent);
|
||||
if (empty($oldIds)) {
|
||||
return false;
|
||||
}
|
||||
$newIds = $this->extractMyciteIds($newContent);
|
||||
|
||||
// old 有引用,new 为空 => 删除
|
||||
if (empty($newIds)) {
|
||||
return true;
|
||||
}
|
||||
$oldSet = array_fill_keys($oldIds, 1);
|
||||
$newSet = array_fill_keys($newIds, 1);
|
||||
foreach ($oldSet as $id => $_) {
|
||||
if (!isset($newSet[$id])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文本/HTML 中提取 <mycite data-id="1,2,3"> 的 id 列表
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* 全文扫描(正文 + table),将 production_article_refer 未被引用的条目标记 is_used=0
|
||||
* - 被引用的条目 is_used=1
|
||||
*
|
||||
* 注意:依赖 production_article_refer 表存在 is_used 字段(int/tinyint)
|
||||
*/
|
||||
|
||||
|
||||
public function getArticleMainsRecycle(){
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
@@ -1369,6 +1489,7 @@ class Preaccept extends Base
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
$refer_info = $this->production_article_refer_obj->where('p_refer_id', $data['p_refer_id'])->find();
|
||||
$sibling_p_refer_id = 0;
|
||||
if ($data['act'] == "up") {
|
||||
$up_info = $this->production_article_refer_obj->where('p_article_id', $refer_info['p_article_id'])->where('index', $refer_info['index'] - 1)->where('state', 0)->find();
|
||||
if (!$up_info) {
|
||||
@@ -1376,6 +1497,7 @@ class Preaccept extends Base
|
||||
}
|
||||
$this->production_article_refer_obj->where('p_refer_id', $up_info['p_refer_id'])->setInc("index");
|
||||
$this->production_article_refer_obj->where('p_refer_id', $refer_info['p_refer_id'])->setDec("index");
|
||||
$sibling_p_refer_id = intval($up_info['p_refer_id']);
|
||||
} else {
|
||||
$down_info = $this->production_article_refer_obj->where('p_article_id', $refer_info['p_article_id'])->where('index', $refer_info['index'] + 1)->where('state', 0)->find();
|
||||
if (!$down_info) {
|
||||
@@ -1383,7 +1505,19 @@ class Preaccept extends Base
|
||||
}
|
||||
$this->production_article_refer_obj->where('p_refer_id', $refer_info['p_refer_id'])->setInc("index");
|
||||
$this->production_article_refer_obj->where('p_refer_id', $down_info['p_refer_id'])->setDec("index");
|
||||
$sibling_p_refer_id = intval($down_info['p_refer_id']);
|
||||
}
|
||||
|
||||
// 仅同步本次交换的两条 p_refer_id 对应的校对明细 reference_no / refer_index
|
||||
try {
|
||||
(new ReferenceCheckService())->syncReferenceNoByPReferIds(
|
||||
[intval($refer_info['p_refer_id']), $sibling_p_refer_id],
|
||||
intval($refer_info['p_article_id'])
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
\think\Log::error('sortRefer syncReferenceNoByPReferIds: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return jsonSuccess([]);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use think\Db;
|
||||
use think\Queue;
|
||||
use think\Validate;
|
||||
use think\log;
|
||||
use app\common\ArticleSymbolNormalizer;
|
||||
|
||||
/**
|
||||
* @title 公共管理相关
|
||||
@@ -1380,6 +1381,10 @@ class Production extends Base
|
||||
return $html;
|
||||
}
|
||||
|
||||
public function testsym(){
|
||||
ArticleSymbolNormalizer::normalize("");
|
||||
}
|
||||
|
||||
|
||||
public function doTypeSettingNew()
|
||||
{
|
||||
@@ -1399,7 +1404,7 @@ class Production extends Base
|
||||
$editor_info = $this->user_obj->where('user_id', $journal_info['editor_id'])->find();
|
||||
|
||||
$typesetInfo = [];
|
||||
$typesetInfo['info_title'] = $p_info['title'];
|
||||
$typesetInfo['info_title'] = ArticleSymbolNormalizer::normalize($p_info['title']);
|
||||
$typesetInfo['info_type'] = $p_info['type'];
|
||||
$typesetInfo['doi'] = $p_info['doi'];
|
||||
$typesetInfo['topic'] = '';
|
||||
@@ -1920,6 +1925,7 @@ class Production extends Base
|
||||
$cache_insert['p_article_id'] = $p_info['p_article_id'];
|
||||
$cache_insert['refer_content'] = $v;
|
||||
$cache_insert['index'] = $k;
|
||||
$cache_insert['old_index'] = $k;
|
||||
$cache_insert['ctime'] = time();
|
||||
$this->production_article_refer_obj->insert($cache_insert);
|
||||
}
|
||||
|
||||
296
application/api/controller/PromotionFactory.php
Normal file
296
application/api/controller/PromotionFactory.php
Normal file
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use think\Db;
|
||||
use think\Validate;
|
||||
|
||||
class PromotionFactory extends Base
|
||||
{
|
||||
public function __construct(\think\Request $request = null)
|
||||
{
|
||||
parent::__construct($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工厂列表
|
||||
* 参数: journal_id, state(-1=全部), page, per_page
|
||||
*/
|
||||
public function getList()
|
||||
{
|
||||
$journalId = intval($this->request->param('journal_id', 0));
|
||||
$editor_id = $this->request->param("user_id",0);
|
||||
$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', 20)), 100));
|
||||
|
||||
// if (!$journalId) {
|
||||
// return jsonError('journal_id is required');
|
||||
// }
|
||||
if($journalId!==0){
|
||||
$where[] = ['journal_id', '=', $journalId];
|
||||
}else{
|
||||
$journalIds = $this->journal_obj->where("editor_id",$editor_id)->where("state",0)->column("journal_id");
|
||||
$where[] = ['journal_id',"in",$journalIds];
|
||||
}
|
||||
|
||||
if ($state !== '-1' && $state !== '') {
|
||||
$where[] = ['state', '=', intval($state)];
|
||||
}
|
||||
|
||||
$countQuery = Db::name('promotion_factory');
|
||||
foreach ($where as $w) {
|
||||
$countQuery->where($w[0], $w[1], $w[2]);
|
||||
}
|
||||
$total = $countQuery->count();
|
||||
|
||||
$listQuery = Db::name('promotion_factory');
|
||||
foreach ($where as $w) {
|
||||
$listQuery->where($w[0], $w[1], $w[2]);
|
||||
}
|
||||
$list = $listQuery
|
||||
->order('promotion_factory_id desc')
|
||||
->page($page, $perPage)
|
||||
->select();
|
||||
|
||||
|
||||
|
||||
foreach ($list as &$item) {
|
||||
$item['ctime_text'] = $item['ctime'] ? date('Y-m-d H:i:s', $item['ctime']) : '';
|
||||
$item['fetch_fields'] = $this->resolveFetchFields($item['fetch_ids']);
|
||||
$item['country_scope_label'] = $this->buildCountryScopeLabel(
|
||||
isset($item['target_partitions']) ? (string)$item['target_partitions'] : '',
|
||||
isset($item['target_country_ids']) ? (string)$item['target_country_ids'] : ''
|
||||
);
|
||||
}
|
||||
|
||||
return jsonSuccess([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total_pages' => $total > 0 ? ceil($total / $perPage) : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工厂详情
|
||||
* 参数: promotion_factory_id
|
||||
*/
|
||||
public function getDetail()
|
||||
{
|
||||
$id = intval($this->request->param('promotion_factory_id', 0));
|
||||
if (!$id) {
|
||||
return jsonError('promotion_factory_id is required');
|
||||
}
|
||||
|
||||
$row = Db::name('promotion_factory')->where('promotion_factory_id', $id)->find();
|
||||
if (!$row) {
|
||||
return jsonError('Factory not found');
|
||||
}
|
||||
|
||||
$row['ctime_text'] = $row['ctime'] ? date('Y-m-d H:i:s', $row['ctime']) : '';
|
||||
$row['fetch_fields'] = $this->resolveFetchFields($row['fetch_ids']);
|
||||
$row['country_scope_label'] = $this->buildCountryScopeLabel(
|
||||
isset($row['target_partitions']) ? (string)$row['target_partitions'] : '',
|
||||
isset($row['target_country_ids']) ? (string)$row['target_country_ids'] : ''
|
||||
);
|
||||
|
||||
return jsonSuccess($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增工厂
|
||||
*/
|
||||
public function add()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
|
||||
$journalId = intval(isset($data['journal_id']) ? $data['journal_id'] : 0);
|
||||
if (!$journalId) {
|
||||
return jsonError('journal_id is required');
|
||||
}
|
||||
|
||||
$journal = Db::name('journal')->where('journal_id', $journalId)->where('state', 0)->find();
|
||||
if (!$journal) {
|
||||
return jsonError('Journal not found');
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$id = Db::name('promotion_factory')->insertGetId([
|
||||
'journal_id' => $journalId,
|
||||
'type' => intval(isset($data['type']) ? $data['type'] : 0),
|
||||
'expert_type' => intval(isset($data['expert_type']) ? $data['expert_type'] : 5),
|
||||
'email_ids' => trim(isset($data['email_ids']) ? (string)$data['email_ids'] : ''),
|
||||
'send_count' => max(1, intval(isset($data['send_count']) ? $data['send_count'] : 100)),
|
||||
'template_id' => intval(isset($data['template_id']) ? $data['template_id'] : 0),
|
||||
'style_id' => intval(isset($data['style_id']) ? $data['style_id'] : 0),
|
||||
'fetch_ids' => trim(isset($data['fetch_ids']) ? (string)$data['fetch_ids'] : ''),
|
||||
'target_partitions' => trim(isset($data['target_partitions']) ? (string)$data['target_partitions'] : ''),
|
||||
'target_country_ids' => trim(isset($data['target_country_ids']) ? (string)$data['target_country_ids'] : ''),
|
||||
'state' => 0,
|
||||
'ctime' => $now,
|
||||
]);
|
||||
|
||||
return jsonSuccess(['promotion_factory_id' => $id]);
|
||||
}
|
||||
|
||||
public function changePromotionAct(){
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
"promotion_factory_id"=>"require",
|
||||
"start_promotion"=>"require"
|
||||
]);
|
||||
if(!$rule->check($data)){
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
Db::name("promotion_factory")->where("promotion_factory_id",$data['promotion_factory_id'])->update(['start_promotion'=>$data['start_promotion']]);
|
||||
return jsonSuccess();
|
||||
}
|
||||
|
||||
public function getCountForPromotionEmailIds(){
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
"ids"=>"require"
|
||||
]);
|
||||
if(!$rule->check($data)){
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
$ids = explode(",",$data["ids"]);
|
||||
$emails = Db::name("journal_email")->whereIn("j_email_id",$ids)->select();
|
||||
$count = 0;
|
||||
foreach ($emails as $item){
|
||||
$count += $item["daily_limit"];
|
||||
}
|
||||
return jsonSuccess(['limit_count'=>$count]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑工厂
|
||||
*/
|
||||
public function edit()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$id = intval(isset($data['promotion_factory_id']) ? $data['promotion_factory_id'] : 0);
|
||||
if (!$id) {
|
||||
return jsonError('promotion_factory_id is required');
|
||||
}
|
||||
|
||||
$row = Db::name('promotion_factory')->where('promotion_factory_id', $id)->find();
|
||||
if (!$row) {
|
||||
return jsonError('Factory not found');
|
||||
}
|
||||
|
||||
$update = [];
|
||||
$allowedFields = [
|
||||
'type', 'expert_type', 'email_ids', 'send_count',
|
||||
'template_id', 'style_id', 'fetch_ids',
|
||||
'target_partitions', 'target_country_ids',
|
||||
];
|
||||
$intFields = ['type', 'expert_type', 'send_count', 'template_id', 'style_id'];
|
||||
|
||||
foreach ($allowedFields as $f) {
|
||||
if (isset($data[$f])) {
|
||||
$update[$f] = in_array($f, $intFields) ? intval($data[$f]) : trim((string)$data[$f]);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($update['send_count'])) {
|
||||
$update['send_count'] = max(1, $update['send_count']);
|
||||
}
|
||||
|
||||
if (empty($update)) {
|
||||
return jsonError('没有可更新的字段');
|
||||
}
|
||||
|
||||
Db::name('promotion_factory')->where('promotion_factory_id', $id)->update($update);
|
||||
|
||||
return jsonSuccess([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除工厂(软删除 state=1)
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
$id = intval($this->request->param('promotion_factory_id', 0));
|
||||
if (!$id) {
|
||||
return jsonError('promotion_factory_id is required');
|
||||
}
|
||||
|
||||
$row = Db::name('promotion_factory')->where('promotion_factory_id', $id)->find();
|
||||
if (!$row) {
|
||||
return jsonError('Factory not found');
|
||||
}
|
||||
|
||||
Db::name('promotion_factory')->where('promotion_factory_id', $id)->delete();
|
||||
|
||||
return jsonSuccess([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用切换
|
||||
*/
|
||||
public function toggle()
|
||||
{
|
||||
$id = intval($this->request->param('promotion_factory_id', 0));
|
||||
if (!$id) {
|
||||
return jsonError('promotion_factory_id is required');
|
||||
}
|
||||
|
||||
$row = Db::name('promotion_factory')->where('promotion_factory_id', $id)->find();
|
||||
if (!$row) {
|
||||
return jsonError('Factory not found');
|
||||
}
|
||||
|
||||
$newState = ($row['state'] == 0) ? 1 : 0;
|
||||
Db::name('promotion_factory')->where('promotion_factory_id', $id)->update(['state' => $newState]);
|
||||
|
||||
return jsonSuccess(['state' => $newState]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 fetch_ids 解析出领域名称列表
|
||||
*/
|
||||
private function resolveFetchFields($fetchIds)
|
||||
{
|
||||
$fetchIds = trim((string)$fetchIds);
|
||||
if ($fetchIds === '') {
|
||||
return [];
|
||||
}
|
||||
$ids = array_map('intval', explode(',', $fetchIds));
|
||||
$ids = array_filter($ids);
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
return Db::name('expert_fetch')
|
||||
->where('expert_fetch_id', 'in', $ids)
|
||||
->column('field', 'expert_fetch_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建国家范围描述标签
|
||||
*/
|
||||
private function buildCountryScopeLabel($targetPartitions, $targetCountryIds)
|
||||
{
|
||||
$tp = trim($targetPartitions);
|
||||
$tc = trim($targetCountryIds);
|
||||
|
||||
if ($tp === '' && $tc === '') {
|
||||
return '全部国家';
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
if ($tp !== '') {
|
||||
$parts[] = '分区' . $tp;
|
||||
}
|
||||
if ($tc !== '') {
|
||||
$cids = array_map('intval', explode(',', $tc));
|
||||
$names = Db::name('country')->where('country_id', 'in', $cids)->column('zh_name');
|
||||
if (!empty($names)) {
|
||||
$parts[] = implode(',', $names);
|
||||
}
|
||||
}
|
||||
return implode(' + ', $parts);
|
||||
}
|
||||
}
|
||||
1537
application/api/controller/References.php
Normal file
1537
application/api/controller/References.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ namespace app\api\controller;
|
||||
use app\api\controller\Base;
|
||||
use think\Validate;
|
||||
use think\Queue;
|
||||
use think\Db;
|
||||
|
||||
|
||||
class Ucenter extends Base{
|
||||
@@ -376,6 +377,14 @@ class Ucenter extends Base{
|
||||
if(isset($data['phone'])&&$data['phone']!=''){
|
||||
$update['phone'] = $data['phone'];
|
||||
}
|
||||
$old = $this->user_obj->where(['user_id'=>$data['user_id']])->find();
|
||||
if(isset($data['email'])&&$data['email']!=$old['email']){
|
||||
$c = $this->user_obj->where("email",trim($data['email']))->find();
|
||||
if($c){
|
||||
return jsonError("Email already exists");
|
||||
}
|
||||
$update['email'] = trim($data['email']);
|
||||
}
|
||||
$update['localname'] = isset($data['localname'])?trim($data['localname']):'';
|
||||
$this->user_obj->where(['user_id'=>$data['user_id']])->update($update);
|
||||
$updata1=[
|
||||
@@ -742,6 +751,73 @@ class Ucenter extends Base{
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function getApplyYboardForExpertBaseInfo(){
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
"expert_id"=>"require",
|
||||
"journal_id"=>"require"
|
||||
]);
|
||||
if(!$rule->check($data)){
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
$expert_info = Db::name("expert")->where("expert_id",$data['expert_id'])->find();
|
||||
$journal_info = Db::name("journal")->field("journal_id,title,issn,abbr,journal_icon,wechat_yboard_qrcode")->where("journal_id",$data['journal_id'])->find();
|
||||
$re['expert_info'] = $expert_info;
|
||||
$re['journal_info'] = $journal_info;
|
||||
return jsonSuccess($re);
|
||||
}
|
||||
|
||||
public function submitApplyYboardForExpert(){
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
"expert_id"=>"require",
|
||||
"journal_id"=>"require",
|
||||
"password"=>"require",
|
||||
"cv"=>"require"
|
||||
]);
|
||||
if(!$rule->check($data)){
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
$expert_info = Db::name("expert")->where("expert_id",$data['expert_id'])->find();
|
||||
$journal_info = Db::name("journal")->field("journal_id,title,issn,abbr,wechat_yboard_qrcode")->where("journal_id",$data['journal_id'])->find();
|
||||
if(isset($data['email'])&&$data['email']!=""){
|
||||
$email = $data['email'];
|
||||
}else{
|
||||
$email = $expert_info['email'];
|
||||
}
|
||||
if(isset($data['name'])&&$data['name']!=""){
|
||||
$name = $data['name'];
|
||||
}else{
|
||||
$name = $expert_info['realname'];
|
||||
}
|
||||
$has = $this->user_obj->where("email",$email)->find();
|
||||
if(!$has){
|
||||
$check = $this->user_obj->where("account",$name)->find();
|
||||
$add_user['account'] = $check?$email:$name;
|
||||
$add_user['email'] = $email;
|
||||
$add_user['password'] = md5($data['password']);
|
||||
$add_user['realname'] = $name;
|
||||
$add_user['ctime'] = time();
|
||||
$id = $this->user_obj->insertGetId($add_user);
|
||||
$insert_reviewer['reviewer_id'] = $id;
|
||||
$insert_reviewer['test_from'] = "submitApplyYboardForExpert";
|
||||
$this->user_reviewer_info_obj->insert($insert_reviewer);
|
||||
$has = $this->user_obj->where("user_id",$id)->find();
|
||||
}
|
||||
$insert['user_id'] = $has['user_id'];
|
||||
$insert['cv'] = $data['cv'];
|
||||
$insert['ctime'] = time();
|
||||
$this->user_cv_obj->insert($insert);
|
||||
$inserta['user_id'] = $has['user_id'];
|
||||
$inserta['journal_id'] = $data['journal_id'];
|
||||
$inserta['ctime'] = time();
|
||||
$id = $this->apply_yboard_obj->insertGetId($inserta);
|
||||
|
||||
$re['journal_info'] = $journal_info;
|
||||
return jsonSuccess($re);
|
||||
|
||||
}
|
||||
|
||||
|
||||
public function up_cv_file()
|
||||
{
|
||||
|
||||
190
application/api/controller/Unsubscribe.php
Normal file
190
application/api/controller/Unsubscribe.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use think\Controller;
|
||||
use think\Db;
|
||||
use think\Response;
|
||||
use app\common\UnsubscribeService;
|
||||
|
||||
/**
|
||||
* 推广 / 约稿邮件退订入口(公开访问,无需登录)
|
||||
*
|
||||
* 路由:
|
||||
* GET /api/Unsubscribe/index?k=&id=&t= 展示确认页
|
||||
* POST /api/Unsubscribe/confirm?k=&id=&t= 执行退订
|
||||
*
|
||||
* k = expert → 操作 t_expert.unsubscribed
|
||||
* k = user → 操作 t_user.unsubscribed
|
||||
*
|
||||
* 安全:
|
||||
* - URL 内带 sha256 签名 (kind, id, email, secret),防伪造
|
||||
* - 必须确认页二次点击才执行(防爬虫预取链接误退订)
|
||||
*/
|
||||
class Unsubscribe extends Controller
|
||||
{
|
||||
/**
|
||||
* 退订确认页(GET)
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$kind = UnsubscribeService::normalizeKind($this->request->param('k', UnsubscribeService::KIND_EXPERT));
|
||||
$id = intval($this->request->param('id', 0));
|
||||
$token = trim((string)$this->request->param('t', ''));
|
||||
|
||||
$audience = $this->loadAudience($kind, $id);
|
||||
if (!$audience) {
|
||||
return $this->html($this->pageInvalid(), 404);
|
||||
}
|
||||
if (!UnsubscribeService::verifyToken($kind, $id, (string)$audience['email'], $token)) {
|
||||
return $this->html($this->pageInvalid(), 403);
|
||||
}
|
||||
if (!empty($audience['unsubscribed'])) {
|
||||
return $this->html($this->pageAlreadyDone((string)$audience['email']));
|
||||
}
|
||||
|
||||
return $this->html($this->pageConfirm(
|
||||
$kind,
|
||||
$id,
|
||||
$token,
|
||||
(string)$audience['email'],
|
||||
(string)$audience['name']
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 真正执行退订(POST 推荐;GET 也允许)
|
||||
*/
|
||||
public function confirm()
|
||||
{
|
||||
$kind = UnsubscribeService::normalizeKind($this->request->param('k', UnsubscribeService::KIND_EXPERT));
|
||||
$id = intval($this->request->param('id', 0));
|
||||
$token = trim((string)$this->request->param('t', ''));
|
||||
|
||||
$audience = $this->loadAudience($kind, $id);
|
||||
if (!$audience) {
|
||||
return $this->html($this->pageInvalid(), 404);
|
||||
}
|
||||
if (!UnsubscribeService::verifyToken($kind, $id, (string)$audience['email'], $token)) {
|
||||
return $this->html($this->pageInvalid(), 403);
|
||||
}
|
||||
|
||||
if (empty($audience['unsubscribed'])) {
|
||||
$table = $kind === UnsubscribeService::KIND_USER ? 'user' : 'expert';
|
||||
$pk = $kind === UnsubscribeService::KIND_USER ? 'user_id' : 'expert_id';
|
||||
Db::name($table)->where($pk, $id)->update([
|
||||
'unsubscribed' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->html($this->pageSuccess((string)$audience['email']));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 kind 加载受众的最少必要信息(id, email, name, unsubscribed)
|
||||
*/
|
||||
private function loadAudience($kind, $id)
|
||||
{
|
||||
if ($id <= 0) return null;
|
||||
|
||||
if ($kind === UnsubscribeService::KIND_USER) {
|
||||
$row = Db::name('user')
|
||||
->where('user_id', $id)
|
||||
->field('user_id, email, realname AS name, unsubscribed')
|
||||
->find();
|
||||
} else {
|
||||
$row = Db::name('expert')
|
||||
->where('expert_id', $id)
|
||||
->field('expert_id, email, name, unsubscribed')
|
||||
->find();
|
||||
}
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
// ==================== HTML 页面 ====================
|
||||
|
||||
private function html($html, $status = 200)
|
||||
{
|
||||
$resp = Response::create($html, 'html', $status);
|
||||
$resp->header('Content-Type', 'text/html; charset=utf-8');
|
||||
return $resp;
|
||||
}
|
||||
|
||||
private function pageShell($title, $bodyHtml)
|
||||
{
|
||||
$titleSafe = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
|
||||
$css = "
|
||||
body{margin:0;padding:0;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#f5f7fa;color:#1f2937;}
|
||||
.wrap{max-width:520px;margin:64px auto;padding:32px;background:#fff;border-radius:12px;box-shadow:0 2px 16px rgba(0,0,0,.06);}
|
||||
h1{font-size:22px;margin:0 0 16px;color:#111827;}
|
||||
p{font-size:15px;line-height:1.6;margin:0 0 16px;color:#374151;}
|
||||
.email{font-weight:600;color:#111827;word-break:break-all;}
|
||||
.btn{display:inline-block;padding:10px 24px;border-radius:6px;border:0;cursor:pointer;font-size:15px;text-decoration:none;}
|
||||
.btn-primary{background:#dc2626;color:#fff;}
|
||||
.btn-primary:hover{background:#b91c1c;}
|
||||
.btn-secondary{background:#e5e7eb;color:#374151;margin-left:8px;}
|
||||
.muted{color:#6b7280;font-size:13px;margin-top:24px;}
|
||||
.ok{color:#16a34a;font-weight:600;}
|
||||
.warn{color:#d97706;font-weight:600;}
|
||||
.err{color:#dc2626;font-weight:600;}
|
||||
";
|
||||
return '<!doctype html><html lang="en"><head><meta charset="utf-8">'
|
||||
. '<meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
. '<meta name="robots" content="noindex,nofollow">'
|
||||
. '<title>' . $titleSafe . '</title>'
|
||||
. '<style>' . $css . '</style></head>'
|
||||
. '<body><div class="wrap">' . $bodyHtml . '</div></body></html>';
|
||||
}
|
||||
|
||||
private function pageConfirm($kind, $id, $token, $email, $name)
|
||||
{
|
||||
$kindSafe = htmlspecialchars($kind, ENT_QUOTES, 'UTF-8');
|
||||
$idSafe = intval($id);
|
||||
$tokenSafe = htmlspecialchars($token, ENT_QUOTES, 'UTF-8');
|
||||
$emailSafe = htmlspecialchars($email, ENT_QUOTES, 'UTF-8');
|
||||
$nameSafe = htmlspecialchars($name !== '' ? $name : $email, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$body = '<h1>Confirm unsubscribe</h1>'
|
||||
. '<p>Hi ' . $nameSafe . ',</p>'
|
||||
. '<p>You are about to unsubscribe <span class="email">' . $emailSafe
|
||||
. '</span> from all promotion and invitation emails sent by TMR Journals. '
|
||||
. 'After unsubscribing you will no longer receive marketing emails from us.</p>'
|
||||
. '<form method="post" action="confirm" style="margin-top:24px;">'
|
||||
. '<input type="hidden" name="k" value="' . $kindSafe . '">'
|
||||
. '<input type="hidden" name="id" value="' . $idSafe . '">'
|
||||
. '<input type="hidden" name="t" value="' . $tokenSafe . '">'
|
||||
. '<button type="submit" class="btn btn-primary">Confirm unsubscribe</button>'
|
||||
. '<a class="btn btn-secondary" href="javascript:window.close();">Cancel</a>'
|
||||
. '</form>'
|
||||
. '<p class="muted">If you didn\'t expect this email, you can safely close this page.</p>';
|
||||
|
||||
return $this->pageShell('Confirm unsubscribe - TMR Journals', $body);
|
||||
}
|
||||
|
||||
private function pageSuccess($email)
|
||||
{
|
||||
$emailSafe = htmlspecialchars($email, ENT_QUOTES, 'UTF-8');
|
||||
$body = '<h1>You have been unsubscribed</h1>'
|
||||
. '<p class="ok">' . $emailSafe . ' will no longer receive promotion or invitation emails from TMR Journals.</p>'
|
||||
. '<p>If this was a mistake, please contact us and we will be happy to resubscribe you.</p>'
|
||||
. '<p class="muted">Thank you for your past interest in our journals.</p>';
|
||||
return $this->pageShell('Unsubscribed - TMR Journals', $body);
|
||||
}
|
||||
|
||||
private function pageAlreadyDone($email)
|
||||
{
|
||||
$emailSafe = htmlspecialchars($email, ENT_QUOTES, 'UTF-8');
|
||||
$body = '<h1>Already unsubscribed</h1>'
|
||||
. '<p class="warn">' . $emailSafe . ' is already unsubscribed from our promotion emails.</p>'
|
||||
. '<p class="muted">No further action is needed.</p>';
|
||||
return $this->pageShell('Already unsubscribed - TMR Journals', $body);
|
||||
}
|
||||
|
||||
private function pageInvalid()
|
||||
{
|
||||
$body = '<h1>Invalid or expired link</h1>'
|
||||
. '<p class="err">This unsubscribe link is invalid or has expired.</p>'
|
||||
. '<p>Please use the most recent unsubscribe link in our emails, or contact us for help.</p>';
|
||||
return $this->pageShell('Invalid link - TMR Journals', $body);
|
||||
}
|
||||
}
|
||||
90
application/api/job/CitationRelevanceQueue.php
Normal file
90
application/api/job/CitationRelevanceQueue.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
namespace app\api\job;
|
||||
|
||||
use think\queue\Job;
|
||||
use app\common\QueueJob;
|
||||
use app\common\QueueRedis;
|
||||
use app\api\controller\References;
|
||||
|
||||
class CitationRelevanceQueue
|
||||
{
|
||||
private $oQueueJob;
|
||||
private $QueueRedis;
|
||||
private $lockExpire = 900; // 单条参考文献鉴别锁 15 分钟
|
||||
private $completedExprie = 86400; // 结果保留 1 天
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->oQueueJob = new QueueJob;
|
||||
$this->QueueRedis = QueueRedis::getInstance();
|
||||
}
|
||||
|
||||
public function fire(Job $job, $data)
|
||||
{
|
||||
// 通用初始化(进程超时 / Redis / DB 自检)
|
||||
$this->oQueueJob->init($job);
|
||||
|
||||
// 校验参数
|
||||
$pReferId = empty($data['p_refer_id']) ? 0 : intval($data['p_refer_id']);
|
||||
if (empty($pReferId)) {
|
||||
$this->oQueueJob->log("无效的 p_refer_id,删除任务");
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成 Redis 键:一个参考文献只允许一个队列在跑
|
||||
$sClassName = get_class($this);
|
||||
$sRedisKey = "queue_job:{$sClassName}:{$pReferId}";
|
||||
$sRedisValue = uniqid() . '_' . getmypid();
|
||||
|
||||
// 加锁并标记为 processing(幂等控制)
|
||||
$isLocked = $this->QueueRedis->startJob($sRedisKey, $sRedisValue, $this->lockExpire);
|
||||
if (!$isLocked) {
|
||||
$jobStatus = $this->QueueRedis->getJobStatus($sRedisKey);
|
||||
if (in_array($jobStatus, ['completed', 'failed'])) {
|
||||
$this->oQueueJob->log("任务已完成或失败,删除任务 | 状态: {$jobStatus}");
|
||||
$job->delete();
|
||||
} else {
|
||||
$attempts = $job->attempts();
|
||||
if ($attempts >= 3) {
|
||||
$this->oQueueJob->log("超过最大重试次数,停止重试");
|
||||
$job->delete();
|
||||
} else {
|
||||
$lockTtl = $this->QueueRedis->getLockTtl($sRedisKey);
|
||||
$delay = $lockTtl > 0 ? $lockTtl + 5 : 30;
|
||||
$this->oQueueJob->log("锁竞争,{$delay}秒后重试({$attempts}/3)");
|
||||
$job->release($delay);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 实际调用 References 控制器里的同步方法,拿到完整 JSON 结果
|
||||
$ctrl = new References();
|
||||
$resp = $ctrl->checkCitationRelevance(['p_refer_id' => $pReferId]);
|
||||
|
||||
$decoded = is_string($resp) ? json_decode($resp, true) : $resp;
|
||||
if (!is_array($decoded)) {
|
||||
$decoded = ['status' => 2, 'msg' => 'Unexpected response from checkCitationRelevance', 'raw' => $resp];
|
||||
}
|
||||
|
||||
// 结果写入 Redis,供 HTTP 轮询读取
|
||||
$this->QueueRedis->setRedisValue($sRedisKey . ':result', json_encode($decoded, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
// 标记完成并释放锁
|
||||
$this->QueueRedis->finishJob($sRedisKey, 'completed', $this->completedExprie, $sRedisValue);
|
||||
$job->delete();
|
||||
$this->oQueueJob->log("CitationRelevanceQueue 任务执行成功 | p_refer_id: {$pReferId}");
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->oQueueJob->handleRetryableException($e, $sRedisKey, $sRedisValue, $job);
|
||||
} catch (\LogicException $e) {
|
||||
$this->oQueueJob->handleNonRetryableException($e, $sRedisKey, $sRedisValue, $job);
|
||||
} catch (\Exception $e) {
|
||||
$this->oQueueJob->handleRetryableException($e, $sRedisKey, $sRedisValue, $job);
|
||||
} finally {
|
||||
$this->oQueueJob->finnal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,25 +3,35 @@
|
||||
namespace app\api\job;
|
||||
|
||||
use think\queue\Job;
|
||||
use think\Log;
|
||||
use app\common\ExpertFinderService;
|
||||
|
||||
/**
|
||||
* 专家抓取队列任务。
|
||||
* 注意:此任务推送到队列名 "FetchExperts",必须单独启动 worker 才会执行:
|
||||
* php think queue:work --queue FetchExperts --sleep=3 --tries=3 --daemon
|
||||
*
|
||||
* 单条任务可能耗时较长(NCBI 接口翻页 + 写库),常驻 worker 受 wait_timeout 影响,
|
||||
* 由 QueueJob 在进程超过 6h 或致命 DB 错误时主动 exit(1) 让 supervisor 拉起新进程。
|
||||
*/
|
||||
class FetchExperts
|
||||
{
|
||||
|
||||
public function fire(Job $job, $data)
|
||||
{
|
||||
try {
|
||||
$finder = new \app\api\controller\ExpertFinder();
|
||||
$result = $finder->doFetchForField(
|
||||
$data['field'],
|
||||
$data['source'] ?? 'pubmed',
|
||||
$data['per_page'] ?? 100,
|
||||
$data['min_year'] ?? null
|
||||
);
|
||||
Log::info('FetchExperts completed: ' . json_encode($result));
|
||||
} catch (\Exception $e) {
|
||||
Log::error('FetchExperts failed: ' . $e->getMessage());
|
||||
|
||||
$field = isset($data['field']) ? (string)$data['field'] : '';
|
||||
if ($field === '') {
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
$job->delete();
|
||||
$service = new ExpertFinderService();
|
||||
$service->doFetchForField(
|
||||
$field,
|
||||
isset($data['source']) ? $data['source'] : 'pubmed',
|
||||
isset($data['per_page']) ? intval($data['per_page']) : 100,
|
||||
isset($data['min_year']) ? $data['min_year'] : null
|
||||
);
|
||||
$job->delete();
|
||||
}
|
||||
}
|
||||
|
||||
38
application/api/job/FillExpertCountry.php
Normal file
38
application/api/job/FillExpertCountry.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\job;
|
||||
|
||||
use think\queue\Job;
|
||||
use app\common\ExpertFinderService;
|
||||
|
||||
/**
|
||||
* 队列任务:用本地大模型从 affiliation 推断国家,写入 expert.country_id / country。
|
||||
* 处理完当前专家后,自动找下一个推入同一队列(链式执行),直到全部处理完。
|
||||
*
|
||||
* 支持多队列并行:通过 $data['queue'] 和 $data['chat_url'] 区分不同的链/模型。
|
||||
*
|
||||
* 单条任务受本地 LLM 响应时间影响(一般 2-10s),常驻 worker 由 QueueJob
|
||||
* 在进程超 6h 或遇致命 DB 错误时主动 exit(1) 让 supervisor 拉起新进程。
|
||||
*/
|
||||
class FillExpertCountry
|
||||
{
|
||||
|
||||
public function fire(Job $job, $data)
|
||||
{
|
||||
|
||||
$expertId = intval(isset($data['expert_id']) ? $data['expert_id'] : 0);
|
||||
$affiliation = isset($data['affiliation']) ? trim((string)$data['affiliation']) : '';
|
||||
$queue = isset($data['queue']) ? (string)$data['queue'] : 'FetchExperts';
|
||||
$chatUrl = isset($data['chat_url']) ? (string)$data['chat_url'] : '';
|
||||
|
||||
$service = new ExpertFinderService();
|
||||
|
||||
if ($expertId && $affiliation !== '') {
|
||||
$service->fillExpertCountry($expertId, $affiliation, $chatUrl);
|
||||
}
|
||||
$job->delete();
|
||||
|
||||
// 链式:处理完当前专家立刻拉下一个进来
|
||||
$service->enqueueNextCountryFill(1, $queue, $chatUrl);
|
||||
}
|
||||
}
|
||||
37
application/api/job/PlagiarismPoll.php
Normal file
37
application/api/job/PlagiarismPoll.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\job;
|
||||
|
||||
use think\queue\Job;
|
||||
use app\common\PlagiarismService;
|
||||
|
||||
/**
|
||||
* 队列任务:轮询 Turnitin similarity 状态。
|
||||
*
|
||||
* 未完成会再次入队(链式延迟),完成后下载 PDF 报告并写本地永久保留。
|
||||
*
|
||||
* data:
|
||||
* - check_id t_plagiarism_check.check_id
|
||||
* - attempt 当前轮询次数(首次为 1)
|
||||
*
|
||||
* 注意:单条 job 通常很短(1 个 HTTP 请求),但会反复入队,常驻 worker 长时间运行
|
||||
* 由 QueueJob 在进程超 6h 或致命 DB 错误时主动 exit(1) 让 supervisor 拉起新进程。
|
||||
*/
|
||||
class PlagiarismPoll
|
||||
{
|
||||
|
||||
public function fire(Job $job, $data)
|
||||
{
|
||||
|
||||
$checkId = isset($data['check_id']) ? intval($data['check_id']) : 0;
|
||||
$attempt = isset($data['attempt']) ? intval($data['attempt']) : 1;
|
||||
|
||||
if ($checkId <= 0) {
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
$svc = new PlagiarismService();
|
||||
$svc->runPollStatus($checkId, $attempt);
|
||||
$job->delete();
|
||||
}
|
||||
}
|
||||
36
application/api/job/PlagiarismRun.php
Normal file
36
application/api/job/PlagiarismRun.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\job;
|
||||
|
||||
use think\queue\Job;
|
||||
use app\common\PlagiarismService;
|
||||
|
||||
/**
|
||||
* 队列任务:上传论文到 Turnitin + 触发 similarity 检测。
|
||||
*
|
||||
* 完成后会自动入队 PlagiarismPoll 进行后续轮询。
|
||||
*
|
||||
* data:
|
||||
* - check_id t_plagiarism_check.check_id
|
||||
* - file_path 本地可读的 PDF/DOCX 绝对路径
|
||||
*
|
||||
* 注意:上传单个 PDF 可能耗时数十秒,常驻 worker 由 QueueJob 在进程超 6h 或致命 DB
|
||||
* 错误时主动 exit(1) 让 supervisor 拉起新进程。
|
||||
*/
|
||||
class PlagiarismRun
|
||||
{
|
||||
|
||||
public function fire(Job $job, $data)
|
||||
{
|
||||
$checkId = isset($data['check_id']) ? intval($data['check_id']) : 0;
|
||||
$filePath = isset($data['file_path']) ? (string)$data['file_path'] : '';
|
||||
if ($checkId <= 0 || $filePath === '') {
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
$svc = new PlagiarismService();
|
||||
$svc->log("PlagiarismRun job act!!");
|
||||
$svc->runUploadAndTrigger($checkId, $filePath);
|
||||
$job->delete();
|
||||
}
|
||||
}
|
||||
31
application/api/job/PromotionPrepare.php
Normal file
31
application/api/job/PromotionPrepare.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\job;
|
||||
|
||||
use think\queue\Job;
|
||||
use app\common\PromotionService;
|
||||
|
||||
/**
|
||||
* 【已废弃 / 兼容保留】
|
||||
*
|
||||
* 旧版 task 级 prepare Job;新逻辑请使用 PromotionPrepareTask + PromotionPrepareEmail。
|
||||
*
|
||||
* 本类仅为兼容队列中可能遗留的旧 payload:接收到旧 job 时转发到新的调度器,
|
||||
* 保证旧消息不会丢失。新代码不应再 push 此 Job。
|
||||
*/
|
||||
class PromotionPrepare
|
||||
{
|
||||
|
||||
public function fire(Job $job, $data)
|
||||
{
|
||||
$this->oQueueJob->init($job);
|
||||
$taskId = isset($data['task_id']) ? intval($data['task_id']) : 0;
|
||||
if ($taskId <= 0) {
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
$service = new PromotionService();
|
||||
$service->enqueuePrepareTask($taskId);
|
||||
$job->delete();
|
||||
}
|
||||
}
|
||||
29
application/api/job/PromotionPrepareEmail.php
Normal file
29
application/api/job/PromotionPrepareEmail.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\job;
|
||||
|
||||
use think\queue\Job;
|
||||
use app\common\PromotionService;
|
||||
|
||||
/**
|
||||
* 队列任务:单封邮件 prepare(调用 LLM 生成个性化描述 + 渲染模板 + 写入 log)。
|
||||
*
|
||||
* 单条 job 可能耗时 30s+(LLM 调用)。常驻 worker 必须配合 supervisor 守护,
|
||||
* 当进程超过 6h 或遇到致命 DB 错误时,QueueJob 会主动 exit(1),由 supervisor 拉起新进程,
|
||||
* 避开 MySQL wait_timeout 把连接关掉后所有任务持续 fail 的问题。
|
||||
*
|
||||
* 启动建议:
|
||||
* php think queue:work --queue PromotionPrepareEmail --sleep=3 --tries=3 --daemon
|
||||
*/
|
||||
class PromotionPrepareEmail
|
||||
{
|
||||
|
||||
public function fire(Job $job, $data)
|
||||
{
|
||||
$logId = isset($data['log_id']) ? intval($data['log_id']) : 0;
|
||||
$service = new PromotionService();
|
||||
$service->log("id:".$logId);
|
||||
$result = $service->prepareSingleEmail($logId);
|
||||
$job->delete();
|
||||
}
|
||||
}
|
||||
33
application/api/job/PromotionPrepareTask.php
Normal file
33
application/api/job/PromotionPrepareTask.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\job;
|
||||
|
||||
use think\queue\Job;
|
||||
use app\common\PromotionService;
|
||||
use app\common\QueueJob;
|
||||
|
||||
/**
|
||||
* 队列任务:task 级别的 prepare 调度器。
|
||||
*
|
||||
* 职责:遍历 task 下待准备的 promotion_email_log,
|
||||
* 将每封邮件拆成一条 PromotionPrepareEmail 推到 promotion_email 队列,
|
||||
* 以便并行调用 LLM 生成个性化描述。
|
||||
*
|
||||
* 队列名:PromotionPrepareTask
|
||||
*/
|
||||
class PromotionPrepareTask
|
||||
{
|
||||
|
||||
public function fire(Job $job, $data)
|
||||
{
|
||||
$taskId = isset($data['task_id']) ? intval($data['task_id']) : 0;
|
||||
if ($taskId <= 0) {
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
$service = new PromotionService();
|
||||
$result = $service->dispatchPrepareEmails($taskId);
|
||||
|
||||
$job->delete();
|
||||
}
|
||||
}
|
||||
32
application/api/job/PromotionSend.php
Normal file
32
application/api/job/PromotionSend.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\job;
|
||||
|
||||
use think\queue\Job;
|
||||
use app\common\PromotionService;
|
||||
|
||||
/**
|
||||
* 队列任务:发送 task 下"已 prepare"的邮件,按 min/max_interval 控制节奏。
|
||||
*
|
||||
* processNextEmail 内部会自动把下一封邮件 later() 入队(链式延迟),
|
||||
* 因此本 job 处理完一封即可 delete,无需在这里再 dispatch 下一封。
|
||||
*
|
||||
* 队列名:PromotionSend
|
||||
*/
|
||||
class PromotionSend
|
||||
{
|
||||
|
||||
public function fire(Job $job, $data)
|
||||
{
|
||||
|
||||
$taskId = isset($data['task_id']) ? intval($data['task_id']) : 0;
|
||||
if ($taskId <= 0) {
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
$service = new PromotionService();
|
||||
$result = $service->processNextEmail($taskId);
|
||||
|
||||
$job->delete();
|
||||
}
|
||||
}
|
||||
114
application/api/job/ReferenceCheck.php
Normal file
114
application/api/job/ReferenceCheck.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
namespace app\api\job;
|
||||
|
||||
use think\Db;
|
||||
use think\queue\Job;
|
||||
use app\common\QueueJob;
|
||||
use app\common\QueueRedis;
|
||||
use app\common\ReferenceCheckService;
|
||||
|
||||
class ReferenceCheck
|
||||
{
|
||||
private $oQueueJob;
|
||||
private $QueueRedis;
|
||||
private $completedExprie = 3600;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->oQueueJob = new QueueJob();
|
||||
$this->QueueRedis = QueueRedis::getInstance();
|
||||
}
|
||||
|
||||
public function fire(Job $job, $data)
|
||||
{
|
||||
$this->oQueueJob->init($job);
|
||||
|
||||
$rawBody = empty($job->getRawBody()) ? '' : $job->getRawBody();
|
||||
$jobData = empty($rawBody) ? [] : json_decode($rawBody, true);
|
||||
$jobId = empty($jobData['id']) ? 'unknown' : $jobData['id'];
|
||||
|
||||
$sRedisKey = '';
|
||||
$sRedisValue = '';
|
||||
|
||||
$this->oQueueJob->log("-----------队列任务开始-----------");
|
||||
$this->oQueueJob->log("当前任务ID: {$jobId}, 尝试次数: {$job->attempts()}");
|
||||
|
||||
try {
|
||||
$checkId = intval(isset($data['check_id']) ? $data['check_id'] : 0);
|
||||
if ($checkId <= 0 && !empty($jobData['data']['check_id'])) {
|
||||
$checkId = intval($jobData['data']['check_id']);
|
||||
}
|
||||
if ($checkId <= 0) {
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
$row = Db::name('article_reference_check_result')->where('id', $checkId)->find();
|
||||
if (empty($row)) {
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (intval($row['status']) === 1) {
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
$sClassName = get_class($this);
|
||||
$sRedisKey = "queue_job:{$sClassName}:{$checkId}";
|
||||
$sRedisValue = uniqid() . '_' . getmypid();
|
||||
|
||||
$svc = new ReferenceCheckService();
|
||||
$svc->clearReferenceCheckQueueLock($checkId);
|
||||
|
||||
if (!$this->oQueueJob->acquireLock($sRedisKey, $sRedisValue, $job)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$svc->runReferenceCheckOnce($checkId);
|
||||
|
||||
$amId = intval(isset($row['am_id']) ? $row['am_id'] : 0);
|
||||
if ($amId > 0) {
|
||||
$svc->syncAmRefCheckStatus($amId);
|
||||
}
|
||||
$this->QueueRedis->finishJob($sRedisKey, 'completed', $this->completedExprie, $sRedisValue);
|
||||
$job->delete();
|
||||
$this->oQueueJob->log("任务执行成功 | 日志ID: {$sRedisKey}");
|
||||
} catch (\Exception $e) {
|
||||
$this->oQueueJob->log('ReferenceCheck error: ' . $e->getMessage());
|
||||
if ($job->attempts() >= 3) {
|
||||
$this->markFailed($checkId, $e->getMessage());
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
$job->release(30);
|
||||
}
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->oQueueJob->handleRetryableException($e, $sRedisKey, $sRedisValue, $job);
|
||||
} catch (\LogicException $e) {
|
||||
$this->oQueueJob->handleNonRetryableException($e, $sRedisKey, $sRedisValue, $job);
|
||||
} catch (\Exception $e) {
|
||||
$this->oQueueJob->handleRetryableException($e, $sRedisKey, $sRedisValue, $job);
|
||||
} finally {
|
||||
$this->oQueueJob->finnal();
|
||||
}
|
||||
}
|
||||
|
||||
private function markFailed($checkId, $msg)
|
||||
{
|
||||
$row = Db::name('article_reference_check_result')->where('id', $checkId)->find();
|
||||
try {
|
||||
(new ReferenceCheckService())->updateCheckResult($checkId, [
|
||||
'status' => 2,
|
||||
'error_msg' => $msg,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
\think\Log::error('ReferenceCheck markFailed: ' . $e->getMessage());
|
||||
}
|
||||
$amId = empty($row) ? 0 : intval(isset($row['am_id']) ? $row['am_id'] : 0);
|
||||
if ($amId > 0) {
|
||||
(new ReferenceCheckService())->syncAmRefCheckStatus($amId);
|
||||
}
|
||||
}
|
||||
}
|
||||
162
application/api/job/ReferenceCheckTwo.php
Normal file
162
application/api/job/ReferenceCheckTwo.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
namespace app\api\job;
|
||||
|
||||
use think\Db;
|
||||
use think\queue\Job;
|
||||
use app\common\QueueJob;
|
||||
use app\common\QueueRedis;
|
||||
use app\common\ReferenceCheckService;
|
||||
use app\common\service\LLMService;
|
||||
|
||||
class ReferenceCheckTwo
|
||||
{
|
||||
private $oQueueJob;
|
||||
private $QueueRedis;
|
||||
private $completedExprie = 3600;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->oQueueJob = new QueueJob();
|
||||
$this->QueueRedis = QueueRedis::getInstance();
|
||||
}
|
||||
|
||||
public function fire(Job $job, $data)
|
||||
{
|
||||
$this->oQueueJob->init($job);
|
||||
|
||||
$rawBody = empty($job->getRawBody()) ? '' : $job->getRawBody();
|
||||
$jobData = empty($rawBody) ? [] : json_decode($rawBody, true);
|
||||
$jobId = empty($jobData['id']) ? 'unknown' : $jobData['id'];
|
||||
|
||||
$sRedisKey = '';
|
||||
$sRedisValue = '';
|
||||
|
||||
$this->oQueueJob->log("-----------队列任务开始-----------");
|
||||
$this->oQueueJob->log("当前任务ID: {$jobId}, 尝试次数: {$job->attempts()}");
|
||||
|
||||
try {
|
||||
$checkId = intval(isset($data['check_id']) ? $data['check_id'] : 0);
|
||||
if ($checkId <= 0 && !empty($jobData['data']['check_id'])) {
|
||||
$checkId = intval($jobData['data']['check_id']);
|
||||
}
|
||||
$sClassName = get_class($this);
|
||||
$sRedisKey = "queue_job_two:{$sClassName}:{$checkId}";
|
||||
$sRedisValue = uniqid() . '_' . getmypid();
|
||||
|
||||
if (!$this->oQueueJob->acquireLock($sRedisKey, $sRedisValue, $job)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($checkId <= 0) {
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
$row = Db::name('article_reference_check_result')->where('id', $checkId)->find();
|
||||
if (empty($row)) {
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
// if (intval($row['status']) === 1) {
|
||||
// $job->delete();
|
||||
// return;
|
||||
// }
|
||||
|
||||
try {
|
||||
$svc = new ReferenceCheckService();
|
||||
|
||||
$contentA = $svc->resolveMainContentForJob($row);
|
||||
$referText = trim((string)(isset($row['refer_text']) ? $row['refer_text'] : ''));
|
||||
$refer = null;
|
||||
|
||||
if (intval($row['p_refer_id']) > 0) {
|
||||
$refer = Db::name('production_article_refer')
|
||||
->where('p_refer_id', intval($row['p_refer_id']))
|
||||
->where('state', 0)
|
||||
->find();
|
||||
}
|
||||
|
||||
$payload = $svc->prepareRecheckPayload(is_array($refer) ? $refer : [], $referText);
|
||||
$doiBlock = $payload['doi_block'];
|
||||
|
||||
if ($contentA === '' || $referText === '') {
|
||||
$this->markFailed($checkId, 'Missing article_main.content or refer_text');
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
$llm = new LLMService();
|
||||
$llmResult = $llm->checkReference($contentA, $referText, true, $doiBlock);
|
||||
|
||||
$requestFailed = !empty($llmResult['request_failed']);
|
||||
$canSupport = $svc->parseLlmCanSupport($llmResult);
|
||||
$tag = $payload['has_abstract']
|
||||
? ('[Crossref复核' . ($payload['doi_used'] !== '' ? ' ' . $payload['doi_used'] : '') . ']')
|
||||
: '[Crossref复核-无摘要]';
|
||||
$reason = $tag . ' ' . (isset($llmResult['reason']) ? $llmResult['reason'] : '');
|
||||
|
||||
// LLM 通讯失败:写 status=2 并抛异常触发队列重试
|
||||
if ($requestFailed) {
|
||||
$svc->updateCheckResult($checkId, [
|
||||
'confidence' => floatval($llmResult['confidence']),
|
||||
'reason' => $reason,
|
||||
'status' => 2,
|
||||
'error_msg' => isset($llmResult['reason']) ? $llmResult['reason'] : 'LLM request failed',
|
||||
]);
|
||||
throw new \RuntimeException(isset($llmResult['reason']) ? $llmResult['reason'] : 'LLM request failed');
|
||||
}
|
||||
|
||||
$affected = $svc->updateCheckResult($checkId, [
|
||||
'can_support' => $canSupport ? 1 : 0,
|
||||
'is_match' => $canSupport ? 1 : 0,
|
||||
'confidence' => floatval($llmResult['confidence']),
|
||||
'reason' => $reason,
|
||||
'status' => 1,
|
||||
'error_msg' => '',
|
||||
]);
|
||||
$this->oQueueJob->log("Crossref复核写入 id={$checkId} affected={$affected} can_support=" . ($canSupport ? 1 : 0) . " confidence=" . floatval($llmResult['confidence']));
|
||||
|
||||
$amId = intval(isset($row['am_id']) ? $row['am_id'] : 0);
|
||||
if ($amId > 0) {
|
||||
$svc->syncAmRefCheckStatus($amId);
|
||||
}
|
||||
$this->QueueRedis->finishJob($sRedisKey, 'completed', $this->completedExprie, $sRedisValue);
|
||||
$job->delete();
|
||||
$this->oQueueJob->log("任务执行成功 | 日志ID: {$sRedisKey}");
|
||||
} catch (\Exception $e) {
|
||||
$this->oQueueJob->log('ReferenceCheckTwo error: ' . $e->getMessage());
|
||||
if ($job->attempts() >= 3) {
|
||||
$this->markFailed($checkId, $e->getMessage());
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
$job->release(30);
|
||||
}
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->oQueueJob->handleRetryableException($e, $sRedisKey, $sRedisValue, $job);
|
||||
} catch (\LogicException $e) {
|
||||
$this->oQueueJob->handleNonRetryableException($e, $sRedisKey, $sRedisValue, $job);
|
||||
} catch (\Exception $e) {
|
||||
$this->oQueueJob->handleRetryableException($e, $sRedisKey, $sRedisValue, $job);
|
||||
} finally {
|
||||
$this->oQueueJob->finnal();
|
||||
}
|
||||
}
|
||||
|
||||
private function markFailed($checkId, $msg)
|
||||
{
|
||||
$row = Db::name('article_reference_check_result')->where('id', $checkId)->find();
|
||||
try {
|
||||
(new ReferenceCheckService())->updateCheckResult($checkId, [
|
||||
'status' => 2,
|
||||
'error_msg' => $msg,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
\think\Log::error('ReferenceCheckTwo markFailed: ' . $e->getMessage());
|
||||
}
|
||||
$amId = empty($row) ? 0 : intval(isset($row['am_id']) ? $row['am_id'] : 0);
|
||||
if ($amId > 0) {
|
||||
(new ReferenceCheckService())->syncAmRefCheckStatus($amId);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
application/api/job/myQueue.php
Normal file
31
application/api/job/myQueue.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\job;
|
||||
|
||||
use think\queue\Job;
|
||||
class myQueue
|
||||
{
|
||||
|
||||
|
||||
public function fire(Job $job, $data)
|
||||
{
|
||||
try {
|
||||
// 你的业务逻辑(哪怕是空的)
|
||||
// 这里写任何代码
|
||||
|
||||
// 执行成功,删除任务
|
||||
$job->delete();
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// 如果你不想重试,直接删除
|
||||
$job->delete();
|
||||
}
|
||||
}
|
||||
|
||||
// 可选:任务失败时执行
|
||||
public function failed($data)
|
||||
{
|
||||
// 失败后不做处理,直接跳过
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1151,6 +1151,123 @@ class ArticleParserService
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 Word 文档中的参考文献列表(仅返回数组,不做入库)
|
||||
* @return array 每条为一个参考文献的纯文本字符串
|
||||
*/
|
||||
public static function getReferencesFromWord($filePath): array
|
||||
{
|
||||
$othis = new self($filePath) ;
|
||||
if (empty($othis->sections)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
foreach ($othis->sections as $section) {
|
||||
foreach ($section->getElements() as $element) {
|
||||
$text = $othis->getTextFromElement($element);
|
||||
$text = trim((string)$text);
|
||||
if ($text === '') continue;
|
||||
$lines[] = $text;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($lines)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 识别参考文献段落起点(允许同一行包含域代码或第一条内容)
|
||||
$startIdx = -1;
|
||||
$startRemainder = ''; // 标题行后可能跟着第一条参考文献内容
|
||||
foreach ($lines as $i => $line) {
|
||||
$t = trim($line);
|
||||
if ($t === '') continue;
|
||||
|
||||
// 行首命中即可(避免 “References { ADDIN... }” / “References 1. ...” 漏判)
|
||||
if (preg_match('/^\s*(references|reference|bibliography|参考文献|文献)\b\s*[::]?\s*/iu', $t, $m)) {
|
||||
$startIdx = $i;
|
||||
$remainder = preg_replace('/^\s*(references|reference|bibliography|参考文献|文献)\b\s*[::]?\s*/iu', '', $t);
|
||||
$remainder = trim($remainder);
|
||||
// 过滤 EndNote 域代码(允许其出现在标题行后)
|
||||
if ($remainder !== '' && !preg_match('/^\{\s*ADDIN\s+EN\.REFLIST\s*\}$/i', $remainder)) {
|
||||
$startRemainder = $remainder;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($startIdx < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 收集参考文献区域内容,遇到常见结尾段落标题则停止
|
||||
$stopKeywords = [
|
||||
'acknowledgements', 'acknowledgments', 'funding', 'appendix', 'supplementary',
|
||||
'conflict of interest', 'competing interests', 'author contributions',
|
||||
'致谢', '基金', '附录', '补充材料', '利益冲突', '作者贡献',
|
||||
];
|
||||
|
||||
// startRemainder 已在起点识别时处理
|
||||
|
||||
$raw = [];
|
||||
if ($startRemainder !== '') {
|
||||
$raw[] = $startRemainder;
|
||||
}
|
||||
|
||||
for ($i = $startIdx + 1; $i < count($lines); $i++) {
|
||||
$line = trim($lines[$i]);
|
||||
if ($line === '') continue;
|
||||
// 跳过 EndNote / Word 域代码
|
||||
if (preg_match('/^\{\s*ADDIN\s+EN\.REFLIST\s*\}$/i', $line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lineLower = strtolower($line);
|
||||
foreach ($stopKeywords as $sk) {
|
||||
$skLower = strtolower($sk);
|
||||
if ($lineLower === $skLower || $lineLower === $skLower . ':' || $lineLower === $skLower . ':') {
|
||||
$i = count($lines); // break outer
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
|
||||
$raw[] = $line;
|
||||
}
|
||||
|
||||
if (empty($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 合并多行:以 “数字.” / “[数字]” / “数字]” 等作为新条目起始
|
||||
$refs = [];
|
||||
$current = '';
|
||||
foreach ($raw as $line) {
|
||||
$isNew = false;
|
||||
if (preg_match('/^\s*(\[\d+\]|\d+\s*[\.\)]|\d+\s*\])\s*/u', $line)) {
|
||||
$isNew = true;
|
||||
}
|
||||
|
||||
if ($isNew) {
|
||||
if (trim($current) !== '') {
|
||||
$refs[] = trim(preg_replace('/\s+/u', ' ', $current));
|
||||
}
|
||||
$current = $line;
|
||||
} else {
|
||||
// 续行拼接
|
||||
if ($current === '') {
|
||||
$current = $line;
|
||||
} else {
|
||||
$current .= ' ' . $line;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (trim($current) !== '') {
|
||||
$refs[] = trim(preg_replace('/\s+/u', ' ', $current));
|
||||
}
|
||||
|
||||
return $refs;
|
||||
}
|
||||
/**
|
||||
* 核心解码方法
|
||||
* @param string $str 待解码字符串
|
||||
|
||||
194
application/common/ArticleSymbolNormalizer.php
Normal file
194
application/common/ArticleSymbolNormalizer.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace app\common;
|
||||
|
||||
/**
|
||||
* 期刊文章内容「符号层」校对:只调整标点、空白、全角半角等,不增删语义文字。
|
||||
*
|
||||
* 设计原则:
|
||||
* - 默认规则保守,可通过 $options 逐项关闭;
|
||||
* - 纯文本用 normalize();含 HTML 时用 normalizeHtml()(仅处理标签之间的文本段,避免破坏属性里的 URL)。
|
||||
* - Abstract 常用:存储时被转义为 > < & 等,可用 normalizeAbstract() 先解码再符号校对。
|
||||
* - 英文期刊正文/摘要通常不含中文:设 english_journal=true(或 normalizeEnglishAbstract)可关闭仅针对汉字的规则。
|
||||
*/
|
||||
class ArticleSymbolNormalizer
|
||||
{
|
||||
/** @var string 常用汉字 BMP 段(含扩展 A 前部,足够覆盖正文) */
|
||||
private static $han = '\x{4E00}-\x{9FFF}\x{3400}-\x{4DBF}';
|
||||
|
||||
/**
|
||||
* 纯文本符号校对。
|
||||
*
|
||||
* @param string $text
|
||||
* @param array $options 可选键(均为 bool,默认 true):
|
||||
* - line_endings CRLF / CR → LF
|
||||
* - fullwidth_space U+3000 全角空格 → 普通空格
|
||||
* - collapse_spaces 连续半角空格(不含换行)压成单个空格
|
||||
* - remove_zwsp 删除零宽空格等不可见格式字符(不改变可见字)
|
||||
* - comma_cjk 两个汉字之间的英文逗号「,」→「,」
|
||||
* - comma_latin 两个 ASCII 字母/数字之间的全角逗号「,」→「,」
|
||||
* - period_cjk 汉字后的全角句点「.」(U+FF0E) →「。」
|
||||
* - bracket_latin 仅由 ASCII 标识包裹时「()」→「()」(如 (a) 类简单情形,保守:仅当括号内全为 ASCII)
|
||||
* - decode_html_entities 将 > < & " ' 及数字实体等转为真实字符(默认 false;abstract 见 normalizeAbstract)
|
||||
* - english_journal 英文期刊:关闭「两汉字间英文逗号→,」「汉字后 FF0E→。」等中文专用规则(默认 false;见 normalizeEnglishAbstract)
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function normalize($text, array $options = [])
|
||||
{
|
||||
$text = (string)$text;
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$o = array_merge([
|
||||
'line_endings' => true,
|
||||
'fullwidth_space' => true,
|
||||
'collapse_spaces' => true,
|
||||
'remove_zwsp' => true,
|
||||
'comma_cjk' => true,
|
||||
'comma_latin' => true,
|
||||
'period_cjk' => true,
|
||||
'bracket_latin' => false,
|
||||
'decode_html_entities' => false,
|
||||
'english_journal' => false,
|
||||
], $options);
|
||||
|
||||
if (!empty($o['english_journal'])) {
|
||||
if (!array_key_exists('comma_cjk', $options)) {
|
||||
$o['comma_cjk'] = false;
|
||||
}
|
||||
if (!array_key_exists('period_cjk', $options)) {
|
||||
$o['period_cjk'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($o['decode_html_entities'])) {
|
||||
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
}
|
||||
|
||||
if (!empty($o['line_endings'])) {
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
}
|
||||
if (!empty($o['fullwidth_space'])) {
|
||||
$text = str_replace("\u{3000}", ' ', $text);
|
||||
}
|
||||
if (!empty($o['remove_zwsp'])) {
|
||||
// 零宽空格、零宽非断空格、BOM、软连字符等(不改变可见字符)
|
||||
$text = preg_replace('/[\x{200B}-\x{200D}\x{FEFF}\x{00AD}]/u', '', $text);
|
||||
}
|
||||
if (!empty($o['collapse_spaces'])) {
|
||||
$text = preg_replace('/[ \t]{2,}/u', ' ', $text);
|
||||
}
|
||||
|
||||
$han = self::$han;
|
||||
|
||||
if (!empty($o['comma_cjk'])) {
|
||||
// 汉字 , 汉字 → 汉字 , 汉字
|
||||
$text = preg_replace('/(?<=[' . $han . ']),(?=[' . $han . '])/u', ',', $text);
|
||||
}
|
||||
if (!empty($o['comma_latin'])) {
|
||||
// 字母/数字 , 字母/数字 → ,
|
||||
$text = preg_replace('/(?<=[0-9A-Za-z]),(?=[0-9A-Za-z])/u', ',', $text);
|
||||
}
|
||||
if (!empty($o['period_cjk'])) {
|
||||
// 汉字后的全角英文句点 FF0E → 中文句号 。
|
||||
$text = preg_replace('/(?<=[' . $han . '])./u', '。', $text);
|
||||
}
|
||||
if (!empty($o['bracket_latin'])) {
|
||||
// ( 仅 ASCII + 常见标点 + 空格 )
|
||||
$text = preg_replace_callback(
|
||||
'/(([0-9A-Za-z\s\.,;:\-\+/=]+))/u',
|
||||
static function ($m) {
|
||||
return '(' . $m[1] . ')';
|
||||
},
|
||||
$text
|
||||
);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 HTML 片段做符号校对:只替换「标签外」的文本,不修改标签名与属性值。
|
||||
*
|
||||
* 实现:按 `<...>` 切分,对偶数段(文本)调用 normalize(),奇数段(标签)原样保留。
|
||||
* 注意:畸形 HTML、属性值中含未转义 `<` 时可能误判,复杂场景请先抽纯文本再校对。
|
||||
*
|
||||
* @param string $html
|
||||
* @param array $options 同 normalize()
|
||||
* @return string
|
||||
*/
|
||||
public static function normalizeHtml($html, array $options = [])
|
||||
{
|
||||
$html = (string)$html;
|
||||
if ($html === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = preg_split('/(<[^>]*>)/u', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||||
if ($parts === false) {
|
||||
return self::normalize($html, $options);
|
||||
}
|
||||
|
||||
$out = '';
|
||||
foreach ($parts as $i => $chunk) {
|
||||
if ($chunk === '') {
|
||||
continue;
|
||||
}
|
||||
// 偶数索引为文本,奇数索引且以 < 开头为标签
|
||||
if ($i % 2 === 1 && isset($chunk[0]) && $chunk[0] === '<') {
|
||||
$out .= $chunk;
|
||||
} else {
|
||||
$out .= self::normalize($chunk, $options);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract 专用:先 HTML 实体解码(> → > 等),再执行与普通正文相同的符号校对。
|
||||
*
|
||||
* 适用于摘要字段在库中/接口中以 htmlspecialchars 形式存储的场景。
|
||||
* 若摘要内本身含真实 HTML 标签且需保留标签结构,请改用 normalizeHtml() 并自行传入 decode_html_entities。
|
||||
*
|
||||
* @param string $abstract
|
||||
* @param array $options 同 normalize(),默认会合并 decode_html_entities=true(可被显式 false 覆盖)
|
||||
* @return string
|
||||
*/
|
||||
public static function normalizeAbstract($abstract, array $options = [])
|
||||
{
|
||||
$opts = array_merge(['decode_html_entities' => true], $options);
|
||||
return self::normalize($abstract, $opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 带 HTML 标签的摘要:仅在「标签外文本」中做实体解码 + 符号校对,不改动标签与属性。
|
||||
*
|
||||
* @param string $html
|
||||
* @param array $options 同 normalize(),默认 decode_html_entities=true
|
||||
* @return string
|
||||
*/
|
||||
public static function normalizeAbstractHtml($html, array $options = [])
|
||||
{
|
||||
$opts = array_merge(['decode_html_entities' => true], $options);
|
||||
return self::normalizeHtml($html, $opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 英文期刊 Abstract:实体解码 + 符号校对,且默认关闭中文专用标点规则。
|
||||
*/
|
||||
public static function normalizeEnglishAbstract($abstract, array $options = [])
|
||||
{
|
||||
return self::normalizeAbstract($abstract, array_merge(['english_journal' => true], $options));
|
||||
}
|
||||
|
||||
/**
|
||||
* 英文期刊、带 HTML 的摘要(标签外文本):实体解码 + 符号校对,且默认关闭中文专用规则。
|
||||
*/
|
||||
public static function normalizeEnglishAbstractHtml($html, array $options = [])
|
||||
{
|
||||
return self::normalizeAbstractHtml($html, array_merge(['english_journal' => true], $options));
|
||||
}
|
||||
}
|
||||
331
application/common/CitationRelevanceService.php
Normal file
331
application/common/CitationRelevanceService.php
Normal file
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
namespace app\common;
|
||||
|
||||
/**
|
||||
* 引用相关性检测服务(PubMed + embedding)
|
||||
*
|
||||
* 依赖:
|
||||
* - PubmedService:用 DOI 抓取 title/abstract/mesh/publication_types
|
||||
* - CrossrefService:撤稿/更正识别(补充)
|
||||
*
|
||||
* embedding:
|
||||
* - 使用你们内部大模型的 embedding 接口(无需 token 付费,但速度慢)
|
||||
* - 通过构造参数传入 embedding_url / headers / timeout
|
||||
* - 内置文件缓存,减少重复 embedding 成本
|
||||
*/
|
||||
class CitationRelevanceService
|
||||
{
|
||||
private $pubmed;
|
||||
private $crossref;
|
||||
|
||||
private $embeddingUrl = '';
|
||||
private $embeddingHeaders = [];
|
||||
private $timeout = 120;
|
||||
private $chatUrl = '';
|
||||
private $chatModel = '';
|
||||
private $embeddingDim = 256;
|
||||
private $chatMaxTokens = 1200;
|
||||
|
||||
public function __construct(PubmedService $pubmed = null, CrossrefService $crossref = null, array $config = [])
|
||||
{
|
||||
$this->pubmed = $pubmed ?: new PubmedService();
|
||||
$this->crossref = $crossref ?: new CrossrefService();
|
||||
|
||||
if (isset($config['embedding_url'])) $this->embeddingUrl = (string)$config['embedding_url'];
|
||||
if (isset($config['embedding_headers']) && is_array($config['embedding_headers'])) $this->embeddingHeaders = $config['embedding_headers'];
|
||||
if (isset($config['timeout'])) $this->timeout = max(10, intval($config['timeout']));
|
||||
if (isset($config['chat_url'])) $this->chatUrl = (string)$config['chat_url'];
|
||||
if (isset($config['chat_model'])) $this->chatModel = (string)$config['chat_model'];
|
||||
if (isset($config['embedding_dim'])) $this->embeddingDim = max(32, intval($config['embedding_dim']));
|
||||
if (isset($config['chat_max_tokens'])) $this->chatMaxTokens = max(256, intval($config['chat_max_tokens']));
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条引用相关性检测
|
||||
*
|
||||
* @param string $contextText 引用处上下文(英文)
|
||||
* @param array $referRow production_article_refer 行(至少含 refer_doi/doilink/title/author/joura/dateno)
|
||||
* @param array $options
|
||||
* - sentence_is_background(bool) 若外部已判断为背景堆引用,可直接降级
|
||||
* - sim_related(float) related 阈值,默认 0.75
|
||||
* - sim_unsure(float) unsure 阈值,默认 0.60
|
||||
* - check_retraction(bool) 是否检查撤稿/更正,默认 true
|
||||
*/
|
||||
public function checkOne(string $contextText, array $referRow, array $options = []): array
|
||||
{
|
||||
$contextText = trim($contextText);
|
||||
$simRelated = isset($options['sim_related']) ? (float)$options['sim_related'] : 0.75;
|
||||
$simUnsure = isset($options['sim_unsure']) ? (float)$options['sim_unsure'] : 0.60;
|
||||
$checkRetraction = isset($options['check_retraction']) ? (bool)$options['check_retraction'] : true;
|
||||
$isBackground = !empty($options['sentence_is_background']);
|
||||
|
||||
// 1) 问题条目(退稿/更正):先 Crossref(有 DOI 才能判断)
|
||||
$problemFlag = 'unknown';
|
||||
$problemReason = '';
|
||||
if ($checkRetraction) {
|
||||
$qc = $this->crossref->qcCitation($contextText, $referRow, ['check_retraction' => true]);
|
||||
$problemFlag = $qc['problem_flag'] ?? 'unknown';
|
||||
$problemReason = $qc['problem_reason'] ?? '';
|
||||
}
|
||||
|
||||
// 2) PubMed 抓取 abstract/mesh(提升语义)
|
||||
$doi = $this->extractDoiFromRefer($referRow);
|
||||
$pub = $doi ? $this->pubmed->fetchByDoi($doi) : null;
|
||||
|
||||
$pubText = '';
|
||||
$pubTypes = [];
|
||||
if ($pub) {
|
||||
$pubTypes = $pub['publication_types'] ?? [];
|
||||
$mesh = $pub['mesh_terms'] ?? [];
|
||||
$pubText = trim(
|
||||
($pub['title'] ?? '') . "\n" .
|
||||
($pub['abstract'] ?? '') . "\n" .
|
||||
(!empty($mesh) ? ('MeSH: ' . implode('; ', $mesh)) : '')
|
||||
);
|
||||
}
|
||||
|
||||
// 3) embedding 相似度(context vs pubmed_text),无 pubmed_text 则退化为 crossref 的证据法
|
||||
if ($pubText !== '') {
|
||||
$v1 = $this->embedCached($contextText);
|
||||
$v2 = $this->embedCached($pubText);
|
||||
$sim = ($v1 && $v2) ? $this->cosine($v1, $v2) : 0.0;
|
||||
|
||||
$relevanceFlag = 'unsure';
|
||||
if ($sim >= $simRelated) {
|
||||
$relevanceFlag = 'related';
|
||||
} elseif ($sim >= $simUnsure) {
|
||||
$relevanceFlag = 'unsure';
|
||||
} else {
|
||||
$relevanceFlag = $isBackground ? 'unsure_background' : 'suspicious_unrelated';
|
||||
}
|
||||
|
||||
// PubMed 自身也能提示撤稿/更正(作为补充)
|
||||
if ($checkRetraction && $problemFlag !== 'retracted_or_corrected' && !empty($pubTypes)) {
|
||||
$ptLower = strtolower(implode(' | ', $pubTypes));
|
||||
if (strpos($ptLower, 'retracted publication') !== false
|
||||
|| strpos($ptLower, 'retraction of publication') !== false
|
||||
|| strpos($ptLower, 'published erratum') !== false
|
||||
) {
|
||||
$problemFlag = 'retracted_or_corrected';
|
||||
$problemReason = 'PubMed publication type indicates retraction/correction';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'problem_flag' => $problemFlag,
|
||||
'problem_reason' => $problemReason,
|
||||
'relevance_flag' => $relevanceFlag,
|
||||
'relevance_score' => round($sim, 4),
|
||||
'reason' => $pubText !== '' ? 'embedding(context,pubmed_text)' : 'embedding_unavailable',
|
||||
'pubmed' => [
|
||||
'pmid' => $pub['pmid'] ?? '',
|
||||
'year' => $pub['year'] ?? '',
|
||||
'journal' => $pub['journal'] ?? '',
|
||||
'publication_types' => $pubTypes,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 退化:没有 PubMed 信息时,用 CrossrefService 证据法(会偏保守)
|
||||
$fallback = $this->crossref->qcCitation($contextText, $referRow, ['check_retraction' => false]);
|
||||
$fallback['problem_flag'] = $problemFlag;
|
||||
$fallback['problem_reason'] = $problemReason;
|
||||
$fallback['reason'] = 'fallback_crossref_evidence; ' . ($fallback['reason'] ?? '');
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
// ---------------- embedding ----------------
|
||||
|
||||
private function embedCached(string $text): ?array
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '') return null;
|
||||
|
||||
$key = 'emb_' . sha1($text);
|
||||
$cached = $this->cacheGet($key, 90 * 86400);
|
||||
if (is_array($cached) && !empty($cached)) return $cached;
|
||||
|
||||
$vec = $this->embed($text);
|
||||
if (is_array($vec) && !empty($vec)) {
|
||||
$this->cacheSet($key, $vec);
|
||||
return $vec;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用内部 embedding 接口
|
||||
* 兼容返回格式:
|
||||
* - OpenAI embeddings: {data:[{embedding:[...] }]}
|
||||
* - {embedding:[...]}
|
||||
* - 直接返回数组 [...]
|
||||
*/
|
||||
private function embed(string $text): ?array
|
||||
{
|
||||
// 1) 优先使用独立 embeddings 接口
|
||||
if ($this->embeddingUrl !== '') {
|
||||
$payload = json_encode(['text' => $text], JSON_UNESCAPED_UNICODE);
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $this->embeddingUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||
|
||||
$headers = array_merge(['Content-Type: application/json'], $this->embeddingHeaders);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
|
||||
$res = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
if (!is_string($res) || trim($res) === '') return null;
|
||||
|
||||
$decoded = json_decode($res, true);
|
||||
if (is_array($decoded)) {
|
||||
if (isset($decoded['data'][0]['embedding']) && is_array($decoded['data'][0]['embedding'])) {
|
||||
return $this->normalizeVector($decoded['data'][0]['embedding']);
|
||||
}
|
||||
if (isset($decoded['embedding']) && is_array($decoded['embedding'])) {
|
||||
return $this->normalizeVector($decoded['embedding']);
|
||||
}
|
||||
$isVec = isset($decoded[0]) && (is_float($decoded[0]) || is_int($decoded[0]));
|
||||
if ($isVec) return $this->normalizeVector($decoded);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2) 没有 embeddings 接口时,使用 chat/completions 生成固定维度向量
|
||||
if ($this->chatUrl === '' || $this->chatModel === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sys = "You are an embedding generator. Output ONLY valid JSON in this exact shape: {\"embedding\":[...]}.\n"
|
||||
. "Rules:\n"
|
||||
. "- embedding must be an array of exactly {$this->embeddingDim} floats\n"
|
||||
. "- each float must be between -1 and 1\n"
|
||||
. "- do not include any other keys or any extra text\n";
|
||||
|
||||
$payload = json_encode([
|
||||
'model' => $this->chatModel,
|
||||
'temperature' => 0,
|
||||
'max_tokens' => $this->chatMaxTokens,
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => $sys],
|
||||
['role' => 'user', 'content' => $text],
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $this->chatUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||
|
||||
$headers = array_merge(['Content-Type: application/json'], $this->embeddingHeaders);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
|
||||
$res = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
if (!is_string($res) || trim($res) === '') return null;
|
||||
|
||||
$decoded = json_decode($res, true);
|
||||
$content = '';
|
||||
if (is_array($decoded) && isset($decoded['choices'][0]['message']['content'])) {
|
||||
$content = (string)$decoded['choices'][0]['message']['content'];
|
||||
}
|
||||
$content = trim($content);
|
||||
if ($content === '') return null;
|
||||
|
||||
// content 可能被包裹在 ```json ... ```
|
||||
if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $content, $m)) {
|
||||
$content = trim($m[1]);
|
||||
}
|
||||
$j = json_decode($content, true);
|
||||
if (!is_array($j) || !isset($j['embedding']) || !is_array($j['embedding'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$vec = $j['embedding'];
|
||||
if (count($vec) !== $this->embeddingDim) {
|
||||
return null;
|
||||
}
|
||||
return $this->normalizeVector($vec);
|
||||
}
|
||||
|
||||
private function cosine(array $a, array $b): float
|
||||
{
|
||||
$n = min(count($a), count($b));
|
||||
if ($n <= 0) return 0.0;
|
||||
$dot = 0.0; $na = 0.0; $nb = 0.0;
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$x = (float)$a[$i];
|
||||
$y = (float)$b[$i];
|
||||
$dot += $x * $y;
|
||||
$na += $x * $x;
|
||||
$nb += $y * $y;
|
||||
}
|
||||
if ($na <= 0.0 || $nb <= 0.0) return 0.0;
|
||||
return $dot / (sqrt($na) * sqrt($nb));
|
||||
}
|
||||
|
||||
private function normalizeVector(array $v): array
|
||||
{
|
||||
$sum = 0.0;
|
||||
$out = [];
|
||||
foreach ($v as $x) {
|
||||
$fx = (float)$x;
|
||||
$out[] = $fx;
|
||||
$sum += $fx * $fx;
|
||||
}
|
||||
if ($sum <= 0.0) return $out;
|
||||
$norm = sqrt($sum);
|
||||
for ($i = 0; $i < count($out); $i++) {
|
||||
$out[$i] = $out[$i] / $norm;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function extractDoiFromRefer(array $referRow): string
|
||||
{
|
||||
// 复用 CrossrefService 内部逻辑(通过 qcCitation 的抽取函数不可直接访问,所以这里简单实现)
|
||||
$doi = trim((string)($referRow['refer_doi'] ?? ''));
|
||||
if ($doi !== '') return $doi;
|
||||
|
||||
$doilink = trim((string)($referRow['doilink'] ?? ''));
|
||||
if ($doilink === '') return '';
|
||||
if (preg_match('#doi\\.org/([^?#]+)#i', $doilink, $m)) {
|
||||
return trim((string)$m[1]);
|
||||
}
|
||||
return $doilink;
|
||||
}
|
||||
|
||||
// ---------------- cache ----------------
|
||||
|
||||
private function cacheDir(): string
|
||||
{
|
||||
return rtrim(ROOT_PATH, '/') . '/runtime/embed_cache';
|
||||
}
|
||||
|
||||
private function cacheGet(string $key, int $ttlSeconds)
|
||||
{
|
||||
$file = $this->cacheDir() . '/' . $key . '.json';
|
||||
if (!is_file($file)) return null;
|
||||
$mtime = filemtime($file);
|
||||
if (!$mtime || (time() - $mtime) > $ttlSeconds) return null;
|
||||
$raw = @file_get_contents($file);
|
||||
$decoded = json_decode((string)$raw, true);
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function cacheSet(string $key, $value): void
|
||||
{
|
||||
$dir = $this->cacheDir();
|
||||
if (!is_dir($dir)) @mkdir($dir, 0777, true);
|
||||
$file = $dir . '/' . $key . '.json';
|
||||
@file_put_contents($file, json_encode($value, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
|
||||
108
application/common/CountryResolverService.php
Normal file
108
application/common/CountryResolverService.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace app\common;
|
||||
|
||||
/**
|
||||
* 使用本地 chat/completions 接口,从 affiliation 文本推断国家。
|
||||
*
|
||||
* 输出约定:返回数组 ['code' => 'US', 'en_name' => 'United States'],任一可为空。
|
||||
*/
|
||||
class CountryResolverService
|
||||
{
|
||||
private $chatUrl = '';
|
||||
private $chatModel = '';
|
||||
private $apiKey = '';
|
||||
private $timeout = 60;
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
if (isset($config['chat_url'])) $this->chatUrl = (string)$config['chat_url'];
|
||||
if (isset($config['chat_model'])) $this->chatModel = (string)$config['chat_model'];
|
||||
if (isset($config['api_key'])) $this->apiKey = (string)$config['api_key'];
|
||||
if (isset($config['timeout'])) $this->timeout = max(5, intval($config['timeout']));
|
||||
}
|
||||
|
||||
public function resolve(string $affiliation): array
|
||||
{
|
||||
$affiliation = trim($affiliation);
|
||||
if ($affiliation === '' || $this->chatUrl === '' || $this->chatModel === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$messages = [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => 'You extract the country from an academic affiliation string. Reply ONLY with minified JSON.',
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => "Affiliation:\n" . $affiliation . "\n\nReturn JSON with keys:\n- code: ISO 3166-1 alpha-3 (preferred)\n- en_name: English country name (optional)\nIf uncertain, return {\"code\":\"\",\"en_name\":\"\"}.",
|
||||
],
|
||||
];
|
||||
|
||||
$payload = [
|
||||
'model' => $this->chatModel,
|
||||
'temperature' => 0,
|
||||
'messages' => $messages,
|
||||
];
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $this->chatUrl);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(10, $this->timeout));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
|
||||
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
];
|
||||
if ($this->apiKey !== '') {
|
||||
$headers[] = 'Authorization: Bearer ' . $this->apiKey;
|
||||
}
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
|
||||
$raw = curl_exec($ch);
|
||||
if ($raw === false) {
|
||||
curl_close($ch);
|
||||
return [];
|
||||
}
|
||||
$httpCode = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE));
|
||||
curl_close($ch);
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) return [];
|
||||
|
||||
// 兼容 OpenAI chat/completions 结构:choices[0].message.content
|
||||
$content = '';
|
||||
if (isset($data['choices'][0]['message']['content'])) {
|
||||
$content = (string)$data['choices'][0]['message']['content'];
|
||||
} elseif (isset($data['content'])) {
|
||||
$content = (string)$data['content'];
|
||||
}
|
||||
|
||||
$content = trim($content);
|
||||
if ($content === '') return [];
|
||||
|
||||
// 尝试提取 JSON(允许模型包裹 ```json)
|
||||
if (preg_match('/\{.*\}/s', $content, $m)) {
|
||||
$content = $m[0];
|
||||
}
|
||||
|
||||
$obj = json_decode($content, true);
|
||||
if (!is_array($obj)) return [];
|
||||
|
||||
$code = isset($obj['code']) ? strtoupper(trim((string)$obj['code'])) : '';
|
||||
$enName = isset($obj['en_name']) ? trim((string)$obj['en_name']) : '';
|
||||
|
||||
// 防止模型乱回长段文本
|
||||
if (strlen($code) > 8) $code = '';
|
||||
if (strlen($enName) > 128) $enName = mb_substr($enName, 0, 128);
|
||||
|
||||
return ['code' => $code, 'en_name' => $enName];
|
||||
}
|
||||
}
|
||||
|
||||
765
application/common/CrossrefService.php
Normal file
765
application/common/CrossrefService.php
Normal file
@@ -0,0 +1,765 @@
|
||||
<?php
|
||||
|
||||
namespace app\common;
|
||||
|
||||
/**
|
||||
* Crossref API 工具类
|
||||
*
|
||||
* 说明:
|
||||
* - 仿照 application/api/controller/Crossrefdoi.php 的实现风格抽成 Service
|
||||
* - 仅做「请求 + 解析」;不包含任何数据库读写
|
||||
*/
|
||||
class CrossrefService
|
||||
{
|
||||
// 配置项
|
||||
private $mailto = ''; // 邮箱(提升优先级)
|
||||
private $timeout = 15; // 请求超时(秒)
|
||||
private $maxRetry = 2; // 单个DOI最大重试次数
|
||||
private $crossrefUrl = "https://api.crossref.org/works/"; // 接口地址
|
||||
|
||||
public function __construct($config = [])
|
||||
{
|
||||
if (is_array($config)) {
|
||||
if (isset($config['mailto'])) $this->mailto = (string)$config['mailto'];
|
||||
if (isset($config['timeout'])) $this->timeout = intval($config['timeout']);
|
||||
if (isset($config['maxRetry'])) $this->maxRetry = intval($config['maxRetry']);
|
||||
if (isset($config['crossrefUrl'])) $this->crossrefUrl = (string)$config['crossrefUrl'];
|
||||
}
|
||||
}
|
||||
|
||||
public function setMailto($mailto)
|
||||
{
|
||||
$this->mailto = (string)$mailto;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 引用标号转换工具:正文里的 [n] 对应 production_article_refer.index = n-1(index 从 0 开始)。
|
||||
*
|
||||
* @param int $citationMark 正文引用编号,如 13(来自 [13])
|
||||
* @return int production_article_refer.index,如 12
|
||||
*/
|
||||
public function referIndexFromCitationMark(int $citationMark): int
|
||||
{
|
||||
$citationMark = intval($citationMark);
|
||||
return max(0, $citationMark - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 反向转换工具:production_article_refer.index(从 0 开始)→ 正文引用编号 [n]。
|
||||
*
|
||||
* @param int $referIndex production_article_refer.index,如 12
|
||||
* @return int 正文引用编号 n,如 13
|
||||
*/
|
||||
public function citationMarkFromReferIndex(int $referIndex): int
|
||||
{
|
||||
$referIndex = intval($referIndex);
|
||||
return max(0, $referIndex + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量引用质检(不查库版):\n
|
||||
* - 输入文章分节内容(t_article_main 的 content 列表)\n
|
||||
* - 输入引用条目(production_article_refer 的行列表)\n
|
||||
* - 自动抽取每个 [n] 的英文句子上下文,并映射到 refer.index=n-1 后调用 qcCitation()\n
|
||||
*
|
||||
* 说明:本方法不做任何数据库查询,方便你在 controller/service 中自由组合数据来源。
|
||||
*
|
||||
* @param array $articleMainContents 文章内容片段数组(按 sort 顺序),元素为 string 或含 content 的数组
|
||||
* @param array $referRows production_article_refer 行数组(至少含 index/title/author/joura/dateno/refer_doi/doilink)
|
||||
* @param array $options 透传给 qcCitation 的 options,并支持:
|
||||
* - sentence_window(int) 上下文句子窗口,默认 1(即前1句+本句+后1句)
|
||||
* @return array 结果列表,每条包含 citation_mark/refer_index/context/ref_meta/qc
|
||||
*/
|
||||
public function qcArticleCitations(array $articleMainContents, array $referRows, array $options = []): array
|
||||
{
|
||||
$window = isset($options['sentence_window']) ? max(0, intval($options['sentence_window'])) : 1;
|
||||
|
||||
// 1) 组装全文纯文本(保留 [n])
|
||||
$chunks = [];
|
||||
foreach ($articleMainContents as $row) {
|
||||
if (is_array($row)) {
|
||||
$text = (string)($row['content'] ?? '');
|
||||
} else {
|
||||
$text = (string)$row;
|
||||
}
|
||||
if ($text === '') continue;
|
||||
// 去掉常见标签,保留 [n]
|
||||
$text = preg_replace('/<\s*\/?\s*blue[^>]*>/i', '', $text);
|
||||
$text = strip_tags($text);
|
||||
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$text = preg_replace('/\s+/u', ' ', trim($text));
|
||||
if ($text !== '') $chunks[] = $text;
|
||||
}
|
||||
$fullText = implode("\n", $chunks);
|
||||
|
||||
if ($fullText === '') return [];
|
||||
|
||||
// 2) 构建引用条目映射:refer_index => row
|
||||
$referMap = [];
|
||||
foreach ($referRows as $r) {
|
||||
if (!is_array($r)) continue;
|
||||
if (!isset($r['index'])) continue;
|
||||
$idx = intval($r['index']);
|
||||
$referMap[$idx] = $r;
|
||||
}
|
||||
|
||||
// 3) 英文切句(简单稳健版)
|
||||
$sentences = $this->splitEnglishSentences($fullText);
|
||||
if (empty($sentences)) return [];
|
||||
|
||||
// 4) 遍历句子,抓取其中的 [n]
|
||||
$results = [];
|
||||
foreach ($sentences as $si => $sent) {
|
||||
if (!preg_match_all('/\[(\d+)\]/', $sent, $m)) {
|
||||
continue;
|
||||
}
|
||||
$marks = array_unique(array_map('intval', $m[1]));
|
||||
foreach ($marks as $citationMark) {
|
||||
if ($citationMark <= 0) continue;
|
||||
$referIndex = $this->referIndexFromCitationMark($citationMark);
|
||||
if (!isset($referMap[$referIndex])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$start = max(0, $si - $window);
|
||||
$end = min(count($sentences) - 1, $si + $window);
|
||||
$ctx = implode(' ', array_slice($sentences, $start, $end - $start + 1));
|
||||
$ctx = trim(preg_replace('/\s+/u', ' ', $ctx));
|
||||
|
||||
$refMeta = $referMap[$referIndex];
|
||||
$qc = $this->qcCitation($ctx, $refMeta, $options);
|
||||
|
||||
$results[] = [
|
||||
'citation_mark' => $citationMark, // 正文编号 n(来自 [n])
|
||||
'refer_index' => $referIndex, // production_article_refer.index
|
||||
'context' => $ctx,
|
||||
'ref_meta' => [
|
||||
'p_refer_id' => $refMeta['p_refer_id'] ?? 0,
|
||||
'title' => $refMeta['title'] ?? '',
|
||||
'author' => $refMeta['author'] ?? '',
|
||||
'joura' => $refMeta['joura'] ?? '',
|
||||
'dateno' => $refMeta['dateno'] ?? '',
|
||||
'refer_doi' => $refMeta['refer_doi'] ?? '',
|
||||
'doilink' => $refMeta['doilink'] ?? '',
|
||||
'index' => $refMeta['index'] ?? $referIndex,
|
||||
],
|
||||
'qc' => $qc,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤非法DOI(仅保留10.xxxx/xxx格式)
|
||||
* @param string $doi
|
||||
* @return string
|
||||
*/
|
||||
public function filterValidDoi($doi = '')
|
||||
{
|
||||
$doi = trim((string)$doi);
|
||||
if ($doi === '') return '';
|
||||
if (preg_match('/^10\.\d{4,}\/.+/', $doi)) {
|
||||
return $doi;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Crossref message(带重试)
|
||||
* @param string $doi
|
||||
* @return array|null
|
||||
*/
|
||||
public function fetchWork($doi)
|
||||
{
|
||||
$doi = $this->filterValidDoi($doi);
|
||||
if ($doi === '') return null;
|
||||
return $this->fetchSingleDoiWithRetry($doi);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回常用字段集合(标题/期刊/作者/卷期页/撤稿/URL)
|
||||
* @param string $doi
|
||||
* @return array|null
|
||||
*/
|
||||
public function fetchWorkSummary($doi)
|
||||
{
|
||||
$msg = $this->fetchWork($doi);
|
||||
if (!$msg) return null;
|
||||
|
||||
$title = $this->getTitle($msg);
|
||||
$publisher = $this->getPublisher($msg);
|
||||
$joura = !empty($publisher['title']) ? $publisher['title'] : ($publisher['short_title'] ?? '');
|
||||
$authors = $this->getAuthors($msg);
|
||||
$dateno = $this->getVolumeIssuePages($msg);
|
||||
$retractInfo = $this->checkRetracted($msg);
|
||||
$dolink = $this->getDolink($msg);
|
||||
if (empty($dolink)) {
|
||||
$dolink = 'https://doi.org/' . $this->filterValidDoi($doi);
|
||||
}
|
||||
|
||||
return [
|
||||
'doi' => $this->filterValidDoi($doi),
|
||||
'title' => $title,
|
||||
'joura' => $joura,
|
||||
'publisher' => $publisher,
|
||||
'authors' => $authors,
|
||||
'author_str' => empty($authors) ? '' : implode(',', $authors),
|
||||
'dateno' => $dateno,
|
||||
'is_retracted' => !empty($retractInfo['is_retracted']) ? 1 : 0,
|
||||
'retract_reason' => $retractInfo['reason'] ?? '',
|
||||
'doilink' => $dolink,
|
||||
'raw' => $msg,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 单DOI查询(带重试)
|
||||
* @param string $doi
|
||||
* @return array|null
|
||||
*/
|
||||
private function fetchSingleDoiWithRetry($doi)
|
||||
{
|
||||
$retryCount = 0;
|
||||
while ($retryCount < $this->maxRetry) {
|
||||
$url = $this->crossrefUrl . rawurlencode($doi);
|
||||
if (!empty($this->mailto)) {
|
||||
$url .= "?mailto=" . rawurlencode($this->mailto);
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
"User-Agent: DOI-Fetcher/1.0 (mailto:{$this->mailto})"
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode == 200) {
|
||||
$data = json_decode($response, true);
|
||||
return (isset($data['status']) && $data['status'] == 'ok') ? ($data['message'] ?? null) : null;
|
||||
}
|
||||
|
||||
if ($httpCode == 429) {
|
||||
sleep(5);
|
||||
$retryCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$retryCount++;
|
||||
sleep(1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取标题
|
||||
*/
|
||||
public function getTitle($aDoiInfo = [])
|
||||
{
|
||||
return $aDoiInfo['title'][0] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取期刊/出版社相关信息
|
||||
*/
|
||||
public function getPublisher($aDoiInfo = [])
|
||||
{
|
||||
return [
|
||||
'title' => isset($aDoiInfo['container-title'][0]) ? $aDoiInfo['container-title'][0] : '',
|
||||
'short_title' => isset($aDoiInfo['short-container-title'][0]) ? $aDoiInfo['short-container-title'][0] : '',
|
||||
'ISSN' => $aDoiInfo['ISSN'] ?? [],
|
||||
'publisher' => $aDoiInfo['publisher'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取作者列表
|
||||
*/
|
||||
public function getAuthors($aDoiInfo = [])
|
||||
{
|
||||
$authors = [];
|
||||
if (!empty($aDoiInfo['author'])) {
|
||||
foreach ($aDoiInfo['author'] as $author) {
|
||||
$name = $author['family'] ?? '';
|
||||
if (!empty($author['given'])) {
|
||||
$name = $author['given'] . ' ' . $name;
|
||||
}
|
||||
if (!empty($name)) {
|
||||
$authors[] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $authors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取发表年份
|
||||
*/
|
||||
public function getPublishYear($aDoiInfo = [])
|
||||
{
|
||||
if (!empty($aDoiInfo['issued']['date-parts'][0][0])) {
|
||||
return (string)$aDoiInfo['issued']['date-parts'][0][0];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取卷(期):起始页-终止页(格式:2024:10(2):100-120)
|
||||
*/
|
||||
public function getVolumeIssuePages($aDoiInfo = [])
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
$year = $this->getPublishYear($aDoiInfo);
|
||||
if ($year) $parts[] = $year;
|
||||
|
||||
$volume = $aDoiInfo['volume'] ?? '';
|
||||
$issue = $aDoiInfo['issue'] ?? '';
|
||||
if ($volume) {
|
||||
$parts[] = $volume . ($issue ? "({$issue})" : '');
|
||||
}
|
||||
|
||||
$pageStart = $aDoiInfo['page']['start'] ?? ($aDoiInfo['first-page'] ?? '');
|
||||
$pageEnd = $aDoiInfo['page']['end'] ?? ($aDoiInfo['last-page'] ?? '');
|
||||
$pages = '';
|
||||
if ($pageStart) {
|
||||
$pages = $pageStart . ($pageEnd ? "-{$pageEnd}" : '');
|
||||
} else {
|
||||
$pages = $aDoiInfo['page'] ?? '';
|
||||
}
|
||||
if ($pages) $parts[] = $pages;
|
||||
|
||||
return implode(':', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别撤稿文章(与 Crossrefdoi.php 同逻辑)
|
||||
*/
|
||||
public function checkRetracted($aDoiInfo = [])
|
||||
{
|
||||
$isRetracted = false;
|
||||
$reason = "未撤稿";
|
||||
|
||||
$sType = strtolower($aDoiInfo['type'] ?? '');
|
||||
$sSubtype = strtolower($aDoiInfo['subtype'] ?? '');
|
||||
if ($sType && in_array($sType, ['retraction', 'correction'])) {
|
||||
$isRetracted = true;
|
||||
$reason = "文章类型为{$sType}(撤稿/更正声明)";
|
||||
}
|
||||
if ($sSubtype && in_array($sSubtype, ['retraction', 'correction'])) {
|
||||
$isRetracted = true;
|
||||
$reason = "文章类型为{$sSubtype}(撤稿/更正声明)";
|
||||
}
|
||||
|
||||
if (isset($aDoiInfo['update-type']) && is_array($aDoiInfo['update-type']) && in_array('retraction', $aDoiInfo['update-type'])) {
|
||||
$isRetracted = true;
|
||||
$reason = "官方标记为撤稿(update-type: retraction)";
|
||||
}
|
||||
|
||||
if (isset($aDoiInfo['relation']) && !empty($aDoiInfo['relation'])) {
|
||||
foreach ($aDoiInfo['relation'] as $relType => $relItems) {
|
||||
if (in_array($relType, ['is-retraction-of', 'corrects'])) {
|
||||
$isRetracted = true;
|
||||
$relatedDoi = $relItems[0]['id'] ?? '未知';
|
||||
$reason = "关联撤稿文章{$relatedDoi}(关系:{$relType})";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($aDoiInfo['update-to']) && is_array($aDoiInfo['update-to'])) {
|
||||
foreach ($aDoiInfo['update-to'] as $update) {
|
||||
$updateType = strtolower($update['type'] ?? '');
|
||||
$updateLabel = strtolower($update['label'] ?? '');
|
||||
if (strpos($updateType, 'retract') !== false || strpos($updateLabel, 'retract') !== false) {
|
||||
$isRetracted = true;
|
||||
$reason = "update-to 标记撤稿({$updateType}/{$updateLabel})";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$aTitles = $aDoiInfo['title'] ?? [];
|
||||
foreach ($aTitles as $value) {
|
||||
$sTitleLower = strtolower($value);
|
||||
if (strpos($sTitleLower, 'retraction') !== false || strpos($sTitleLower, 'retracted') !== false
|
||||
|| strpos($sTitleLower, 'withdrawal') !== false || strpos($sTitleLower, 'withdrawn') !== false) {
|
||||
$isRetracted = true;
|
||||
$reason = "标题包含撤稿关键词";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'is_retracted' => $isRetracted,
|
||||
'reason' => $reason,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别 doi 链接
|
||||
*/
|
||||
public function getDolink($aDoiInfo = [])
|
||||
{
|
||||
return $aDoiInfo['URL'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Crossref date-parts
|
||||
*/
|
||||
public function parseDateParts($dateObj)
|
||||
{
|
||||
$parts = $dateObj['date-parts'][0] ?? [];
|
||||
if (empty($parts)) return '';
|
||||
$y = $parts[0] ?? '';
|
||||
$m = $parts[1] ?? '';
|
||||
$d = $parts[2] ?? '';
|
||||
$out = (string)$y;
|
||||
if ($m !== '') $out .= '-' . str_pad((string)$m, 2, '0', STR_PAD_LEFT);
|
||||
if ($d !== '') $out .= '-' . str_pad((string)$d, 2, '0', STR_PAD_LEFT);
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 引用质检:判断(1) 被引条目是否疑似退稿/更正(基于 Crossref);(2) 引用上下文是否与被引条目相关(基于证据命中)。
|
||||
*
|
||||
* 说明:
|
||||
* - 适用于没有 abstract/keywords 的场景(仅用 title/author/journal/year + 引用上下文句子)。
|
||||
* - 如果 refer_doi/doilink 为空,则 problem_flag 只能返回 unknown。
|
||||
*
|
||||
* @param string $contextText 引用处的上下文句子(英文,最好只包含引用所在句 + 少量相邻句)
|
||||
* @param array $refMeta 被引条目元信息(建议来自 production_article_refer)
|
||||
* - refer_doi / doilink / title / author / joura / dateno
|
||||
* @param array $options 可选参数
|
||||
* - check_retraction(bool) 是否调用 Crossref 判断退稿/更正;默认 true
|
||||
* - background_phrases(array) 背景堆引用触发短语;默认使用内置
|
||||
*
|
||||
* @return array
|
||||
* [
|
||||
* 'problem_flag' => 'ok'|'retracted_or_corrected'|'unknown',
|
||||
* 'problem_reason' => string,
|
||||
* 'relevance_flag' => 'related'|'unsure'|'unsure_background'|'suspicious_unrelated',
|
||||
* 'relevance_score' => float,
|
||||
* 'reason' => string
|
||||
* ]
|
||||
*/
|
||||
public function qcCitation(string $contextText, array $refMeta, array $options = []): array
|
||||
{
|
||||
$contextText = trim($contextText);
|
||||
$checkRetraction = isset($options['check_retraction']) ? (bool)$options['check_retraction'] : true;
|
||||
|
||||
$refTitle = (string)($refMeta['title'] ?? '');
|
||||
$refAuthor = (string)($refMeta['author'] ?? '');
|
||||
$refJoura = (string)($refMeta['joura'] ?? '');
|
||||
$refDateno = (string)($refMeta['dateno'] ?? '');
|
||||
$referDoi = (string)($refMeta['refer_doi'] ?? '');
|
||||
$doilink = (string)($refMeta['doilink'] ?? '');
|
||||
|
||||
$doi = $this->extractDoiFromMeta($referDoi, $doilink);
|
||||
|
||||
// 1) 退稿/更正判断(强规则,影响 problem_flag)
|
||||
$problemFlag = 'unknown';
|
||||
$problemReason = '';
|
||||
if ($checkRetraction) {
|
||||
if (!empty($doi)) {
|
||||
$summary = $this->fetchWorkSummary($doi);
|
||||
if ($summary && isset($summary['is_retracted'])) {
|
||||
if ((int)$summary['is_retracted'] === 1) {
|
||||
$problemFlag = 'retracted_or_corrected';
|
||||
$problemReason = !empty($summary['retract_reason']) ? $summary['retract_reason'] : 'Crossref indicates retraction/correction';
|
||||
} else {
|
||||
$problemFlag = 'ok';
|
||||
$problemReason = 'Crossref indicates not retracted/corrected';
|
||||
}
|
||||
} else {
|
||||
$problemFlag = 'unknown';
|
||||
$problemReason = 'Crossref fetch failed or returned unexpected data';
|
||||
}
|
||||
} else {
|
||||
$problemFlag = 'unknown';
|
||||
$problemReason = 'DOI is empty';
|
||||
}
|
||||
} else {
|
||||
$problemFlag = 'unknown';
|
||||
$problemReason = 'Skip retraction check';
|
||||
}
|
||||
|
||||
// 2) 相关性判断(弱规则+证据命中)
|
||||
$backgroundPhrases = isset($options['background_phrases']) ? (array)$options['background_phrases'] : [
|
||||
'several studies',
|
||||
'many studies',
|
||||
'the literature',
|
||||
'the existing literature',
|
||||
'has been reported',
|
||||
'have been reported',
|
||||
'it has been shown',
|
||||
'previous studies',
|
||||
'the study suggests',
|
||||
'the literature suggests',
|
||||
'in the literature',
|
||||
];
|
||||
|
||||
$ctxLower = strtolower($contextText);
|
||||
$isBackground = false;
|
||||
foreach ($backgroundPhrases as $ph) {
|
||||
$ph = strtolower(trim((string)$ph));
|
||||
if ($ph !== '' && $ph !== '0' && strpos($ctxLower, $ph) !== false) {
|
||||
$isBackground = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$refTokens = $this->buildEvidenceTokens([
|
||||
'title' => $refTitle,
|
||||
'author' => $refAuthor,
|
||||
'journal' => $refJoura,
|
||||
'year' => $refDateno,
|
||||
]);
|
||||
|
||||
$ctxTokens = $this->tokenize($contextText);
|
||||
|
||||
$titleOverlap = 0.0;
|
||||
$authorHit = 0.0;
|
||||
$journalOverlap = 0.0;
|
||||
$yearHit = 0.0;
|
||||
|
||||
$titleTokens = $refTokens['titleTokens'] ?? [];
|
||||
$authorTokens = $refTokens['authorTokens'] ?? [];
|
||||
$journalTokens = $refTokens['journalTokens'] ?? [];
|
||||
$yearToken = $refTokens['yearToken'] ?? '';
|
||||
|
||||
if (!empty($titleTokens)) {
|
||||
$inter = array_intersect($titleTokens, $ctxTokens);
|
||||
$titleOverlap = count($inter) / max(1, count($titleTokens));
|
||||
}
|
||||
|
||||
if (!empty($authorTokens)) {
|
||||
foreach ($authorTokens as $at) {
|
||||
if ($at !== '' && in_array($at, $ctxTokens, true)) {
|
||||
$authorHit = 1.0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($journalTokens)) {
|
||||
$interJ = array_intersect($journalTokens, $ctxTokens);
|
||||
$journalOverlap = count($interJ) / max(1, count($journalTokens));
|
||||
}
|
||||
|
||||
if (!empty($yearToken) && strpos($ctxLower, (string)$yearToken) !== false) {
|
||||
$yearHit = 1.0;
|
||||
}
|
||||
|
||||
// 综合得分(保持解释性:越高越相关)
|
||||
$score = round((
|
||||
0.60 * $titleOverlap +
|
||||
0.20 * $authorHit +
|
||||
0.15 * $yearHit +
|
||||
0.05 * $journalOverlap
|
||||
), 4);
|
||||
|
||||
$relevanceFlag = 'unsure';
|
||||
$reasonParts = [];
|
||||
|
||||
if ($score >= 0.35 && ($authorHit > 0.0 || $yearHit > 0.0)) {
|
||||
$relevanceFlag = 'related';
|
||||
$reasonParts[] = 'title_keyword_overlap_high=' . $titleOverlap;
|
||||
} elseif ($score >= 0.25) {
|
||||
$relevanceFlag = 'unsure';
|
||||
$reasonParts[] = 'evidence_score_mid=' . $score;
|
||||
} else {
|
||||
if ($isBackground) {
|
||||
$relevanceFlag = 'unsure_background';
|
||||
$reasonParts[] = 'background_phrases_detected';
|
||||
} else {
|
||||
$relevanceFlag = 'suspicious_unrelated';
|
||||
$reasonParts[] = 'evidence_score_low=' . $score;
|
||||
}
|
||||
}
|
||||
|
||||
$reasonParts[] = 'titleOverlap=' . $titleOverlap;
|
||||
$reasonParts[] = 'authorHit=' . $authorHit;
|
||||
$reasonParts[] = 'yearHit=' . $yearHit;
|
||||
$reasonParts[] = 'journalOverlap=' . $journalOverlap;
|
||||
|
||||
$reason = implode('; ', $reasonParts);
|
||||
|
||||
return [
|
||||
'problem_flag' => $problemFlag,
|
||||
'problem_reason' => $problemReason,
|
||||
'relevance_flag' => $relevanceFlag,
|
||||
'relevance_score' => (float)$score,
|
||||
'reason' => $reason,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 refer_doi / doilink 中抽取 DOI 字符串。
|
||||
* @param string $referDoi
|
||||
* @param string $doilink
|
||||
* @return string
|
||||
*/
|
||||
private function extractDoiFromMeta(string $referDoi, string $doilink): string
|
||||
{
|
||||
$doi = trim($referDoi);
|
||||
if (!empty($doi)) {
|
||||
return $this->filterValidDoi($doi);
|
||||
}
|
||||
|
||||
$link = trim($doilink);
|
||||
if ($link === '') return '';
|
||||
|
||||
// 常见:https://doi.org/10.xxxx/xxxx 或 http://doi.org/...
|
||||
if (preg_match('#doi\.org/([^?#]+)#i', $link, $m)) {
|
||||
$candidate = trim((string)$m[1]);
|
||||
return $this->filterValidDoi($candidate);
|
||||
}
|
||||
|
||||
// 兜底:如果doilink本身就是doi格式
|
||||
return $this->filterValidDoi($link);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建证据 token(用于证据命中/相似度粗判)
|
||||
* @param array $src
|
||||
* @return array
|
||||
*/
|
||||
private function buildEvidenceTokens(array $src): array
|
||||
{
|
||||
$stop = [
|
||||
'the','a','an','and','or','of','in','on','for','with','to','from','by','at','as','is','are',
|
||||
'was','were','be','been','being','that','this','these','those','which','who','whom','it','its',
|
||||
'we','our','us','they','their','them','i','you','your','he','she','his','her',
|
||||
'study','studies','report','reported','reports','model','models','analysis','analyses','method','methods',
|
||||
'results','result','using','used','show','shown','demonstrated','demonstrate',
|
||||
];
|
||||
|
||||
$titleTokens = $this->tokenize((string)($src['title'] ?? ''));
|
||||
$titleTokens = array_values(array_filter(array_unique($titleTokens), function ($t) use ($stop) {
|
||||
return !in_array($t, $stop, true) && mb_strlen($t) >= 4;
|
||||
}));
|
||||
|
||||
$authorTokens = $this->extractAuthorTokens((string)($src['author'] ?? ''));
|
||||
$authorTokens = array_values(array_unique(array_filter(array_map(function ($t) use ($stop) {
|
||||
$t = trim($t);
|
||||
if ($t === '') return '';
|
||||
if (in_array($t, $stop, true)) return '';
|
||||
return $t;
|
||||
}, $authorTokens))));
|
||||
|
||||
$journalTokens = $this->tokenize((string)($src['journal'] ?? ''));
|
||||
$journalTokens = array_values(array_filter(array_unique($journalTokens), function ($t) use ($stop) {
|
||||
return !in_array($t, $stop, true) && mb_strlen($t) >= 4;
|
||||
}));
|
||||
|
||||
$yearToken = '';
|
||||
$yearRaw = (string)($src['year'] ?? '');
|
||||
if (preg_match('/(19\d{2}|20\d{2})/', $yearRaw, $m)) {
|
||||
$yearToken = (string)$m[1];
|
||||
}
|
||||
|
||||
return [
|
||||
'titleTokens' => $titleTokens,
|
||||
'authorTokens' => $authorTokens,
|
||||
'journalTokens' => $journalTokens,
|
||||
'yearToken' => $yearToken,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取作者姓/缩写 token(简化版)
|
||||
* @param string $authorStr
|
||||
* @return array
|
||||
*/
|
||||
private function extractAuthorTokens(string $authorStr): array
|
||||
{
|
||||
$authorStr = trim($authorStr);
|
||||
if ($authorStr === '') return [];
|
||||
|
||||
// 把常见分隔符拆开
|
||||
$parts = preg_split('/[,;]| and /i', $authorStr);
|
||||
$tokens = [];
|
||||
foreach ($parts as $p) {
|
||||
$p = trim((string)$p);
|
||||
if ($p === '') continue;
|
||||
|
||||
// 取最后一个词当作姓(例如 "Smith J" -> "Smith"),或取首段词
|
||||
$words = preg_split('/\s+/', $p);
|
||||
if (empty($words)) continue;
|
||||
|
||||
$cand = trim((string)end($words));
|
||||
if ($cand === '') $cand = trim((string)($words[0] ?? ''));
|
||||
|
||||
// 只保留字母/点号(去掉异常符号)
|
||||
$cand = preg_replace('/[^A-Za-z\.\-]/', '', $cand);
|
||||
$cand = strtolower($cand);
|
||||
if ($cand !== '') {
|
||||
$tokens[] = $cand;
|
||||
}
|
||||
}
|
||||
|
||||
// 去掉过短的 token
|
||||
$tokens = array_values(array_filter(array_unique($tokens), function ($t) {
|
||||
return mb_strlen($t) >= 4;
|
||||
}));
|
||||
|
||||
return $tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本 tokenize(英文下的轻量分词)
|
||||
* @param string $text
|
||||
* @return array
|
||||
*/
|
||||
private function tokenize(string $text): array
|
||||
{
|
||||
$text = strtolower(trim($text));
|
||||
if ($text === '') return [];
|
||||
|
||||
$parts = preg_split('/[^a-z0-9]+/i', $text);
|
||||
$tokens = [];
|
||||
foreach ($parts as $p) {
|
||||
$p = trim((string)$p);
|
||||
if ($p === '') continue;
|
||||
// 保留较有信息量的 token
|
||||
if (mb_strlen($p) < 3) continue;
|
||||
$tokens[] = $p;
|
||||
}
|
||||
|
||||
return array_values(array_unique($tokens));
|
||||
}
|
||||
|
||||
/**
|
||||
* 英文切句(轻量实现):按 .?! 分割,同时保留句内的 [n]。
|
||||
* @param string $text
|
||||
* @return array
|
||||
*/
|
||||
private function splitEnglishSentences(string $text): array
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '') return [];
|
||||
|
||||
// 先把换行统一为空格,避免断句被打断
|
||||
$text = preg_replace('/\s+/u', ' ', $text);
|
||||
|
||||
// 按句末标点断句:. ? ! 后面跟空格/结尾
|
||||
$parts = preg_split('/(?<=[\.\?\!])\s+/', $text);
|
||||
$sentences = [];
|
||||
foreach ($parts as $p) {
|
||||
$p = trim((string)$p);
|
||||
if ($p === '') continue;
|
||||
$sentences[] = $p;
|
||||
}
|
||||
return $sentences;
|
||||
}
|
||||
}
|
||||
|
||||
750
application/common/ExpertFinderService.php
Normal file
750
application/common/ExpertFinderService.php
Normal file
@@ -0,0 +1,750 @@
|
||||
<?php
|
||||
|
||||
namespace app\common;
|
||||
|
||||
use think\Db;
|
||||
use think\Queue;
|
||||
use GuzzleHttp\Client;
|
||||
use think\Env;
|
||||
|
||||
class ExpertFinderService
|
||||
{
|
||||
private $httpClient;
|
||||
private $ncbiBaseUrl = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/';
|
||||
private $logFile;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->httpClient = new Client([
|
||||
'timeout' => 180,
|
||||
'connect_timeout' => 15,
|
||||
'verify' => false,
|
||||
]);
|
||||
$this->logFile = ROOT_PATH . 'runtime' . DS . 'expert_finder.log';
|
||||
}
|
||||
|
||||
public function doFetchForField($field, $source = 'pubmed', $perPage = 100, $minYear = null)
|
||||
{
|
||||
if ($minYear === null) {
|
||||
$minYear = date('Y') - 3;
|
||||
}
|
||||
|
||||
$fetchLog = $this->getFetchLog($field, $source);
|
||||
$page = $fetchLog['last_page'] + 1;
|
||||
|
||||
if ($source === 'pmc') {
|
||||
$result = $this->searchViaPMC($field, $perPage, $minYear, $page);
|
||||
} else {
|
||||
$result = $this->searchViaPubMed($field, $perPage, $minYear, $page);
|
||||
}
|
||||
|
||||
if(!isset($result['total'])){
|
||||
return [
|
||||
"has_more"=>"no"
|
||||
];
|
||||
}
|
||||
$saveResult = $this->saveExperts($result['experts'], $field, $source);
|
||||
|
||||
$nextPage = $result['has_more'] ? $page : $fetchLog['last_page'];
|
||||
$totalPages = $result['total_pages'] ?? $fetchLog['total_pages'];
|
||||
$this->updateFetchLog($field, $source, $nextPage, $totalPages);
|
||||
|
||||
return [
|
||||
'keyword' => $field,
|
||||
'page' => $page,
|
||||
'experts_found' => $result['total'],
|
||||
'saved_new' => $saveResult['inserted'],
|
||||
'saved_exist' => $saveResult['existing'],
|
||||
'list' => $result['experts'],
|
||||
'field_enriched' => $saveResult['field_enriched'],
|
||||
'has_more' => $result['has_more'],
|
||||
];
|
||||
}
|
||||
|
||||
public function searchExperts($keyword, $perPage, $minYear, $page, $source)
|
||||
{
|
||||
if ($source === 'pmc') {
|
||||
return $this->searchViaPMC($keyword, $perPage, $minYear, $page);
|
||||
}
|
||||
return $this->searchViaPubMed($keyword, $perPage, $minYear, $page);
|
||||
}
|
||||
|
||||
public function saveExperts($experts, $field, $source)
|
||||
{
|
||||
$inserted = 0;
|
||||
$existing = 0;
|
||||
$fieldEnrich = 0;
|
||||
|
||||
foreach ($experts as $expert) {
|
||||
$email = strtolower(trim($expert['email']));
|
||||
if (empty($email)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$exists = Db::name('expert')->where('email', $email)->find();
|
||||
$expertId = null;
|
||||
|
||||
if ($exists) {
|
||||
$existing++;
|
||||
$expertId = intval($exists['expert_id']);
|
||||
} else {
|
||||
try {
|
||||
$expertId = Db::name('expert')->insertGetId([
|
||||
'name' => mb_substr($expert['name'], 0, 255),
|
||||
'email' => mb_substr($email, 0, 128),
|
||||
'affiliation' => mb_substr($expert['affiliation'], 0, 128),
|
||||
'source' => mb_substr($source, 0, 128),
|
||||
'ctime' => time(),
|
||||
'ltime' => 0,
|
||||
'state' => 0,
|
||||
]);
|
||||
$inserted++;
|
||||
} catch (\Exception $e) {
|
||||
$existing++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$papers = (isset($expert['papers']) && is_array($expert['papers'])) ? $expert['papers'] : [];
|
||||
$fieldEnrich += $this->saveFieldWithPapers($expertId, $field, $source, $papers);
|
||||
}
|
||||
|
||||
return ['inserted' => $inserted, 'existing' => $existing, 'field_enriched' => $fieldEnrich];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存领域与论文的关联。
|
||||
* 有论文时:每篇论文一行(expert_id + field + source + paper_article_id 去重)。
|
||||
* 无论文时:只存一条领域行(expert_id + field 去重)。
|
||||
*/
|
||||
private function saveFieldWithPapers($expertId, $field, $source, $papers)
|
||||
{
|
||||
$field = trim($field);
|
||||
if (empty($field)) return 0;
|
||||
|
||||
$added = 0;
|
||||
|
||||
if (empty($papers)) {
|
||||
$exists = Db::name('expert_field')
|
||||
->where('expert_id', $expertId)
|
||||
->where('field', $field)
|
||||
->where('state', 0)
|
||||
->find();
|
||||
if (!$exists) {
|
||||
Db::name('expert_field')->insert([
|
||||
'expert_id' => $expertId,
|
||||
'source' => mb_substr((string)$source, 0, 64),
|
||||
'field' => mb_substr($field, 0, 128),
|
||||
'paper_title' => '',
|
||||
'paper_article_id' => '',
|
||||
'paper_journal' => '',
|
||||
'state' => 0,
|
||||
]);
|
||||
$added = 1;
|
||||
}
|
||||
} else {
|
||||
foreach ($papers as $paper) {
|
||||
$articleId = isset($paper['article_id']) ? (string)$paper['article_id'] : '';
|
||||
if ($articleId === '' || $articleId === '0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$check = Db::name('expert_field')
|
||||
->where('expert_id', $expertId)
|
||||
->where('field', $field)
|
||||
->where('source', $source)
|
||||
->where('paper_article_id', $articleId)
|
||||
->where('state', 0)
|
||||
->find();
|
||||
if ($check) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Db::name('expert_field')->insert([
|
||||
'expert_id' => $expertId,
|
||||
'source' => mb_substr((string)$source, 0, 64),
|
||||
'paper_title' => isset($paper['title']) ? mb_substr((string)$paper['title'], 0, 255) : '',
|
||||
'paper_article_id' => mb_substr($articleId, 0, 64),
|
||||
'paper_journal' => isset($paper['journal']) ? mb_substr((string)$paper['journal'], 0, 255) : '',
|
||||
'field' => mb_substr($field, 0, 128),
|
||||
'state' => 0,
|
||||
]);
|
||||
$added++;
|
||||
}
|
||||
}
|
||||
|
||||
return $added;
|
||||
}
|
||||
|
||||
public function getFetchLog($field, $source)
|
||||
{
|
||||
$log = Db::name('expert_fetch')
|
||||
->where('field', $field)
|
||||
->where('source', $source)
|
||||
->find();
|
||||
|
||||
if (!$log) {
|
||||
return ['last_page' => 0, 'total_pages' => 0, 'last_time' => 0];
|
||||
}
|
||||
|
||||
return $log;
|
||||
}
|
||||
|
||||
public function updateFetchLog($field, $source, $lastPage, $totalPages)
|
||||
{
|
||||
$exists = Db::name('expert_fetch')
|
||||
->where('field', $field)
|
||||
->where('source', $source)
|
||||
->find();
|
||||
|
||||
if ($exists) {
|
||||
Db::name('expert_fetch')
|
||||
->where('expert_fetch_id', $exists['expert_fetch_id'])
|
||||
->update([
|
||||
'last_page' => $lastPage,
|
||||
'total_pages' => $totalPages,
|
||||
'last_time' => time(),
|
||||
]);
|
||||
} else {
|
||||
Db::name('expert_fetch')->insert([
|
||||
'field' => mb_substr($field, 0, 128),
|
||||
'source' => mb_substr($source, 0, 128),
|
||||
'last_page' => $lastPage,
|
||||
'total_pages' => $totalPages,
|
||||
'last_time' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PubMed Search ====================
|
||||
|
||||
private function searchViaPubMed($keyword, $perPage, $minYear, $page = 1)
|
||||
{
|
||||
set_time_limit(600);
|
||||
|
||||
$searchResult = $this->esearch('pubmed', $keyword, $perPage, $minYear, $page);
|
||||
$ids = $searchResult['ids'];
|
||||
$totalArticles = $searchResult['total'];
|
||||
|
||||
if (empty($ids)) {
|
||||
return $this->buildPagedResult([], 0, 0, $totalArticles, $page, $perPage, 'pubmed');
|
||||
}
|
||||
|
||||
$allAuthors = [];
|
||||
$batches = array_chunk($ids, 50);
|
||||
foreach ($batches as $batch) {
|
||||
$xml = $this->efetchWithRetry('pubmed', $batch);
|
||||
if ($xml) {
|
||||
$authors = $this->parsePubMedXml($xml);
|
||||
$allAuthors = array_merge($allAuthors, $authors);
|
||||
}
|
||||
usleep(400000);
|
||||
}
|
||||
|
||||
$experts = $this->aggregateExperts($allAuthors);
|
||||
|
||||
return $this->buildPagedResult($experts, count($experts), count($ids), $totalArticles, $page, $perPage, 'pubmed');
|
||||
}
|
||||
|
||||
// ==================== PMC Search ====================
|
||||
|
||||
private function searchViaPMC($keyword, $perPage, $minYear, $page = 1)
|
||||
{
|
||||
set_time_limit(600);
|
||||
|
||||
$searchResult = $this->esearch('pmc', $keyword, $perPage, $minYear, $page);
|
||||
$ids = $searchResult['ids'];
|
||||
$totalArticles = $searchResult['total'];
|
||||
|
||||
if (empty($ids)) {
|
||||
return $this->buildPagedResult([], 0, 0, $totalArticles, $page, $perPage, 'pmc');
|
||||
}
|
||||
|
||||
$allAuthors = [];
|
||||
$batches = array_chunk($ids, 5);
|
||||
foreach ($batches as $batch) {
|
||||
$xml = $this->efetchWithRetry('pmc', $batch);
|
||||
if ($xml) {
|
||||
$authors = $this->parsePMCXml($xml);
|
||||
$allAuthors = array_merge($allAuthors, $authors);
|
||||
}
|
||||
usleep(500000);
|
||||
}
|
||||
|
||||
$experts = $this->aggregateExperts($allAuthors);
|
||||
|
||||
return $this->buildPagedResult($experts, count($experts), count($ids), $totalArticles, $page, $perPage, 'pmc');
|
||||
}
|
||||
|
||||
// ==================== NCBI API ====================
|
||||
|
||||
private function esearch($db, $keyword, $perPage, $minYear, $page = 1)
|
||||
{
|
||||
$term = $keyword . ' AND ' . $minYear . ':' . date('Y') . '[pdat]';
|
||||
$retstart = ($page - 1) * $perPage;
|
||||
|
||||
$response = $this->httpClient->get($this->ncbiBaseUrl . 'esearch.fcgi', [
|
||||
'query' => [
|
||||
'db' => $db,
|
||||
'term' => $term,
|
||||
'retstart' => $retstart,
|
||||
'retmax' => $perPage,
|
||||
'retmode' => 'json',
|
||||
'sort' => 'relevance',
|
||||
],
|
||||
]);
|
||||
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
$ids = $data['esearchresult']['idlist'] ?? [];
|
||||
$total = intval($data['esearchresult']['count'] ?? 0);
|
||||
|
||||
return ['ids' => $ids, 'total' => $total];
|
||||
}
|
||||
|
||||
private function efetch($db, $ids)
|
||||
{
|
||||
$response = $this->httpClient->post($this->ncbiBaseUrl . 'efetch.fcgi', [
|
||||
'form_params' => [
|
||||
'db' => $db,
|
||||
'id' => implode(',', $ids),
|
||||
'retmode' => 'xml',
|
||||
],
|
||||
]);
|
||||
|
||||
return $response->getBody()->getContents();
|
||||
}
|
||||
|
||||
private function efetchWithRetry($db, $ids, $maxRetries = 3)
|
||||
{
|
||||
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
|
||||
try {
|
||||
return $this->efetch($db, $ids);
|
||||
} catch (\Exception $e) {
|
||||
if ($attempt === $maxRetries) {
|
||||
if (count($ids) > 1) {
|
||||
$half = ceil(count($ids) / 2);
|
||||
$firstHalf = array_slice($ids, 0, $half);
|
||||
$secondHalf = array_slice($ids, $half);
|
||||
$xml1 = $this->efetchWithRetry($db, $firstHalf, 2);
|
||||
$xml2 = $this->efetchWithRetry($db, $secondHalf, 2);
|
||||
return $this->mergeXml($xml1, $xml2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
sleep($attempt * 2);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function mergeXml($xml1, $xml2)
|
||||
{
|
||||
if (empty($xml1)) return $xml2;
|
||||
if (empty($xml2)) return $xml1;
|
||||
return $xml1 . "\n" . $xml2;
|
||||
}
|
||||
|
||||
// ==================== PubMed XML Parsing ====================
|
||||
|
||||
private function parsePubMedXml($xmlString)
|
||||
{
|
||||
$results = [];
|
||||
libxml_use_internal_errors(true);
|
||||
$xml = simplexml_load_string($xmlString);
|
||||
if ($xml === false) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
foreach ($xml->PubmedArticle as $article) {
|
||||
$citation = $article->MedlineCitation;
|
||||
$articleData = $citation->Article;
|
||||
$title = $this->xmlNodeToString($articleData->ArticleTitle);
|
||||
$pmid = (string) $citation->PMID;
|
||||
|
||||
$journal = '';
|
||||
if (isset($articleData->Journal->Title)) {
|
||||
$journal = (string) $articleData->Journal->Title;
|
||||
}
|
||||
if (!isset($articleData->AuthorList->Author)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($articleData->AuthorList->Author as $author) {
|
||||
$lastName = (string) ($author->LastName ?? '');
|
||||
$foreName = (string) ($author->ForeName ?? '');
|
||||
$fullName = trim($foreName . ' ' . $lastName);
|
||||
if (empty($fullName)) continue;
|
||||
|
||||
$email = '';
|
||||
$affiliation = '';
|
||||
if (isset($author->AffiliationInfo)) {
|
||||
foreach ($author->AffiliationInfo as $affInfo) {
|
||||
$affText = (string) $affInfo->Affiliation;
|
||||
if (empty($affiliation)) $affiliation = $affText;
|
||||
if (empty($email)) $email = $this->extractEmailFromText($affText);
|
||||
}
|
||||
}
|
||||
if (empty($email)) continue;
|
||||
|
||||
$results[] = [
|
||||
'name' => $fullName,
|
||||
'email' => strtolower($email),
|
||||
'affiliation' => $this->cleanAffiliation($affiliation),
|
||||
'article_title' => $title,
|
||||
'article_id' => $pmid,
|
||||
'journal' => $journal,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
// ==================== PMC XML Parsing ====================
|
||||
|
||||
private function parsePMCXml($xmlString)
|
||||
{
|
||||
$results = [];
|
||||
libxml_use_internal_errors(true);
|
||||
$xml = simplexml_load_string($xmlString);
|
||||
if ($xml === false) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
$articles = $xml->article ?? $xml->children();
|
||||
|
||||
foreach ($articles as $article) {
|
||||
if ($article->getName() !== 'article') continue;
|
||||
|
||||
$front = $article->front;
|
||||
if (!$front) continue;
|
||||
$articleMeta = $front->{'article-meta'};
|
||||
if (!$articleMeta) continue;
|
||||
|
||||
$title = $this->xmlNodeToString($articleMeta->{'title-group'}->{'article-title'} ?? null);
|
||||
$pmcId = '';
|
||||
if (isset($articleMeta->{'article-id'})) {
|
||||
foreach ($articleMeta->{'article-id'} as $idNode) {
|
||||
if ((string) $idNode['pub-id-type'] === 'pmc') {
|
||||
$pmcId = (string) $idNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$journal = '';
|
||||
if (isset($front->{'journal-meta'}->{'journal-title'})) {
|
||||
$journal = (string) $front->{'journal-meta'}->{'journal-title'};
|
||||
} elseif (isset($front->{'journal-meta'}->{'journal-title-group'}->{'journal-title'})) {
|
||||
$journal = (string) $front->{'journal-meta'}->{'journal-title-group'}->{'journal-title'};
|
||||
}
|
||||
|
||||
$correspEmails = [];
|
||||
if (isset($articleMeta->{'author-notes'})) {
|
||||
$this->extractEmailsFromNode($articleMeta->{'author-notes'}, $correspEmails);
|
||||
}
|
||||
|
||||
$affiliationMap = [];
|
||||
if (isset($articleMeta->{'contrib-group'})) {
|
||||
foreach ($articleMeta->{'contrib-group'}->children() as $child) {
|
||||
if ($child->getName() === 'aff') {
|
||||
$affId = (string) ($child['id'] ?? '');
|
||||
$affText = $this->xmlNodeToString($child);
|
||||
if ($affId) $affiliationMap[$affId] = $affText;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($front->{'article-meta'}->{'aff'})) {
|
||||
foreach ($front->{'article-meta'}->{'aff'} as $aff) {
|
||||
$affId = (string) ($aff['id'] ?? '');
|
||||
$affText = $this->xmlNodeToString($aff);
|
||||
if ($affId) $affiliationMap[$affId] = $affText;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($articleMeta->{'contrib-group'})) continue;
|
||||
|
||||
foreach ($articleMeta->{'contrib-group'}->contrib as $contrib) {
|
||||
if ((string) ($contrib['contrib-type'] ?? '') !== 'author') continue;
|
||||
$nameNode = $contrib->name;
|
||||
if (!$nameNode) continue;
|
||||
|
||||
$surname = (string) ($nameNode->surname ?? '');
|
||||
$givenNames = (string) ($nameNode->{'given-names'} ?? '');
|
||||
$fullName = trim($givenNames . ' ' . $surname);
|
||||
if (empty($fullName)) continue;
|
||||
|
||||
$email = '';
|
||||
if (isset($contrib->email)) {
|
||||
$email = strtolower(trim((string) $contrib->email));
|
||||
}
|
||||
|
||||
$affiliation = '';
|
||||
if (isset($contrib->xref)) {
|
||||
foreach ($contrib->xref as $xref) {
|
||||
if ((string) $xref['ref-type'] === 'aff') {
|
||||
$rid = (string) $xref['rid'];
|
||||
if (isset($affiliationMap[$rid])) {
|
||||
$affiliation = $affiliationMap[$rid];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (empty($affiliation) && isset($contrib->aff)) {
|
||||
$affiliation = $this->xmlNodeToString($contrib->aff);
|
||||
}
|
||||
|
||||
$isCorresponding = false;
|
||||
if (isset($contrib->xref)) {
|
||||
foreach ($contrib->xref as $xref) {
|
||||
if ((string) $xref['ref-type'] === 'corresp') $isCorresponding = true;
|
||||
}
|
||||
}
|
||||
if ((string) ($contrib['corresp'] ?? '') === 'yes') $isCorresponding = true;
|
||||
|
||||
if (empty($email) && $isCorresponding && !empty($correspEmails)) {
|
||||
$email = $correspEmails[0];
|
||||
}
|
||||
if (empty($email)) {
|
||||
$extracted = $this->extractEmailFromText($affiliation);
|
||||
if ($extracted) $email = $extracted;
|
||||
}
|
||||
if (empty($email)) continue;
|
||||
|
||||
$results[] = [
|
||||
'name' => $fullName,
|
||||
'email' => strtolower($email),
|
||||
'affiliation' => $this->cleanAffiliation($affiliation),
|
||||
'article_title' => $title,
|
||||
'article_id' => $pmcId,
|
||||
'journal' => $journal,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
// ==================== Aggregation / Pagination ====================
|
||||
|
||||
private function aggregateExperts($authorRecords)
|
||||
{
|
||||
$map = [];
|
||||
foreach ($authorRecords as $record) {
|
||||
$key = strtolower(trim($record['email']));
|
||||
if (empty($key)) continue;
|
||||
|
||||
if (!isset($map[$key])) {
|
||||
$map[$key] = [
|
||||
'name' => $record['name'],
|
||||
'email' => $record['email'],
|
||||
'affiliation' => $record['affiliation'],
|
||||
'paper_count' => 0,
|
||||
'papers' => [],
|
||||
];
|
||||
}
|
||||
$map[$key]['paper_count']++;
|
||||
if (count($map[$key]['papers']) < 10) {
|
||||
$map[$key]['papers'][] = [
|
||||
'title' => $record['article_title'],
|
||||
'article_id' => $record['article_id'],
|
||||
'journal' => $record['journal'],
|
||||
];
|
||||
}
|
||||
if (empty($map[$key]['affiliation']) && !empty($record['affiliation'])) {
|
||||
$map[$key]['affiliation'] = $record['affiliation'];
|
||||
}
|
||||
}
|
||||
|
||||
$experts = array_values($map);
|
||||
usort($experts, function ($a, $b) {
|
||||
return $b['paper_count'] - $a['paper_count'];
|
||||
});
|
||||
return $experts;
|
||||
}
|
||||
|
||||
private function buildPagedResult($experts, $expertCount, $articlesScanned, $totalArticles, $page, $perPage, $source)
|
||||
{
|
||||
$totalPages = $totalArticles > 0 ? ceil($totalArticles / $perPage) : 0;
|
||||
return [
|
||||
'experts' => $experts,
|
||||
'total' => $expertCount,
|
||||
'articles_scanned' => $articlesScanned,
|
||||
'total_articles' => $totalArticles,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total_pages' => $totalPages,
|
||||
'has_more' => $page < $totalPages,
|
||||
'source' => $source,
|
||||
];
|
||||
}
|
||||
|
||||
// ==================== Country Resolution ====================
|
||||
|
||||
/**
|
||||
* 启动国家解析链:找到下一个缺国家的专家推入队列。
|
||||
* 队列 Job 处理完一个后会再调此方法,自动找下一个,直到全部处理完。
|
||||
* 控制器只需调一次即可。
|
||||
*
|
||||
* @param int $delay 延迟秒数(防止打满模型,默认1秒)
|
||||
* @return bool 是否成功推入了一条
|
||||
*/
|
||||
/**
|
||||
* 启动国家解析链:找到下一个缺国家的专家推入指定队列。
|
||||
*
|
||||
* @param int $delay 延迟秒数
|
||||
* @param string $queue 队列名(不同队列跑不同 worker,互不阻塞)
|
||||
* @param string $chatUrl 该链使用的模型地址(为空则用默认)
|
||||
* @return bool
|
||||
*/
|
||||
public function enqueueNextCountryFill($delay = 1, $queue = 'FetchExperts', $chatUrl = '')
|
||||
{
|
||||
$row = Db::name('expert')
|
||||
->where('affiliation', '<>', '')
|
||||
->where('country_id', 0)
|
||||
->where('state', '<>', 5)
|
||||
->field('expert_id, affiliation')
|
||||
->order('expert_id asc')
|
||||
->find();
|
||||
|
||||
if (!$row) {
|
||||
$this->log('[CountryFill] no more pending experts, queue=' . $queue);
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'expert_id' => intval($row['expert_id']),
|
||||
'affiliation' => trim((string)$row['affiliation']),
|
||||
'queue' => $queue,
|
||||
'chat_url' => $chatUrl,
|
||||
];
|
||||
|
||||
$jobClass = 'app\api\job\FillExpertCountry@fire';
|
||||
if ($delay > 0) {
|
||||
Queue::later($delay, $jobClass, $data, $queue);
|
||||
} else {
|
||||
Queue::push($jobClass, $data, $queue);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对单个专家执行国家解析(同步),由队列 Job FillExpertCountry 调用,也可直接调用测试。
|
||||
*/
|
||||
public function fillExpertCountry($expertId, $affiliation, $chatUrl = '')
|
||||
{
|
||||
$affiliation = trim((string)$affiliation);
|
||||
if ($affiliation === '') {
|
||||
Db::name('expert')->where('expert_id', intval($expertId))->update(['country_id' => -1]);
|
||||
return;
|
||||
}
|
||||
|
||||
$defaultUrl = trim((string)Env::get('expert_country_chat_url', Env::get('citation_chat_url', 'http://chat.taimed.cn/v1/chat/completions')));
|
||||
$url = ($chatUrl !== '') ? $chatUrl : $defaultUrl;
|
||||
|
||||
$resolver = new CountryResolverService([
|
||||
'chat_url' => $url,
|
||||
'chat_model' => trim((string)Env::get('expert_country_chat_model', Env::get('citation_chat_model', 'gpt-4.1'))),
|
||||
'api_key' => trim((string)Env::get('expert_country_chat_api_key', Env::get('citation_chat_api_key', ''))),
|
||||
'timeout' => max(20, intval(Env::get('expert_country_chat_timeout', 60))),
|
||||
]);
|
||||
|
||||
$result = $resolver->resolve($affiliation);
|
||||
|
||||
$countryId = 0;
|
||||
$enName = '';
|
||||
|
||||
if (!empty($result['code'])) {
|
||||
$row = Db::name('country')->where('code', strtoupper(trim((string)$result['code'])))->find();
|
||||
if ($row) {
|
||||
$countryId = intval($row['country_id']);
|
||||
$enName = (string)$row['en_name'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($countryId === 0 && !empty($result['en_name'])) {
|
||||
$row = Db::name('country')
|
||||
->whereRaw("LOWER(en_name) = ?", [strtolower(trim((string)$result['en_name']))])
|
||||
->find();
|
||||
if ($row) {
|
||||
$countryId = intval($row['country_id']);
|
||||
$enName = (string)$row['en_name'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($countryId > 0 && $enName !== '') {
|
||||
Db::name('expert')->where('expert_id', intval($expertId))->update([
|
||||
'country_id' => $countryId,
|
||||
'country' => $enName,
|
||||
]);
|
||||
} else {
|
||||
// country_id = -1 表示「已尝试但未识别」,避免链式执行时反复卡在同一条
|
||||
Db::name('expert')->where('expert_id', intval($expertId))->update([
|
||||
'country_id' => -1,
|
||||
]);
|
||||
$this->log('[CountryFill] expert_id=' . $expertId . ' unresolved, code=' . ($result['code'] ?? '') . ' en_name=' . ($result['en_name'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Text Helpers ====================
|
||||
|
||||
private function extractEmailFromText($text)
|
||||
{
|
||||
if (empty($text)) return '';
|
||||
if (preg_match('/[Ee]lectronic address:\s*([^\s;,]+@[^\s;,]+)/', $text, $m)) {
|
||||
return strtolower(trim($m[1], '.'));
|
||||
}
|
||||
if (preg_match('/[Ee]-?mail:\s*([^\s;,]+@[^\s;,]+)/', $text, $m)) {
|
||||
return strtolower(trim($m[1], '.'));
|
||||
}
|
||||
if (preg_match('/\b([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})\b/', $text, $m)) {
|
||||
return strtolower(trim($m[1], '.'));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function extractEmailsFromNode($node, &$emails)
|
||||
{
|
||||
if ($node === null) return;
|
||||
foreach ($node->children() as $child) {
|
||||
if ($child->getName() === 'email') {
|
||||
$email = strtolower(trim((string) $child));
|
||||
if (!empty($email) && !in_array($email, $emails)) $emails[] = $email;
|
||||
}
|
||||
$this->extractEmailsFromNode($child, $emails);
|
||||
}
|
||||
$text = (string) $node;
|
||||
if (preg_match_all('/\b([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})\b/', $text, $matches)) {
|
||||
foreach ($matches[1] as $email) {
|
||||
$email = strtolower(trim($email, '.'));
|
||||
if (!in_array($email, $emails)) $emails[] = $email;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanAffiliation($text)
|
||||
{
|
||||
$text = preg_replace('/\s*[Ee]lectronic address:\s*[^\s;,]+@[^\s;,]+/', '', $text);
|
||||
$text = preg_replace('/\s*[Ee]-?mail:\s*[^\s;,]+@[^\s;,]+/', '', $text);
|
||||
$text = preg_replace('/\s*\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b/', '', $text);
|
||||
return trim($text, " \t\n\r\0\x0B.,;");
|
||||
}
|
||||
|
||||
private function xmlNodeToString($node)
|
||||
{
|
||||
if ($node === null) return '';
|
||||
$xml = $node->asXML();
|
||||
$text = strip_tags($xml);
|
||||
$text = html_entity_decode($text, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
return trim(preg_replace('/\s+/', ' ', $text));
|
||||
}
|
||||
|
||||
// ==================== Logging ====================
|
||||
|
||||
public function log($msg)
|
||||
{
|
||||
$line = date('Y-m-d H:i:s') . ' ' . $msg . PHP_EOL;
|
||||
@file_put_contents($this->logFile, $line, FILE_APPEND);
|
||||
}
|
||||
}
|
||||
440
application/common/PlagiarismService.php
Normal file
440
application/common/PlagiarismService.php
Normal file
@@ -0,0 +1,440 @@
|
||||
<?php
|
||||
|
||||
namespace app\common;
|
||||
|
||||
use think\Db;
|
||||
use think\Env;
|
||||
use think\Queue;
|
||||
use think\Exception;
|
||||
|
||||
/**
|
||||
* 查重业务层:把 TurnitinService 的低层调用包装成"按 article 查重"的高层流程,
|
||||
* 并维护 t_plagiarism_check 状态机。
|
||||
*
|
||||
* 状态流:
|
||||
* submit() → state=1(上传中),入队 PlagiarismRun
|
||||
* PlagiarismRun.fire → 上传 + 触发 similarity → state=2(比对中),入队 PlagiarismPoll
|
||||
* PlagiarismPoll.fire → 轮询 status,完成后下载 PDF → state=3(完成)
|
||||
* 任意环节抛异常 → state=4(失败),写 error_msg
|
||||
*/
|
||||
class PlagiarismService
|
||||
{
|
||||
/**
|
||||
* 报告 PDF 本地保存目录(相对于项目根,永久保留)
|
||||
*/
|
||||
const REPORT_DIR = 'public/plagiarism';
|
||||
|
||||
/**
|
||||
* 轮询间隔(秒)。Turnitin 一般 1-5 分钟出结果,30 秒一次比较合适
|
||||
*/
|
||||
const POLL_INTERVAL = 30;
|
||||
|
||||
/**
|
||||
* 最长轮询次数(30s × 60 = 30 分钟)
|
||||
*/
|
||||
const MAX_POLL_ATTEMPTS = 60;
|
||||
|
||||
private $logFile;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->logFile = ROOT_PATH . 'runtime' . DS . 'plagiarism_task.log';
|
||||
}
|
||||
|
||||
// ---------- 顶层入口 ----------
|
||||
|
||||
/**
|
||||
* 提交查重(入队,立即返回 check_id)
|
||||
*
|
||||
* @param int $articleId 投稿 ID
|
||||
* @param string $filePath 本地可读的 PDF/DOCX 绝对路径
|
||||
* @param int $triggeredBy 触发人 user_id(手工触发时编辑后台的 user_id)
|
||||
* @param string $source 'manual' / 'auto_xxx'
|
||||
* @return int check_id
|
||||
*/
|
||||
public function submit($articleId, $filePath, $triggeredBy = 0, $source = 'manual')
|
||||
{
|
||||
if (!is_file($filePath) || !is_readable($filePath)) {
|
||||
throw new Exception("File not readable: {$filePath}");
|
||||
}
|
||||
|
||||
$journalId = (int) Db::name('article')
|
||||
->where('article_id', $articleId)
|
||||
->value('journal_id');
|
||||
|
||||
|
||||
$now = time();
|
||||
$checkId = Db::name('plagiarism_check')->insertGetId([
|
||||
'article_id' => $articleId,
|
||||
'journal_id' => $journalId,
|
||||
'triggered_by' => $triggeredBy,
|
||||
'trigger_source' => $source,
|
||||
'state' => 1, // 上传中
|
||||
'source_file_name' => basename($filePath),
|
||||
'source_file_size' => filesize($filePath) ?: 0,
|
||||
'ctime' => $now,
|
||||
'utime' => $now,
|
||||
]);
|
||||
$this->log("submit service act");
|
||||
// 入队执行:上传 + 触发 similarity
|
||||
Queue::push(
|
||||
'app\\api\\job\\PlagiarismRun',
|
||||
['check_id' => $checkId, 'file_path' => $filePath],
|
||||
'PlagiarismRun'
|
||||
);
|
||||
|
||||
return (int)$checkId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Job 调用:上传文件到 Turnitin 并触发 similarity,然后入队 PlagiarismPoll
|
||||
*/
|
||||
public function runUploadAndTrigger($checkId, $filePath)
|
||||
{
|
||||
$check = $this->mustGetCheck($checkId);
|
||||
$this->log("runUploadAndTrigger is act0");
|
||||
try {
|
||||
$tii = new TurnitinService();
|
||||
|
||||
// 1. 创建 submission
|
||||
$articleTitle = (string) Db::name('article')
|
||||
->where('article_id', $check['article_id'])
|
||||
->value('title');
|
||||
if ($articleTitle === '') {
|
||||
$articleTitle = 'Article #' . $check['article_id'];
|
||||
}
|
||||
$this->log("runUploadAndTrigger is act1");
|
||||
$createResp = $tii->createSubmission([
|
||||
'title' => mb_substr($articleTitle, 0, 250),
|
||||
'owner' => 'editor_' . $check['triggered_by'],
|
||||
'submitter' => 'editor_' . $check['triggered_by'],
|
||||
'metadata' => [
|
||||
'article_id' => (string)$check['article_id'],
|
||||
'check_id' => (string)$check['check_id'],
|
||||
],
|
||||
]);
|
||||
$submissionId = isset($createResp['id']) ? $createResp['id'] : '';
|
||||
if ($submissionId === '') {
|
||||
throw new Exception('Turnitin createSubmission returned empty id: ' . json_encode($createResp));
|
||||
}
|
||||
|
||||
$this->updateCheck($checkId, [
|
||||
'tii_submission_id' => $submissionId,
|
||||
'raw_response' => json_encode($createResp, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
$this->log("runUploadAndTrigger is act2");
|
||||
// 2. 上传文件
|
||||
$tii->uploadFile($submissionId, $filePath, basename($filePath));
|
||||
|
||||
// 3. 触发 similarity
|
||||
$simResp = $tii->triggerSimilarity($submissionId);
|
||||
|
||||
$this->updateCheck($checkId, [
|
||||
'state' => 2, // 比对中
|
||||
'tii_report_status' => 'PROCESSING',
|
||||
'raw_response' => json_encode($simResp, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
|
||||
$this->log("runUploadAndTrigger is act3");
|
||||
|
||||
// 4. 排队首次轮询(晚一点开始,让 Turnitin 先处理)
|
||||
Queue::later(
|
||||
self::POLL_INTERVAL,
|
||||
'app\\api\\job\\PlagiarismPoll',
|
||||
['check_id' => $checkId, 'attempt' => 1],
|
||||
'PlagiarismPoll'
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->markFailed($checkId, '[upload] ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Job 调用:轮询 similarity 状态,完成后下载 PDF。未完成则重新入队。
|
||||
*/
|
||||
public function runPollStatus($checkId, $attempt = 1)
|
||||
{
|
||||
$check = $this->mustGetCheck($checkId);
|
||||
if (empty($check['tii_submission_id'])) {
|
||||
$this->markFailed($checkId, '[poll] tii_submission_id empty');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$tii = new TurnitinService();
|
||||
$statusResp = $tii->getSimilarityStatus($check['tii_submission_id']);
|
||||
$status = isset($statusResp['status']) ? strtoupper($statusResp['status']) : '';
|
||||
|
||||
$this->updateCheck($checkId, [
|
||||
'tii_report_status' => $status,
|
||||
'attempts' => $attempt,
|
||||
'raw_response' => json_encode($statusResp, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
|
||||
if ($status === 'COMPLETE') {
|
||||
$score = isset($statusResp['overall_match_percentage'])
|
||||
? floatval($statusResp['overall_match_percentage']) : 0;
|
||||
|
||||
// 下载 PDF + 取在线查看 URL
|
||||
$localPdf = $this->downloadAndStorePdf($tii, $check['tii_submission_id'], $checkId);
|
||||
$viewerInfo = $this->refreshViewerUrl($tii, $check['tii_submission_id']);
|
||||
|
||||
$this->updateCheck($checkId, [
|
||||
'state' => 3,
|
||||
'similarity_score' => $score,
|
||||
'pdf_local_path' => $localPdf,
|
||||
'view_only_url' => $viewerInfo['url'],
|
||||
'view_only_url_expire' => $viewerInfo['expire'],
|
||||
'error_msg' => '',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($status === 'ERROR') {
|
||||
$errMsg = isset($statusResp['error_code']) ? (string)$statusResp['error_code'] : 'Turnitin reported ERROR';
|
||||
$this->markFailed($checkId, '[poll] ' . $errMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// PROCESSING 或其它中间态:继续轮询
|
||||
if ($attempt >= self::MAX_POLL_ATTEMPTS) {
|
||||
$this->markFailed($checkId, '[poll] timeout after ' . $attempt . ' attempts');
|
||||
return;
|
||||
}
|
||||
Queue::later(
|
||||
self::POLL_INTERVAL,
|
||||
'app\\api\\job\\PlagiarismPoll',
|
||||
['check_id' => $checkId, 'attempt' => $attempt + 1],
|
||||
'plagiarism'
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// 网络抖动不要直接 fail,给一定容错次数
|
||||
if ($attempt < self::MAX_POLL_ATTEMPTS) {
|
||||
Queue::later(
|
||||
self::POLL_INTERVAL,
|
||||
'app\\api\\job\\PlagiarismPoll',
|
||||
['check_id' => $checkId, 'attempt' => $attempt + 1],
|
||||
'plagiarism'
|
||||
);
|
||||
$this->updateCheck($checkId, [
|
||||
'attempts' => $attempt,
|
||||
'error_msg' => '[poll] transient: ' . $e->getMessage(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
$this->markFailed($checkId, '[poll] exhausted: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新生成在线查看 URL(已有的过期了用)
|
||||
*
|
||||
* @return array{url:string, expire:int, local_pdf:string}
|
||||
*/
|
||||
public function refreshViewerUrlFor($checkId)
|
||||
{
|
||||
$check = $this->mustGetCheck($checkId);
|
||||
if (empty($check['tii_submission_id'])) {
|
||||
throw new Exception('check has no tii_submission_id');
|
||||
}
|
||||
$tii = new TurnitinService();
|
||||
$info = $this->refreshViewerUrl($tii, $check['tii_submission_id']);
|
||||
$this->updateCheck($checkId, [
|
||||
'view_only_url' => $info['url'],
|
||||
'view_only_url_expire' => $info['expire'],
|
||||
]);
|
||||
return [
|
||||
'url' => $info['url'],
|
||||
'expire' => $info['expire'],
|
||||
'local_pdf' => $check['pdf_local_path'],
|
||||
];
|
||||
}
|
||||
|
||||
// ---------- 内部 ----------
|
||||
|
||||
private function refreshViewerUrl($tii, $submissionId)
|
||||
{
|
||||
$resp = $tii->getViewerUrl($submissionId);
|
||||
$url = '';
|
||||
if (isset($resp['viewer_url'])) {
|
||||
$url = (string)$resp['viewer_url'];
|
||||
} elseif (isset($resp['url'])) {
|
||||
$url = (string)$resp['url'];
|
||||
}
|
||||
// 默认 2 小时过期,保守起见
|
||||
return ['url' => $url, 'expire' => time() + 7200];
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发生成 + 轮询 + 下载 PDF 到本地,返回相对路径
|
||||
*/
|
||||
private function downloadAndStorePdf($tii, $submissionId, $checkId)
|
||||
{
|
||||
// 1. 请求生成
|
||||
$req = $tii->requestPdfReport($submissionId);
|
||||
$pdfId = isset($req['id']) ? $req['id'] : '';
|
||||
if ($pdfId === '') {
|
||||
throw new Exception('requestPdfReport empty id: ' . json_encode($req));
|
||||
}
|
||||
|
||||
// 2. 内联轮询 PDF 状态(最多 3 分钟,每 6 秒一次)
|
||||
$maxLoops = 30;
|
||||
for ($i = 0; $i < $maxLoops; $i++) {
|
||||
$st = $tii->getPdfReportStatus($submissionId, $pdfId);
|
||||
$stCode = isset($st['status']) ? strtoupper($st['status']) : '';
|
||||
if ($stCode === 'SUCCESS') {
|
||||
break;
|
||||
}
|
||||
if ($stCode === 'FAILED') {
|
||||
throw new Exception('PDF report generation failed: ' . json_encode($st));
|
||||
}
|
||||
sleep(6);
|
||||
}
|
||||
// 3. 下载
|
||||
$binary = $tii->downloadPdfReport($submissionId, $pdfId);
|
||||
if (!is_string($binary) || strlen($binary) < 100) {
|
||||
throw new Exception('downloaded pdf is empty/too small');
|
||||
}
|
||||
|
||||
// 4. 落盘
|
||||
$rootDir = ROOT_PATH ?: dirname(dirname(__DIR__));
|
||||
$absDir = rtrim($rootDir, '/\\') . DIRECTORY_SEPARATOR . self::REPORT_DIR;
|
||||
if (!is_dir($absDir)) {
|
||||
@mkdir($absDir, 0755, true);
|
||||
}
|
||||
$filename = sprintf('check_%d_%s.pdf', $checkId, date('Ymd_His'));
|
||||
$absPath = $absDir . DIRECTORY_SEPARATOR . $filename;
|
||||
$bytes = file_put_contents($absPath, $binary);
|
||||
if ($bytes === false || $bytes < 100) {
|
||||
throw new Exception('failed to save pdf to ' . $absPath);
|
||||
}
|
||||
return self::REPORT_DIR . '/' . $filename;
|
||||
}
|
||||
|
||||
private function mustGetCheck($checkId)
|
||||
{
|
||||
$row = Db::name('plagiarism_check')->where('check_id', $checkId)->find();
|
||||
if (!$row) {
|
||||
throw new Exception("plagiarism_check #{$checkId} not found");
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function updateCheck($checkId, array $data)
|
||||
{
|
||||
$data['utime'] = time();
|
||||
Db::name('plagiarism_check')->where('check_id', $checkId)->update($data);
|
||||
}
|
||||
|
||||
private function markFailed($checkId, $errMsg)
|
||||
{
|
||||
$this->log("markFailed act");
|
||||
$this->updateCheck($checkId, [
|
||||
'state' => 4,
|
||||
'error_msg' => mb_substr($errMsg, 0, 1000),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 t_article_file 找到投稿主稿(manuscirpt)的本地绝对路径。
|
||||
* file_url 在系统里可能是 URL 或相对路径,调用方负责保证可读。
|
||||
*
|
||||
* @return string 文件绝对路径,找不到时抛异常
|
||||
*/
|
||||
public function locateArticleManuscript($articleId)
|
||||
{
|
||||
$row = Db::name('article_file')
|
||||
->where('article_id', $articleId)
|
||||
->where('type_name', 'manuscirpt') // 历史拼写
|
||||
->order('file_id desc')
|
||||
->find();
|
||||
if (!$row || empty($row['file_url'])) {
|
||||
throw new Exception("article #{$articleId} has no manuscirpt file");
|
||||
}
|
||||
return $this->resolveFileUrlToLocal($row['file_url']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 file_url(可能是 http URL 或相对路径)解析成本地绝对路径。
|
||||
* 不同环境部署可能有差异,这里用 .env 配置的 STATIC_ROOT 作前缀。
|
||||
*/
|
||||
public function resolveFileUrlToLocal($fileUrl)
|
||||
{
|
||||
$fileUrl = trim((string)$fileUrl);
|
||||
if ($fileUrl === '') {
|
||||
throw new Exception('empty file_url');
|
||||
}
|
||||
// 已是绝对路径
|
||||
if (preg_match('/^([a-zA-Z]:[\\\\\/]|\/)/', $fileUrl) && is_file($fileUrl)) {
|
||||
return $fileUrl;
|
||||
}
|
||||
|
||||
$staticRoot = trim((string)Env::get('plagiarism.static_root', ''));
|
||||
$cdnPrefix = trim((string)Env::get('plagiarism.cdn_prefix', ''));
|
||||
|
||||
// 是 http URL:先试着剥掉 cdn 前缀,映射到本地
|
||||
if (preg_match('#^https?://#i', $fileUrl)) {
|
||||
if ($cdnPrefix !== '' && stripos($fileUrl, $cdnPrefix) === 0) {
|
||||
$rel = ltrim(substr($fileUrl, strlen($cdnPrefix)), '/');
|
||||
$local = rtrim($staticRoot, '/\\') . DIRECTORY_SEPARATOR . $rel;
|
||||
if (is_file($local)) {
|
||||
return $local;
|
||||
}
|
||||
}
|
||||
// 实在不行,下载到 runtime/plagiarism/tmp 临时目录
|
||||
return $this->downloadRemoteFile($fileUrl);
|
||||
}
|
||||
|
||||
// 相对路径:拼 static_root
|
||||
if ($staticRoot !== '') {
|
||||
$local = rtrim($staticRoot, '/\\') . DIRECTORY_SEPARATOR . ltrim($fileUrl, '/\\');
|
||||
if (is_file($local)) {
|
||||
return $local;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Exception("cannot resolve file_url to local path: {$fileUrl} (set [plagiarism] STATIC_ROOT/CDN_PREFIX in .env)");
|
||||
}
|
||||
|
||||
private function downloadRemoteFile($url)
|
||||
{
|
||||
$rootDir = ROOT_PATH ?: dirname(dirname(__DIR__));
|
||||
$tmpDir = rtrim($rootDir, '/\\') . DIRECTORY_SEPARATOR . self::REPORT_DIR . DIRECTORY_SEPARATOR . 'tmp';
|
||||
if (!is_dir($tmpDir)) {
|
||||
@mkdir($tmpDir, 0755, true);
|
||||
}
|
||||
$ext = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION) ?: 'pdf';
|
||||
$local = $tmpDir . DIRECTORY_SEPARATOR . md5($url) . '_' . time() . '.' . $ext;
|
||||
|
||||
$ch = curl_init($url);
|
||||
$fh = fopen($local, 'wb');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_FILE => $fh,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
$ok = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
fclose($fh);
|
||||
if (!$ok || $code !== 200 || filesize($local) < 100) {
|
||||
@unlink($local);
|
||||
throw new Exception("download failed url={$url} http={$code}");
|
||||
}
|
||||
return $local;
|
||||
}
|
||||
|
||||
public function getCheck($checkId)
|
||||
{
|
||||
return Db::name('plagiarism_check')->where('check_id', $checkId)->find();
|
||||
}
|
||||
|
||||
public function log($msg)
|
||||
{
|
||||
$line = date('Y-m-d H:i:s') . ' ' . $msg . PHP_EOL;
|
||||
@file_put_contents($this->logFile, $line, FILE_APPEND);
|
||||
}
|
||||
}
|
||||
303
application/common/ProductionArticleRefer.php
Normal file
303
application/common/ProductionArticleRefer.php
Normal file
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
namespace app\common;
|
||||
use think\Db;
|
||||
use think\Env;
|
||||
class ProductionArticleRefer
|
||||
{
|
||||
|
||||
// DOI匹配核心正则(生产级优化:支持%字符、限制长度、单词边界断言)
|
||||
// private const DOI_PATTERN = '/(?:doi[:\s]?|DOI[:\s]?)?\b10\.\d+(?:\.\d+)*\/[a-zA-Z0-9._\-!()%\/:;@$&+=?#[\]<>~`|^]+/i';
|
||||
|
||||
// 错误码与错误信息映射(标准化错误处理)
|
||||
private const ERROR_CODES = [
|
||||
'EMPTY_STRING' => 'Input string is empty (preprocessed))',
|
||||
'NO_MATCH' => 'No valid DOI detected',
|
||||
'INVALID_AFTER_CLEAN' => 'No effective DOI after cleaning',
|
||||
'FORCE_EXTRACT_FAILED' => 'Forced extraction still has no valid DOI',
|
||||
'EXTRACTION_EXCEPTION' => 'Exception occurred during DOI extraction process',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取未处理的参考文献
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function top($aParam = []) {
|
||||
|
||||
//文章ID
|
||||
$iArticleId = empty($aParam['article_id']) ? '' : $aParam['article_id'];
|
||||
if(empty($iArticleId)){
|
||||
return json_encode(array('status' => 2,'msg' => 'Please select an article'.json_encode($aParam) ));
|
||||
}
|
||||
// 获取生产文章ID
|
||||
$iPArticleId = empty($aParam['p_article_id']) ? 0 : $aParam['p_article_id'];
|
||||
if(empty($iPArticleId)) {
|
||||
return json_encode(array('status' => 2,'msg' => 'Please select an production article'.json_encode($aParam) ));
|
||||
}
|
||||
|
||||
//查询未处理过的数据
|
||||
$aWhere = ['p_article_id' => $iPArticleId,'article_id' => $iArticleId,'state' => 0,'refer_doi' => ['<>',''],'is_deal' => 2];
|
||||
$aResult = Db::name('production_article_refer')->field('article_id,p_article_id,p_refer_id,refer_doi')->where($aWhere)->select();
|
||||
if(empty($aResult)){
|
||||
return json_encode(array('status' => 2,'msg' => 'The reference data to be processed is empty'.json_encode($aParam)));
|
||||
}
|
||||
|
||||
//数据处理
|
||||
foreach ($aResult as $key => $value) {
|
||||
if(empty($value['refer_doi'])){
|
||||
continue;
|
||||
}
|
||||
//调用获取参考文献详情队列
|
||||
\think\Queue::push('app\api\job\ArticleReferDetailQueue@fire', $value, 'ArticleReferDetailQueue');
|
||||
}
|
||||
return json_encode(['status' => 1,'msg' => 'Add to reference processing queue']);
|
||||
}
|
||||
/**
|
||||
* 处理参考文献
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function get($aParam = []) {
|
||||
// 获取生产文章ID
|
||||
$iPReferId = empty($aParam['p_refer_id']) ? 0 : $aParam['p_refer_id'];
|
||||
if(empty($iPReferId)) {
|
||||
return json_encode(array('status' => 2,'msg' => 'Please select a reference'.json_encode($aParam) ));
|
||||
}
|
||||
// 获取生产文章ID
|
||||
$iPArticleId = empty($aParam['p_article_id']) ? 0 : $aParam['p_article_id'];
|
||||
if(empty($iPArticleId)) {
|
||||
return json_encode(array('status' => 2,'msg' => 'Please select an production article'.json_encode($aParam) ));
|
||||
}
|
||||
//查询未处理过的数据
|
||||
$aWhere = ['p_refer_id' => $iPReferId,'p_article_id' => $iPArticleId,'state' => 0];
|
||||
$aRefer = Db::name('production_article_refer')->field('refer_doi,refer_content')->where($aWhere)->find();
|
||||
if(empty($aRefer)){
|
||||
return json_encode(array('status' => 2,'msg' => 'No reference records found'.json_encode($aParam)));
|
||||
}
|
||||
if(empty($aRefer['refer_doi'])){
|
||||
return json_encode(['status' => 4,'msg' => 'Reference DOI is empty'.json_encode($aParam)]);
|
||||
}
|
||||
|
||||
//数据处理
|
||||
$doi = str_replace('/', '%2F', $aRefer['refer_doi']);
|
||||
$url = "https://citation.doi.org/format?doi=$doi&style=cancer-translational-medicine&lang=en-US";
|
||||
$res = myGet($url);
|
||||
$frag = trim(substr($res, strpos($res, '.') + 1));
|
||||
if(empty($frag)){
|
||||
$aUpdate = ['refer_frag' => $aRefer['refer_content'],'refer_type' => 'other','is_deal' => 1,'update_time' => time()];
|
||||
$aWhere = ['p_refer_id' => $iPReferId];
|
||||
$result = Db::name('production_article_refer')->where($aWhere)->limit(1)->update($aUpdate);
|
||||
//写入通过AI获取参考文献详情队列
|
||||
// \think\Queue::push('app\api\job\AiCheckReferByDoi@fire',$aParam,'AiCheckReferByDoi');
|
||||
return json_encode(array('status' => 2,'msg' => 'The data obtained from the interface is empty'.$url));
|
||||
}
|
||||
|
||||
//整理数据入库
|
||||
$update = [];
|
||||
if (mb_substr_count($frag, '.') != 3){
|
||||
$f = $frag . " Available at: " . PHP_EOL . "https://doi.org/" . $aRefer['refer_doi'];
|
||||
$update['refer_type'] = "other";
|
||||
$update['refer_frag'] = $f;
|
||||
$update['cs'] = 1;
|
||||
//写入通过AI获取参考文献详情队列
|
||||
// \think\Queue::push('app\api\job\AiCheckReferByDoi@fire',$aParam,'AiCheckReferByDoi');
|
||||
}
|
||||
if (mb_substr_count($frag, '.') == 3){
|
||||
$res = explode('.', $frag);
|
||||
$update['author'] = prgeAuthor($res[0]);
|
||||
$update['title'] = trim($res[1]);
|
||||
$bj = bekjournal($res[2]);
|
||||
$joura = formateJournal(trim($bj[0]));
|
||||
$update['joura'] = $joura;
|
||||
$is_js = 0;
|
||||
if ($joura == trim($bj[0])) {
|
||||
}
|
||||
$update['refer_type'] = "journal";
|
||||
$update['is_ja'] = $joura == trim($bj[0]) ? 0 : 1;
|
||||
$update['dateno'] = str_replace(' ', '', str_replace('-', '–', trim($bj[1])));
|
||||
//新增处理 期卷页码 20251127 start
|
||||
if(!empty($update['dateno'])){
|
||||
$sStr = $update['dateno'];
|
||||
$aStr = explode(':', $sStr);
|
||||
if(!empty($aStr[1])){
|
||||
$parts = explode('–', $aStr[1]);
|
||||
if(count($parts) == 2){
|
||||
$prefix = empty($parts[0]) ? 0 : intval($parts[0]);
|
||||
$suffix = empty($parts[1]) ? 0 : intval($parts[1]);
|
||||
if($prefix > $suffix){
|
||||
$prefixLen = strlen($prefix);
|
||||
$suffixLen = strlen($suffix);
|
||||
$missingLen = $prefixLen - $suffixLen;
|
||||
if ($missingLen > 0) {
|
||||
$fillPart = substr($prefix, 0, $missingLen);
|
||||
$newSuffix = $fillPart . $suffix;
|
||||
$update['dateno'] = $aStr[0].':'.$prefix.'–'.$newSuffix;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// if(empty($aStr[1])){
|
||||
// //写入通过AI获取参考文献详情队列
|
||||
// \think\Queue::push('app\api\job\AiCheckReferByDoi@fire',$aParam,'AiCheckReferByDoi');
|
||||
// }
|
||||
}
|
||||
//新增处理 期卷页码 20251127 end
|
||||
$update['doilink'] = strpos($aRefer['refer_doi'],"http")===false?"https://doi.org/" . $aRefer['refer_doi']:$aRefer['refer_doi'];
|
||||
$update['cs'] = 1;
|
||||
}
|
||||
//数据库更新
|
||||
if(empty($update)){
|
||||
return json_encode(array('status' => 3,'msg' => 'Update data to empty'.$url.'====='.$frag));
|
||||
}
|
||||
$aWhere = ['p_refer_id' => $iPReferId];
|
||||
$update += ['is_deal' => 1,'update_time' => time()];
|
||||
$result = Db::name('production_article_refer')->where($aWhere)->limit(1)->update($update);
|
||||
if($result === false){
|
||||
return json_encode(array('status' => 3,'msg' => 'Update failed'.json_encode($update)));
|
||||
}
|
||||
return json_encode(['status' => 1,'msg' => 'Update successful']);
|
||||
}
|
||||
|
||||
// /**
|
||||
// * 实例方法:提取单个DOI(核心逻辑,生产级优化)
|
||||
// * @param string $str 待检测字符串
|
||||
// * @param bool $standardize 是否标准化DOI(转小写)
|
||||
// * @param bool $forceExtract 是否强制提取(忽略微小格式瑕疵)
|
||||
// * @return array 提取结果(含错误码、错误信息、DOI)
|
||||
// */
|
||||
// // public function extractDoiFromString(string $str, bool $standardize = true, bool $forceExtract = false): array
|
||||
// // {
|
||||
// // // 初始化标准化结果
|
||||
// // $result = [
|
||||
// // 'has_doi' => false,
|
||||
// // 'doi' => null,
|
||||
// // 'error_code' => null,
|
||||
// // 'error_msg' => null,
|
||||
// // ];
|
||||
|
||||
// // try {
|
||||
// // // 严格类型校验(防止非字符串参数传入)
|
||||
// // if (!is_string($str)) {
|
||||
// // throw new InvalidArgumentException('输入参数必须为字符串类型', 1001);
|
||||
// // }
|
||||
// // // 字符串预处理(生产级:全角转半角、URL解码、HTML标签移除等)
|
||||
// // $processedStr = $this->preprocessString($str);
|
||||
// // if (trim($processedStr) === '') {
|
||||
// // $result['error_code'] = 'EMPTY_STRING';
|
||||
// // $result['error_msg'] = self::ERROR_CODES['EMPTY_STRING'];
|
||||
// // return $result;
|
||||
// // }
|
||||
|
||||
// // // 性能优化:用preg_match仅匹配首个DOI,替代preg_match_all
|
||||
// // // 优化后的带前缀版正则
|
||||
// // $pattern = '/(?:doi[:\s]*|DOI[:\s]*)?\b10\.\d+(?:\.\d+)*\/[a-zA-Z0-9._\-!()%\/:;@$&+=?#[\]<>~`|^'"{},\\\\]+(?![\w?#])/i";
|
||||
// // if (!preg_match($pattern, $processedStr, $match)) {
|
||||
// // $result['error_code'] = 'NO_MATCH';
|
||||
// // $result['error_msg'] = self::ERROR_CODES['NO_MATCH'];
|
||||
// // return $result;
|
||||
// // }
|
||||
|
||||
// // // 清洗并验证首个DOI
|
||||
// // $cleanDoi = $this->cleanAndValidateDoi($match[0], $standardize, $forceExtract);
|
||||
// // if ($cleanDoi !== null) {
|
||||
// // $result['has_doi'] = true;
|
||||
// // $result['doi'] = $cleanDoi;
|
||||
// // } else {
|
||||
// // // 根据是否强制提取设置错误信息
|
||||
// // $errorKey = $forceExtract ? 'FORCE_EXTRACT_FAILED' : 'INVALID_AFTER_CLEAN';
|
||||
// // $result['error_code'] = $errorKey;
|
||||
// // $result['error_msg'] = self::ERROR_CODES[$errorKey];
|
||||
// // }
|
||||
|
||||
// // } catch (InvalidArgumentException $e) {
|
||||
// // // 业务异常:标准化错误码和信息
|
||||
// // $result['error_code'] = 'INVALID_PARAM';
|
||||
// // $result['error_msg'] = '参数错误:' . $e->getMessage();
|
||||
// // } catch (Exception $e) {
|
||||
// // // 系统异常:隐藏敏感信息,记录通用错误
|
||||
// // $result['error_code'] = 'EXTRACTION_EXCEPTION';
|
||||
// // $result['error_msg'] = self::ERROR_CODES['EXTRACTION_EXCEPTION'] . ':' . $e->getMessage();
|
||||
// // }
|
||||
|
||||
// // return $result;
|
||||
// // }
|
||||
|
||||
// // /**
|
||||
// // * 字符串预处理(生产级:覆盖所有编码/格式干扰场景)
|
||||
// // * @param string $str 原始字符串
|
||||
// // * @return string 预处理后的纯净字符串
|
||||
// // */
|
||||
// // private function preprocessString(string $str): string
|
||||
// // {
|
||||
// // // 1. 全角转半角(解决中文全角字符干扰,如10.1007/s11042-020-10103-4)
|
||||
// // $str = $this->fullWidthToHalfWidth($str);
|
||||
// // // 2. 移除所有HTML标签(解决网页文本中DOI被<p>/<a>/<b>等标签包裹的问题)
|
||||
// // $str = strip_tags($str);
|
||||
// // // 3. URL解码(处理%2F等URL编码的特殊字符,如10.1007%2Fs11042-020-10103-4)
|
||||
// // $str = urldecode($str);
|
||||
// // // 4. 解码HTML实体(处理&、/等HTML实体编码)
|
||||
// // $str = html_entity_decode($str, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
// // // 5. 移除不可见字符(换行、制表符、零宽空格、控制字符等)
|
||||
// // $str = preg_replace('/[\x00-\x1F\x7F\x{200B}-\x{200F}]/u', ' ', $str);
|
||||
// // // 6. 合并多个空格为单个(避免连续空格干扰正则匹配)
|
||||
// // $str = preg_replace('/\s+/', ' ', $str);
|
||||
|
||||
// // return $str;
|
||||
// // }
|
||||
|
||||
// // /**
|
||||
// // * 清洗并验证DOI(生产级:优化正则规则,严格长度校验)
|
||||
// // * @param string $match 原始正则匹配结果
|
||||
// // * @param bool $standardize 是否标准化DOI(转小写)
|
||||
// // * @param bool $forceExtract 是否强制提取
|
||||
// // * @return string|null 有效DOI或null
|
||||
// // */
|
||||
// // private function cleanAndValidateDoi(string $match, bool $standardize, bool $forceExtract): ?string
|
||||
// // {
|
||||
// // // 1. 移除DOI前缀(doi:/DOI:)和首尾空白字符
|
||||
// // $cleanDoi = preg_replace('/^doi[:\s]?|^DOI[:\s]?/i', '', trim($match));
|
||||
// // // 2. 移除尾部常见标点(避免DOI被标点包裹,如10.1007/s11042-020-10103-4.)
|
||||
// // $cleanDoi = rtrim($cleanDoi, '.,;(){}[]!?"\'');
|
||||
|
||||
// // // 3. 严格的长度校验(DOI官方规范:6-200字符)
|
||||
// // $doiLength = strlen($cleanDoi);
|
||||
// // if ($doiLength < 6 || $doiLength > 200) {
|
||||
// // return null;
|
||||
// // }
|
||||
|
||||
// // // 4. 验证规则(生产级优化:添加单词边界,避免匹配不完整DOI)
|
||||
// // // 基础规则:严格遵循官方规范,10.开头+包含/+/后有内容
|
||||
// // $basicRule = '/^10\.\d+\/.+$/D';
|
||||
// // // 宽松规则:强制提取时使用,添加单词边界,避免匹配被字符包裹的DOI
|
||||
// // $looseRule = '/\b10\.\d+\/[^\s%]{1,190}\b/';
|
||||
|
||||
// // $validateRule = $forceExtract ? $looseRule : $basicRule;
|
||||
// // $isValid = preg_match($validateRule, $cleanDoi) === 1;
|
||||
|
||||
// // // 5. 验证通过则标准化(转小写),否则返回null
|
||||
// // if ($isValid) {
|
||||
// // return $standardize ? strtolower($cleanDoi) : $cleanDoi;
|
||||
// // }
|
||||
|
||||
// // return null;
|
||||
// // }
|
||||
|
||||
// // /**
|
||||
// // * 辅助方法:全角转半角
|
||||
// // * @param string $str 包含全角字符的字符串
|
||||
// // * @return string 半角字符串
|
||||
// // */
|
||||
// // private function fullWidthToHalfWidth(string $str): string
|
||||
// // {
|
||||
// // $fullWidthChars = [
|
||||
// // '0' => '0', '1' => '1', '2' => '2', '3' => '3', '4' => '4',
|
||||
// // '5' => '5', '6' => '6', '7' => '7', '8' => '8', '9' => '9',
|
||||
// // '.' => '.', '/' => '/', '-' => '-', '%' => '%', '!' => '!',
|
||||
// // '(' => '(', ')' => ')', ':' => ':', ';' => ';', ',' => ',',
|
||||
// // '"' => '"', ''' => '\''
|
||||
// // ];
|
||||
|
||||
// // return strtr($str, $fullWidthChars);
|
||||
// // }
|
||||
}
|
||||
?>
|
||||
339
application/common/PromotionLlmService.php
Normal file
339
application/common/PromotionLlmService.php
Normal file
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
namespace app\common;
|
||||
|
||||
use think\Env;
|
||||
|
||||
/**
|
||||
* 推广邮件 LLM 服务
|
||||
*
|
||||
* 作用:基于 expert 的代表作 (paper_title)、expert 的研究领域、期刊信息 (name / scope / fields 等),
|
||||
* 通过一次 LLM 调用同时生成两段邮件内容:
|
||||
* - description 个性化赞美 + 邀稿段落(对应模板变量 {{ai_content_analysis}} / {{llm_description}})
|
||||
* - advised_topics "我们特别关注 X / Y / Z 领域" 段落(对应模板变量 {{ai_advised_topics}})
|
||||
*
|
||||
* 单次调用返回 JSON,避免两次 LLM 请求导致的延迟翻倍;任一段失败可独立兜底。
|
||||
*
|
||||
* 配置(.env 的 [promotion] 段):
|
||||
* PROMOTION_LLM_URL chat/completions 接口地址
|
||||
* PROMOTION_LLM_MODEL 模型名
|
||||
* PROMOTION_LLM_TIMEOUT 超时时间(秒),默认 30
|
||||
* PROMOTION_LLM_FALLBACK description 兜底文案
|
||||
* PROMOTION_LLM_ADVISED_FALLBACK advised_topics 兜底文案
|
||||
*
|
||||
* 状态约定:
|
||||
* 1 = LLM 成功生成
|
||||
* 2 = LLM 调用失败 / 解析失败,使用兜底
|
||||
* 3 = 前置条件不足(缺代表作、缺交集等),使用兜底
|
||||
*/
|
||||
class PromotionLlmService
|
||||
{
|
||||
private $url;
|
||||
private $model;
|
||||
private $timeout;
|
||||
private $apiKey;
|
||||
private $fallback;
|
||||
private $advisedFallback;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->url = trim((string)Env::get('promotion.promotion_llm_url', ''));
|
||||
$this->model = trim((string)Env::get('promotion.promotion_llm_model', ''));
|
||||
$this->timeout = max(5, intval(Env::get('promotion.promotion_llm_timeout', 30)));
|
||||
$this->apiKey = trim((string)Env::get('promotion.promotion_llm_api_key', ''));
|
||||
$this->fallback = trim((string)Env::get('promotion.promotion_llm_fallback',
|
||||
'Your recent work aligns closely with the scope of our journal, and we would be honored to consider a contribution from you.'));
|
||||
$this->advisedFallback = trim((string)Env::get('promotion.promotion_llm_advised_fallback',
|
||||
'We are especially interested in the research directions that align with your expertise, and warmly welcome your future submissions in these areas.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次 LLM 调用生成邮件两段内容。
|
||||
*
|
||||
* @param array $expert expert 行(含 name / representative_work_title / fields 等)
|
||||
* @param array $journal journal 行(含 title / aims / databases 等)
|
||||
* @param array $overlapFields 预先计算的 expert & journal 领域交集(可能为空)
|
||||
* @param array $journalFields journal(或 task/工厂)的目标领域
|
||||
* @param array $expertFields expert 的研究领域
|
||||
* @return array [
|
||||
* 'description' => string,
|
||||
* 'description_status' => 1|2|3,
|
||||
* 'advised_topics' => string,
|
||||
* 'advised_topics_status' => 1|2|3,
|
||||
* ]
|
||||
*/
|
||||
public function generateEmailContent(
|
||||
array $expert,
|
||||
array $journal,
|
||||
array $overlapFields = [],
|
||||
array $journalFields = [],
|
||||
array $expertFields = []
|
||||
): array {
|
||||
$paperTitle = trim((string)($expert['representative_work_title'] ?? ''));
|
||||
$expertName = trim((string)($expert['name'] ?? ''));
|
||||
$journalName = trim((string)($journal['title'] ?? ''));
|
||||
|
||||
$overlapList = $this->cleanList($overlapFields);
|
||||
$journalList = $this->cleanList($journalFields);
|
||||
$expertList = $this->cleanList($expertFields);
|
||||
|
||||
$hasDescInput = ($paperTitle !== '' && $journalName !== '');
|
||||
$hasAdvisedInput = (!empty($overlapList) || (!empty($journalList) && !empty($expertList)));
|
||||
|
||||
// 两段都缺输入时直接走双兜底
|
||||
if (!$hasDescInput && !$hasAdvisedInput) {
|
||||
return $this->allFallback(3, 3);
|
||||
}
|
||||
|
||||
// LLM 未配置
|
||||
if ($this->url === '' || $this->model === '') {
|
||||
return $this->allFallback(
|
||||
$hasDescInput ? 2 : 3,
|
||||
$hasAdvisedInput ? 2 : 3
|
||||
);
|
||||
}
|
||||
|
||||
$expertField = trim((string)($expert['fields'] ?? ($expert['field'] ?? '')));
|
||||
$journalAims = trim((string)($journal['aims'] ?? ''));
|
||||
$journalDbs = trim((string)($journal['databases'] ?? ''));
|
||||
|
||||
// advised_topics 目标数量:期望邮件里总是体现 3 个推荐方向
|
||||
$targetTopicCount = 3;
|
||||
$overlapCount = count($overlapList);
|
||||
$needFill = max(0, $targetTopicCount - $overlapCount);
|
||||
|
||||
$system = 'You are an academic editorial assistant. '
|
||||
. 'You will receive context about an author, their recent paper, and a target journal, '
|
||||
. 'and you must produce TWO English paragraphs for an invitation email. '
|
||||
. 'Output STRICT MINIFIED JSON ONLY with exactly these keys:'
|
||||
. '{"description":"...","advised_topics":"..."} '
|
||||
. 'Rules for "description": 2-3 sentences, <=50 words, warm and professional; '
|
||||
. '(a) briefly appreciate the author\'s recent paper, '
|
||||
. '(b) explain why it fits the journal\'s scope, '
|
||||
. '(c) gently invite a future submission. '
|
||||
. 'Rules for "advised_topics": 1-2 sentences, <=60 words; '
|
||||
. 'Must recommend EXACTLY ' . $targetTopicCount . ' research directions the journal is particularly interested in, '
|
||||
. 'and explicitly list these ' . $targetTopicCount . ' topics in the sentence. '
|
||||
. 'Topic selection rules, in order: '
|
||||
. '(i) include all items from "Overlap topics (exact match)" first, preserving their order; '
|
||||
. '(ii) if overlap has fewer than ' . $targetTopicCount . ' items, fill the remaining slots '
|
||||
. 'with topics that are semantically close to BOTH the journal\'s focus fields and the author\'s research fields '
|
||||
. '(prefer concrete sub-topics over generic umbrella terms; '
|
||||
. 'do NOT invent topics unrelated to either list); '
|
||||
. '(iii) never repeat topics, keep wording concise (2-4 words each). '
|
||||
. 'End the paragraph by inviting contributions leaning toward those directions. '
|
||||
. 'No greetings, no signatures, no placeholders, no markdown, no code fences. '
|
||||
. 'If a section genuinely cannot be produced, return an empty string for that key.';
|
||||
|
||||
$userLines = [];
|
||||
$userLines[] = 'Author name: ' . ($expertName !== '' ? $expertName : '(unknown)');
|
||||
if ($expertField !== '') {
|
||||
$userLines[] = 'Author research field (raw): ' . $expertField;
|
||||
}
|
||||
if (!empty($expertList)) {
|
||||
$userLines[] = 'Author research fields (list): ' . implode(', ', $expertList);
|
||||
}
|
||||
$userLines[] = 'Recent paper title: ' . ($paperTitle !== '' ? $paperTitle : '(none)');
|
||||
$userLines[] = 'Target journal: ' . ($journalName !== '' ? $journalName : '(unknown)');
|
||||
if (!empty($journalList)) {
|
||||
$userLines[] = 'Journal focus fields: ' . implode(', ', $journalList);
|
||||
}
|
||||
if (!empty($overlapList)) {
|
||||
$userLines[] = 'Overlap topics (exact match): ' . implode(', ', $overlapList)
|
||||
. ' [count=' . $overlapCount . ', need_fill=' . $needFill . ']';
|
||||
} else {
|
||||
$userLines[] = 'Overlap topics (exact match): (none) '
|
||||
. '[count=0, need_fill=' . $targetTopicCount
|
||||
. '; please pick ' . $targetTopicCount
|
||||
. ' semantically related topics from the two field lists above]';
|
||||
}
|
||||
if ($journalAims !== '') {
|
||||
$userLines[] = 'Journal aims & scope: ' . mb_substr($journalAims, 0, 500);
|
||||
}
|
||||
if ($journalDbs !== '') {
|
||||
$userLines[] = 'Journal indexing: ' . mb_substr($journalDbs, 0, 200);
|
||||
}
|
||||
$userLines[] = 'Advised topics required count: ' . $targetTopicCount;
|
||||
$userLines[] = 'Return only minified JSON {"description":"...","advised_topics":"..."}.';
|
||||
$user = implode("\n", $userLines);
|
||||
|
||||
$payload = [
|
||||
'model' => $this->model,
|
||||
'temperature' => 0.4,
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => $system],
|
||||
['role' => 'user', 'content' => $user],
|
||||
],
|
||||
];
|
||||
|
||||
$content = $this->postChat($payload);
|
||||
if ($content === null) {
|
||||
return $this->allFallback(
|
||||
$hasDescInput ? 2 : 3,
|
||||
$hasAdvisedInput ? 2 : 3
|
||||
);
|
||||
}
|
||||
|
||||
$parsed = $this->parseJson($content);
|
||||
if ($parsed === null) {
|
||||
return $this->allFallback(
|
||||
$hasDescInput ? 2 : 3,
|
||||
$hasAdvisedInput ? 2 : 3
|
||||
);
|
||||
}
|
||||
|
||||
$desc = $this->cleanContent((string)($parsed['description'] ?? ''));
|
||||
$advised = $this->cleanContent((string)($parsed['advised_topics'] ?? ''));
|
||||
|
||||
$descStatus = 1;
|
||||
if ($desc === '') {
|
||||
$desc = $this->fallback;
|
||||
$descStatus = $hasDescInput ? 2 : 3;
|
||||
}
|
||||
|
||||
$advisedStatus = 1;
|
||||
if ($advised === '') {
|
||||
$advised = $this->advisedFallback;
|
||||
$advisedStatus = $hasAdvisedInput ? 2 : 3;
|
||||
}
|
||||
|
||||
return [
|
||||
'description' => $desc,
|
||||
'description_status' => $descStatus,
|
||||
'advised_topics' => $advised,
|
||||
'advised_topics_status' => $advisedStatus,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧接口:单段 description 生成(仍走 generateEmailContent,取第一段)
|
||||
*
|
||||
* @return array ['status' => 1|2|3, 'text' => string]
|
||||
*/
|
||||
public function generateDescription(array $expert, array $journal): array
|
||||
{
|
||||
$r = $this->generateEmailContent($expert, $journal);
|
||||
return [
|
||||
'status' => intval($r['description_status']),
|
||||
'text' => (string)$r['description'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 chat/completions 接口,返回 content 字符串;失败返回 null。
|
||||
*/
|
||||
private function postChat(array $payload)
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $this->url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(10, $this->timeout));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
|
||||
|
||||
$headers = ['Content-Type: application/json'];
|
||||
if ($this->apiKey !== '') {
|
||||
$headers[] = 'Authorization: Bearer ' . $this->apiKey;
|
||||
}
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
|
||||
$raw = curl_exec($ch);
|
||||
if ($raw === false) {
|
||||
curl_close($ch);
|
||||
return null;
|
||||
}
|
||||
$httpCode = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE));
|
||||
curl_close($ch);
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) return null;
|
||||
|
||||
if (isset($data['choices'][0]['message']['content'])) {
|
||||
return (string)$data['choices'][0]['message']['content'];
|
||||
}
|
||||
if (isset($data['content'])) {
|
||||
return (string)$data['content'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从模型原始输出里提取 JSON 对象;失败返回 null。
|
||||
*/
|
||||
private function parseJson(string $raw)
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') return null;
|
||||
$raw = preg_replace('/^```[a-zA-Z]*\s*|```$/m', '', $raw);
|
||||
$raw = trim($raw);
|
||||
|
||||
// 直接 decode
|
||||
$obj = json_decode($raw, true);
|
||||
if (is_array($obj)) return $obj;
|
||||
|
||||
// 抓出第一个 {...} 块再 decode
|
||||
if (preg_match('/\{.*\}/s', $raw, $m)) {
|
||||
$obj = json_decode($m[0], true);
|
||||
if (is_array($obj)) return $obj;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗 LLM 输出:去除 markdown 包裹、多余空白、首尾引号、过长截断。
|
||||
*/
|
||||
private function cleanContent(string $text): string
|
||||
{
|
||||
$text = trim($text);
|
||||
$text = preg_replace('/^```[a-zA-Z]*\s*|```$/m', '', $text);
|
||||
$text = trim($text);
|
||||
$text = trim($text, "\"' \t\n\r\0\x0B");
|
||||
$text = preg_replace('/\s+/', ' ', $text);
|
||||
if (mb_strlen($text) > 800) {
|
||||
$text = mb_substr($text, 0, 800);
|
||||
}
|
||||
return trim($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对领域列表做 trim / 去空 / 去重,保留首次出现顺序。
|
||||
*/
|
||||
private function cleanList(array $list): array
|
||||
{
|
||||
$out = [];
|
||||
$seen = [];
|
||||
foreach ($list as $item) {
|
||||
$v = trim((string)$item);
|
||||
if ($v === '') continue;
|
||||
$key = strtolower($v);
|
||||
if (isset($seen[$key])) continue;
|
||||
$seen[$key] = true;
|
||||
$out[] = $v;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function allFallback(int $descStatus, int $advisedStatus): array
|
||||
{
|
||||
return [
|
||||
'description' => $this->fallback,
|
||||
'description_status' => $descStatus,
|
||||
'advised_topics' => $this->advisedFallback,
|
||||
'advised_topics_status' => $advisedStatus,
|
||||
];
|
||||
}
|
||||
|
||||
public function getFallback(): string
|
||||
{
|
||||
return $this->fallback;
|
||||
}
|
||||
|
||||
public function getAdvisedFallback(): string
|
||||
{
|
||||
return $this->advisedFallback;
|
||||
}
|
||||
}
|
||||
1096
application/common/PromotionService.php
Normal file
1096
application/common/PromotionService.php
Normal file
File diff suppressed because it is too large
Load Diff
237
application/common/PubmedService.php
Normal file
237
application/common/PubmedService.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace app\common;
|
||||
|
||||
/**
|
||||
* PubMed 工具类(E-utilities)
|
||||
*
|
||||
* 功能:
|
||||
* - DOI -> PMID
|
||||
* - PMID -> 文章结构化信息(title/abstract/mesh/publication_types/year/journal)
|
||||
*
|
||||
* 说明:
|
||||
* - 默认使用 runtime 文件缓存,避免重复请求 NCBI
|
||||
*/
|
||||
class PubmedService
|
||||
{
|
||||
private $base = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/';
|
||||
private $timeout = 20;
|
||||
private $tool = 'tmrjournals';
|
||||
private $email = '';
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
if (isset($config['base'])) $this->base = rtrim((string)$config['base'], '/') . '/';
|
||||
if (isset($config['timeout'])) $this->timeout = max(5, intval($config['timeout']));
|
||||
if (isset($config['tool'])) $this->tool = (string)$config['tool'];
|
||||
if (isset($config['email'])) $this->email = (string)$config['email'];
|
||||
}
|
||||
|
||||
/**
|
||||
* DOI -> PMID(优先用 [DOI],命中不到再用 [AID])
|
||||
*/
|
||||
public function doiToPmid(string $doi): ?string
|
||||
{
|
||||
$doi = trim($doi);
|
||||
if ($doi === '') return null;
|
||||
|
||||
$cacheKey = 'doi2pmid_' . sha1(strtolower($doi));
|
||||
$cached = $this->cacheGet($cacheKey, 30 * 86400);
|
||||
if (is_string($cached) && $cached !== '') {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$pmid = $this->esearch($doi . '[DOI]');
|
||||
if (!$pmid) {
|
||||
$pmid = $this->esearch($doi . '[AID]');
|
||||
}
|
||||
if ($pmid) {
|
||||
$this->cacheSet($cacheKey, $pmid);
|
||||
return $pmid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PMID -> 文章信息(title/abstract/mesh/publication_types/year/journal)
|
||||
*/
|
||||
public function fetchByPmid(string $pmid): ?array
|
||||
{
|
||||
$pmid = trim($pmid);
|
||||
if ($pmid === '') return null;
|
||||
|
||||
$cacheKey = 'pmid_' . $pmid;
|
||||
$cached = $this->cacheGet($cacheKey, 30 * 86400);
|
||||
if (is_array($cached)) return $cached;
|
||||
|
||||
$url = $this->base . 'efetch.fcgi?' . http_build_query([
|
||||
'db' => 'pubmed',
|
||||
'id' => $pmid,
|
||||
'retmode' => 'xml',
|
||||
'tool' => $this->tool,
|
||||
'email' => $this->email,
|
||||
]);
|
||||
|
||||
$xml = $this->httpGet($url);
|
||||
if (!is_string($xml) || trim($xml) === '') return null;
|
||||
|
||||
$data = $this->parseEfetchXml($xml);
|
||||
if (!$data) return null;
|
||||
|
||||
$this->cacheSet($cacheKey, $data);
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* DOI -> PubMed 信息(含 abstract/mesh)
|
||||
*/
|
||||
public function fetchByDoi(string $doi): ?array
|
||||
{
|
||||
$pmid = $this->doiToPmid($doi);
|
||||
if (!$pmid) return null;
|
||||
$info = $this->fetchByPmid($pmid);
|
||||
if (!$info) return null;
|
||||
$info['pmid'] = $pmid;
|
||||
$info['doi'] = $doi;
|
||||
return $info;
|
||||
}
|
||||
|
||||
// ----------------- Internals -----------------
|
||||
|
||||
private function esearch(string $term): ?string
|
||||
{
|
||||
$url = $this->base . 'esearch.fcgi?' . http_build_query([
|
||||
'db' => 'pubmed',
|
||||
'retmode' => 'json',
|
||||
'retmax' => 1,
|
||||
'term' => $term,
|
||||
'tool' => $this->tool,
|
||||
'email' => $this->email,
|
||||
]);
|
||||
|
||||
$res = $this->httpGet($url);
|
||||
$json = json_decode((string)$res, true);
|
||||
$ids = $json['esearchresult']['idlist'] ?? [];
|
||||
if (!empty($ids[0])) return (string)$ids[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
private function parseEfetchXml(string $xml): ?array
|
||||
{
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new \DOMDocument();
|
||||
if (!$doc->loadXML($xml)) {
|
||||
return null;
|
||||
}
|
||||
$xp = new \DOMXPath($doc);
|
||||
|
||||
$title = $this->xpText($xp, '//PubmedArticle//ArticleTitle');
|
||||
|
||||
$abstractParts = [];
|
||||
$absNodes = $xp->query('//PubmedArticle//Abstract//AbstractText');
|
||||
if ($absNodes) {
|
||||
foreach ($absNodes as $n) {
|
||||
$label = $n->attributes && $n->attributes->getNamedItem('Label')
|
||||
? trim($n->attributes->getNamedItem('Label')->nodeValue)
|
||||
: '';
|
||||
$txt = trim($n->textContent);
|
||||
if ($txt === '') continue;
|
||||
$abstractParts[] = $label ? ($label . ': ' . $txt) : $txt;
|
||||
}
|
||||
}
|
||||
$abstract = trim(implode("\n", $abstractParts));
|
||||
|
||||
$mesh = [];
|
||||
$meshNodes = $xp->query('//PubmedArticle//MeshHeadingList//MeshHeading//DescriptorName');
|
||||
if ($meshNodes) {
|
||||
foreach ($meshNodes as $n) {
|
||||
$t = trim($n->textContent);
|
||||
if ($t !== '') $mesh[] = $t;
|
||||
}
|
||||
}
|
||||
$mesh = array_values(array_unique($mesh));
|
||||
|
||||
$pubTypes = [];
|
||||
$ptNodes = $xp->query('//PubmedArticle//PublicationTypeList//PublicationType');
|
||||
if ($ptNodes) {
|
||||
foreach ($ptNodes as $n) {
|
||||
$t = trim($n->textContent);
|
||||
if ($t !== '') $pubTypes[] = $t;
|
||||
}
|
||||
}
|
||||
$pubTypes = array_values(array_unique($pubTypes));
|
||||
|
||||
$journal = $this->xpText($xp, '//PubmedArticle//Journal//Title');
|
||||
|
||||
$year = '';
|
||||
$year = $this->xpText($xp, '//PubmedArticle//JournalIssue//PubDate//Year');
|
||||
if ($year === '') {
|
||||
$medlineDate = $this->xpText($xp, '//PubmedArticle//JournalIssue//PubDate//MedlineDate');
|
||||
if (preg_match('/(19\\d{2}|20\\d{2})/', $medlineDate, $m)) {
|
||||
$year = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
if ($title === '' && $abstract === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'abstract' => $abstract,
|
||||
'mesh_terms' => $mesh,
|
||||
'publication_types' => $pubTypes,
|
||||
'journal' => $journal,
|
||||
'year' => $year,
|
||||
];
|
||||
}
|
||||
|
||||
private function xpText(\DOMXPath $xp, string $query): string
|
||||
{
|
||||
$n = $xp->query($query);
|
||||
if ($n && $n->length > 0) {
|
||||
return trim($n->item(0)->textContent);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function httpGet(string $url): string
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'User-Agent: TMRjournals-PubMed/1.0'
|
||||
]);
|
||||
$res = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
return is_string($res) ? $res : '';
|
||||
}
|
||||
|
||||
private function cacheDir(): string
|
||||
{
|
||||
return rtrim(ROOT_PATH, '/') . '/runtime/pubmed_cache';
|
||||
}
|
||||
|
||||
private function cacheGet(string $key, int $ttlSeconds)
|
||||
{
|
||||
$file = $this->cacheDir() . '/' . $key . '.json';
|
||||
if (!is_file($file)) return null;
|
||||
$mtime = filemtime($file);
|
||||
if (!$mtime || (time() - $mtime) > $ttlSeconds) return null;
|
||||
$raw = @file_get_contents($file);
|
||||
$decoded = json_decode((string)$raw, true);
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function cacheSet(string $key, $value): void
|
||||
{
|
||||
$dir = $this->cacheDir();
|
||||
if (!is_dir($dir)) @mkdir($dir, 0777, true);
|
||||
$file = $dir . '/' . $key . '.json';
|
||||
@file_put_contents($file, json_encode($value, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,54 @@ class QueueJob
|
||||
$job->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化版异常处理(无 Redis 锁场景)。
|
||||
*
|
||||
* 适用于一对一类型的 job:每个 job 处理一个自然唯一的资源(如 log_id/expert_id),
|
||||
* 不需要 Redis 锁也不会重复消费。
|
||||
*
|
||||
* 行为:
|
||||
* - 致命 DB 错误(连接断开等)→ release 任务回队列 + exit(1) 让 supervisor 重启进程
|
||||
* - 普通异常 → 按 attempts 限次重试(默认最多 3 次,超过 delete)
|
||||
*
|
||||
* @param \Exception|\Throwable $e
|
||||
* @param \think\queue\Job $job
|
||||
* @param string $context 日志中显示的上下文标识(如 "log_id=123" / "task_id=45")
|
||||
*/
|
||||
public function handleException($e, $job, $context = '')
|
||||
{
|
||||
$msg = $e->getMessage();
|
||||
$this->log("队列异常 | {$context} | msg={$msg}");
|
||||
|
||||
if ($e instanceof \Exception && $this->isFatalDatabaseError($e)) {
|
||||
$this->log("致命 DB 错误,释放任务并退出进程让 supervisor 重启 | {$context}");
|
||||
$job->release(60);
|
||||
exit(1);
|
||||
}
|
||||
// 兼容 \Throwable(PHP7 fatal 等)
|
||||
if (!($e instanceof \Exception)) {
|
||||
$low = strtolower((string)$msg);
|
||||
$hits = ['mysql server has gone away', 'lost connection to mysql', 'gone away', 'sqlstate[hy000]'];
|
||||
foreach ($hits as $kw) {
|
||||
if (strpos($low, $kw) !== false) {
|
||||
$this->log("致命 DB 错误(Throwable),释放任务并退出进程 | {$context}");
|
||||
$job->release(60);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$attempts = method_exists($job, 'attempts') ? $job->attempts() : 1;
|
||||
if ($attempts >= $this->maxRetries) {
|
||||
$this->log("超过最大重试次数({$this->maxRetries}),删除任务 | {$context}");
|
||||
$job->delete();
|
||||
return;
|
||||
}
|
||||
$delay = $this->getRetryDelay($msg);
|
||||
$this->log("{$delay} 秒后重试({$attempts}/{$this->maxRetries}) | {$context}");
|
||||
$job->release($delay);
|
||||
}
|
||||
|
||||
// 判断是否为需要重启的致命数据库错误
|
||||
private function isFatalDatabaseError(\Exception $e)
|
||||
{
|
||||
|
||||
@@ -80,6 +80,25 @@ class QueueRedis
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除一个或多个 Redis 键(用于重检前清除队列任务 completed 标记)
|
||||
*/
|
||||
public function deleteRedisKeys(array $keys)
|
||||
{
|
||||
$keys = array_values(array_filter($keys, function ($k) {
|
||||
return $k !== null && $k !== '';
|
||||
}));
|
||||
if (empty($keys)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
$this->connect()->del(...$keys);
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 安全释放锁(仅当值匹配时删除)
|
||||
public function releaseRedisLock($key, $value)
|
||||
|
||||
2578
application/common/ReferenceCheckService.php
Normal file
2578
application/common/ReferenceCheckService.php
Normal file
File diff suppressed because it is too large
Load Diff
134
application/common/Scopus.php
Normal file
134
application/common/Scopus.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
namespace app\api\controller;
|
||||
use app\api\controller\Base;
|
||||
|
||||
|
||||
class Scopus extends Base
|
||||
{
|
||||
|
||||
private $sApiKey = '434dcaf0e787b8676745c97687184f71';
|
||||
|
||||
// 获取h指数
|
||||
public function getHindex($aParam = [])
|
||||
{
|
||||
|
||||
$aParam = empty($aParam) ? $this->request->post() : $aParam;
|
||||
// 获取输入参数
|
||||
$sAuthorName = empty($aParam['author_name']) ? '' : $aParam['author_name'];
|
||||
$affiliation = empty($aParam['affiliation']) ? '' : $aParam['affiliation'];
|
||||
|
||||
// 验证输入
|
||||
if (empty($sAuthorName) || empty($affiliation)) {
|
||||
return json_encode(['status' => 2,'msg' => '']);
|
||||
}
|
||||
|
||||
|
||||
|
||||
$url = "https://api.elsevier.com/content/search/author?query=affil(beijing)&count=1";
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'X-ELS-APIKey: ' . $this->sApiKey,
|
||||
'Accept: application/json'
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_FAILONERROR => true
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
var_dump(curl_getinfo($ch));exit;
|
||||
// 第一步:检索作者ID
|
||||
$url = "https://api.elsevier.com/content/search/author";
|
||||
$params = [
|
||||
'query' => "AUTHFIRST({$sAuthorName}) AND AFFIL({$affiliation})",
|
||||
'count' => 1
|
||||
];
|
||||
var_dump($this->sApiKey);exit();
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url . '?' . http_build_query($params),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'X-ELS-APIKey: ' . $this->sApiKey,
|
||||
'Accept: application/json'
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
// CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_FAILONERROR => true
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
echo '<pre>';var_dump($response,curl_getinfo($ch));exit;
|
||||
if (curl_errno($ch)) {
|
||||
throw new \Exception('Scopus API错误: ' . curl_error($ch));
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
throw new \Exception("Scopus API返回错误: HTTP {$httpCode}");
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (empty($data['search-results']['entry'][0]['dc:identifier'])) {
|
||||
throw new \Exception('未找到匹配的作者');
|
||||
}
|
||||
|
||||
// 提取作者ID(从"AUTHOR_ID:7004212771"中分离)
|
||||
$identifier = $data['search-results']['entry'][0]['dc:identifier'];
|
||||
$authorId = explode(':', $identifier)[1];
|
||||
|
||||
// 第二步:获取作者指标
|
||||
$url = "https://api.elsevier.com/content/author/author_id/{$authorId}?view=METRICS";
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'X-ELS-APIKey: ' . $apiKey,
|
||||
'Accept: application/json'
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_FAILONERROR => true
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
if (curl_errno($ch)) {
|
||||
throw new \Exception('指标API错误: ' . curl_error($ch));
|
||||
}
|
||||
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
throw new \Exception("指标API返回错误: HTTP {$httpCode}");
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (empty($data['author-retrieval-response'][0]['h-index'])) {
|
||||
throw new \Exception('未找到h指数数据');
|
||||
}
|
||||
|
||||
// 提取其他作者信息
|
||||
$author = $data['author-retrieval-response'][0];
|
||||
$metrics = [
|
||||
'h_index' => $author['h-index'],
|
||||
'citation_count' => $author['citation-count'] ?? '未知',
|
||||
'document_count' => $author['document-count'] ?? '未知',
|
||||
'coauthors_count' => $author['coauthor-count'] ?? '未知',
|
||||
'affiliation_current' => $author['affiliation-current']['affiliation']['ip-doc']['afdispname'] ?? '未知'
|
||||
];
|
||||
|
||||
return json([
|
||||
'status' => 'success',
|
||||
'author_id' => $authorId,
|
||||
'author_name' => $author['author-profile']['preferred-name']['ce:indexed-name'] ?? '未知',
|
||||
'metrics' => $metrics
|
||||
]);
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
322
application/common/TurnitinService.php
Normal file
322
application/common/TurnitinService.php
Normal file
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
namespace app\common;
|
||||
|
||||
use think\Env;
|
||||
use think\Exception;
|
||||
|
||||
/**
|
||||
* Turnitin Core API (TCA) REST 客户端封装。
|
||||
*
|
||||
* 适用 Crossref Similarity Check 通道(product_name=Crossref)以及标准 TCA 接入。
|
||||
*
|
||||
* 鉴权:Authorization: Bearer <API_KEY>
|
||||
* X-Turnitin-Integration-Name / X-Turnitin-Integration-Version 用于审计
|
||||
*
|
||||
* .env 配置([turnitin] 段):
|
||||
* BASE_URL 形如 https://crossref-12345.turnitin.com/api/v1(不带尾斜杠)
|
||||
* API_KEY 生成的 Bearer token
|
||||
* INTEGRATION_NAME Scope Name(创建 integration 时填的名字)
|
||||
* INTEGRATION_VERSION 自定义版本号,便于审计 e.g. 1.0.0
|
||||
*
|
||||
* API 文档:https://developers.turnitin.com/docs/tca
|
||||
*
|
||||
* 注意:
|
||||
* - 所有方法返回原始 decode 后的数组;HTTP 错误抛 Exception
|
||||
* - 不做任何业务层逻辑(业务层在 PlagiarismService 里)
|
||||
* - 不缓存 token(Bearer 不需要登录,每次请求自带)
|
||||
*/
|
||||
class TurnitinService
|
||||
{
|
||||
private $baseUrl;
|
||||
private $apiKey;
|
||||
private $integrationName;
|
||||
private $integrationVersion;
|
||||
private $timeout = 60;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->baseUrl = rtrim(trim((string)Env::get('turnitin.base_url', '')), '/');
|
||||
$this->apiKey = trim((string)Env::get('turnitin.api_key', ''));
|
||||
$this->integrationName = trim((string)Env::get('turnitin.integration_name', 'tmr'));
|
||||
$this->integrationVersion = trim((string)Env::get('turnitin.integration_version', '1.0.0'));
|
||||
|
||||
if ($this->baseUrl === '' || $this->apiKey === '') {
|
||||
throw new Exception('Turnitin not configured: missing BASE_URL or API_KEY in .env [turnitin] section');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Public API ====================
|
||||
|
||||
/**
|
||||
* 探活 / 拿账户能力
|
||||
* GET /features-enabled
|
||||
*/
|
||||
public function featuresEnabled()
|
||||
{
|
||||
return $this->request('GET', '/features-enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 submission(拿到 id 之后才能上传文件)
|
||||
* POST /submissions
|
||||
*
|
||||
* @param array $meta 必填字段:
|
||||
* - title 论文标题
|
||||
* - owner submission owner 标识符(自定义字符串,比如投稿系统 user_id)
|
||||
* - submitter 提交者标识符(同上)
|
||||
* - eula (可选) ['version' => '...', 'language' => 'en-US', 'accepted_timestamp' => ISO8601]
|
||||
* 如果 features-enabled 返回 require_eula=false 可省略
|
||||
* 可选字段:
|
||||
* - extract_text_only bool
|
||||
* - metadata array 自定义键值,供后续追溯
|
||||
*
|
||||
* @return array 含 id(submission UUID), status, owner, ...
|
||||
*/
|
||||
public function createSubmission($meta)
|
||||
{
|
||||
return $this->request('POST', '/submissions', $meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到 submission
|
||||
* PUT /submissions/{id}/original/{filename}
|
||||
*
|
||||
* @param string $submissionId
|
||||
* @param string $filePath 本地 PDF/DOCX 路径
|
||||
* @param string $filename 传给 Turnitin 的文件名(用于报告显示)
|
||||
* @return array
|
||||
*/
|
||||
public function uploadFile($submissionId, $filePath, $filename = '')
|
||||
{
|
||||
if (!is_file($filePath) || !is_readable($filePath)) {
|
||||
throw new Exception("File not found or not readable: {$filePath}");
|
||||
}
|
||||
if ($filename === '') {
|
||||
$filename = basename($filePath);
|
||||
}
|
||||
$body = file_get_contents($filePath);
|
||||
|
||||
return $this->request(
|
||||
'PUT',
|
||||
'/submissions/' . urlencode($submissionId) . '/original/' . rawurlencode($filename),
|
||||
$body,
|
||||
[
|
||||
'Content-Type' => 'binary/octet-stream',
|
||||
'Content-Disposition' => 'inline; filename="' . $filename . '"',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发 similarity 比对
|
||||
* PUT /submissions/{id}/similarity
|
||||
*
|
||||
* @param string $submissionId
|
||||
* @param array $opts
|
||||
* - generation_settings.search_repositories 默认 ['INTERNET','PUBLICATION','CROSSREF','CROSSREF_POSTED_CONTENT','SUBMITTED_WORK']
|
||||
* - generation_settings.submission_auto_excludes bool
|
||||
* - view_settings.exclude_quotes / exclude_bibliography / exclude_citations / exclude_abstract / exclude_methods bool
|
||||
* - indexing_settings.add_to_index bool 是否把本文加进 SUBMITTED_WORK 索引(一般 true)
|
||||
* @return array
|
||||
*/
|
||||
public function triggerSimilarity($submissionId, $opts = [])
|
||||
{
|
||||
$body = array_merge([
|
||||
'generation_settings' => [
|
||||
'search_repositories' => ['INTERNET', 'PUBLICATION', 'CROSSREF', 'CROSSREF_POSTED_CONTENT', 'SUBMITTED_WORK'],
|
||||
'submission_auto_excludes' => true,
|
||||
'auto_exclude_self_matching_scope' => 'GROUP_CONTEXT',
|
||||
],
|
||||
'view_settings' => [
|
||||
'exclude_quotes' => true,
|
||||
'exclude_bibliography' => true,
|
||||
'exclude_citations' => true,
|
||||
],
|
||||
'indexing_settings' => [
|
||||
'add_to_index' => true,
|
||||
],
|
||||
], $opts);
|
||||
|
||||
return $this->request(
|
||||
'PUT',
|
||||
'/submissions/' . urlencode($submissionId) . '/similarity',
|
||||
$body
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 similarity 状态
|
||||
* GET /submissions/{id}/similarity
|
||||
*
|
||||
* 返回 status: PROCESSING / COMPLETE / ERROR
|
||||
* COMPLETE 时返回 overall_match_percentage / time_requested / time_generated
|
||||
*/
|
||||
public function getSimilarityStatus($submissionId)
|
||||
{
|
||||
return $this->request(
|
||||
'GET',
|
||||
'/submissions/' . urlencode($submissionId) . '/similarity'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取在线查看报告的临时 URL
|
||||
* POST /submissions/{id}/viewer-url
|
||||
*
|
||||
* 返回 viewer_url(数小时有效)
|
||||
*
|
||||
* @param array $viewer 可选 viewer 设置 e.g. ['viewer_default_permission_set' => 'INSTRUCTOR']
|
||||
*/
|
||||
public function getViewerUrl($submissionId, $viewer = [])
|
||||
{
|
||||
$body = array_merge([
|
||||
'viewer_default_permission_set' => 'INSTRUCTOR',
|
||||
'similarity' => [
|
||||
'default_mode' => 'MATCH_OVERVIEW',
|
||||
'view_settings' => ['save_changes' => true],
|
||||
'modes' => ['match_overview' => true, 'all_sources' => true],
|
||||
],
|
||||
'locale' => 'en-US',
|
||||
], $viewer);
|
||||
|
||||
return $this->request(
|
||||
'POST',
|
||||
'/submissions/' . urlencode($submissionId) . '/viewer-url',
|
||||
$body
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发生成 PDF 报告(异步,状态在另一个轮询里看)
|
||||
* POST /submissions/{id}/similarity/pdf
|
||||
*
|
||||
* 返回 id(pdf 报告 ID)
|
||||
*/
|
||||
public function requestPdfReport($submissionId, $opts = [])
|
||||
{
|
||||
$body = array_merge([
|
||||
'locale' => 'en-US',
|
||||
], $opts);
|
||||
|
||||
return $this->request(
|
||||
'POST',
|
||||
'/submissions/' . urlencode($submissionId) . '/similarity/pdf',
|
||||
$body
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 PDF 报告状态
|
||||
* GET /submissions/{id}/similarity/pdf/{pdf_id}/status
|
||||
*
|
||||
* status: PENDING / SUCCESS / FAILED
|
||||
*/
|
||||
public function getPdfReportStatus($submissionId, $pdfId)
|
||||
{
|
||||
return $this->request(
|
||||
'GET',
|
||||
'/submissions/' . urlencode($submissionId) . '/similarity/pdf/' . urlencode($pdfId) . '/status'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 PDF 报告内容(status=SUCCESS 后才可调用)
|
||||
* GET /submissions/{id}/similarity/pdf/{pdf_id}
|
||||
*
|
||||
* 返回 raw PDF binary 字符串;调用方负责落盘
|
||||
*/
|
||||
public function downloadPdfReport($submissionId, $pdfId)
|
||||
{
|
||||
return $this->request(
|
||||
'GET',
|
||||
'/submissions/' . urlencode($submissionId) . '/similarity/pdf/' . urlencode($pdfId),
|
||||
null,
|
||||
[],
|
||||
true // raw response (不 json_decode)
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Internal HTTP layer ====================
|
||||
|
||||
/**
|
||||
* 统一 HTTP 调用
|
||||
*
|
||||
* @param string $method GET/POST/PUT/DELETE
|
||||
* @param string $path 以 / 开头的相对路径,会拼到 baseUrl 后
|
||||
* @param mixed $body array 时按 JSON 编码;string 时直接当 raw body
|
||||
* @param array $extraHeaders 额外 header
|
||||
* @param bool $rawResponse true=返回 raw 字符串;false=json_decode
|
||||
* @return mixed
|
||||
* @throws Exception
|
||||
*/
|
||||
private function request($method, $path, $body = null, $extraHeaders = [], $rawResponse = false)
|
||||
{
|
||||
$url = $this->baseUrl . $path;
|
||||
|
||||
$headers = [
|
||||
'Authorization: Bearer ' . $this->apiKey,
|
||||
'X-Turnitin-Integration-Name: ' . $this->integrationName,
|
||||
'X-Turnitin-Integration-Version: ' . $this->integrationVersion,
|
||||
];
|
||||
|
||||
$payload = null;
|
||||
if ($body !== null) {
|
||||
if (is_array($body)) {
|
||||
$payload = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
} else {
|
||||
$payload = $body;
|
||||
if (!isset($extraHeaders['Content-Type'])) {
|
||||
$headers[] = 'Content-Type: application/octet-stream';
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($extraHeaders as $k => $v) {
|
||||
$headers[] = $k . ': ' . $v;
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_CUSTOMREQUEST => strtoupper($method),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => 15,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
if ($payload !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||
}
|
||||
|
||||
$resp = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($resp === false) {
|
||||
throw new Exception("Turnitin curl error: {$err} (url={$url})");
|
||||
}
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
// 把响应体的前 1k 也带上方便排错
|
||||
$excerpt = mb_substr((string)$resp, 0, 1000);
|
||||
throw new Exception("Turnitin HTTP {$httpCode} {$method} {$path}: {$excerpt}");
|
||||
}
|
||||
|
||||
if ($rawResponse) {
|
||||
return $resp;
|
||||
}
|
||||
// 部分响应可能是 204 No Content
|
||||
if ($resp === '' || $resp === null) {
|
||||
return [];
|
||||
}
|
||||
$data = json_decode($resp, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
// 不是 JSON 也直接抛回原文
|
||||
return $resp;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
105
application/common/UnsubscribeService.php
Normal file
105
application/common/UnsubscribeService.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace app\common;
|
||||
|
||||
use think\Env;
|
||||
|
||||
/**
|
||||
* 退订工具:生成/校验签名 token,构造退订 URL(支持 expert / user 两类受众)。
|
||||
*
|
||||
* .env 配置([unsubscribe] 段):
|
||||
* UNSUBSCRIBE_SECRET 用于签名的密钥(必须自行设置一个长随机字符串)
|
||||
* UNSUBSCRIBE_BASE_URL 退订入口 URL,指向 Unsubscribe 控制器的 index 方法,例如:
|
||||
* https://api.tmrjournals.com/api/Unsubscribe/index
|
||||
*
|
||||
* URL 格式:
|
||||
* {UNSUBSCRIBE_BASE_URL}?k={kind}&id={audience_id}&t={sha256(kind|id|email_lower|secret)}
|
||||
*
|
||||
* kind:
|
||||
* expert → 外部专家库 t_expert.expert_id
|
||||
* user → 系统内部用户 t_user.user_id(编委 / 主编 / 青年编委等)
|
||||
*
|
||||
* 设计要点:
|
||||
* - 不依赖 session:纯 HMAC 风格签名,token 永久有效(直到密钥更换或邮箱变更)。
|
||||
* - 收到请求后服务端按 kind+id 查邮箱,重新计算签名比对,hash_equals 防时序攻击。
|
||||
* - 邮箱大小写敏感性:统一 strtolower 后再签名/校验。
|
||||
*/
|
||||
class UnsubscribeService
|
||||
{
|
||||
const KIND_EXPERT = 'expert';
|
||||
const KIND_USER = 'user';
|
||||
|
||||
/**
|
||||
* 读取签名密钥;未配置时使用一个固定默认值(仅供本地测试,生产必须覆盖)。
|
||||
*/
|
||||
public static function getSecret()
|
||||
{
|
||||
$secret = trim((string)Env::get('unsubscribe.unsubscribe_secret', ''));
|
||||
if ($secret === '') {
|
||||
$secret = 'tmrjournals-default-unsubscribe-secret-change-me';
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取退订入口 URL(指向 Unsubscribe/index);未配置返回空串。
|
||||
*/
|
||||
public static function getBaseUrl()
|
||||
{
|
||||
return rtrim(trim((string)Env::get('unsubscribe.unsubscribe_base_url', '')), '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化 kind;未知值降级为 expert(保持向后兼容)。
|
||||
*/
|
||||
public static function normalizeKind($kind)
|
||||
{
|
||||
$kind = strtolower(trim((string)$kind));
|
||||
return $kind === self::KIND_USER ? self::KIND_USER : self::KIND_EXPERT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签名 token。
|
||||
*/
|
||||
public static function buildToken($kind, $audienceId, $email)
|
||||
{
|
||||
$kind = self::normalizeKind($kind);
|
||||
$audienceId = intval($audienceId);
|
||||
$email = strtolower(trim((string)$email));
|
||||
return hash('sha256', $kind . '|' . $audienceId . '|' . $email . '|' . self::getSecret());
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验签名 token;恒定时间比较,防时序攻击。
|
||||
*/
|
||||
public static function verifyToken($kind, $audienceId, $email, $token)
|
||||
{
|
||||
$kind = self::normalizeKind($kind);
|
||||
$audienceId = intval($audienceId);
|
||||
$email = strtolower(trim((string)$email));
|
||||
$token = strtolower(trim((string)$token));
|
||||
if ($audienceId <= 0 || $email === '' || $token === '') {
|
||||
return false;
|
||||
}
|
||||
$expected = self::buildToken($kind, $audienceId, $email);
|
||||
return hash_equals($expected, $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造完整退订 URL(用于邮件模板变量 {{unsubscribe_url}})。
|
||||
* BASE_URL 未配置或参数无效时返回空串。
|
||||
*/
|
||||
public static function buildUrl($kind, $audienceId, $email)
|
||||
{
|
||||
$kind = self::normalizeKind($kind);
|
||||
$audienceId = intval($audienceId);
|
||||
$email = trim((string)$email);
|
||||
if ($audienceId <= 0 || $email === '') return '';
|
||||
$base = self::getBaseUrl();
|
||||
if ($base === '') return '';
|
||||
return $base
|
||||
. '?k=' . $kind
|
||||
. '&id=' . $audienceId
|
||||
. '&t=' . self::buildToken($kind, $audienceId, $email);
|
||||
}
|
||||
}
|
||||
1271
application/common/service/LLMService.php
Normal file
1271
application/common/service/LLMService.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -172,8 +172,11 @@ return [
|
||||
// 日志保存目录
|
||||
'path' => LOG_PATH,
|
||||
// 日志记录级别
|
||||
'level' => ['log'],
|
||||
"max_files"=>3,
|
||||
'level' => ['error', 'warning', 'info', 'notice', 'debug'],
|
||||
'file_size' => 1024 * 1024 * 2, // 2MB
|
||||
'max_files'=>30,
|
||||
'apart_day' => true,
|
||||
'format' => '[%s][%s] %s',
|
||||
],
|
||||
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
@@ -57,4 +57,6 @@ return [
|
||||
'datetime_format' => 'Y-m-d H:i:s',
|
||||
// 是否需要进行SQL性能分析
|
||||
'sql_explain' => false,
|
||||
// 断线重连(长驻 worker 必开):MySQL wait_timeout 关闭连接后,下一次查询自动重连
|
||||
'break_reconnect' => true,
|
||||
];
|
||||
|
||||
3
sql/add_country_scope_to_promotion_task.sql
Normal file
3
sql/add_country_scope_to_promotion_task.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE t_promotion_task
|
||||
ADD COLUMN target_partitions VARCHAR(32) NOT NULL DEFAULT '' COMMENT '目标分区,逗号分隔,如 1,2,3;空=不限',
|
||||
ADD COLUMN target_country_ids VARCHAR(255) NOT NULL DEFAULT '' COMMENT '单独指定的国家ID,逗号分隔;空=不额外指定';
|
||||
7
sql/add_llm_advised_topics_to_promotion_email_log.sql
Normal file
7
sql/add_llm_advised_topics_to_promotion_email_log.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- 为 t_promotion_email_log 增加第二段 AI 生成字段(推荐投稿方向)
|
||||
-- llm_advised_topics: 大模型生成的"我们特别关注 X/Y/Z 领域"段落,对应模板变量 {{ai_advised_topics}}
|
||||
-- llm_advised_topics_status: 0未处理 1成功 2失败兜底 3跳过(无可用领域)
|
||||
|
||||
ALTER TABLE `t_promotion_email_log`
|
||||
ADD COLUMN `llm_advised_topics` TEXT NULL COMMENT '大模型生成的推荐投稿方向文案' AFTER `llm_description`,
|
||||
ADD COLUMN `llm_advised_topics_status` TINYINT NOT NULL DEFAULT 0 COMMENT 'AI 推荐方向状态: 0未处理 1成功 2失败兜底 3跳过' AFTER `llm_advised_topics`;
|
||||
7
sql/add_llm_fields_to_promotion_email_log.sql
Normal file
7
sql/add_llm_fields_to_promotion_email_log.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- 为 t_promotion_email_log 增加 LLM 生成字段
|
||||
-- llm_description: 大模型生成的个性化描述(用于模板变量 {{llm_description}})
|
||||
-- llm_status: 0=未处理 1=成功 2=失败已回退兜底 3=跳过(缺代表作)
|
||||
|
||||
ALTER TABLE `t_promotion_email_log`
|
||||
ADD COLUMN `llm_description` TEXT NULL COMMENT '大模型生成的个性化描述' AFTER `body_prepared`,
|
||||
ADD COLUMN `llm_status` TINYINT NOT NULL DEFAULT 0 COMMENT 'LLM 处理状态: 0未处理 1成功 2失败兜底 3跳过' AFTER `llm_description`;
|
||||
44
sql/add_plagiarism_check_table.sql
Normal file
44
sql/add_plagiarism_check_table.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
-- 查重任务表(Turnitin TCA / Crossref Similarity Check)
|
||||
--
|
||||
-- 一篇 article 可重复触发多次查重;同一 article 的最近一次显示在编辑详情页。
|
||||
-- state 流转:0 待上传 → 1 上传中 → 2 比对中 → 3 完成 → 4 失败
|
||||
-- 失败可重新触发,会创建新行(保留历史)
|
||||
--
|
||||
-- 报告永久保留:pdf_local_path 指向 runtime/plagiarism/ 下的本地 PDF;
|
||||
-- view_only_url 是 Turnitin 临时签名(数小时过期),过期需重新生成
|
||||
|
||||
DROP TABLE IF EXISTS `t_plagiarism_check`;
|
||||
CREATE TABLE `t_plagiarism_check` (
|
||||
`check_id` INT NOT NULL AUTO_INCREMENT,
|
||||
`article_id` INT NOT NULL DEFAULT 0 COMMENT '关联投稿 t_article.article_id',
|
||||
`journal_id` INT NOT NULL DEFAULT 0 COMMENT '所属期刊(冗余便于按期刊统计)',
|
||||
`triggered_by` INT NOT NULL DEFAULT 0 COMMENT '触发人 user_id(手工触发时编辑的 user_id)',
|
||||
`trigger_source` VARCHAR(32) NOT NULL DEFAULT 'manual' COMMENT 'manual/auto_initial_review/...',
|
||||
`state` TINYINT NOT NULL DEFAULT 0 COMMENT '0待上传 1上传中 2比对中 3完成 4失败',
|
||||
|
||||
-- Turnitin 端的实体 ID
|
||||
`tii_submission_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'Turnitin submission UUID',
|
||||
`tii_report_status` VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'PROCESSING/COMPLETE/ERROR',
|
||||
|
||||
-- 结果
|
||||
`similarity_score` DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '总相似度 %(如 12.34)',
|
||||
`view_only_url` VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '在线查看报告 URL(临时签名)',
|
||||
`view_only_url_expire` INT NOT NULL DEFAULT 0 COMMENT '在线查看 URL 过期时间戳',
|
||||
`pdf_local_path` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '本地缓存的 PDF 报告相对路径',
|
||||
|
||||
-- 文件元数据(上传时记录,便于追踪)
|
||||
`source_file_name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '原始 PDF 文件名',
|
||||
`source_file_size` INT NOT NULL DEFAULT 0 COMMENT '原始 PDF 字节数',
|
||||
|
||||
-- 调试与重试
|
||||
`attempts` INT NOT NULL DEFAULT 0 COMMENT '总轮询/重试次数',
|
||||
`error_msg` VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '失败原因',
|
||||
`raw_response` MEDIUMTEXT COMMENT '最近一次 Turnitin API 原始返回(调试用)',
|
||||
|
||||
`ctime` INT NOT NULL DEFAULT 0,
|
||||
`utime` INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`check_id`),
|
||||
KEY `idx_article` (`article_id`, `state`),
|
||||
KEY `idx_state` (`state`),
|
||||
KEY `idx_tii_submission` (`tii_submission_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COMMENT='Turnitin 查重任务表';
|
||||
9
sql/add_unsubscribed_to_expert.sql
Normal file
9
sql/add_unsubscribed_to_expert.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- 退订功能:t_expert 增加退订标记
|
||||
-- 用户已手动新增 unsubscribed 字段,以下为完整建议建表语句(含索引)
|
||||
-- 0=正常 1=已退订;选人时统一过滤 unsubscribed=0
|
||||
|
||||
ALTER TABLE `t_expert`
|
||||
ADD COLUMN `unsubscribed` TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1已退订' AFTER `state`;
|
||||
|
||||
ALTER TABLE `t_expert`
|
||||
ADD INDEX `idx_unsubscribed` (`unsubscribed`);
|
||||
11
sql/add_user_id_to_promotion_email_log.sql
Normal file
11
sql/add_user_id_to_promotion_email_log.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- 推广邮件日志:增加内部受众 user_id 列
|
||||
-- expert_type=5(外部 expert 库):expert_id 有值,user_id=0
|
||||
-- expert_type∈{1,2,3,4}(内部主编/编委/青年编委/作者):user_id 有值,expert_id=0
|
||||
|
||||
ALTER TABLE `t_promotion_email_log`
|
||||
ADD COLUMN `user_id` INT NOT NULL DEFAULT 0
|
||||
COMMENT '内部受众 user_id;expert_type∈{1..4} 时填,=5 时为 0'
|
||||
AFTER `expert_id`;
|
||||
|
||||
ALTER TABLE `t_promotion_email_log`
|
||||
ADD INDEX `idx_user_send_time` (`user_id`, `send_time`);
|
||||
102
test_plagiarism_e2e.php
Normal file
102
test_plagiarism_e2e.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
/**
|
||||
* Turnitin TCA 端到端连通性测试。
|
||||
*
|
||||
* 用法(在项目根执行):
|
||||
* php test_plagiarism_e2e.php features # 探活
|
||||
* php test_plagiarism_e2e.php submit <article_id> # 用 article 主稿提交查重(手工触发)
|
||||
* php test_plagiarism_e2e.php submit-file <pdf> # 用本地 PDF 提交(不绑定 article)
|
||||
* php test_plagiarism_e2e.php status <check_id> # 查询状态
|
||||
* php test_plagiarism_e2e.php list <article_id> # 列出某 article 的查重记录
|
||||
* php test_plagiarism_e2e.php viewer <check_id> # 取在线查看 URL
|
||||
*
|
||||
* 说明:
|
||||
* submit-file 不会真正落库(仅用于联通验证),它会用 article_id=0 走完整套流程。
|
||||
* submit 会写入 t_plagiarism_check,并把 check_id 打回,再用 status 自己轮询。
|
||||
*/
|
||||
|
||||
define('IS_CLI', true);
|
||||
|
||||
require __DIR__ . '/thinkphp/start.php';
|
||||
|
||||
use think\Db;
|
||||
use app\common\PlagiarismService;
|
||||
use app\common\TurnitinService;
|
||||
|
||||
if ($argc < 2) {
|
||||
echo "Usage: php test_plagiarism_e2e.php <command> [args...]\n";
|
||||
exit(1);
|
||||
}
|
||||
$cmd = $argv[1];
|
||||
|
||||
try {
|
||||
switch ($cmd) {
|
||||
case 'features': {
|
||||
$tii = new TurnitinService();
|
||||
print_r($tii->featuresEnabled());
|
||||
break;
|
||||
}
|
||||
case 'submit': {
|
||||
if ($argc < 3) {
|
||||
echo "Usage: ... submit <article_id>\n";
|
||||
exit(1);
|
||||
}
|
||||
$articleId = intval($argv[2]);
|
||||
$svc = new PlagiarismService();
|
||||
$local = $svc->locateArticleManuscript($articleId);
|
||||
echo "manuscript local path: {$local}\n";
|
||||
$checkId = $svc->submit($articleId, $local, 0, 'cli_test');
|
||||
echo "submitted, check_id = {$checkId}\n";
|
||||
echo "now run: php think queue:work --queue plagiarism --tries=1 -v\n";
|
||||
break;
|
||||
}
|
||||
case 'submit-file': {
|
||||
if ($argc < 3) {
|
||||
echo "Usage: ... submit-file <pdf_path>\n";
|
||||
exit(1);
|
||||
}
|
||||
$path = $argv[2];
|
||||
if (!is_file($path)) {
|
||||
echo "file not exists: {$path}\n";
|
||||
exit(1);
|
||||
}
|
||||
$svc = new PlagiarismService();
|
||||
$checkId = $svc->submit(0, $path, 0, 'cli_test_file');
|
||||
echo "submitted, check_id = {$checkId}\n";
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
if ($argc < 3) {
|
||||
echo "Usage: ... status <check_id>\n";
|
||||
exit(1);
|
||||
}
|
||||
$row = Db::name('plagiarism_check')->where('check_id', intval($argv[2]))->find();
|
||||
print_r($row);
|
||||
break;
|
||||
}
|
||||
case 'list': {
|
||||
if ($argc < 3) {
|
||||
echo "Usage: ... list <article_id>\n";
|
||||
exit(1);
|
||||
}
|
||||
$rows = Db::name('plagiarism_check')->where('article_id', intval($argv[2]))->order('check_id desc')->select();
|
||||
print_r($rows);
|
||||
break;
|
||||
}
|
||||
case 'viewer': {
|
||||
if ($argc < 3) {
|
||||
echo "Usage: ... viewer <check_id>\n";
|
||||
exit(1);
|
||||
}
|
||||
$svc = new PlagiarismService();
|
||||
print_r($svc->refreshViewerUrlFor(intval($argv[2])));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
echo "unknown command: {$cmd}\n";
|
||||
exit(1);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "ERROR: " . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user