Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -10,6 +10,7 @@ use PhpOffice\PhpWord\IOFactory;
|
||||
use app\common\OpenAi;
|
||||
use app\common\CrossrefService;
|
||||
use app\common\PubmedService;
|
||||
use app\common\ArticleParserService;
|
||||
|
||||
/**
|
||||
* @title 文章接口
|
||||
@@ -1181,6 +1182,213 @@ class Article extends Base
|
||||
return jsonSuccess($re);
|
||||
}
|
||||
|
||||
/**
|
||||
* @title 判断作者是否已按审稿意见修改(逐条核对最终版)
|
||||
* @description 异步检测:立即返回检测中,后台执行 LLM;请轮询 getRevisionCommentCheckResult
|
||||
* @param name:article_id type:int require:1 desc:文章id
|
||||
* @param name:type type:string require:0 desc:不传=综合(意见+回复信+修回稿);manuscript/response/comment 为单侧触发
|
||||
* @param name:force type:int require:0 desc:1强制重跑,忽略缓存
|
||||
* @param name:mode type:string require:0 desc:full=逐条核对(默认) quick=仅时间门
|
||||
*
|
||||
* @url /api/Article/checkAuthorRevisedByReview
|
||||
* @method POST
|
||||
*/
|
||||
public function checkAuthorRevisedByReview()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
'article_id' => 'require|number',
|
||||
]);
|
||||
if (!$rule->check($data)) {
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
|
||||
$articleId = intval($data['article_id']);
|
||||
$mode = strtolower(trim((string)($data['mode'] ?? 'full')));
|
||||
$force = !empty($data['force']) ? 1 : 0;
|
||||
|
||||
try {
|
||||
$service = new \app\common\service\RevisionCommentMatchService();
|
||||
$checkType = $service->normalizeCheckType($data['type'] ?? '');
|
||||
if ($mode === 'quick') {
|
||||
$quick = $service->quickCheck($articleId);
|
||||
$quick['check_type'] = $checkType;
|
||||
return jsonSuccess($quick);
|
||||
}
|
||||
|
||||
$result = $service->check($articleId, [
|
||||
'force' => $force,
|
||||
'use_cache' => $force ? 0 : 1,
|
||||
'type' => $checkType,
|
||||
'async' => 1,
|
||||
]);
|
||||
$result['mode'] = 'full';
|
||||
return jsonSuccess($result);
|
||||
} catch (\Exception $e) {
|
||||
return jsonError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @title 获取/触发作者按审稿意见修改的逐条核对结果
|
||||
* @description 有成功结果直接返回;排队/检测中返回 status=1;无任务则投递 RabbitMQ。type 不传=综合检测(意见+回复信+修回稿)
|
||||
* @param name:article_id type:int require:1 desc:文章id
|
||||
* @param name:type type:string require:0 desc:不传=综合;manuscript/response/comment
|
||||
* @param name:force type:int require:0 desc:1强制重新检测,忽略缓存
|
||||
* @url /api/Article/getRevisionCommentCheckResult
|
||||
* @method POST
|
||||
*/
|
||||
public function getRevisionCommentCheckResult()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
'article_id' => 'require|number',
|
||||
]);
|
||||
if (!$rule->check($data)) {
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
$articleId = intval($data['article_id']);
|
||||
$force = !empty($data['force']) ? 1 : 0;
|
||||
|
||||
try {
|
||||
$service = new \app\common\service\RevisionCommentMatchService();
|
||||
$checkType = $service->normalizeCheckType($data['type'] ?? '');
|
||||
|
||||
if (!$force) {
|
||||
// 未传 force:始终返回该文章该类型「最新一条」检测记录的进度/结果
|
||||
$latest = $service->getLatestJob($articleId, $checkType, null);
|
||||
if (!empty($latest)) {
|
||||
$latestStatus = intval($latest['status'] ?? 0);
|
||||
if ($latestStatus === \app\common\service\RevisionCommentMatchService::STATUS_SUCCESS) {
|
||||
$detail = $service->getCheckDetail(intval($latest['id']));
|
||||
$detail['mode'] = 'full';
|
||||
return jsonSuccess($detail);
|
||||
}
|
||||
|
||||
$meta = json_decode((string)($latest['result_json'] ?? ''), true);
|
||||
if (!is_array($meta)) {
|
||||
$meta = [];
|
||||
}
|
||||
if ($latestStatus === \app\common\service\RevisionCommentMatchService::STATUS_FAIL) {
|
||||
return jsonSuccess([
|
||||
'article_id' => $articleId,
|
||||
'check_id' => intval($latest['id']),
|
||||
'check_no' => (string)($latest['check_no'] ?? ''),
|
||||
'check_type' => $checkType,
|
||||
'status' => \app\common\service\RevisionCommentMatchService::STATUS_FAIL,
|
||||
'status_text' => '检测失败',
|
||||
'message' => $meta['message'] ?? ($latest['error_msg'] ?? '检测失败'),
|
||||
'error_msg' => $latest['error_msg'] ?? '',
|
||||
'mode' => 'full',
|
||||
]);
|
||||
}
|
||||
|
||||
return jsonSuccess([
|
||||
'article_id' => $articleId,
|
||||
'check_id' => intval($latest['id']),
|
||||
'check_no' => (string)($latest['check_no'] ?? ''),
|
||||
'check_type' => $checkType,
|
||||
'check_type_label' => $meta['check_type_label'] ?? '',
|
||||
'status' => \app\common\service\RevisionCommentMatchService::STATUS_RUNNING,
|
||||
'status_text' => '检测中',
|
||||
'message' => $meta['message'] ?? '检测进行中,请稍后轮询',
|
||||
'mode' => 'full',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$result = $service->check($articleId, [
|
||||
'force' => $force ? 1 : 0,
|
||||
'use_cache' => $force ? 0 : 1,
|
||||
'type' => $checkType,
|
||||
'async' => 1,
|
||||
]);
|
||||
$result['mode'] = 'full';
|
||||
return jsonSuccess($result);
|
||||
} catch (\Exception $e) {
|
||||
return jsonError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @title 审稿意见修改检测可视化报告页
|
||||
* @description 渲染检测报告页面;页面内可触发检测并实时查看逐条结果
|
||||
* @url /api/Article/revisionCommentCheckReport
|
||||
* @method GET
|
||||
*/
|
||||
public function revisionCommentCheckReport()
|
||||
{
|
||||
$apiGetResult = '/index.php/api/Article/getRevisionCommentCheckResult';
|
||||
$apiGetDetail = '/index.php/api/Article/getRevisionCommentCheckDetail';
|
||||
$this->assign([
|
||||
'api_get_result' => $apiGetResult,
|
||||
'api_get_detail' => $apiGetDetail,
|
||||
'init_article_id' => intval($this->request->param('article_id', 0)),
|
||||
'init_check_id' => intval($this->request->param('check_id', 0)),
|
||||
'init_type' => (string)$this->request->param('type', 'all'),
|
||||
]);
|
||||
return $this->fetch('article/revision_comment_check_report');
|
||||
}
|
||||
|
||||
/**
|
||||
* @title 审稿意见修改检测记录列表
|
||||
* @description 仅返回某篇文章最新一条检测记录摘要(含明细条数);详情用 getRevisionCommentCheckDetail
|
||||
* @param name:article_id type:int require:1 desc:文章id
|
||||
* @param name:type type:string require:0 desc:按检测类型过滤,不传=全部
|
||||
* @param name:page type:int require:0 desc:页码,默认1
|
||||
* @param name:page_size type:int require:0 desc:每页条数,默认20
|
||||
* @url /api/Article/listRevisionCommentCheck
|
||||
* @method POST
|
||||
*/
|
||||
public function listRevisionCommentCheck()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
'article_id' => 'require|number',
|
||||
]);
|
||||
if (!$rule->check($data)) {
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
try {
|
||||
$service = new \app\common\service\RevisionCommentMatchService();
|
||||
$list = $service->listChecks(
|
||||
intval($data['article_id']),
|
||||
intval($data['page'] ?? 1),
|
||||
intval($data['page_size'] ?? ($data['pageSize'] ?? 20)),
|
||||
(string)($data['type'] ?? '')
|
||||
);
|
||||
return jsonSuccess($list);
|
||||
} catch (\Exception $e) {
|
||||
return jsonError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @title 审稿意见修改检测记录详情(含每条结果)
|
||||
* @description 按 check_id 返回完整检测结果;items 来自明细表,逐条含意见文本、质量评估、是否落实、证据与理由
|
||||
* @param name:check_id type:int require:1 desc:检测记录id
|
||||
* @url /api/Article/getRevisionCommentCheckDetail
|
||||
* @method POST
|
||||
*/
|
||||
public function getRevisionCommentCheckDetail()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
'check_id' => 'require|number',
|
||||
]);
|
||||
if (!$rule->check($data)) {
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
try {
|
||||
$service = new \app\common\service\RevisionCommentMatchService();
|
||||
$detail = $service->getCheckDetail(intval($data['check_id']));
|
||||
$detail['mode'] = 'full';
|
||||
return jsonSuccess($detail);
|
||||
} catch (\Exception $e) {
|
||||
return jsonError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**获取用户所投的文章
|
||||
* @return \think\response\Json|void
|
||||
@@ -3221,11 +3429,34 @@ class Article extends Base
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文章作者信息
|
||||
* 支持 article_id(查库)或 file_url(解析稿件,不读写库)
|
||||
* @return void
|
||||
*/
|
||||
public function getAuthors()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
|
||||
// 从稿件解析作者(对齐 Contribute 读取逻辑,不入库)
|
||||
if (!empty($data['file_url'])) {
|
||||
$sFileUrl = rtrim(ROOT_PATH, '/') . '/public/' . ltrim(ltrim($data['file_url'], '/'), 'public');
|
||||
if (!file_exists($sFileUrl)) {
|
||||
return jsonError('The uploaded file does not exist');
|
||||
}
|
||||
if (!is_readable($sFileUrl)) {
|
||||
return jsonError('The uploaded file is unreadable');
|
||||
}
|
||||
|
||||
$aDealData = json_decode(ArticleParserService::uploadAndParse($sFileUrl), true);
|
||||
$iStatus = empty($aDealData['status']) ? 0 : $aDealData['status'];
|
||||
if ($iStatus != 1) {
|
||||
return jsonError(empty($aDealData['msg']) ? 'Content parsing failed' : $aDealData['msg']);
|
||||
}
|
||||
$aParseData = empty($aDealData['data']) ? [] : $aDealData['data'];
|
||||
$re['authors'] = $this->buildAuthorsFromParseData($aParseData);
|
||||
return jsonSuccess($re);
|
||||
}
|
||||
|
||||
$rule = new Validate([
|
||||
"article_id" => "require"
|
||||
]);
|
||||
@@ -3946,6 +4177,12 @@ class Article extends Base
|
||||
if (isset($data['is_agree'])) {
|
||||
$update_l['is_agree'] = $data['is_agree'];
|
||||
}
|
||||
if (isset($data['special_num'])) {
|
||||
$update_l['special_num'] = intval($data['special_num']);
|
||||
}
|
||||
if (isset($data['special_title'])) {
|
||||
$update_l['special_title'] = trim((string)$data['special_title']);
|
||||
}
|
||||
|
||||
if(!empty($sArticleSn)){
|
||||
$update_l['accept_sn'] = $sArticleSn;
|
||||
@@ -5737,7 +5974,9 @@ class Article extends Base
|
||||
// if(empty($iJournalId)){
|
||||
// return json_encode(['status' => 2,'msg' => 'Please select a journal']);
|
||||
// }
|
||||
$aArticleInsert = ['journal_id' => $iJournalId,'title' => $sTitle,'state' => -1,'user_id' => $iUserId];
|
||||
$aArticleInsert = ['journal_id' => $iJournalId,'title' => $sTitle,'abstrart' => '','use_ai_explain' => '','state' => -1,'user_id' => $iUserId];
|
||||
if(isset($aParam['special_num']))$aArticleInsert['special_num'] = $aParam['special_num'];
|
||||
if(isset($aParam['special_title']))$aArticleInsert['special_title'] = $aParam['special_title'];
|
||||
$aArticleInsert['is_use_ai'] = 3;
|
||||
$aArticleInsert['is_figure_copyright'] = 3;
|
||||
// $aArticleInsert['is_transfer'] = 3;
|
||||
@@ -5785,7 +6024,7 @@ class Article extends Base
|
||||
if($becomeRev == false){
|
||||
$aParam['is_become_reviewer'] = 2;
|
||||
}
|
||||
$aField = ['is_use_ai','use_ai_explain','is_figure_copyright','is_become_reviewer','approval','approval_file','approval_content','code','is_become_reviewer','is_agree','title','abstrart','keywords','topics','fund','type','journal_id'];//,'title','abstrart','keywords','topics','fund','type','is_transfer',
|
||||
$aField = ['is_use_ai','use_ai_explain','is_figure_copyright','is_become_reviewer','approval','approval_file','approval_content','code','is_become_reviewer','is_agree','title','abstrart','keywords','topics','fund','type','journal_id','special_num','special_title'];//,'title','abstrart','keywords','topics','fund','type','is_transfer',
|
||||
$sMsg = '';
|
||||
$iIsUpdate = 1;
|
||||
foreach ($aField as $key => $value) {
|
||||
@@ -6412,9 +6651,65 @@ class Article extends Base
|
||||
// $update_result = Db::name('article')->where($aWhere)->limit(1)->update($aArticleUpdate);
|
||||
// }
|
||||
//操作日志
|
||||
$aLog = ['article_id' => $iArticleId,'user_id' => $iUserId,'type' => 7,'create_time' => time(),'content' => $sUserAccount . ':Operating the article copyright statement','is_view' => 1];
|
||||
$aLog = ['article_id' => $iArticleId,'user_id' => $iUserId,'type' => 7,'create_time' => time(),'update_time' => time(),'content' => $sUserAccount . ':Operating the article copyright statement','is_view' => 1];
|
||||
Db::name('user_act_log')->insert($aLog);
|
||||
Db::commit();
|
||||
return json_encode(['status' => 1,'msg' => 'success']);
|
||||
}
|
||||
/**
|
||||
* 修改未发表文章对应专刊(仅更新 tougao 本地 t_article)
|
||||
* special_num = journal_special_id,special_title 由前端一并传入
|
||||
*/
|
||||
public function changeArticleSpecialForSubmit()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
'article_id' => 'require',
|
||||
/*'journal_special_id' => 'require',
|
||||
'special_title' => 'require',*/
|
||||
]);
|
||||
if (!$rule->check($data)) {
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
|
||||
$article_info = $this->article_obj->where('article_id', $data['article_id'])->find();
|
||||
if (!$article_info) {
|
||||
return jsonError('article not found');
|
||||
}
|
||||
|
||||
$journalSpecialRaw = isset($data['journal_special_id'])&&$data['journal_special_id'] ? trim((string)$data['journal_special_id']) : '';
|
||||
// journal_special_id 为空:清空专刊信息
|
||||
if ($journalSpecialRaw === '') {
|
||||
$update = [
|
||||
'special_num' => 0,
|
||||
'special_title' => '',
|
||||
];
|
||||
$this->article_obj->where('article_id', $data['article_id'])->update($update);
|
||||
|
||||
return jsonSuccess([
|
||||
'article_id' => intval($data['article_id']),
|
||||
'special_num' => 0,
|
||||
'special_title' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
// journal_special_id 有值:special_title 必填
|
||||
$specialTitle = isset($data['special_title']) ? trim((string)$data['special_title']) : '';
|
||||
if ($specialTitle === '') {
|
||||
return jsonError('special_title is required');
|
||||
}
|
||||
|
||||
$specialId = intval($journalSpecialRaw);
|
||||
$update = [
|
||||
'special_num' => $specialId,
|
||||
'special_title' => $specialTitle,
|
||||
];
|
||||
$this->article_obj->where('article_id', $data['article_id'])->update($update);
|
||||
|
||||
return jsonSuccess([
|
||||
'article_id' => intval($data['article_id']),
|
||||
'special_num' => $specialId,
|
||||
'special_title' => $update['special_title'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,21 @@ class References extends Base
|
||||
public function __construct(\think\Request $request = null) {
|
||||
parent::__construct($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 参考文献堆叠统计可视化页
|
||||
* 访问:/api/References/index?p_article_id=xxx
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$pArticleId = intval($this->request->param('p_article_id', 0));
|
||||
$this->assign([
|
||||
'api_stats_js' => json_encode((string) url('api/References/referenceStackingStats'), JSON_UNESCAPED_SLASHES),
|
||||
'init_p_article_id' => $pArticleId,
|
||||
]);
|
||||
return $this->fetch('references/index');
|
||||
}
|
||||
|
||||
//OPENAI token
|
||||
private $sApiKey = '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';
|
||||
//OPENAI URL
|
||||
@@ -1847,6 +1862,8 @@ class References extends Base
|
||||
|
||||
/**
|
||||
* 参考文献引用堆叠统计(同作者>15%、同刊>20%、自引>10%,实时计算)
|
||||
* 仅读本地库,不实时请求 Crossref/OpenAlex。
|
||||
* 额外返回 author_data_issues:未获取到作者 / 作者信息不全的参考文献列表
|
||||
*
|
||||
* POST/GET: p_article_id(必填)
|
||||
*/
|
||||
@@ -1871,6 +1888,66 @@ class References extends Base
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 参考文献堆叠摘要(展示用)
|
||||
* 返回:同作者名称+比例、同刊名称+比例、自引比例(仅超阈值的同作者/同刊)
|
||||
*
|
||||
* POST/GET: p_article_id(必填)
|
||||
*/
|
||||
public function referenceStackingSummary()
|
||||
{
|
||||
$aParam = $this->request->post();
|
||||
if (empty($aParam)) {
|
||||
$aParam = $this->request->param();
|
||||
}
|
||||
|
||||
$iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']);
|
||||
if ($iPArticleId <= 0) {
|
||||
return json_encode(array('status' => 2, 'msg' => 'Please select an article'));
|
||||
}
|
||||
|
||||
try {
|
||||
$svc = new ReferenceStackingStatsService();
|
||||
$result = $svc->getStackingSummaryByPArticleId($iPArticleId);
|
||||
return jsonSuccess($result);
|
||||
} catch (\Exception $e) {
|
||||
return jsonError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单条参考文献作者信息:先 Crossref/OpenAlex(或兜底 refer.author)同步入库,再返回明细
|
||||
*
|
||||
* POST/GET: p_article_id(必填), p_refer_id(必填)
|
||||
*/
|
||||
public function referenceReferAuthors()
|
||||
{
|
||||
$aParam = $this->request->post();
|
||||
if (empty($aParam)) {
|
||||
$aParam = $this->request->param();
|
||||
}
|
||||
|
||||
$iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']);
|
||||
$iPReferId = empty($aParam['p_refer_id']) ? 0 : intval($aParam['p_refer_id']);
|
||||
if ($iPArticleId <= 0) {
|
||||
return json_encode(array('status' => 2, 'msg' => 'Please select an article'));
|
||||
}
|
||||
if ($iPReferId <= 0) {
|
||||
return jsonError('p_refer_id is required');
|
||||
}
|
||||
|
||||
try {
|
||||
$svc = new ReferenceReferAuthorService();
|
||||
$result = $svc->fetchAuthorsByPReferId($iPArticleId, $iPReferId);
|
||||
if (empty($result['refer'])) {
|
||||
return jsonError('Reference not found');
|
||||
}
|
||||
return jsonSuccess($result);
|
||||
} catch (\Exception $e) {
|
||||
return jsonError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试:同步参考文献作者明细到 t_production_article_refer_author
|
||||
* POST/GET: p_article_id(必填), p_refer_id(可选,仅同步单条), include_authors(可选,默认 0;1 返回作者明细), sleep_ms(可选,默认 120)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2081,7 +2081,149 @@ class User extends Base
|
||||
$re['user'] = $user_info;
|
||||
return jsonSuccess($re);
|
||||
}
|
||||
public function createUserForEditor(){
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
"email"=>"require",
|
||||
"address"=>"require",
|
||||
"firstname"=>"require",
|
||||
"lastname"=>"require",
|
||||
"intro"=>"require"
|
||||
]);
|
||||
if(!$rule->check($data)){
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
$account = isset($data['account'])?$data['account']:$data['email'];
|
||||
$email = $data['email'];
|
||||
$res_once = $this->user_obj->where("account='$account' or email = '$email'")->find();
|
||||
if ($res_once != null) {
|
||||
return json('existence');
|
||||
}
|
||||
Db::startTrans();
|
||||
|
||||
|
||||
$inser_data['account'] = trim($account);
|
||||
$inser_data['password'] = md5(isset($data['password'])?$data['password']:"123456qwe");
|
||||
$inser_data['email'] = $email;
|
||||
if(isset($data['phone']))$inser_data['phone'] = $data['phone'];
|
||||
if(isset($data['orcid']))$inser_data['orcid'] = $data['orcid'];
|
||||
$inser_data['realname'] = $data['firstname']." ".$data['lastname'];
|
||||
$inser_data['icon'] = $data['icon'];
|
||||
$inser_data['ctime'] = time();
|
||||
$inser_data['openid'] = "";
|
||||
$id = $this->user_obj->insertGetId($inser_data);
|
||||
//存入个人额外信息
|
||||
$insert_reviewer['reviewer_id'] = $id;
|
||||
$insert_reviewer['test_from'] = "kzeditor";
|
||||
$insert_reviewer['address'] = $data['address'];
|
||||
$insert_reviewer['introduction'] = $data['intro'];
|
||||
$insert_reviewer['firstname'] = $data['firstname'];
|
||||
$insert_reviewer['lastname'] = $data['lastname'];
|
||||
if(isset($data['website']))$insert_reviewer['website'] = $data['website'];
|
||||
if(isset($data['interests']))$insert_reviewer['interests'] = $data['interests'];
|
||||
$r_res = $this->user_reviewer_info_obj->insert($insert_reviewer);
|
||||
|
||||
if($id&&$r_res){
|
||||
Db::commit();
|
||||
return jsonSuccess($id);
|
||||
}else{
|
||||
Db::rollback();
|
||||
return jsonError("system error");
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 修改客座编辑本地资料
|
||||
* t_user: orcid、phone、realname(firstname lastname)、icon
|
||||
* t_user_reviewer_info: address、interests、firstname、lastname、introduction(intro)
|
||||
*/
|
||||
public function updateUserForEditor()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$rule = new Validate([
|
||||
'user_id' => 'require|number',
|
||||
'firstname' => 'require',
|
||||
'lastname' => 'require',
|
||||
'intro' => 'require',
|
||||
]);
|
||||
if (!$rule->check($data)) {
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
|
||||
$userId = intval($data['user_id']);
|
||||
$user = $this->user_obj->where('user_id', $userId)->where('state', 0)->find();
|
||||
if (!$user) {
|
||||
return jsonError('user not found');
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$firstName = trim((string)$data['firstname']);
|
||||
$lastName = trim((string)$data['lastname']);
|
||||
|
||||
$userUpdate = [
|
||||
'realname' => trim($firstName . ' ' . $lastName),
|
||||
];
|
||||
if (isset($data['orcid'])) {
|
||||
$userUpdate['orcid'] = trim((string)$data['orcid']);
|
||||
}
|
||||
if (isset($data['phone'])) {
|
||||
$userUpdate['phone'] = trim((string)$data['phone']);
|
||||
}
|
||||
if (isset($data['icon'])) {
|
||||
$userUpdate['icon'] = trim((string)$data['icon']);
|
||||
}
|
||||
$upUser = $this->user_obj->where('user_id', $userId)->update($userUpdate);
|
||||
if ($upUser === false) {
|
||||
throw new \Exception('update t_user failed');
|
||||
}
|
||||
|
||||
$reviewerInfo = $this->user_reviewer_info_obj
|
||||
->where('reviewer_id', $userId)
|
||||
->where('state', 0)
|
||||
->find();
|
||||
$reviewerUpdate = [
|
||||
'firstname' => $firstName,
|
||||
'lastname' => $lastName,
|
||||
];
|
||||
if (isset($data['address'])) {
|
||||
$reviewerUpdate['address'] = trim((string)$data['address']);
|
||||
}
|
||||
if (isset($data['website'])) {
|
||||
$reviewerUpdate['website'] = trim((string)$data['website']);
|
||||
}
|
||||
if (isset($data['interests'])) {
|
||||
$reviewerUpdate['interests'] = trim((string)$data['interests']);
|
||||
}
|
||||
$reviewerUpdate['introduction'] = trim((string)$data['intro']);
|
||||
|
||||
if ($reviewerInfo) {
|
||||
$upReviewer = $this->user_reviewer_info_obj
|
||||
->where('reviewer_id', $userId)
|
||||
->where('state', 0)
|
||||
->update($reviewerUpdate);
|
||||
if ($upReviewer === false) {
|
||||
throw new \Exception('update t_user_reviewer_info failed');
|
||||
}
|
||||
} else {
|
||||
$reviewerUpdate['reviewer_id'] = $userId;
|
||||
$reviewerUpdate['test_from'] = 'kzeditor';
|
||||
$insReviewer = $this->user_reviewer_info_obj->insert($reviewerUpdate);
|
||||
if (!$insReviewer) {
|
||||
throw new \Exception('insert t_user_reviewer_info failed');
|
||||
}
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
return jsonSuccess([
|
||||
'user_id' => $userId,
|
||||
'updated' => 1,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return jsonError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册功能
|
||||
*/
|
||||
@@ -3370,4 +3512,17 @@ class User extends Base
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
public function up_editorIcon_file()
|
||||
{
|
||||
$file = request()->file('icon');
|
||||
if ($file) {
|
||||
$info = $file->move(ROOT_PATH . 'public' . DS . 'usericon');
|
||||
if ($info) {
|
||||
return json(['code' => 0, 'upurl' => str_replace("\\", "/", $info->getSaveName())]);
|
||||
} else {
|
||||
return json(['code' => 1, 'msg' => $file->getError()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
674
application/api/view/references/index.html
Normal file
674
application/api/view/references/index.html
Normal file
@@ -0,0 +1,674 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>参考文献堆叠统计</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Fraunces:opsz,wght@9..144,500;9..144,650&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--ink: #14212b;
|
||||
--muted: #5c6b76;
|
||||
--line: #d7e0e6;
|
||||
--accent: #0f766e;
|
||||
--accent-soft: #d9f3ef;
|
||||
--warn: #b45309;
|
||||
--danger: #b91c1c;
|
||||
--ok: #047857;
|
||||
--font: "DM Sans", system-ui, sans-serif;
|
||||
--display: "Fraunces", Georgia, serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
font-family: var(--font);
|
||||
color: var(--ink);
|
||||
line-height: 1.45;
|
||||
background:
|
||||
linear-gradient(160deg, rgba(15, 118, 110, 0.18), transparent 42%),
|
||||
linear-gradient(340deg, rgba(20, 33, 43, 0.08), transparent 40%),
|
||||
#cfd8df;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.window {
|
||||
width: min(520px, 100%);
|
||||
height: min(680px, calc(100vh - 32px));
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(20, 33, 43, 0.12);
|
||||
box-shadow:
|
||||
0 24px 48px rgba(20, 33, 43, 0.18),
|
||||
0 2px 0 rgba(255, 255, 255, 0.5) inset;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px 10px 14px;
|
||||
background: linear-gradient(180deg, #f7fafb, #eef3f6);
|
||||
border-bottom: 1px solid var(--line);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
.titlebar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.traffic {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.traffic i {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
.traffic .r { background: #ff5f57; }
|
||||
.traffic .y { background: #febc2e; }
|
||||
.traffic .g { background: #28c840; }
|
||||
.titlebar h1 {
|
||||
margin: 0;
|
||||
font-family: var(--display);
|
||||
font-size: 0.98rem;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.titlebar .sub {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #fafcfd;
|
||||
}
|
||||
.start-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
input[type="number"] {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
outline: none;
|
||||
}
|
||||
input:focus {
|
||||
border-color: #7bbbb4;
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font: inherit;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
height: 36px;
|
||||
}
|
||||
.btn:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
|
||||
.error {
|
||||
display: none;
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: #fef2f2;
|
||||
color: var(--danger);
|
||||
border: 1px solid #fecaca;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.error.show { display: block; }
|
||||
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
.results { display: none; }
|
||||
.results.show { display: block; }
|
||||
.placeholder {
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
text-align: center;
|
||||
padding: 36px 12px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.stat {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
background: #fafcfd;
|
||||
}
|
||||
.stat .k { font-size: 0.7rem; color: var(--muted); }
|
||||
.stat .v {
|
||||
font-family: var(--display);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 650;
|
||||
margin-top: 2px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat .sub { font-size: 0.72rem; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
.pill.ok { background: #dcfce7; color: var(--ok); }
|
||||
.pill.bad { background: #fee2e2; color: var(--danger); }
|
||||
.pill.warn { background: #ffedd5; color: var(--warn); }
|
||||
|
||||
.section-title {
|
||||
font-size: 0.88rem;
|
||||
margin: 14px 0 8px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.section-title .hint {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.card-list { display: grid; gap: 8px; }
|
||||
.item-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
.item-card h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.meta strong { color: var(--ink); font-weight: 600; }
|
||||
.ratio-bar {
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: #e7eef2;
|
||||
overflow: hidden;
|
||||
margin: 6px 0 8px;
|
||||
}
|
||||
.ratio-bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #0f766e, #14968c);
|
||||
border-radius: inherit;
|
||||
}
|
||||
.ratio-bar.bad > i { background: linear-gradient(90deg, #b91c1c, #dc2626); }
|
||||
|
||||
.ref-chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
padding: 1px 6px;
|
||||
border-radius: 5px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip.warn { background: #ffedd5; color: var(--warn); }
|
||||
.chip.bad { background: #fee2e2; color: var(--danger); }
|
||||
|
||||
.ref-list {
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.ref-list li {
|
||||
font-size: 0.74rem;
|
||||
color: var(--muted);
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: #f7fafb;
|
||||
border: 1px solid #e8eef2;
|
||||
max-height: 3.2em;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ref-list .no {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.ref-list a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.ref-list a:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.chip a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.chip a:hover { text-decoration: underline; }
|
||||
.chip.linkable { cursor: pointer; }
|
||||
|
||||
.empty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fafcfd;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.note {
|
||||
margin-top: 6px;
|
||||
font-size: 0.74rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.foot-meta {
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.statusbar {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: #f7fafb;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="window" role="dialog" aria-label="参考文献堆叠统计">
|
||||
<div class="titlebar">
|
||||
<div class="titlebar-left">
|
||||
<div class="traffic" aria-hidden="true"><i class="r"></i><i class="y"></i><i class="g"></i></div>
|
||||
<div>
|
||||
<h1>参考文献堆叠统计</h1>
|
||||
<div class="sub">同作者 >15% · 同刊 >20% · 自引 >10%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="start-grid">
|
||||
<div>
|
||||
<label for="pArticleId">p_article_id</label>
|
||||
<input id="pArticleId" type="number" min="1" placeholder="例如 3649" value="{$init_p_article_id|default=0}" />
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btnLoad" type="button">查询</button>
|
||||
</div>
|
||||
<div class="error" id="errorBox"></div>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="placeholder" id="placeholder">输入 p_article_id 后点击查询</div>
|
||||
<div class="results" id="results">
|
||||
<div class="summary" id="summary"></div>
|
||||
|
||||
<h2 class="section-title">
|
||||
同作者堆叠
|
||||
<span class="hint" id="authorThreshold"></span>
|
||||
<span class="pill" id="authorPill"></span>
|
||||
</h2>
|
||||
<div id="authorSection"></div>
|
||||
|
||||
<h2 class="section-title">
|
||||
同刊堆叠
|
||||
<span class="hint" id="journalThreshold"></span>
|
||||
<span class="pill" id="journalPill"></span>
|
||||
</h2>
|
||||
<div id="journalSection"></div>
|
||||
|
||||
<h2 class="section-title">
|
||||
自引
|
||||
<span class="hint" id="selfThreshold"></span>
|
||||
<span class="pill" id="selfPill"></span>
|
||||
</h2>
|
||||
<div id="selfSection"></div>
|
||||
|
||||
<h2 class="section-title">
|
||||
作者数据问题
|
||||
<span class="hint">缺失 / 不全</span>
|
||||
</h2>
|
||||
<div id="issueSection"></div>
|
||||
|
||||
<p class="foot-meta" id="footMeta"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="statusbar">
|
||||
<span>Stacking Stats</span>
|
||||
<span id="statusText">就绪</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var API_STATS = {$api_stats_js};
|
||||
var input = document.getElementById('pArticleId');
|
||||
var btn = document.getElementById('btnLoad');
|
||||
var errorBox = document.getElementById('errorBox');
|
||||
var results = document.getElementById('results');
|
||||
var placeholder = document.getElementById('placeholder');
|
||||
var statusText = document.getElementById('statusText');
|
||||
|
||||
if (Number(input.value) === 0) input.value = '';
|
||||
|
||||
function showError(msg) {
|
||||
errorBox.textContent = msg || '请求失败';
|
||||
errorBox.classList.add('show');
|
||||
statusText.textContent = '出错';
|
||||
}
|
||||
function clearError() {
|
||||
errorBox.textContent = '';
|
||||
errorBox.classList.remove('show');
|
||||
}
|
||||
function pct(ratio) {
|
||||
return (Number(ratio || 0) * 100).toFixed(1) + '%';
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
function setPill(el, exceeded) {
|
||||
el.className = 'pill ' + (exceeded ? 'bad' : 'ok');
|
||||
el.textContent = exceeded ? '已超阈值' : '未超阈值';
|
||||
}
|
||||
function renderRefs(list) {
|
||||
if (!list || !list.length) return '';
|
||||
var html = '<ul class="ref-list">';
|
||||
list.forEach(function (r) {
|
||||
var text = escapeHtml(r.refer_text || ('p_refer_id=' + r.p_refer_id));
|
||||
var no = '[' + escapeHtml(r.reference_no) + ']';
|
||||
var url = String(r.url || '').trim();
|
||||
if (url) {
|
||||
html += '<li><a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer" title="打开文献">'
|
||||
+ '<span class="no">' + no + '</span>' + text + '</a></li>';
|
||||
} else {
|
||||
html += '<li><span class="no">' + no + '</span>' + text + '</li>';
|
||||
}
|
||||
});
|
||||
html += '</ul>';
|
||||
return html;
|
||||
}
|
||||
function renderChips(nos, cls, refs) {
|
||||
if (!nos || !nos.length) return '<span class="chip">无</span>';
|
||||
var urlMap = {};
|
||||
(refs || []).forEach(function (r) {
|
||||
if (r && r.reference_no != null && r.url) {
|
||||
urlMap[String(r.reference_no)] = String(r.url);
|
||||
}
|
||||
});
|
||||
return nos.map(function (n) {
|
||||
var url = urlMap[String(n)] || '';
|
||||
var label = '[' + escapeHtml(n) + ']';
|
||||
if (url) {
|
||||
return '<span class="chip linkable ' + (cls || '') + '"><a href="'
|
||||
+ escapeHtml(url) + '" target="_blank" rel="noopener noreferrer" title="打开文献">'
|
||||
+ label + '</a></span>';
|
||||
}
|
||||
return '<span class="chip ' + (cls || '') + '">' + label + '</span>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderAuthor(data) {
|
||||
var block = data.same_author_stacking || {};
|
||||
document.getElementById('authorThreshold').textContent = '阈值 > ' + pct(block.threshold);
|
||||
setPill(document.getElementById('authorPill'), !!block.exceeded);
|
||||
var items = block.items || [];
|
||||
var box = document.getElementById('authorSection');
|
||||
if (!items.length) {
|
||||
box.innerHTML = '<div class="empty">未发现超过阈值的同作者堆叠</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = '<div class="card-list">' + items.map(function (it) {
|
||||
return '<div class="item-card">'
|
||||
+ '<h3>' + escapeHtml(it.author_name || '未知作者') + '</h3>'
|
||||
+ '<div class="meta">'
|
||||
+ '<span>引用 <strong>' + escapeHtml(it.cite_count) + '</strong> 条</span>'
|
||||
+ '<span>占比 <strong>' + pct(it.cite_ratio) + '</strong></span>'
|
||||
+ (it.orcid ? '<span>ORCID <strong>' + escapeHtml(it.orcid) + '</strong></span>' : '')
|
||||
+ '</div>'
|
||||
+ '<div class="ratio-bar bad"><i style="width:' + Math.min(100, Number(it.cite_ratio || 0) * 100) + '%"></i></div>'
|
||||
+ '<div class="ref-chips">' + renderChips(it.reference_nos, '', it.references) + '</div>'
|
||||
+ renderRefs(it.references)
|
||||
+ '</div>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
function renderJournal(data) {
|
||||
var block = data.same_journal_stacking || {};
|
||||
document.getElementById('journalThreshold').textContent = '阈值 > ' + pct(block.threshold);
|
||||
setPill(document.getElementById('journalPill'), !!block.exceeded);
|
||||
var items = block.items || [];
|
||||
var box = document.getElementById('journalSection');
|
||||
if (!items.length) {
|
||||
box.innerHTML = '<div class="empty">未发现超过阈值的同刊堆叠</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = '<div class="card-list">' + items.map(function (it) {
|
||||
return '<div class="item-card">'
|
||||
+ '<h3>' + escapeHtml(it.journal_name || '未知期刊') + '</h3>'
|
||||
+ '<div class="meta">'
|
||||
+ '<span>引用 <strong>' + escapeHtml(it.cite_count) + '</strong> 条</span>'
|
||||
+ '<span>占比 <strong>' + pct(it.cite_ratio) + '</strong></span>'
|
||||
+ '</div>'
|
||||
+ '<div class="ratio-bar bad"><i style="width:' + Math.min(100, Number(it.cite_ratio || 0) * 100) + '%"></i></div>'
|
||||
+ '<div class="ref-chips">' + renderChips(it.reference_nos, '', it.references) + '</div>'
|
||||
+ renderRefs(it.references)
|
||||
+ '</div>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
function renderSelf(data) {
|
||||
var block = data.self_citation || {};
|
||||
document.getElementById('selfThreshold').textContent = '阈值 > ' + pct(block.threshold);
|
||||
setPill(document.getElementById('selfPill'), !!block.exceeded);
|
||||
var box = document.getElementById('selfSection');
|
||||
var items = block.items || [];
|
||||
var head = '<div class="item-card">'
|
||||
+ '<div class="meta">'
|
||||
+ '<span>自引条数 <strong>' + escapeHtml(block.cite_count || 0) + '</strong></span>'
|
||||
+ '<span>占比 <strong>' + pct(block.cite_ratio) + '</strong></span>'
|
||||
+ '</div>'
|
||||
+ '<div class="ratio-bar ' + (block.exceeded ? 'bad' : '') + '"><i style="width:' + Math.min(100, Number(block.cite_ratio || 0) * 100) + '%"></i></div>'
|
||||
+ '<div class="ref-chips">' + renderChips(block.reference_nos, block.exceeded ? 'bad' : '', items.map(function (it) { return it.reference; }).filter(Boolean)) + '</div>'
|
||||
+ (block.note ? '<p class="note">' + escapeHtml(block.note) + '</p>' : '')
|
||||
+ '</div>';
|
||||
|
||||
if (!items.length) {
|
||||
box.innerHTML = head + '<div class="empty" style="margin-top:8px">无自引命中</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = head + '<div class="card-list" style="margin-top:8px">' + items.map(function (it) {
|
||||
return '<div class="item-card">'
|
||||
+ '<h3>参考文献 [' + escapeHtml(it.reference_no) + ']</h3>'
|
||||
+ '<div class="meta">'
|
||||
+ '<span>本文作者 <strong>' + escapeHtml(it.manuscript_author || '-') + '</strong></span>'
|
||||
+ '<span>匹配文献作者 <strong>' + escapeHtml(it.matched_refer_author || '-') + '</strong></span>'
|
||||
+ (it.manuscript_orcid ? '<span>ORCID <strong>' + escapeHtml(it.manuscript_orcid) + '</strong></span>' : '')
|
||||
+ '</div>'
|
||||
+ (it.reference ? renderRefs([it.reference]) : '')
|
||||
+ '</div>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
function renderIssues(data) {
|
||||
var issues = data.author_data_issues || {};
|
||||
var box = document.getElementById('issueSection');
|
||||
var items = issues.items || [];
|
||||
var refsForChips = items.map(function (it) { return it.reference; }).filter(Boolean);
|
||||
var head = '<div class="item-card"><div class="meta">'
|
||||
+ '<span>缺失 <strong>' + escapeHtml(issues.missing_count || 0) + '</strong></span>'
|
||||
+ '<span>不全 <strong>' + escapeHtml(issues.incomplete_count || 0) + '</strong></span>'
|
||||
+ '</div>'
|
||||
+ '<div class="ref-chips" style="margin-top:6px">'
|
||||
+ '<span style="font-size:0.7rem;color:var(--muted);margin-right:4px">缺失:</span>'
|
||||
+ renderChips(issues.missing_reference_nos, 'bad', refsForChips)
|
||||
+ '<span style="font-size:0.7rem;color:var(--muted);margin:0 4px 0 8px">不全:</span>'
|
||||
+ renderChips(issues.incomplete_reference_nos, 'warn', refsForChips)
|
||||
+ '</div></div>';
|
||||
|
||||
if (!items.length) {
|
||||
box.innerHTML = head + '<div class="empty" style="margin-top:8px">作者数据无明显问题</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = head + '<div class="card-list" style="margin-top:8px">' + items.map(function (it) {
|
||||
var cls = it.issue_type === 'missing' ? 'bad' : 'warn';
|
||||
return '<div class="item-card">'
|
||||
+ '<h3>[' + escapeHtml(it.reference_no) + '] '
|
||||
+ '<span class="pill ' + cls + '">' + escapeHtml(it.issue_type) + '</span></h3>'
|
||||
+ '<div class="meta">'
|
||||
+ '<span>原因 <strong>' + escapeHtml(it.reason || '-') + '</strong></span>'
|
||||
+ '<span>来源 <strong>' + escapeHtml(it.author_source || '-') + '</strong></span>'
|
||||
+ '<span>作者数 <strong>' + escapeHtml(it.author_count || 0) + '</strong></span>'
|
||||
+ '</div>'
|
||||
+ (it.raw_author ? '<p class="note">raw_author: ' + escapeHtml(it.raw_author) + '</p>' : '')
|
||||
+ (it.reference ? renderRefs([it.reference]) : '')
|
||||
+ '</div>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
function renderAll(data) {
|
||||
var authorExceeded = !!(data.same_author_stacking && data.same_author_stacking.exceeded);
|
||||
var journalExceeded = !!(data.same_journal_stacking && data.same_journal_stacking.exceeded);
|
||||
var selfExceeded = !!(data.self_citation && data.self_citation.exceeded);
|
||||
var issues = data.author_data_issues || {};
|
||||
|
||||
document.getElementById('summary').innerHTML =
|
||||
'<div class="stat"><div class="k">参考文献总数</div><div class="v">' + escapeHtml(data.total_references || 0) + '</div></div>'
|
||||
+ '<div class="stat"><div class="k">同作者堆叠</div><div class="v">' + ((data.same_author_stacking && data.same_author_stacking.items) || []).length + '</div><div class="sub">' + (authorExceeded ? '已超阈值' : '未超阈值') + '</div></div>'
|
||||
+ '<div class="stat"><div class="k">同刊堆叠</div><div class="v">' + ((data.same_journal_stacking && data.same_journal_stacking.items) || []).length + '</div><div class="sub">' + (journalExceeded ? '已超阈值' : '未超阈值') + '</div></div>'
|
||||
+ '<div class="stat"><div class="k">自引 / 作者问题</div><div class="v">' + escapeHtml((data.self_citation && data.self_citation.cite_count) || 0) + ' / ' + escapeHtml((issues.missing_count || 0) + (issues.incomplete_count || 0)) + '</div><div class="sub">' + (selfExceeded ? '自引已超阈值' : '自引未超阈值') + '</div></div>';
|
||||
|
||||
renderAuthor(data);
|
||||
renderJournal(data);
|
||||
renderSelf(data);
|
||||
renderIssues(data);
|
||||
|
||||
document.getElementById('footMeta').textContent =
|
||||
'p_article_id=' + (data.p_article_id || '-')
|
||||
+ ' · article_id=' + (data.article_id || '-')
|
||||
+ ' · ' + (data.computed_at || '-');
|
||||
|
||||
placeholder.style.display = 'none';
|
||||
results.classList.add('show');
|
||||
statusText.textContent = '共 ' + (data.total_references || 0) + ' 条 · ' + (data.computed_at || '');
|
||||
}
|
||||
|
||||
function load() {
|
||||
clearError();
|
||||
var id = parseInt(input.value, 10) || 0;
|
||||
if (id <= 0) {
|
||||
showError('请填写有效的 p_article_id');
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
statusText.textContent = '查询中';
|
||||
|
||||
var url = API_STATS + (API_STATS.indexOf('?') >= 0 ? '&' : '?') + 'p_article_id=' + encodeURIComponent(id);
|
||||
fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
}).then(function (res) {
|
||||
return res.json();
|
||||
}).then(function (json) {
|
||||
if (!json || Number(json.code) !== 0) {
|
||||
throw new Error((json && json.msg) || '接口返回失败');
|
||||
}
|
||||
renderAll(json.data || {});
|
||||
try {
|
||||
var u = new URL(window.location.href);
|
||||
u.searchParams.set('p_article_id', String(id));
|
||||
window.history.replaceState({}, '', u.toString());
|
||||
} catch (e) {}
|
||||
}).catch(function (err) {
|
||||
results.classList.remove('show');
|
||||
placeholder.style.display = '';
|
||||
showError(err.message || String(err));
|
||||
}).finally(function () {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '查询';
|
||||
});
|
||||
}
|
||||
|
||||
btn.addEventListener('click', load);
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') load();
|
||||
});
|
||||
|
||||
if (parseInt(input.value, 10) > 0) load();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -12,4 +12,6 @@
|
||||
return [
|
||||
'app\\command\\ReferenceCheckMqConsume',
|
||||
'app\\command\\AiWritingRiskMqConsume',
|
||||
'app\\command\\RevisionCommentCheckRun',
|
||||
'app\\command\\RevisionCommentMqConsume',
|
||||
];
|
||||
|
||||
@@ -20,20 +20,28 @@ class ArticleParserService
|
||||
if (!file_exists($filePath)) {
|
||||
return json_encode(['status' => 5, 'msg' => '"文档不存在:{$filePath}"']);
|
||||
}
|
||||
$processedFilePath = null;
|
||||
try {
|
||||
// 含 OMML 公式时先展平:PhpWord 单独 saveXML(m:oMath) 会丢掉 xmlns:m,触发 loadXML 告警
|
||||
$loadPath = $filePath;
|
||||
if ($this->docxContainsOfficeMath($filePath)) {
|
||||
$processedFilePath = $this->removeEmfFromDocx($filePath);
|
||||
$loadPath = $processedFilePath;
|
||||
}
|
||||
|
||||
// 关键配置:关闭“仅读数据”,保留完整节结构
|
||||
$reader = IOFactory::createReader();
|
||||
$reader->setReadDataOnly(false);
|
||||
Settings::setCompatibility(false);
|
||||
Settings::setOutputEscapingEnabled(true); // 避免XML转义冲突
|
||||
|
||||
$doc = $reader->load($filePath);
|
||||
$sectionCount = count($doc->getSections());
|
||||
// $this->log("✅ 文档直接加载成功,节数量:{$sectionCount}");
|
||||
$this->phpWord = $reader->load($filePath);
|
||||
$this->phpWord = $reader->load($loadPath);
|
||||
$this->sections = $this->phpWord->getSections();
|
||||
} catch (\Throwable $e) {
|
||||
// 预处理:移除 EMF、表格内分页符等 PhpWord 不兼容内容后重试
|
||||
// 预处理:移除 EMF、表格内分页符、OMML 公式等 PhpWord 不兼容内容后重试
|
||||
if ($processedFilePath && is_file($processedFilePath)) {
|
||||
@unlink($processedFilePath);
|
||||
}
|
||||
$processedFilePath = $this->removeEmfFromDocx($filePath);
|
||||
$reader = IOFactory::createReader();
|
||||
$reader->setReadDataOnly(false);
|
||||
@@ -42,8 +50,8 @@ class ArticleParserService
|
||||
|
||||
$this->phpWord = $reader->load($processedFilePath);
|
||||
$this->sections = $this->phpWord->getSections();
|
||||
|
||||
if (is_file($processedFilePath)) {
|
||||
} finally {
|
||||
if ($processedFilePath && is_file($processedFilePath)) {
|
||||
@unlink($processedFilePath);
|
||||
}
|
||||
}
|
||||
@@ -77,7 +85,7 @@ class ArticleParserService
|
||||
}
|
||||
}
|
||||
|
||||
// 3.1 清理表格单元格内分页符(PhpWord 无法解析,会抛 Cannot add PageBreak in Cell)
|
||||
// 3.1 清理表格单元格内分页符、展平 OMML 公式(避免 PhpWord OfficeMathML 缺命名空间告警)
|
||||
$this->sanitizePageBreaksInDocxXmlFiles($tempDir);
|
||||
|
||||
// 4. 重新打包为 DOCX
|
||||
@@ -98,7 +106,24 @@ class ArticleParserService
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 word/*.xml 中表格单元格里的分页符,避免 PhpWord 读取失败
|
||||
* 文档是否含 Word OMML 公式(m:oMath / m:oMathPara)
|
||||
*/
|
||||
private function docxContainsOfficeMath($docxPath): bool
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($docxPath) !== true) {
|
||||
return false;
|
||||
}
|
||||
$xml = $zip->getFromName('word/document.xml');
|
||||
$zip->close();
|
||||
if ($xml === false || $xml === '') {
|
||||
return false;
|
||||
}
|
||||
return stripos($xml, 'oMath') !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 word/*.xml:表格内分页符 + OMML 公式展平为纯文本
|
||||
*/
|
||||
private function sanitizePageBreaksInDocxXmlFiles($tempDir)
|
||||
{
|
||||
@@ -113,14 +138,14 @@ class ArticleParserService
|
||||
}
|
||||
|
||||
foreach ($xmlFiles as $xmlPath) {
|
||||
$this->sanitizePageBreaksInWordXmlFile($xmlPath);
|
||||
$this->sanitizeWordXmlFile($xmlPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $xmlPath
|
||||
*/
|
||||
private function sanitizePageBreaksInWordXmlFile($xmlPath)
|
||||
private function sanitizeWordXmlFile($xmlPath)
|
||||
{
|
||||
if (!is_file($xmlPath) || !is_readable($xmlPath)) {
|
||||
return;
|
||||
@@ -131,15 +156,72 @@ class ArticleParserService
|
||||
return;
|
||||
}
|
||||
|
||||
// 缺 xmlns:m 时先补到根节点,便于 DOM 解析
|
||||
if (stripos($xml, 'oMath') !== false && stripos($xml, 'xmlns:m=') === false) {
|
||||
$xml = preg_replace(
|
||||
'/<(w:document|w:hdr|w:ftr|w:footnotes|w:endnotes)\b([^>]*)>/',
|
||||
'<$1$2 xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">',
|
||||
$xml,
|
||||
1
|
||||
);
|
||||
if (!is_string($xml)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$prev = libxml_use_internal_errors(true);
|
||||
$dom = new DOMDocument();
|
||||
$dom->preserveWhiteSpace = true;
|
||||
$dom->formatOutput = false;
|
||||
if (@$dom->loadXML($xml) === false) {
|
||||
$ok = $dom->loadXML($xml);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($prev);
|
||||
if ($ok === false) {
|
||||
// DOM 失败时用正则展平公式,避免 PhpWord 再踩 oMath 命名空间问题
|
||||
$flattened = $this->flattenOfficeMathByRegex($xml);
|
||||
if ($flattened !== $xml) {
|
||||
file_put_contents($xmlPath, $flattened);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$xpath = new DOMXPath($dom);
|
||||
$xpath->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main');
|
||||
$wNs = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||||
$mNs = 'http://schemas.openxmlformats.org/officeDocument/2006/math';
|
||||
$xpath->registerNamespace('w', $wNs);
|
||||
$xpath->registerNamespace('m', $mNs);
|
||||
|
||||
$changed = false;
|
||||
|
||||
// 先处理 oMathPara,再处理剩余 oMath,保留 m:t 可见文本
|
||||
foreach (['//m:oMathPara', '//m:oMath'] as $query) {
|
||||
$nodes = $xpath->query($query);
|
||||
if (!$nodes || $nodes->length === 0) {
|
||||
continue;
|
||||
}
|
||||
for ($i = $nodes->length - 1; $i >= 0; $i--) {
|
||||
$mathNode = $nodes->item($i);
|
||||
if (!$mathNode || !$mathNode->parentNode) {
|
||||
continue;
|
||||
}
|
||||
$text = '';
|
||||
$tNodes = $xpath->query('.//m:t', $mathNode);
|
||||
if ($tNodes) {
|
||||
foreach ($tNodes as $tNode) {
|
||||
$text .= $tNode->textContent;
|
||||
}
|
||||
}
|
||||
$run = $dom->createElementNS($wNs, 'w:r');
|
||||
$tEl = $dom->createElementNS($wNs, 'w:t');
|
||||
$tEl->appendChild($dom->createTextNode($text));
|
||||
if ($text !== '' && preg_match('/^\s|\s$/u', $text)) {
|
||||
$tEl->setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve');
|
||||
}
|
||||
$run->appendChild($tEl);
|
||||
$mathNode->parentNode->replaceChild($run, $mathNode);
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
$queries = [
|
||||
'//w:tc//w:br[@w:type="page"]',
|
||||
@@ -147,7 +229,6 @@ class ArticleParserService
|
||||
'//w:tc//w:pPr/w:pageBreakBefore',
|
||||
];
|
||||
|
||||
$removed = false;
|
||||
foreach ($queries as $query) {
|
||||
$nodes = $xpath->query($query);
|
||||
if (!$nodes || $nodes->length === 0) {
|
||||
@@ -157,16 +238,55 @@ class ArticleParserService
|
||||
$node = $nodes->item($i);
|
||||
if ($node && $node->parentNode) {
|
||||
$node->parentNode->removeChild($node);
|
||||
$removed = true;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($removed) {
|
||||
if ($changed) {
|
||||
file_put_contents($xmlPath, $dom->saveXML());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 正则展平 OMML:保留 m:t 文本,去掉 oMath/oMathPara(DOM 不可用时兜底)
|
||||
*/
|
||||
private function flattenOfficeMathByRegex(string $xml): string
|
||||
{
|
||||
if (stripos($xml, 'oMath') === false) {
|
||||
return $xml;
|
||||
}
|
||||
|
||||
$toRun = function (string $inner): string {
|
||||
$text = '';
|
||||
if (preg_match_all('/<m:t\b[^>]*>([\s\S]*?)<\/m:t>/i', $inner, $m)) {
|
||||
foreach ($m[1] as $part) {
|
||||
$text .= html_entity_decode(strip_tags($part), ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
}
|
||||
}
|
||||
$safe = htmlspecialchars($text, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
$space = ($text !== '' && preg_match('/^\s|\s$/u', $text)) ? ' xml:space="preserve"' : '';
|
||||
return '<w:r><w:t' . $space . '>' . $safe . '</w:t></w:r>';
|
||||
};
|
||||
|
||||
$xml = preg_replace_callback(
|
||||
'/<m:oMathPara\b[^>]*>([\s\S]*?)<\/m:oMathPara>/i',
|
||||
function ($m) use ($toRun) {
|
||||
return $toRun($m[1]);
|
||||
},
|
||||
$xml
|
||||
);
|
||||
$xml = preg_replace_callback(
|
||||
'/<m:oMath\b[^>]*>([\s\S]*?)<\/m:oMath>/i',
|
||||
function ($m) use ($toRun) {
|
||||
return $toRun($m[1]);
|
||||
},
|
||||
is_string($xml) ? $xml : ''
|
||||
);
|
||||
|
||||
return is_string($xml) ? $xml : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归添加目录文件到 ZipArchive
|
||||
* @param string $dir 目录路径
|
||||
|
||||
@@ -246,6 +246,131 @@ class ReferenceReferAuthorService
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条参考文献:外网拉取作者并入库,再返回明细
|
||||
* 有 DOI → Crossref + OpenAlex;失败或无 DOI → 解析 refer.author
|
||||
*
|
||||
* @return array{
|
||||
* p_article_id:int,
|
||||
* p_refer_id:int,
|
||||
* reference_no:int,
|
||||
* doi:string,
|
||||
* synced:int,
|
||||
* refer:array|null,
|
||||
* author_count:int,
|
||||
* authors:array
|
||||
* }
|
||||
*/
|
||||
public function fetchAuthorsByPReferId($pArticleId, $pReferId)
|
||||
{
|
||||
$pArticleId = intval($pArticleId);
|
||||
$pReferId = intval($pReferId);
|
||||
if ($pArticleId <= 0 || $pReferId <= 0) {
|
||||
throw new \InvalidArgumentException('p_article_id and p_refer_id are required');
|
||||
}
|
||||
|
||||
$refer = Db::name('production_article_refer')
|
||||
->where('p_refer_id', $pReferId)
|
||||
->where('p_article_id', $pArticleId)
|
||||
->where('state', 0)
|
||||
->find();
|
||||
|
||||
if (empty($refer)) {
|
||||
return [
|
||||
'p_article_id' => $pArticleId,
|
||||
'p_refer_id' => $pReferId,
|
||||
'reference_no' => 0,
|
||||
'doi' => '',
|
||||
'synced' => 0,
|
||||
'refer' => null,
|
||||
'author_count' => 0,
|
||||
'authors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$refUtil = new ReferenceCheckService();
|
||||
$doi = $refUtil->extractDoiFromRefer($refer);
|
||||
$syncedCount = $this->syncOneRefer($pReferId, $pArticleId, $refer);
|
||||
$result = $this->getAuthorsByPReferId($pArticleId, $pReferId);
|
||||
$result['doi'] = $doi;
|
||||
$result['synced'] = $syncedCount > 0 ? 1 : 0;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 p_article_id + p_refer_id 读取作者明细(只读本地库)
|
||||
*
|
||||
* @return array{p_article_id:int,p_refer_id:int,reference_no:int,refer:array|null,author_count:int,authors:array}
|
||||
*/
|
||||
public function getAuthorsByPReferId($pArticleId, $pReferId)
|
||||
{
|
||||
$pArticleId = intval($pArticleId);
|
||||
$pReferId = intval($pReferId);
|
||||
if ($pArticleId <= 0 || $pReferId <= 0) {
|
||||
throw new \InvalidArgumentException('p_article_id and p_refer_id are required');
|
||||
}
|
||||
|
||||
$refer = Db::name('production_article_refer')
|
||||
->field('p_refer_id,p_article_id,index,author,title,joura,refer_doi,doilink,refer_type')
|
||||
->where('p_refer_id', $pReferId)
|
||||
->where('p_article_id', $pArticleId)
|
||||
->where('state', 0)
|
||||
->find();
|
||||
|
||||
if (empty($refer)) {
|
||||
return [
|
||||
'p_article_id' => $pArticleId,
|
||||
'p_refer_id' => $pReferId,
|
||||
'reference_no' => 0,
|
||||
'refer' => null,
|
||||
'author_count' => 0,
|
||||
'authors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$rows = Db::name('production_article_refer_author')
|
||||
->where('p_article_id', $pArticleId)
|
||||
->where('p_refer_id', $pReferId)
|
||||
->order('author_seq asc, id asc')
|
||||
->field('author_seq,author_position,is_first_author,family,given,display_name,citation_name,orcid,openalex_id,identity_source')
|
||||
->select();
|
||||
|
||||
$authors = [];
|
||||
foreach ($rows as $row) {
|
||||
$authors[] = [
|
||||
'author_seq' => intval($row['author_seq'] ?? 0),
|
||||
'author_position' => (string)($row['author_position'] ?? ''),
|
||||
'is_first_author' => intval($row['is_first_author'] ?? 0),
|
||||
'family' => (string)($row['family'] ?? ''),
|
||||
'given' => (string)($row['given'] ?? ''),
|
||||
'display_name' => (string)($row['display_name'] ?? ''),
|
||||
'citation_name' => (string)($row['citation_name'] ?? ''),
|
||||
'orcid' => (string)($row['orcid'] ?? ''),
|
||||
'openalex_id' => (string)($row['openalex_id'] ?? ''),
|
||||
'identity_source' => (string)($row['identity_source'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'p_article_id' => $pArticleId,
|
||||
'p_refer_id' => $pReferId,
|
||||
'reference_no' => intval($refer['index']) + 1,
|
||||
'refer' => [
|
||||
'p_refer_id' => $pReferId,
|
||||
'reference_no' => intval($refer['index']) + 1,
|
||||
'author' => (string)($refer['author'] ?? ''),
|
||||
'title' => (string)($refer['title'] ?? ''),
|
||||
'joura' => (string)($refer['joura'] ?? ''),
|
||||
'refer_doi' => (string)($refer['refer_doi'] ?? ''),
|
||||
'doilink' => (string)($refer['doilink'] ?? ''),
|
||||
'refer_type' => (string)($refer['refer_type'] ?? ''),
|
||||
],
|
||||
'author_count' => count($authors),
|
||||
'authors' => $authors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取已入库的作者身份(与 ReferenceAuthorIdentityService 结构兼容)
|
||||
*
|
||||
|
||||
@@ -6,33 +6,16 @@ use think\Db;
|
||||
|
||||
/**
|
||||
* 参考文献引用堆叠统计:同刊、同作者、自引(实时计算,不入库)
|
||||
* 仅读本地库/字段,不请求 Crossref/OpenAlex,避免网关超时。
|
||||
*/
|
||||
class ReferenceStackingStatsService
|
||||
{
|
||||
const DETAIL_JOURNAL = 'journal';
|
||||
const DETAIL_AUTHOR = 'author';
|
||||
|
||||
const THRESHOLD_SAME_AUTHOR = 0.15;
|
||||
const THRESHOLD_SAME_JOURNAL = 0.20;
|
||||
const THRESHOLD_SELF_CITATION = 0.10;
|
||||
|
||||
/** @var ReferenceCheckService */
|
||||
private $refUtil;
|
||||
|
||||
/** @var CrossrefService */
|
||||
private $crossref;
|
||||
|
||||
/** @var ReferenceAuthorIdentityService */
|
||||
private $identity;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->refUtil = new ReferenceCheckService();
|
||||
$this->crossref = new CrossrefService([
|
||||
'mailto' => trim((string)\think\Env::get('crossref_mailto', '')),
|
||||
]);
|
||||
$this->identity = new ReferenceAuthorIdentityService();
|
||||
}
|
||||
const THRESHOLD_SAME_AUTHOR = 0.15;//相同作者;
|
||||
const THRESHOLD_SAME_JOURNAL = 0.20;//同一期刊;
|
||||
const THRESHOLD_SELF_CITATION = 0.10;//作者自引;
|
||||
|
||||
/**
|
||||
* 按阈值规则实时统计:同作者(>15%)、同刊(>20%)、自引(>10%)
|
||||
@@ -50,6 +33,44 @@ class ReferenceStackingStatsService
|
||||
return $this->formatThresholdStackingReport($this->compute($pArticleId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 堆叠摘要(展示用):同作者名称+比例、同刊名称+比例、自引比例
|
||||
*
|
||||
* @param int $pArticleId
|
||||
* @return array
|
||||
*/
|
||||
public function getStackingSummaryByPArticleId($pArticleId)
|
||||
{
|
||||
$full = $this->getThresholdStackingByPArticleId($pArticleId);
|
||||
|
||||
$authors = [];
|
||||
foreach ((array)(($full['same_author_stacking']['items'] ?? [])) as $item) {
|
||||
$authors[] = [
|
||||
'name' => (string)($item['author_name'] ?? ''),
|
||||
'ratio' => round(floatval($item['cite_ratio'] ?? 0), 4),
|
||||
];
|
||||
}
|
||||
|
||||
$journals = [];
|
||||
foreach ((array)(($full['same_journal_stacking']['items'] ?? [])) as $item) {
|
||||
$journals[] = [
|
||||
'name' => (string)($item['journal_name'] ?? ''),
|
||||
'ratio' => round(floatval($item['cite_ratio'] ?? 0), 4),
|
||||
];
|
||||
}
|
||||
|
||||
$self = (array)($full['self_citation'] ?? []);
|
||||
|
||||
return [
|
||||
'p_article_id' => intval($full['p_article_id'] ?? 0),
|
||||
'total_references' => intval($full['total_references'] ?? 0),
|
||||
'same_author' => $authors,
|
||||
'same_journal' => $journals,
|
||||
'self_citation_ratio' => round(floatval($self['cite_ratio'] ?? 0), 4),
|
||||
'computed_at' => (string)($full['computed_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $full compute/analyze 或 getStored 的完整结果
|
||||
*/
|
||||
@@ -118,6 +139,11 @@ class ReferenceStackingStatsService
|
||||
];
|
||||
}
|
||||
|
||||
$authorDataIssues = $this->formatAuthorDataIssues(
|
||||
(array)($full['author_data_issues'] ?? []),
|
||||
$referMap
|
||||
);
|
||||
|
||||
return [
|
||||
'p_article_id' => intval($full['p_article_id'] ?? 0),
|
||||
'article_id' => intval($full['article_id'] ?? 0),
|
||||
@@ -141,6 +167,7 @@ class ReferenceStackingStatsService
|
||||
'items' => $selfItems,
|
||||
'note' => (string)($full['author_identity_note'] ?? ''),
|
||||
],
|
||||
'author_data_issues' => $authorDataIssues,
|
||||
'computed_at' => (string)($full['computed_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
@@ -167,21 +194,22 @@ class ReferenceStackingStatsService
|
||||
->order('index asc')
|
||||
->select();
|
||||
|
||||
$manuscriptAuthors = $this->identity->resolveManuscriptAuthors($pArticleId);
|
||||
$manuscriptAuthors = $this->loadManuscriptAuthorsLocal($pArticleId);
|
||||
$ambiguousManuscriptNameKeys = $this->buildAmbiguousManuscriptNameKeys($manuscriptAuthors);
|
||||
$doiCache = [];
|
||||
$referAuthorRowsMap = $this->loadReferAuthorRowsByPArticleId($pArticleId);
|
||||
$referMap = [];
|
||||
|
||||
$journalBuckets = [];
|
||||
$authorBuckets = [];
|
||||
$selfCitationDetails = [];
|
||||
$authorDataIssues = [];
|
||||
|
||||
foreach ($refers as $refer) {
|
||||
$refNo = intval($refer['index']) + 1;
|
||||
$pReferId = intval($refer['p_refer_id']);
|
||||
$referMap[$pReferId] = $refer;
|
||||
|
||||
$meta = $this->resolveReferMeta($refer, $doiCache);
|
||||
$meta = $this->resolveReferMetaLocal($refer);
|
||||
$joura = (string)$meta['joura'];
|
||||
$journalKey = $this->normalizeJournalKey($joura);
|
||||
if ($journalKey !== '') {
|
||||
@@ -199,7 +227,14 @@ class ReferenceStackingStatsService
|
||||
$journalBuckets[$journalKey]['p_refer_ids'][] = $pReferId;
|
||||
}
|
||||
|
||||
$referAuthors = $this->resolveReferAuthorsWithMeta($pReferId, $meta);
|
||||
$dbRows = isset($referAuthorRowsMap[$pReferId]) ? $referAuthorRowsMap[$pReferId] : [];
|
||||
$resolved = $this->resolveReferAuthorsWithMetaDetailed($pReferId, $meta, $dbRows);
|
||||
$referAuthors = $resolved['authors'];
|
||||
$authorIssue = $this->buildAuthorDataIssue($refNo, $pReferId, $meta, $resolved);
|
||||
if ($authorIssue !== null) {
|
||||
$authorDataIssues[] = $authorIssue;
|
||||
}
|
||||
|
||||
$this->accumulateAuthorBuckets($authorBuckets, $referAuthors, $refNo, $pReferId);
|
||||
|
||||
$matchedManuscript = $this->matchManuscriptAuthorForSelfCitation(
|
||||
@@ -238,12 +273,61 @@ class ReferenceStackingStatsService
|
||||
'journal_details' => $journalDetails,
|
||||
'author_details' => $authorDetails,
|
||||
'self_citation_details' => $selfDetails,
|
||||
'author_data_issues' => $authorDataIssues,
|
||||
'refer_map' => $referMap,
|
||||
'author_identity_note' => '同作者堆叠按 citation_name(空则 display_name)姓名精确匹配,同名即同人;本文出现重名作者(如两位 Lin)时跳过该姓名的自引判定。优先读 t_production_article_refer_author,否则解析 refer.author。',
|
||||
'author_identity_note' => '同作者堆叠按 citation_name(空则 display_name)姓名精确匹配,同名即同人;本文出现重名作者(如两位 Lin)时跳过该姓名的自引判定。优先读 t_production_article_refer_author,否则解析 refer.author。统计仅用本地数据,不实时请求 Crossref/OpenAlex。',
|
||||
'computed_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 本文作者(仅本地库,不打 OpenAlex)
|
||||
*
|
||||
* @return array<int, array{display_name:string,orcid:string}>
|
||||
*/
|
||||
private function loadManuscriptAuthorsLocal($pArticleId)
|
||||
{
|
||||
$rows = Db::name('production_article_author')
|
||||
->field('first_name,last_name,author_name,orcid')
|
||||
->where('p_article_id', intval($pArticleId))
|
||||
->where('state', 0)
|
||||
->select();
|
||||
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$first = trim((string)($row['first_name'] ?? ''));
|
||||
$last = trim((string)($row['last_name'] ?? ''));
|
||||
$displayName = ($first !== '' && $last !== '')
|
||||
? trim($first . ' ' . $last)
|
||||
: trim((string)($row['author_name'] ?? ''));
|
||||
$list[] = [
|
||||
'display_name' => $displayName,
|
||||
'orcid' => $this->cleanOrcid($row['orcid'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array>
|
||||
*/
|
||||
private function loadReferAuthorRowsByPArticleId($pArticleId)
|
||||
{
|
||||
$rows = Db::name('production_article_refer_author')
|
||||
->where('p_article_id', intval($pArticleId))
|
||||
->order('p_refer_id asc, author_seq asc, id asc')
|
||||
->field('p_refer_id,display_name,citation_name,orcid')
|
||||
->select();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[intval($row['p_refer_id'])][] = $row;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function formatAuthorBucketDetails(array $buckets, $detailType)
|
||||
{
|
||||
$list = [];
|
||||
@@ -280,23 +364,47 @@ class ReferenceStackingStatsService
|
||||
/**
|
||||
* @return array<int, array{name:string,orcid:string}>
|
||||
*/
|
||||
private function resolveReferAuthorsWithMeta($pReferId, array $meta)
|
||||
private function resolveReferAuthorsWithMeta($pReferId, array $meta, array $dbRows = null)
|
||||
{
|
||||
return $this->resolveReferAuthorsWithMetaDetailed($pReferId, $meta, $dbRows)['authors'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array|null $dbRows 预加载的 production_article_refer_author 行;null 则按 p_refer_id 查询
|
||||
* @return array{
|
||||
* authors:array<int, array{name:string,orcid:string}>,
|
||||
* source:string,
|
||||
* db_row_count:int,
|
||||
* db_usable_count:int,
|
||||
* db_blank_name_count:int,
|
||||
* has_et_al:bool
|
||||
* }
|
||||
*/
|
||||
private function resolveReferAuthorsWithMetaDetailed($pReferId, array $meta, array $dbRows = null)
|
||||
{
|
||||
$pReferId = intval($pReferId);
|
||||
$list = [];
|
||||
$dbRowCount = 0;
|
||||
$dbBlankNameCount = 0;
|
||||
$authorString = (string)($meta['author'] ?? '');
|
||||
$hasEtAl = (bool)preg_match('/\bet\s+al\.?\b/iu', $authorString);
|
||||
|
||||
if ($pReferId > 0) {
|
||||
$rows = Db::name('production_article_refer_author')
|
||||
->where('p_refer_id', $pReferId)
|
||||
->order('author_seq asc, id asc')
|
||||
->field('display_name,citation_name,orcid')
|
||||
->select();
|
||||
foreach ($rows as $row) {
|
||||
if ($dbRows === null) {
|
||||
$dbRows = Db::name('production_article_refer_author')
|
||||
->where('p_refer_id', $pReferId)
|
||||
->order('author_seq asc, id asc')
|
||||
->field('display_name,citation_name,orcid')
|
||||
->select();
|
||||
}
|
||||
$dbRowCount = count($dbRows);
|
||||
foreach ($dbRows as $row) {
|
||||
$name = trim((string)($row['citation_name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = trim((string)($row['display_name'] ?? ''));
|
||||
}
|
||||
if ($name === '') {
|
||||
$dbBlankNameCount++;
|
||||
continue;
|
||||
}
|
||||
$list[] = [
|
||||
@@ -307,17 +415,148 @@ class ReferenceStackingStatsService
|
||||
}
|
||||
|
||||
if (!empty($list)) {
|
||||
return $list;
|
||||
return [
|
||||
'authors' => $list,
|
||||
'source' => 'refer_author_table',
|
||||
'db_row_count' => $dbRowCount,
|
||||
'db_usable_count' => count($list),
|
||||
'db_blank_name_count' => $dbBlankNameCount,
|
||||
'has_et_al' => $hasEtAl,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($this->parseAuthorStringParts((string)($meta['author'] ?? '')) as $name) {
|
||||
foreach ($this->parseAuthorStringParts($authorString) as $name) {
|
||||
$list[] = [
|
||||
'name' => $name,
|
||||
'orcid' => '',
|
||||
];
|
||||
}
|
||||
|
||||
return $list;
|
||||
return [
|
||||
'authors' => $list,
|
||||
'source' => empty($list) ? 'none' : 'author_string',
|
||||
'db_row_count' => $dbRowCount,
|
||||
'db_usable_count' => 0,
|
||||
'db_blank_name_count' => $dbBlankNameCount,
|
||||
'has_et_al' => $hasEtAl,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* authors:array,
|
||||
* source:string,
|
||||
* db_row_count:int,
|
||||
* db_usable_count:int,
|
||||
* db_blank_name_count:int,
|
||||
* has_et_al:bool
|
||||
* } $resolved
|
||||
* @return array|null
|
||||
*/
|
||||
private function buildAuthorDataIssue($refNo, $pReferId, array $meta, array $resolved)
|
||||
{
|
||||
$authors = (array)($resolved['authors'] ?? []);
|
||||
$authorCount = count($authors);
|
||||
$source = (string)($resolved['source'] ?? 'none');
|
||||
$dbRowCount = intval($resolved['db_row_count'] ?? 0);
|
||||
$dbBlankNameCount = intval($resolved['db_blank_name_count'] ?? 0);
|
||||
$hasEtAl = !empty($resolved['has_et_al']);
|
||||
$rawAuthor = trim((string)($meta['author'] ?? ''));
|
||||
|
||||
if ($authorCount <= 0) {
|
||||
$reason = '未获取到作者';
|
||||
if ($dbRowCount > 0 && $dbBlankNameCount > 0) {
|
||||
$reason = '作者明细表有记录但姓名均为空';
|
||||
} elseif ($rawAuthor !== '') {
|
||||
$reason = '作者字段无法解析出有效姓名';
|
||||
}
|
||||
|
||||
return [
|
||||
'issue_type' => 'missing',
|
||||
'reason' => $reason,
|
||||
'reference_no' => intval($refNo),
|
||||
'p_refer_id' => intval($pReferId),
|
||||
'author_source' => $source,
|
||||
'author_count' => 0,
|
||||
'raw_author' => $rawAuthor,
|
||||
'meta_source' => (string)($meta['meta_source'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$reasons = [];
|
||||
if ($dbRowCount > 0 && $dbBlankNameCount > 0) {
|
||||
$reasons[] = '部分作者姓名为空(空姓名 ' . $dbBlankNameCount . ' 条)';
|
||||
}
|
||||
// refer.author 常因引用格式截断带 et al.;明细表已有完整作者时不再判为不全
|
||||
if ($hasEtAl && $source !== 'refer_author_table') {
|
||||
$reasons[] = '作者列表含 et al.,可能不全';
|
||||
}
|
||||
|
||||
if (empty($reasons)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'issue_type' => 'incomplete',
|
||||
'reason' => implode(';', $reasons),
|
||||
'reference_no' => intval($refNo),
|
||||
'p_refer_id' => intval($pReferId),
|
||||
'author_source' => $source,
|
||||
'author_count' => $authorCount,
|
||||
'raw_author' => $rawAuthor,
|
||||
'meta_source' => (string)($meta['meta_source'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $issues
|
||||
* @param array<int,array> $referMap
|
||||
* @return array{
|
||||
* missing_count:int,
|
||||
* incomplete_count:int,
|
||||
* missing_reference_nos:int[],
|
||||
* incomplete_reference_nos:int[],
|
||||
* items:array
|
||||
* }
|
||||
*/
|
||||
private function formatAuthorDataIssues(array $issues, array $referMap)
|
||||
{
|
||||
$missing = [];
|
||||
$incomplete = [];
|
||||
$items = [];
|
||||
|
||||
usort($issues, function ($a, $b) {
|
||||
return intval($a['reference_no'] ?? 0) <=> intval($b['reference_no'] ?? 0);
|
||||
});
|
||||
|
||||
foreach ($issues as $issue) {
|
||||
$pReferId = intval($issue['p_refer_id'] ?? 0);
|
||||
$item = [
|
||||
'issue_type' => (string)($issue['issue_type'] ?? ''),
|
||||
'reason' => (string)($issue['reason'] ?? ''),
|
||||
'reference_no' => intval($issue['reference_no'] ?? 0),
|
||||
'p_refer_id' => $pReferId,
|
||||
'author_source' => (string)($issue['author_source'] ?? ''),
|
||||
'author_count' => intval($issue['author_count'] ?? 0),
|
||||
'raw_author' => (string)($issue['raw_author'] ?? ''),
|
||||
'meta_source' => (string)($issue['meta_source'] ?? ''),
|
||||
'reference' => $this->buildReferBriefs([$pReferId], $referMap)[0] ?? null,
|
||||
];
|
||||
$items[] = $item;
|
||||
if ($item['issue_type'] === 'missing') {
|
||||
$missing[] = $item['reference_no'];
|
||||
} elseif ($item['issue_type'] === 'incomplete') {
|
||||
$incomplete[] = $item['reference_no'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'missing_count' => count($missing),
|
||||
'incomplete_count' => count($incomplete),
|
||||
'missing_reference_nos' => array_values($missing),
|
||||
'incomplete_reference_nos' => array_values($incomplete),
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -462,7 +701,7 @@ class ReferenceStackingStatsService
|
||||
/**
|
||||
* @param int[] $pReferIds
|
||||
* @param array<int,array> $referMap
|
||||
* @return array<int,array{p_refer_id:int,reference_no:int,refer_text:string}>
|
||||
* @return array<int,array{p_refer_id:int,reference_no:int,refer_text:string,doi:string,url:string}>
|
||||
*/
|
||||
private function buildReferBriefs(array $pReferIds, array $referMap)
|
||||
{
|
||||
@@ -473,16 +712,62 @@ class ReferenceStackingStatsService
|
||||
continue;
|
||||
}
|
||||
$refer = $referMap[$pReferId];
|
||||
$doi = $this->extractReferDoi($refer);
|
||||
$list[] = [
|
||||
'p_refer_id' => $pReferId,
|
||||
'reference_no' => intval($refer['index'] ?? 0) + 1,
|
||||
'refer_text' => $this->referSnippet($refer),
|
||||
'doi' => $doi,
|
||||
'url' => $this->buildReferOpenUrl($refer, $doi),
|
||||
];
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从参考文献行提取 DOI(去前缀)
|
||||
*/
|
||||
private function extractReferDoi(array $refer)
|
||||
{
|
||||
foreach (['refer_doi', 'doilink'] as $field) {
|
||||
$raw = trim((string)($refer[$field] ?? ''));
|
||||
if ($raw === '') {
|
||||
continue;
|
||||
}
|
||||
$raw = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', $raw);
|
||||
$raw = trim($raw, " \t\n\r\0\x0B/");
|
||||
if ($raw !== '' && stripos($raw, '10.') !== false) {
|
||||
if (preg_match('#(10\.\d{4,9}/[^\s]+)#i', $raw, $m)) {
|
||||
return rtrim($m[1], '.,;');
|
||||
}
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 可跳转打开的文献链接:优先 DOI,其次 doilink 若已是 URL
|
||||
*/
|
||||
private function buildReferOpenUrl(array $refer, $doi = '')
|
||||
{
|
||||
$doi = trim((string)$doi);
|
||||
if ($doi === '') {
|
||||
$doi = $this->extractReferDoi($refer);
|
||||
}
|
||||
if ($doi !== '') {
|
||||
return 'https://doi.org/' . $doi;
|
||||
}
|
||||
|
||||
$doilink = trim((string)($refer['doilink'] ?? ''));
|
||||
if ($doilink !== '' && preg_match('#^https?://#i', $doilink)) {
|
||||
return $doilink;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function loadReferMapByPArticleId($pArticleId)
|
||||
{
|
||||
$pArticleId = intval($pArticleId);
|
||||
@@ -540,7 +825,7 @@ class ReferenceStackingStatsService
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 refer 行已有字段、Crossref、refer_frag/refer_content 解析结果
|
||||
* 仅用本地字段 + refer_frag/refer_content 解析,不请求 Crossref
|
||||
*
|
||||
* @return array{
|
||||
* author:string,
|
||||
@@ -551,7 +836,7 @@ class ReferenceStackingStatsService
|
||||
* resolved:bool
|
||||
* }
|
||||
*/
|
||||
private function resolveReferMeta(array $refer, array &$doiCache)
|
||||
private function resolveReferMetaLocal(array $refer)
|
||||
{
|
||||
$author = trim(trim((string)($refer['author'] ?? '')), '.');
|
||||
$joura = trim(trim((string)($refer['joura'] ?? '')), '.');
|
||||
@@ -563,38 +848,6 @@ class ReferenceStackingStatsService
|
||||
$authorKeys = $this->extractAuthorKeysFromAuthorString($author);
|
||||
}
|
||||
|
||||
$doi = $this->refUtil->extractDoiFromRefer($refer);
|
||||
$summary = null;
|
||||
if ($doi !== '') {
|
||||
if (!array_key_exists($doi, $doiCache)) {
|
||||
try {
|
||||
$doiCache[$doi] = $this->crossref->fetchWorkSummary($doi);
|
||||
} catch (\Throwable $e) {
|
||||
$doiCache[$doi] = null;
|
||||
}
|
||||
}
|
||||
$summary = $doiCache[$doi];
|
||||
}
|
||||
|
||||
if (is_array($summary)) {
|
||||
$sources[] = 'crossref';
|
||||
if ($joura === '' && trim((string)($summary['joura'] ?? '')) !== '') {
|
||||
$joura = trim((string)$summary['joura']);
|
||||
}
|
||||
$crossrefKeys = $this->authorKeysFromCrossrefMessage($summary['raw'] ?? []);
|
||||
if ($author === '') {
|
||||
$citationAuthor = $this->crossref->getAuthorsCitation($summary['raw'] ?? [], 3);
|
||||
if ($citationAuthor !== '') {
|
||||
$author = $citationAuthor;
|
||||
}
|
||||
$authorKeys = !empty($crossrefKeys)
|
||||
? $crossrefKeys
|
||||
: $this->extractAuthorKeysFromAuthorString($author);
|
||||
} elseif (!empty($crossrefKeys)) {
|
||||
$authorKeys = array_values(array_unique(array_merge($authorKeys, $crossrefKeys)));
|
||||
}
|
||||
}
|
||||
|
||||
if ($joura === '' || $author === '') {
|
||||
$fragParsed = $this->parseStructuredReferText($refer);
|
||||
if (is_array($fragParsed)) {
|
||||
@@ -668,37 +921,6 @@ class ReferenceStackingStatsService
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function authorKeysFromCrossrefMessage(array $message)
|
||||
{
|
||||
$keys = [];
|
||||
if (empty($message['author']) || !is_array($message['author'])) {
|
||||
return $keys;
|
||||
}
|
||||
|
||||
foreach ($message['author'] as $author) {
|
||||
if (!is_array($author)) {
|
||||
continue;
|
||||
}
|
||||
$family = trim((string)($author['family'] ?? ''));
|
||||
$given = trim((string)($author['given'] ?? ''));
|
||||
if ($family === '' && $given === '') {
|
||||
$org = trim((string)($author['name'] ?? ''));
|
||||
if ($org !== '') {
|
||||
$keys[] = $this->authorKeyFromCitationPart($org);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($family !== '') {
|
||||
$keys[] = mb_strtoupper($family) . '|' . $this->givenToInitials($given);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($keys)));
|
||||
}
|
||||
|
||||
private function referSnippet(array $refer)
|
||||
{
|
||||
foreach (['refer_content', 'refer_frag'] as $field) {
|
||||
@@ -805,23 +1027,6 @@ class ReferenceStackingStatsService
|
||||
return $key;
|
||||
}
|
||||
|
||||
private function givenToInitials($given)
|
||||
{
|
||||
$given = trim((string)$given);
|
||||
if ($given === '') {
|
||||
return '';
|
||||
}
|
||||
$parts = preg_split('/[\s\-\.]+/u', $given, -1, PREG_SPLIT_NO_EMPTY);
|
||||
$initials = '';
|
||||
foreach ($parts as $part) {
|
||||
$first = mb_substr($part, 0, 1);
|
||||
if ($first !== '') {
|
||||
$initials .= mb_strtoupper($first);
|
||||
}
|
||||
}
|
||||
return $initials;
|
||||
}
|
||||
|
||||
private function cleanOrcid($orcid)
|
||||
{
|
||||
$orcid = trim((string)$orcid);
|
||||
|
||||
@@ -26,6 +26,7 @@ class UserActLog
|
||||
return ['status' => 2, 'msg' => '非法操作'];
|
||||
}
|
||||
$aInsert['create_time'] = time();
|
||||
$aInsert['update_time'] = time();
|
||||
$result = Db::name('user_act_log')->insertGetId($aInsert);
|
||||
if(empty($result)){
|
||||
return ['status' => 3, 'msg' => '数据插入失败'.Db::getLastSql()."\n数据内容:",'data' => $aParam];
|
||||
|
||||
@@ -27,4 +27,10 @@ class RabbitMqConfig
|
||||
$cfg = self::get('ai_writing_risk', []);
|
||||
return is_array($cfg) ? $cfg : [];
|
||||
}
|
||||
|
||||
public static function revisionComment()
|
||||
{
|
||||
$cfg = self::get('revision_comment', []);
|
||||
return is_array($cfg) ? $cfg : [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ class ReferenceCheckArticleWorker
|
||||
}
|
||||
$this->svc->log('ReferenceCheckArticleWorker start p_article_id=' . $pArticleId . ' batch_id=' . $batchId);
|
||||
|
||||
// 快照本批待处理 id:联合引用组长一次会整组落库,循环计数会小于 total_count,收尾按快照回填
|
||||
$trackedIds = $this->listPendingCheckIds($pArticleId);
|
||||
$done = 0;
|
||||
$failed = 0;
|
||||
while (true) {
|
||||
@@ -84,12 +86,58 @@ class ReferenceCheckArticleWorker
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($trackedIds)) {
|
||||
$stats = $this->summarizeTrackedCheckIds($trackedIds);
|
||||
$done = intval($stats['done']);
|
||||
$failed = intval($stats['failed']);
|
||||
}
|
||||
$this->finalizeBatch($batchId, $done, $failed);
|
||||
$this->svc->log('ReferenceCheckArticleWorker done p_article_id=' . $pArticleId . ' batch_id=' . $batchId . ' done=' . $done . ' failed=' . $failed);
|
||||
|
||||
$this->publishNextWaitingBatch();
|
||||
}
|
||||
|
||||
private function listPendingCheckIds($pArticleId)
|
||||
{
|
||||
$rows = Db::name('article_reference_relevance_check_result')
|
||||
->where('p_article_id', intval($pArticleId))
|
||||
->where('queue_status', ReferenceRelevanceCheckService::QUEUE_PENDING)
|
||||
->where('status', ReferenceRelevanceCheckService::RECORD_PENDING)
|
||||
->field('id')
|
||||
->select();
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$id = intval(isset($row['id']) ? $row['id'] : 0);
|
||||
if ($id > 0) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private function summarizeTrackedCheckIds(array $checkIds)
|
||||
{
|
||||
$checkIds = array_values(array_filter(array_map('intval', $checkIds)));
|
||||
if (empty($checkIds)) {
|
||||
return ['done' => 0, 'failed' => 0];
|
||||
}
|
||||
$rows = Db::name('article_reference_relevance_check_result')
|
||||
->whereIn('id', $checkIds)
|
||||
->field('id,status')
|
||||
->select();
|
||||
$done = 0;
|
||||
$failed = 0;
|
||||
foreach ($rows as $row) {
|
||||
$st = intval(isset($row['status']) ? $row['status'] : -1);
|
||||
if ($st === ReferenceRelevanceCheckService::RECORD_COMPLETED) {
|
||||
$done++;
|
||||
} elseif ($st === ReferenceRelevanceCheckService::RECORD_FAILED) {
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
return ['done' => $done, 'failed' => $failed];
|
||||
}
|
||||
|
||||
private function canStartArticleWork($batchId)
|
||||
{
|
||||
$running = Db::name('article_reference_relevance_check_batch')
|
||||
@@ -182,18 +230,25 @@ class ReferenceCheckArticleWorker
|
||||
return;
|
||||
}
|
||||
$total = intval($batch['total_count']);
|
||||
$done = intval($done);
|
||||
$failed = intval($failed);
|
||||
// 快照回填后若实际终态条数多于入队 total,抬升 total 保持一致
|
||||
if (($done + $failed) > $total) {
|
||||
$total = $done + $failed;
|
||||
}
|
||||
$status = self::BATCH_DONE;
|
||||
if ($failed > 0) {
|
||||
$status = self::BATCH_PARTIAL_FAILED;
|
||||
}
|
||||
Db::name('article_reference_relevance_check_batch')->where('id', intval($batchId))->update([
|
||||
'batch_status' => $status,
|
||||
'done_count' => intval($done),
|
||||
'failed_count' => intval($failed),
|
||||
'total_count' => $total,
|
||||
'done_count' => $done,
|
||||
'failed_count' => $failed,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
if ($total > 0 && ($done + $failed) < $total) {
|
||||
$this->svc->log('ReferenceCheckArticleWorker batch_id=' . $batchId . ' incomplete total=' . $total);
|
||||
$this->svc->log('ReferenceCheckArticleWorker batch_id=' . $batchId . ' incomplete total=' . $total . ' done=' . $done . ' failed=' . $failed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,9 +119,10 @@ class LLMService
|
||||
*
|
||||
* @param array $messages OpenAI messages
|
||||
* @param float $temperature
|
||||
* @param int|null $timeoutSec 可选覆盖超时秒数
|
||||
* @return string|null 助手回复正文
|
||||
*/
|
||||
public function requestChat(array $messages, $temperature = 0)
|
||||
public function requestChat(array $messages, $temperature = 0, $timeoutSec = null)
|
||||
{
|
||||
if ($this->url === '' || $this->model === '') {
|
||||
\think\Log::warning('LLM requestChat: url or model not configured');
|
||||
@@ -132,7 +133,15 @@ class LLMService
|
||||
'temperature' => $temperature,
|
||||
'messages' => $messages,
|
||||
];
|
||||
return $this->postChat($payload);
|
||||
$oldTimeout = $this->timeout;
|
||||
if ($timeoutSec !== null && intval($timeoutSec) > 0) {
|
||||
$this->timeout = max(30, intval($timeoutSec));
|
||||
}
|
||||
try {
|
||||
return $this->postChat($payload);
|
||||
} finally {
|
||||
$this->timeout = $oldTimeout;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,4 +20,12 @@ return [
|
||||
'dlq' => 'ai_writing_risk.task.dlq',
|
||||
'route_key' => 'task.start',
|
||||
],
|
||||
|
||||
// 作者按审稿意见修改检测:串行消费(prefetch=1),失败 ack 不重试
|
||||
'revision_comment' => [
|
||||
'exchange' => 'revision_comment',
|
||||
'queue' => 'revision_comment.task',
|
||||
'dlq' => 'revision_comment.task.dlq',
|
||||
'route_key' => 'check.start',
|
||||
],
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user