diff --git a/.env b/.env index aa55d762..c7f7c3d5 100644 --- a/.env +++ b/.env @@ -66,6 +66,8 @@ static_root="/home/wwwroot/api.tmrjournals.com/public" [journal] ;官网服务器地址 base_url = http://journalapi.tmrjournals.com/public/index.php +;base_url =http://192.168.110.131/journal/public/index.php +journal_image_url = http://192.168.110.131/journal/public/ [gpt] api_key = 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 diff --git a/application/api/controller/Article.php b/application/api/controller/Article.php index 70371bb7..6272c181 100644 --- a/application/api/controller/Article.php +++ b/application/api/controller/Article.php @@ -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'], + ]); + } } diff --git a/application/api/controller/References.php b/application/api/controller/References.php index 97ae255e..71695d70 100644 --- a/application/api/controller/References.php +++ b/application/api/controller/References.php @@ -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) diff --git a/application/api/controller/Special.php b/application/api/controller/Special.php index e80110ce..6685d75c 100644 --- a/application/api/controller/Special.php +++ b/application/api/controller/Special.php @@ -1009,4 +1009,1225 @@ class Special extends Controller $aResult = object_to_array(json_decode(myPost1($sUrl,$aParam))); return $aResult; } + + /** + * 获取期刊的专刊列表 + */ + public function getSpecialList(){ + $data = $this->request->post(); + $rule = new Validate([ + 'journal_id' => 'require' + ]); + if (!$rule->check($data)) { + return jsonError($rule->getError()); + } + // 分页参数:pageIndex=页码,pageSize=每页条数 + $iPage = isset($data['pageIndex']) ? intval($data['pageIndex']) : 1; + $iSize = isset($data['pageSize']) ? intval($data['pageSize']) : 15; + if ($iPage < 1) { + $iPage = 1; + } + if ($iSize < 1) { + $iSize = 15; + } + $journal_id = $data['journal_id']; + $base_url = Env::get('journal.base_url'); + $journal_image_url = Env::get('journal.journal_image_url'); + $state = isset($data['state'])?$data['state']:-1; + $gwData = [ + 'journal_id' => $journal_id, + 'pageIndex' => $iPage, + 'pageSize' => $iSize, + 'state' => $state, + ]; + + $res = object_to_array(json_decode(myPost($base_url . "/master/Special/getSpecialList", $gwData))); + // master 接口通常直接返回 specials/count,也兼容 data 包裹结构 + $specials = isset($res['specials']) ? $res['specials'] : (isset($res['data']['specials']) ? $res['data']['specials'] : []); + foreach ($specials as $k => $v) { + unset($specials[$k]['abstract']); + $specials[$k]['icon'] = $specials[$k]['icon']?$journal_image_url."specialIcon/".$specials[$k]['icon']:""; + } + $re['specials'] = $specials; + $re['count'] = isset($res['data']['count']) ? intval($res['data']['count']) : 0; + $re['pageIndex'] = $iPage; + $re['pageSize'] = $iSize; + return jsonSuccess($re); + } + /** + * 获取期刊的专刊列表 + */ + public function getSpecialDetail(){ + $data = $this->request->post(); + $rule = new Validate([ + 'journal_special_id' => 'require' + ]); + if (!$rule->check($data)) { + return jsonError($rule->getError()); + } + + $journal_special_id = $data['journal_special_id']; + $base_url = Env::get('journal.base_url'); + $res = object_to_array(json_decode(myPost($base_url . "/master/Special/getSpecialDetail", ['journal_special_id' => $journal_special_id]))); + $specialInfo = isset($res['data'])?$res['data']:[]; + $editorsGw = isset($specialInfo['editors']) && is_array($specialInfo['editors']) ? $specialInfo['editors'] : []; + // 客座编辑信息以投稿系统本地数据为准 + $localEditors = $this->user_to_special_obj + ->alias('uts') + ->field('u.user_id,u.account,u.email,u.realname,u.icon,u.orcid,u.g_website,r.firstname,r.lastname,r.address,r.introduction,r.website,r.interests,r.company,r.department,r.country') + ->join('t_user u', 'u.user_id = uts.user_id', 'left') + ->join('t_user_reviewer_info r', 'r.reviewer_id = u.user_id AND r.state = 0', 'left') + ->where('uts.special_id', intval($journal_special_id)) + ->where('uts.uts_state', 0) + ->where('u.state', 0) + ->select(); + $specialInfo['editors'] = empty($localEditors) ? [] : $localEditors; + + // editors_gw:去掉与本地 editors 邮箱相同的项,并去除邮箱字段 + $localEmails = []; + foreach ($specialInfo['editors'] as $localEditor) { + $email = trim((string)(is_array($localEditor) ? ($localEditor['email'] ?? '') : ($localEditor['email'] ?? ''))); + if ($email !== '') { + $localEmails[strtolower($email)] = true; + } + } + $filteredGw = []; + foreach ($editorsGw as $gwEditor) { + if (!is_array($gwEditor)) { + continue; + } + $gwEmail = trim((string)($gwEditor['email'] ?? '')); + if ($gwEmail !== '' && isset($localEmails[strtolower($gwEmail)])) { + continue; + } + unset($gwEditor['email']); + $filteredGw[] = $gwEditor; + } + $specialInfo['editors_gw'] = $filteredGw; + unset($specialInfo['applys']); + return jsonSuccess($specialInfo); + } + + /** + * 添加专刊 + * 投稿系统:一专刊最多一个客座编辑(hasEditor=1 时绑定一个 user_id) + * 官网:可存在多个客座编辑,hasEditor=1 时会同步创建/关联一个 + */ + public function addSpecial(){ + $data = $this->request->post(); + $rule = new Validate([ + 'journal_id' => 'require', + 'title' => 'require', + 'abstract' => 'require', +// 'intro' => 'require', + 'keywords' => 'require', + 'deadline' => 'require', + ]); + if (!$rule->check($data)) { + return jsonError($rule->getError()); + } + + $hasEditor = $this->parseHasEditor(isset($data['hasEditor']) ? $data['hasEditor'] : 0); + if ($hasEditor) { + $editorRule = new Validate([ + 'user_id' => 'require|number', + /*'email' => 'require', + 'first_name' => 'require', + 'last_name' => 'require', + 'address' => 'require', + 'intro' => 'require',*/ + ]); + if (!$editorRule->check($data)) { + return jsonError($editorRule->getError()); + } + } + + $base_url = Env::get('journal.base_url'); + // 注意:官网 journal 的 /api/Special/addSpecial 在 hasEditor=1 时会同时创建专刊+客座编辑+关系 + $remoteData = [ + 'journal_id' => $data['journal_id'], + 'title' => $data['title'], + // 专刊封面(由前端先调 up_icon_file 拿到路径后传入) + 'specialIcon' => isset($data['specialIcon']) ? trim((string)$data['specialIcon']) : '', + 'abstract' => $data['abstract'], + 'intro' => $data['intro'], + 'keywords' => $data['keywords'], + 'deadline' => $data['deadline'], + // 同步 hasEditor 标记到官网;非 1 时不传/不存客座编辑信息 + 'hasEditor' => $hasEditor ? 1 : 0, + ]; + + $userId = 0; + if ($hasEditor) { + $userId = intval($data['user_id']); + // t_user + t_user_reviewer_info 联查,组装官网客座编辑 + $user = $this->user_obj + ->alias('u') + ->field('u.user_id,u.email,u.realname,u.icon,u.orcid,u.g_website,r.company,r.country,r.website,r.field,r.address,r.introduction,r.department,r.firstname,r.lastname,r.interests') + ->join('t_user_reviewer_info r', 'r.reviewer_id = u.user_id AND r.state = 0', 'LEFT') + ->where('u.user_id', $userId) + ->where('u.state', 0) + ->find(); + if (!$user) { + return jsonError('guest editor user not found'); + } + + $realname = trim(isset($user['realname']) ? $user['realname'] : ''); + $nameParts = preg_split('/\s+/', $realname, 2); + $addressParts = array_filter([ + trim(isset($user['company']) ? $user['company'] : ''), + trim(isset($user['department']) ? $user['department'] : ''), + trim(isset($user['country']) ? $user['country'] : ''), + ]); + $specialIcon = ''; + // 客座编辑头像 + $iconPath = trim(isset($user['icon']) ? $user['icon'] : ''); + if ($iconPath !== '') { + $normalized = $this->normalizeJournalSpecialIconPath($iconPath); + if ($normalized !== '') { + $specialIcon = $normalized; + } else { + $iconSync = $this->uploadGuestEditorIconToJournal($iconPath, $base_url); + if (!$iconSync['ok']) { + return jsonError('sync guest editor icon failed: ' . $iconSync['msg']); + } + $specialIcon = $iconSync['special_icon']; + } + } + + $remoteData['email'] = trim(isset($user['email']) ? $user['email'] : ''); + $remoteData['first_name'] = !empty($user['firstname']) ? $user['firstname'] : (isset($nameParts[0]) ? $nameParts[0] : ''); + $remoteData['last_name'] = !empty($user['lastname']) ? $user['lastname'] : (isset($nameParts[1]) ? $nameParts[1] : ''); + $remoteData['address'] = !empty($user['address']) ? $user['address'] : (!empty($addressParts) ? implode(', ', $addressParts) : '-'); + $remoteData['interests'] = isset($user['interests']) ? trim($user['interests']) : ''; + $remoteData['website'] = trim(!empty($user['website']) ? $user['website'] : (isset($user['g_website']) ? $user['g_website'] : '')); + $remoteData['orcid'] = trim(isset($user['orcid']) ? $user['orcid'] : ''); + // 客座编辑头像 + $remoteData['icon'] = $specialIcon; + } + + $raw = myPost($base_url . "/api/Special/addSpecial", $remoteData); + $res = object_to_array(json_decode($raw)); + if (!isset($res['code']) || intval($res['code']) !== 0) { + $msg = isset($res['msg']) ? $res['msg'] : ('add special failed: ' . (is_string($raw) ? $raw : 'remote empty')); + return jsonError($msg); + } + $specialInfo = isset($res['data']) ? $res['data'] : []; + + $specialInfo['guest_editor_bound'] = 0; + $specialInfo['guest_editor_synced'] = 0; + $specialInfo['hasEditor'] = $hasEditor ? 1 : 0; + $specialId = isset($specialInfo['journal_special_id']) ? intval($specialInfo['journal_special_id']) : 0; + if ($specialId <= 0) { + $specialId = $this->resolveCreatedSpecialIdFromJournal($base_url, $data); + } + if ($specialId <= 0) { + return jsonError('add special success but missing journal_special_id'); + } + $specialInfo['special_id'] = $specialId; + + // hasEditor=1:同步 tougao 客座身份(官网侧 addSpecial 已完成编辑与关系写入) + if ($hasEditor) { + $check = $this->user_to_special_obj + ->where('user_id', $userId) + ->where('special_id', $specialId) + ->where('uts_state', 0) + ->find(); + if (!$check) { + $insertId = $this->user_to_special_obj->insertGetId([ + 'user_id' => $userId, + 'special_id' => $specialId, + 'uts_ctime' => time(), + 'uts_state' => 0, + ]); + if (!$insertId) { + return jsonError('bind guest editor to special failed'); + } + } + $specialInfo['guest_editor_synced'] = 1; + $specialInfo['guest_editor_bound'] = 1; + $specialInfo['user_id'] = $userId; + } + + return jsonSuccess($specialInfo); + } + + /** + * 解析前端“是否有客座编辑”参数 + */ + private function parseHasEditor($value) + { + if (is_bool($value)) { + return $value; + } + if (is_numeric($value)) { + return intval($value) === 1; + } + $value = strtolower(trim(strval($value))); + return in_array($value, ['1', 'true', 'yes', 'y'], true); + } + + /** + * 将投稿系统的用户头像上传到官网 specialIcon 目录 + * @return array{ok:bool,msg:string,special_icon:string} + */ + private function uploadGuestEditorIconToJournal($iconPath, $baseUrl) + { + $localPath = $this->resolveGuestEditorIconLocalPath($iconPath); + if ($localPath === '' || !is_file($localPath)) { + return ['ok' => false, 'msg' => 'icon file not found: ' . $iconPath, 'special_icon' => '']; + } + + $mime = function_exists('mime_content_type') ? mime_content_type($localPath) : ''; + if ($mime === '' || $mime === false) { + $mime = 'application/octet-stream'; + } + $originName = basename($localPath); + $url = rtrim($baseUrl, '/') . '/api/Special/up_icon_file'; + $postFields = [ + 'specialIcon' => new \CURLFile($localPath, $mime, $originName), + ]; + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + $raw = curl_exec($ch); + $err = curl_error($ch); + curl_close($ch); + + if ($raw === false || $raw === '') { + return ['ok' => false, 'msg' => ($err !== '' ? $err : 'upload icon failed'), 'special_icon' => '']; + } + + $res = object_to_array(json_decode($raw)); + if (!isset($res['code']) || intval($res['code']) !== 0) { + $msg = isset($res['msg']) ? $res['msg'] : 'upload icon failed'; + return ['ok' => false, 'msg' => $msg, 'special_icon' => '']; + } + $upurl = trim(isset($res['upurl']) ? $res['upurl'] : ''); + if ($upurl === '') { + return ['ok' => false, 'msg' => 'journal up_icon_file returned empty upurl', 'special_icon' => '']; + } + $upurl = ltrim(str_replace('\\', '/', $upurl), '/'); + return ['ok' => true, 'msg' => 'success', 'special_icon' => 'specialIcon/' . $upurl]; + } + + /** + * 解析投稿系统头像相对路径/URL到本地文件路径 + */ + private function resolveGuestEditorIconLocalPath($iconPath) + { + $iconPath = trim((string)$iconPath); + if ($iconPath === '') { + return ''; + } + + // 绝对路径 + if (preg_match('/^([a-zA-Z]:[\\\\\/]|\/)/', $iconPath) && is_file($iconPath)) { + return $iconPath; + } + + // URL 场景:尝试取 path 映射到本地 ROOT/public + if (preg_match('#^https?://#i', $iconPath)) { + $path = parse_url($iconPath, PHP_URL_PATH); + if (!empty($path)) { + $candidate = rtrim(ROOT_PATH, '/\\') . ltrim($path, '/\\'); + if (is_file($candidate)) { + return $candidate; + } + $candidate = rtrim(ROOT_PATH, '/\\') . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . ltrim($path, '/\\'); + if (is_file($candidate)) { + return $candidate; + } + } + } + + // 相对路径场景:优先按 public 目录解析 + $relative = ltrim($iconPath, '/\\'); + $relativeWithoutPublic = preg_replace('#^public[\/\\\\]#i', '', $relative); + $relativeWithoutUsericon = preg_replace('#^usericon[\/\\\\]#i', '', $relativeWithoutPublic); + $candidates = [ + rtrim(ROOT_PATH, '/\\') . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'usericon' . DIRECTORY_SEPARATOR . $relativeWithoutUsericon, + rtrim(ROOT_PATH, '/\\') . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . $relativeWithoutPublic, + rtrim(ROOT_PATH, '/\\') . DIRECTORY_SEPARATOR . $relative, + ]; + foreach ($candidates as $candidate) { + if (is_file($candidate)) { + return $candidate; + } + } + return ''; + } + + /** + * 若入参已是官网 specialIcon 路径,直接标准化返回;否则返回空字符串 + */ + private function normalizeJournalSpecialIconPath($iconPath) + { + $iconPath = trim((string)$iconPath); + if ($iconPath === '') { + return ''; + } + $path = str_replace('\\', '/', $iconPath); + // 已是相对路径 specialIcon/xxx + if (stripos($path, 'specialIcon/') === 0) { + return 'specialIcon/' . ltrim(substr($path, strlen('specialIcon/')), '/'); + } + // 可能是官网完整 URL,提取 /specialIcon/ 后半段 + if (preg_match('#/specialIcon/(.+)$#i', $path, $m)) { + return 'specialIcon/' . ltrim($m[1], '/'); + } + return ''; + } + + /** + * 官网 addSpecial 成功但未返回 journal_special_id 时,按条件反查 + */ + private function resolveCreatedSpecialIdFromJournal($baseUrl, array $data) + { + $journalId = intval(isset($data['journal_id']) ? $data['journal_id'] : 0); + $title = trim(isset($data['title']) ? $data['title'] : ''); + $deadline = trim(isset($data['deadline']) ? $data['deadline'] : ''); + if ($journalId <= 0 || $title === '') { + return 0; + } + + // 1) 优先走精确查询接口(不受 state=2 过滤影响) + $rawUnique = myPost(rtrim($baseUrl, '/') . '/api/Special/getSpecialIdByUnique', [ + 'journal_id' => $journalId, + 'title' => $title, + 'deadline' => $deadline, + ]); + $resUnique = object_to_array(json_decode($rawUnique)); + if (isset($resUnique['code']) && intval($resUnique['code']) === 0) { + $id = intval(isset($resUnique['data']['journal_special_id']) ? $resUnique['data']['journal_special_id'] : 0); + if ($id > 0) { + return $id; + } + } + + // 2) API 端(仅 state=2) + $raw = myPost(rtrim($baseUrl, '/') . '/api/Special/getSpecialsNew', [ + 'journal_id' => $journalId, + 'pageIndex' => 1, + 'pageSize' => 30, + ]); + $res = object_to_array(json_decode($raw)); + $specials = isset($res['data']['specials']) && is_array($res['data']['specials']) ? $res['data']['specials'] : []; + $id = $this->pickSpecialIdByTitleDeadline($specials, $title, $deadline); + if ($id > 0) { + return $id; + } + + // 3) Master 端(state<>1),覆盖“刚创建未到 state=2”场景 + $rawMaster = myPost(rtrim($baseUrl, '/') . '/master/Special/getSpecialList', [ + 'journal_id' => $journalId, + 'state' => -1, + 'pageIndex' => 1, + 'pageSize' => 50, + ]); + $resMaster = object_to_array(json_decode($rawMaster)); + $specialsMaster = isset($resMaster['specials']) && is_array($resMaster['specials']) ? $resMaster['specials'] : []; + if (empty($specialsMaster) && isset($resMaster['data']['specials']) && is_array($resMaster['data']['specials'])) { + $specialsMaster = $resMaster['data']['specials']; + } + $id = $this->pickSpecialIdByTitleDeadline($specialsMaster, $title, $deadline); + if ($id > 0) { + return $id; + } + + return 0; + } + + /** + * 按 title/deadline 从列表里挑选 journal_special_id + */ + private function pickSpecialIdByTitleDeadline(array $specials, $title, $deadline) + { + if (empty($specials)) { + return 0; + } + foreach ($specials as $sp) { + if (trim((string)($sp['title'] ?? '')) === $title + && trim((string)($sp['deadline'] ?? '')) === $deadline + ) { + return intval($sp['journal_special_id'] ?? 0); + } + } + foreach ($specials as $sp) { + if (trim((string)($sp['title'] ?? '')) === $title) { + return intval($sp['journal_special_id'] ?? 0); + } + } + return 0; + } + /** + * 获取已开通的专刊列表(代理 journal getSpecials,state=2) + * 入参为投稿系统 journal_id:先按 ISSN 换官网 journal_id,再调官网接口 + */ + public function getSpecials() + { + $data = $this->request->post(); + $rule = new Validate([ + 'journal_id' => 'require|number', + ]); + if (!$rule->check($data)) { + return jsonError($rule->getError()); + } + + $localJournalId = intval($data['journal_id']); + $journalInfo = $this->journal_obj->where('journal_id', $localJournalId)->find(); + if (!$journalInfo) { + return jsonError('journal not found'); + } + $issn = trim(isset($journalInfo['issn']) ? (string)$journalInfo['issn'] : ''); + if ($issn === '') { + return jsonError('journal issn is empty'); + } + + $resolved = $this->resolveSiteJournalIdByIssn($issn); + if (!$resolved['ok']) { + return jsonError($resolved['msg']); + } + $siteJournalId = intval($resolved['journal_id']); + + $base_url = Env::get('journal.base_url'); + $res = object_to_array(json_decode(myPost(rtrim($base_url, '/') . '/api/Special/getSpecials', [ + 'journal_id' => $siteJournalId, + ]))); + if (!isset($res['code']) || intval($res['code']) !== 0) { + $msg = isset($res['msg']) ? $res['msg'] : 'get specials failed'; + return jsonError($msg); + } + + $re = isset($res['data']) ? $res['data'] : []; + if (!isset($re['specials'])) { + $re['specials'] = []; + } + if (!isset($re['is_show'])) { + $re['is_show'] = count($re['specials']) > 0 ? 'true' : 'false'; + } + return jsonSuccess($re); + } + + /** + * 编辑专刊基础信息(代理 journal master/Special/editSpecialBasic) + */ + public function editSpecialBasic() + { + $data = $this->request->post(); + $rule = new Validate([ + 'journal_special_id' => 'require|number', + 'title' => 'require', + 'intro' => 'require', + 'abstract' => 'require', + 'keywords' => 'require', + 'deadline' => 'require', + ]); + if (!$rule->check($data)) { + return jsonError($rule->getError()); + } + + $base_url = Env::get('journal.base_url'); + $postData = [ + 'journal_special_id' => intval($data['journal_special_id']), + 'title' => trim((string)$data['title']), + 'intro' => trim((string)$data['intro']), + // 专刊封面 + 'icon' => isset($data['specialIcon']) ? trim((string)$data['specialIcon']) : '', + 'abstract' => trim((string)$data['abstract']), + 'keywords' => trim((string)$data['keywords']), + 'deadline' => trim((string)$data['deadline']), + ]; + $raw = myPost($base_url . "/master/Special/editSpecialBasic", $postData); + $res = object_to_array(json_decode($raw)); + if (!isset($res['code']) || intval($res['code']) !== 0) { + $msg = isset($res['msg']) ? $res['msg'] : 'edit special basic failed'; + return jsonError($msg); + } + return jsonSuccess([]); + } + + /** + * 修改专刊信息(同步官网)+ 客座编辑信息(仅更新投稿系统本地) + * 专刊:调用 journal master/Special/editSpecialBasic + * 投稿系统一专刊仅一个客座编辑:hasEditor=1 时若已有其他绑定则先解绑旧编辑(本地+官网),再绑定传入 user_id + * 官网允许一专刊多编辑,替换时只处理被替换的那条关系 + * hasEditor=1 时 user_id 必填;hasEditor 改为 0 时解除本地该专刊全部绑定,并调官网 delAllSpecialToEditor 清空关系 + */ + public function editSpecialAndEditorLocal() + { + $data = $this->request->post(); + $hasEditor = $this->parseHasEditor(isset($data['hasEditor']) ? $data['hasEditor'] : 0); + + $ruleFields = [ + 'journal_special_id' => 'require|number', + 'title' => 'require', +// 'intro' => 'require', + 'abstract' => 'require', + 'keywords' => 'require', + 'deadline' => 'require', + // 必填:漏传会被当成无客座编辑,导致误解绑 + 'hasEditor' => 'require', + ]; + if ($hasEditor) { + $ruleFields['user_id'] = 'require|number'; + } + $rule = new Validate($ruleFields); + if (!$rule->check($data)) { + return jsonError($rule->getError()); + } + + $specialId = intval($data['journal_special_id']); + $userId = isset($data['user_id']) ? intval($data['user_id']) : 0; + $base_url = Env::get('journal.base_url'); + + // 1) 专刊信息同步官网(含 hasEditor 标记) + $remoteData = [ + 'journal_special_id' => $specialId, + 'title' => trim((string)$data['title']), + 'intro' => trim((string)$data['intro']), + // 专刊封面 + 'icon' => isset($data['specialIcon']) ? trim((string)$data['specialIcon']) : '', + 'abstract' => trim((string)$data['abstract']), + 'keywords' => trim((string)$data['keywords']), + 'deadline' => trim((string)$data['deadline']), + 'hasEditor' => $hasEditor ? 1 : 0, + ]; + $raw = myPost($base_url . "/master/Special/editSpecialBasic", $remoteData); + $res = object_to_array(json_decode($raw)); + if (!isset($res['code']) || intval($res['code']) !== 0) { + $msg = isset($res['msg']) ? $res['msg'] : 'sync special to journal failed'; + return jsonError($msg); + } + + // hasEditor 非 1:专刊无客座编辑,解除官网全部关系 + 本地全部绑定 + if (!$hasEditor) { + $rawDelAll = myPost($base_url . "/master/Special/delAllSpecialToEditor", [ + 'journal_special_id' => $specialId, + ]); + $delAllRes = object_to_array(json_decode($rawDelAll)); + if (!isset($delAllRes['code']) || intval($delAllRes['code']) !== 0) { + $msg = isset($delAllRes['msg']) ? $delAllRes['msg'] : 'delete all remote relations failed'; + return jsonError($msg); + } + + $localUnbound = $this->user_to_special_obj + ->where('special_id', $specialId) + ->where('uts_state', 0) + ->update(['uts_state' => 1]); + if ($localUnbound === false) { + return jsonError('delete local relation failed'); + } + + return jsonSuccess([ + 'journal_special_id' => $specialId, + 'hasEditor' => 0, + 'special_synced' => 1, + 'editor_local_updated' => 0, + 'local_unbound' => intval($localUnbound), + 'remote_unbound' => 1, + ]); + } + + // 2) 客座编辑信息只更新投稿系统本地 + 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)->where('state', 0)->update($userUpdate); + if ($upUser === false) { + throw new \Exception('update user failed'); + } + + $reviewerInfo = $this->user_reviewer_info_obj + ->where('reviewer_id', $userId) + ->where('state', 0) + ->find(); + $reviewerUpdate = [ + 'firstname' => $firstname, + 'lastname' => $lastname, + ]; + if (isset($data['website'])) { + $reviewerUpdate['website'] = trim((string)$data['website']); + } + if (isset($data['address'])) { + $reviewerUpdate['address'] = trim((string)$data['address']); + } + if (isset($data['interests'])) { + $reviewerUpdate['interests'] = trim((string)$data['interests']); + } + + if ($reviewerInfo) { + $upReviewer = $this->user_reviewer_info_obj + ->where('reviewer_id', $userId) + ->where('state', 0) + ->update($reviewerUpdate); + if ($upReviewer === false) { + throw new \Exception('update 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 user_reviewer_info failed'); + } + } + + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + return jsonError($e->getMessage()); + } + + // 3) 一专刊一个客座编辑:先解绑其他本地绑定(并解除对应官网关系),再绑定当前 user_id + $editorReplaced = 0; + $unbindOthers = $this->unbindGuestEditorsOfSpecial($specialId, $base_url, $userId); + if (!$unbindOthers['ok']) { + return jsonError($unbindOthers['msg']); + } + if ($unbindOthers['local_unbound'] > 0) { + $editorReplaced = 1; + } + + $guestBound = 0; + $guestSynced = 0; + $check = $this->user_to_special_obj + ->where('user_id', $userId) + ->where('special_id', $specialId) + ->where('uts_state', 0) + ->find(); + if ($check) { + $guestBound = 1; + } else { + $bindResult = $this->bindGuestEditorToSpecial($specialId, $userId, $base_url); + if (!$bindResult['ok']) { + return jsonError($bindResult['msg']); + } + $guestBound = 1; + $guestSynced = intval($bindResult['remote_bound']); + } + + return jsonSuccess([ + 'journal_special_id' => $specialId, + 'user_id' => $userId, + 'hasEditor' => 1, + 'special_synced' => 1, + 'editor_local_updated' => 1, + 'editor_replaced' => $editorReplaced, + 'local_unbound' => $unbindOthers['local_unbound'], + 'remote_unbound' => $unbindOthers['remote_unbound'], + 'guest_editor_bound' => $guestBound, + 'guest_editor_synced' => $guestSynced, + ]); + } + + /** + * 删除专刊的客座编辑 + * 默认会同步删除官网专刊-编辑关系(可通过 sync_remote=0 关闭) + */ + public function delSpecialEditor() + { + $data = $this->request->post(); + $rule = new Validate([ + 'journal_special_id' => 'require|number', + 'user_id' => 'require|number', + ]); + if (!$rule->check($data)) { + return jsonError($rule->getError()); + } + + $specialId = intval($data['journal_special_id']); + $userId = intval($data['user_id']); + $syncRemote = !isset($data['sync_remote']) || intval($data['sync_remote']) === 1; + + $bind = $this->user_to_special_obj + ->where('special_id', $specialId) + ->where('user_id', $userId) + ->where('uts_state', 0) + ->find(); + if (!$bind) { + return jsonSuccess([ + 'journal_special_id' => $specialId, + 'user_id' => $userId, + 'local_deleted' => 0, + 'remote_deleted' => 0, + 'msg' => 'already deleted or not bound', + ]); + } + + $remoteDeleted = 0; + if ($syncRemote) { + $base_url = Env::get('journal.base_url'); + $user = $this->user_obj->where('user_id', $userId)->find(); + $email = trim(isset($user['email']) ? $user['email'] : ''); + + if ($email !== '') { + $raw = myPost($base_url . "/master/Special/getSpecialDetail", [ + 'journal_special_id' => $specialId + ]); + $res = object_to_array(json_decode($raw)); + $editors = isset($res['editors']) ? $res['editors'] : (isset($res['data']['editors']) ? $res['data']['editors'] : []); + if (!is_array($editors)) { + $editors = []; + } + + $journalSpecialEditorId = 0; + foreach ($editors as $editor) { + if (trim((string)($editor['email'] ?? '')) === $email) { + $journalSpecialEditorId = intval($editor['journal_special_editor_id'] ?? 0); + if ($journalSpecialEditorId > 0) { + break; + } + } + } + + if ($journalSpecialEditorId > 0) { + $rawDel = myPost($base_url . "/master/Special/delSpecialToEditor", [ + 'journal_special_id' => $specialId, + 'journal_special_editor_id' => $journalSpecialEditorId, + ]); + $delRes = object_to_array(json_decode($rawDel)); + if (!isset($delRes['code']) || intval($delRes['code']) !== 0) { + $msg = isset($delRes['msg']) ? $delRes['msg'] : 'delete remote relation failed'; + return jsonError($msg); + } + $remoteDeleted = 1; + } + } + } + + $up = $this->user_to_special_obj + ->where('special_id', $specialId) + ->where('user_id', $userId) + ->where('uts_state', 0) + ->update(['uts_state' => 1]); + if ($up === false) { + return jsonError('delete local relation failed'); + } + + return jsonSuccess([ + 'journal_special_id' => $specialId, + 'user_id' => $userId, + 'local_deleted' => $up > 0 ? 1 : 0, + 'remote_deleted' => $remoteDeleted, + ]); + } + + /** + * hasEditor=1 且传入 user_id 无有效绑定时: + * 1) 本地把该 user_id 绑定到专刊 + * 2) 同步官网:邮箱已存在则跳过 addSpecialEditor;关系已存在则跳过 addSpecialToEditor + * @return array{ok:bool,msg:string,remote_bound:int} + */ + private function bindGuestEditorToSpecial($specialId, $userId, $baseUrl) + { + $specialId = intval($specialId); + $userId = intval($userId); + $baseUrl = rtrim($baseUrl, '/'); + + $user = $this->user_obj + ->alias('u') + ->field('u.user_id,u.email,u.realname,u.icon,u.orcid,u.g_website,r.company,r.country,r.website,r.address,r.department,r.firstname,r.lastname,r.interests') + ->join('t_user_reviewer_info r', 'r.reviewer_id = u.user_id AND r.state = 0', 'LEFT') + ->where('u.user_id', $userId) + ->where('u.state', 0) + ->find(); + if (!$user) { + return ['ok' => false, 'msg' => 'guest editor user not found', 'remote_bound' => 0]; + } + + $email = trim(isset($user['email']) ? $user['email'] : ''); + if ($email === '') { + return ['ok' => false, 'msg' => 'guest editor email is empty', 'remote_bound' => 0]; + } + + $raw = myPost($baseUrl . "/master/Special/getSpecialDetail", [ + 'journal_special_id' => $specialId, + ]); + $res = object_to_array(json_decode($raw)); + if (!isset($res['code']) || intval($res['code']) !== 0) { + $msg = isset($res['msg']) ? $res['msg'] : 'get special detail failed'; + return ['ok' => false, 'msg' => $msg, 'remote_bound' => 0]; + } + $payload = isset($res['data']) && is_array($res['data']) ? $res['data'] : $res; + $special = isset($payload['special']) ? $payload['special'] : []; + $journalId = intval(isset($special['journal_id']) ? $special['journal_id'] : 0); + if ($journalId <= 0) { + return ['ok' => false, 'msg' => 'journal_id missing from special detail', 'remote_bound' => 0]; + } + + $editors = isset($payload['editors']) ? $payload['editors'] : []; + if (!is_array($editors)) { + $editors = []; + } + + // 专刊下已建立有效关系:按邮箱匹配,跳过 addSpecialEditor / addSpecialToEditor + $journalSpecialEditorId = 0; + $alreadyRelated = false; + $emailLower = strtolower($email); + foreach ($editors as $editor) { + if ($emailLower !== '' && strtolower(trim((string)($editor['email'] ?? ''))) === $emailLower) { + $journalSpecialEditorId = intval($editor['journal_special_editor_id'] ?? 0); + if ($journalSpecialEditorId > 0) { + $alreadyRelated = true; + break; + } + } + } + + if (!$alreadyRelated) { + // 官网是否已有该邮箱编辑档案 + if ($journalSpecialEditorId <= 0) { + $rawFind = myPost($baseUrl . "/master/Special/getSpecialEditorByEmail", [ + 'journal_id' => $journalId, + 'email' => $email, + ]); + $findRes = object_to_array(json_decode($rawFind)); + if (isset($findRes['code']) && intval($findRes['code']) === 0) { + $findData = isset($findRes['data']) && is_array($findRes['data']) ? $findRes['data'] : $findRes; + $journalSpecialEditorId = intval(isset($findData['journal_special_editor_id']) ? $findData['journal_special_editor_id'] : 0); + } + } + + // 邮箱不存在才创建编辑档案 + if ($journalSpecialEditorId <= 0) { + $realname = trim(isset($user['realname']) ? $user['realname'] : ''); + $nameParts = preg_split('/\s+/', $realname, 2); + $firstname = !empty($user['firstname']) ? $user['firstname'] : (isset($nameParts[0]) ? $nameParts[0] : ''); + $lastname = !empty($user['lastname']) ? $user['lastname'] : (isset($nameParts[1]) ? $nameParts[1] : ''); + $addressParts = array_filter([ + trim(isset($user['company']) ? $user['company'] : ''), + trim(isset($user['department']) ? $user['department'] : ''), + trim(isset($user['country']) ? $user['country'] : ''), + ]); + $address = !empty($user['address']) ? $user['address'] : (!empty($addressParts) ? implode(', ', $addressParts) : '-'); + + $specialIcon = ''; + $iconPath = trim(isset($user['icon']) ? $user['icon'] : ''); + if ($iconPath !== '') { + $normalized = $this->normalizeJournalSpecialIconPath($iconPath); + if ($normalized !== '') { + $specialIcon = $normalized; + } else { + $iconSync = $this->uploadGuestEditorIconToJournal($iconPath, $baseUrl); + if ($iconSync['ok']) { + $specialIcon = $iconSync['special_icon']; + } + } + } + + $rawAddEditor = myPost($baseUrl . "/master/Special/addSpecialEditor", [ + 'journal_id' => $journalId, + 'email' => $email, + 'first_name' => $firstname !== '' ? $firstname : '-', + 'last_name' => $lastname !== '' ? $lastname : '-', + 'address' => $address !== '' ? $address : '-', + 'interests' => isset($user['interests']) ? trim($user['interests']) : '', + 'website' => trim(!empty($user['website']) ? $user['website'] : (isset($user['g_website']) ? $user['g_website'] : '')), + 'orcid' => trim(isset($user['orcid']) ? $user['orcid'] : ''), + 'specialIcon' => $specialIcon, + ]); + $addEditorRes = object_to_array(json_decode($rawAddEditor)); + if (!isset($addEditorRes['code']) || intval($addEditorRes['code']) !== 0) { + $msg = isset($addEditorRes['msg']) ? $addEditorRes['msg'] : 'add special editor failed'; + return ['ok' => false, 'msg' => $msg, 'remote_bound' => 0]; + } + $journalSpecialEditorId = intval(isset($addEditorRes['data']['journal_special_editor_id']) + ? $addEditorRes['data']['journal_special_editor_id'] + : (isset($addEditorRes['journal_special_editor_id']) ? $addEditorRes['journal_special_editor_id'] : 0)); + if ($journalSpecialEditorId <= 0) { + return ['ok' => false, 'msg' => 'add special editor success but missing journal_special_editor_id', 'remote_bound' => 0]; + } + } + + // 未建立关系:调用 addSpecialToEditor + $rawBind = myPost($baseUrl . "/master/Special/addSpecialToEditor", [ + 'journal_id' => $journalId, + 'journal_special_id' => $specialId, + 'journal_special_editor_id' => $journalSpecialEditorId, + ]); + $bindRes = object_to_array(json_decode($rawBind)); + if (!isset($bindRes['code']) || intval($bindRes['code']) !== 0) { + $msg = isset($bindRes['msg']) ? $bindRes['msg'] : 'add special to editor failed'; + return ['ok' => false, 'msg' => $msg, 'remote_bound' => 0]; + } + } + + // 本地绑定传入的 user_id:优先恢复软删,否则新建 + $softBind = $this->user_to_special_obj + ->where('user_id', $userId) + ->where('special_id', $specialId) + ->where('uts_state', 1) + ->find(); + if ($softBind) { + $up = $this->user_to_special_obj + ->where('user_id', $userId) + ->where('special_id', $specialId) + ->where('uts_state', 1) + ->update(['uts_state' => 0]); + if ($up === false) { + return ['ok' => false, 'msg' => 'restore local bind failed', 'remote_bound' => $alreadyRelated ? 0 : 1]; + } + } else { + $insertId = $this->user_to_special_obj->insertGetId([ + 'user_id' => $userId, + 'special_id' => $specialId, + 'uts_ctime' => time(), + 'uts_state' => 0, + ]); + if (!$insertId) { + return ['ok' => false, 'msg' => 'bind guest editor to special failed', 'remote_bound' => $alreadyRelated ? 0 : 1]; + } + } + + return ['ok' => true, 'msg' => '', 'remote_bound' => $alreadyRelated ? 0 : 1]; + } + + /** + * 解除专刊下本地客座绑定,并同步官网 delSpecialToEditor + * 投稿系统一专刊仅一个客座编辑;exceptUserId>0 时保留该 user_id(替换场景) + * 官网可有多编辑:只解除投稿侧曾绑定编辑的 delSpecialToEditor,不清理其他官网编辑 + * @return array{ok:bool,msg:string,local_unbound:int,remote_unbound:int} + */ + private function unbindGuestEditorsOfSpecial($specialId, $baseUrl, $exceptUserId = 0) + { + $specialId = intval($specialId); + $baseUrl = rtrim($baseUrl, '/'); + + $query = $this->user_to_special_obj + ->where('special_id', $specialId) + ->where('uts_state', 0); + if (intval($exceptUserId) > 0) { + $query->where('user_id', '<>', intval($exceptUserId)); + } + $oldUserIds = $query->column('user_id'); + if (empty($oldUserIds)) { + return ['ok' => true, 'msg' => '', 'local_unbound' => 0, 'remote_unbound' => 0]; + } + + // 旧 user_id → 邮箱 → 官网编辑关系 + $emails = []; + foreach ($this->user_obj->where('user_id', 'in', $oldUserIds)->column('email') as $email) { + $email = strtolower(trim((string)$email)); + if ($email !== '') { + $emails[$email] = true; + } + } + + $remoteUnbound = 0; + foreach ($this->fetchJournalSpecialEditors($specialId, $baseUrl) as $editor) { + $editorId = intval($editor['journal_special_editor_id'] ?? 0); + if ($editorId <= 0 || !isset($emails[strtolower(trim((string)($editor['email'] ?? '')))])) { + continue; + } + $delRes = object_to_array(json_decode(myPost($baseUrl . "/master/Special/delSpecialToEditor", [ + 'journal_special_id' => $specialId, + 'journal_special_editor_id' => $editorId, + ]))); + if (!isset($delRes['code']) || intval($delRes['code']) !== 0) { + $msg = isset($delRes['msg']) ? $delRes['msg'] : 'delete remote relation failed'; + return ['ok' => false, 'msg' => $msg, 'local_unbound' => 0, 'remote_unbound' => $remoteUnbound]; + } + $remoteUnbound++; + } + + $localUnbound = $this->user_to_special_obj + ->where('special_id', $specialId) + ->where('user_id', 'in', $oldUserIds) + ->where('uts_state', 0) + ->update(['uts_state' => 1]); + if ($localUnbound === false) { + return ['ok' => false, 'msg' => 'delete local relation failed', 'local_unbound' => 0, 'remote_unbound' => $remoteUnbound]; + } + + return [ + 'ok' => true, + 'msg' => '', + 'local_unbound' => intval($localUnbound), + 'remote_unbound' => $remoteUnbound, + ]; + } + + /** + * 获取官网专刊已关联的客座编辑列表 + */ + private function fetchJournalSpecialEditors($specialId, $baseUrl) + { + $res = object_to_array(json_decode(myPost(rtrim($baseUrl, '/') . "/master/Special/getSpecialDetail", [ + 'journal_special_id' => intval($specialId), + ]))); + $payload = isset($res['data']) && is_array($res['data']) ? $res['data'] : $res; + return isset($payload['editors']) && is_array($payload['editors']) ? $payload['editors'] : []; + } + + /** + * 上传专刊封面图(转发至 journal Special/up_icon_file) + * 表单字段名:specialIcon + */ + public function up_icon_file() + { + $file = request()->file('specialIcon'); + if (!$file) { + return jsonError('specialIcon is required'); + } + + $info = $file->getInfo(); + $tmpPath = isset($info['tmp_name']) ? $info['tmp_name'] : ''; + $originName = isset($info['name']) ? $info['name'] : 'specialIcon'; + $mime = isset($info['type']) && $info['type'] !== '' ? $info['type'] : 'application/octet-stream'; + if ($tmpPath === '' || !is_file($tmpPath)) { + return jsonError('upload file invalid'); + } + + $base_url = Env::get('journal.base_url'); + $url = rtrim($base_url, '/') . '/api/Special/up_icon_file'; + $postFields = [ + 'specialIcon' => new \CURLFile(realpath($tmpPath), $mime, $originName), + ]; + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + $raw = curl_exec($ch); + $err = curl_error($ch); + curl_close($ch); + + if ($raw === false || $raw === '') { + return jsonError($err !== '' ? $err : 'upload to journal failed'); + } + + $res = object_to_array(json_decode($raw)); + if (!isset($res['code']) || intval($res['code']) !== 0) { + $msg = isset($res['msg']) ? $res['msg'] : 'upload icon failed'; + return jsonError($msg); + } + + // 与 journal 返回保持一致,便于前端直接用 upurl + return json([ + 'code' => 0, + 'msg' => 'success', + 'upurl' => isset($res['upurl']) ? $res['upurl'] : '', + ]); + } + + /** + * 更改专刊状态(代理 journal master/Special/changeSpecialState) + * state: 0初始 1拒绝 2通过 + */ + public function changeSpecialState() + { + $data = $this->request->post(); + $rule = new Validate([ + 'journal_special_id' => 'require|number', + 'state' => 'require|number|in:0,1,2', + ]); + if (!$rule->check($data)) { + return jsonError($rule->getError()); + } + + $specialId = intval($data['journal_special_id']); + $state = intval($data['state']); + $base_url = Env::get('journal.base_url'); + $raw = myPost($base_url . "/master/Special/changeSpecialState", [ + 'journal_special_id' => $specialId, + 'state' => $state, + ]); + $res = object_to_array(json_decode($raw)); + if (!isset($res['code']) || intval($res['code']) !== 0) { + $msg = isset($res['msg']) ? $res['msg'] : 'change special state failed'; + return jsonError($msg); + } + + return jsonSuccess([ + 'journal_special_id' => $specialId, + 'state' => $state, + ]); + } + + /** + * 根据 ISSN 获取官网期刊 ID(代理 journal master/Special/getJournalIdByIssn) + * 入参兼容 journal_issn / issn;转发官网时使用字段名 issn + */ + public function getJournalIdByIssn() + { + $data = $this->request->post(); + $issn = ''; + if (isset($data['journal_issn'])) { + $issn = trim((string)$data['journal_issn']); + } + if ($issn === '' && isset($data['issn'])) { + $issn = trim((string)$data['issn']); + } + if ($issn === '') { + return jsonError('issn不能为空'); + } + + $resolved = $this->resolveSiteJournalIdByIssn($issn); + if (!$resolved['ok']) { + return jsonError($resolved['msg']); + } + + return jsonSuccess([ + 'journal_issn' => $issn, + 'journal_id' => intval($resolved['journal_id']), + ]); + } + + /** + * 按 ISSN 向官网解析 journal_id + * @return array{ok:bool,msg:string,journal_id:int} + */ + private function resolveSiteJournalIdByIssn($issn) + { + $issn = trim((string)$issn); + if ($issn === '') { + return ['ok' => false, 'msg' => 'issn不能为空', 'journal_id' => 0]; + } + + $base_url = Env::get('journal.base_url'); + $raw = myPost(rtrim($base_url, '/') . '/master/Special/getJournalIdByIssn', [ + 'issn' => $issn, + ]); + $res = object_to_array(json_decode($raw)); + if (!isset($res['code']) || intval($res['code']) !== 0) { + $msg = isset($res['msg']) ? $res['msg'] : ('get journal id by issn failed: ' . (is_string($raw) ? $raw : 'remote empty')); + return ['ok' => false, 'msg' => $msg, 'journal_id' => 0]; + } + + $payload = isset($res['data']) ? $res['data'] : []; + $journalId = 0; + if (is_array($payload)) { + $journalId = intval(isset($payload['journal_id']) ? $payload['journal_id'] : 0); + } elseif (is_numeric($payload)) { + $journalId = intval($payload); + } + if ($journalId <= 0 && isset($res['journal_id'])) { + $journalId = intval($res['journal_id']); + } + if ($journalId <= 0) { + return ['ok' => false, 'msg' => 'journal id not found for issn', 'journal_id' => 0]; + } + + return ['ok' => true, 'msg' => '', 'journal_id' => $journalId]; + } + } diff --git a/application/api/controller/User.php b/application/api/controller/User.php index 1b8edef4..bd4e29db 100644 --- a/application/api/controller/User.php +++ b/application/api/controller/User.php @@ -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()]); + } + } + } } diff --git a/application/api/view/references/index.html b/application/api/view/references/index.html new file mode 100644 index 00000000..6ce5cb13 --- /dev/null +++ b/application/api/view/references/index.html @@ -0,0 +1,674 @@ + + + + + + 参考文献堆叠统计 + + + + + + + + + + + diff --git a/application/command.php b/application/command.php index 7be87dc3..be8e47c8 100644 --- a/application/command.php +++ b/application/command.php @@ -12,4 +12,6 @@ return [ 'app\\command\\ReferenceCheckMqConsume', 'app\\command\\AiWritingRiskMqConsume', + 'app\\command\\RevisionCommentCheckRun', + 'app\\command\\RevisionCommentMqConsume', ]; diff --git a/application/common/ArticleParserService.php b/application/common/ArticleParserService.php index c6efeabe..c93caf1b 100644 --- a/application/common/ArticleParserService.php +++ b/application/common/ArticleParserService.php @@ -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('/]*>([\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 '' . $safe . ''; + }; + + $xml = preg_replace_callback( + '/]*>([\s\S]*?)<\/m:oMathPara>/i', + function ($m) use ($toRun) { + return $toRun($m[1]); + }, + $xml + ); + $xml = preg_replace_callback( + '/]*>([\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 目录路径 diff --git a/application/common/ReferenceReferAuthorService.php b/application/common/ReferenceReferAuthorService.php index 0303b7d6..6aabc550 100644 --- a/application/common/ReferenceReferAuthorService.php +++ b/application/common/ReferenceReferAuthorService.php @@ -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 结构兼容) * diff --git a/application/common/ReferenceStackingStatsService.php b/application/common/ReferenceStackingStatsService.php index 635234c2..3ed401e2 100644 --- a/application/common/ReferenceStackingStatsService.php +++ b/application/common/ReferenceStackingStatsService.php @@ -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 + */ + 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 + */ + 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 */ - 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, + * 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 $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 $referMap - * @return array + * @return array */ 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); diff --git a/application/common/UserActLog.php b/application/common/UserActLog.php index acf68a65..32a5f61b 100644 --- a/application/common/UserActLog.php +++ b/application/common/UserActLog.php @@ -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]; diff --git a/application/common/mq/RabbitMqConfig.php b/application/common/mq/RabbitMqConfig.php index 64f8e97e..a2b1e631 100644 --- a/application/common/mq/RabbitMqConfig.php +++ b/application/common/mq/RabbitMqConfig.php @@ -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 : []; + } } diff --git a/application/common/mq/ReferenceCheckArticleWorker.php b/application/common/mq/ReferenceCheckArticleWorker.php index 7724ec15..61f54085 100644 --- a/application/common/mq/ReferenceCheckArticleWorker.php +++ b/application/common/mq/ReferenceCheckArticleWorker.php @@ -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); } } diff --git a/application/common/service/LLMService.php b/application/common/service/LLMService.php index 3daca96a..50c2fa84 100644 --- a/application/common/service/LLMService.php +++ b/application/common/service/LLMService.php @@ -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; + } } /** diff --git a/application/extra/rabbitmq.php b/application/extra/rabbitmq.php index 5eb8c0ca..b29c1627 100644 --- a/application/extra/rabbitmq.php +++ b/application/extra/rabbitmq.php @@ -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', + ], ];