diff --git a/.env b/.env index 77fa783..2c6d45f 100644 --- a/.env +++ b/.env @@ -184,4 +184,45 @@ net = '

-' \ No newline at end of file +' + +; ===================================================================== +; 数据库收录推送凭证(由 application/extra/dbpush.php 读取) +; 修改后无需改代码。留空则回退到 dbpush.php 中的默认值。 +; ===================================================================== + +[portico] +host = ftp.portico.org +port = 22 +user = TMR +pass = h6EHD8 + +[scopus] +host = sftp-opsbank2.elsevier.com +port = 22 +user = pdf-e +pass = '?b#W5=T4' +base = /data/incoming/tmr/ + +[ebsco] +host = ftp.epnet.com +port = 21 +user = 56704 +pass = 'o3Mys"(S' + +[cnki] +host = FTP.cnki.net +port = 21 +user = glo616cnki +pass = glo616cnki + +[dbpush] +; 是否通过队列异步推送(true 需常驻 php think queue:work --queue dbpush) +; 默认 false = 同步执行,行为与历史一致 +async = false +; 失败自动重试次数(async=true 时生效) +max_attempt = 3 +ts_base_url = http://ts.tmrjournals.com/ +article_pdf = http://journalapi.tmrjournals.com/public/articlePDF/ +article_sub = http://journalapi.tmrjournals.com/public/articleSUB/ +public_base = http://journalapi.tmrjournals.com/public/ \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ffa3f87 --- /dev/null +++ b/.env.example @@ -0,0 +1,47 @@ +; ===================================================================== +; 环境配置示例文件 +; 用法:复制为 .env 并填入真实值。.env 不应提交到版本库。 +; ===================================================================== + +; 邮件模板(HTML,多行,用单引号包裹)。实际值见线上 .env。 +; [emailtemplete] +; pre = ' ... ' +; net = '

... ' + +; --------------------------------------------------------------------- +; 数据库收录推送凭证(application/extra/dbpush.php 读取) +; --------------------------------------------------------------------- + +[portico] +host = ftp.portico.org +port = 22 +user = your_portico_user +pass = your_portico_pass + +[scopus] +host = sftp-opsbank2.elsevier.com +port = 22 +user = your_scopus_user +pass = 'your_scopus_pass' +base = /data/incoming/tmr/ + +[ebsco] +host = ftp.epnet.com +port = 21 +user = your_ebsco_user +pass = 'your_ebsco_pass' + +[cnki] +host = FTP.cnki.net +port = 21 +user = your_cnki_user +pass = your_cnki_pass + +[dbpush] +; false=同步(默认);true=异步入队(需 php think queue:work --queue dbpush) +async = false +max_attempt = 3 +ts_base_url = http://ts.tmrjournals.com/ +article_pdf = http://journalapi.tmrjournals.com/public/articlePDF/ +article_sub = http://journalapi.tmrjournals.com/public/articleSUB/ +public_base = http://journalapi.tmrjournals.com/public/ diff --git a/.gitignore b/.gitignore index 04d116d..fc6d3ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ /nbproject/private/ /public/ -/.vscode/ \ No newline at end of file +/.vscode/ + +# 环境配置:包含第三方数据库凭证,不入库 +/.env \ No newline at end of file diff --git a/application/api/controller/Journal.php b/application/api/controller/Journal.php index b04774a..d64564d 100644 --- a/application/api/controller/Journal.php +++ b/application/api/controller/Journal.php @@ -107,7 +107,7 @@ class Journal extends Controller } //新增查询信息journalAbs chengxiaoling 20250928 start - $abs = $this->journal_abs_obj->where('journal_id', $v['journal_id'])->where('state', 0)->order('sort')->select(); + $abs = $this->journal_abs_obj->where('journal_id', $v['journal_id'])->where('state', 0)->whereRaw('(is_show IS NULL OR is_show <> 0)')->order('sort')->select(); $res[$k]['journalAbs'] = $abs; //查询期刊编委 @@ -161,7 +161,7 @@ class Journal extends Controller $journal_info = $this->journal_obj->where('journal_id', $data['journal_id'])->find(); $rearr = $journal_info['relate'] == '' ? [] : explode(',', $journal_info['relate']); $relatelist = $this->journal_obj->where('journal_id', 'in', $rearr)->where('state', 0)->select(); - $absList = $this->journal_abs_obj->where('journal_id', $data['journal_id'])->where('state', 0)->order('sort')->select(); + $absList = $this->journal_abs_obj->where('journal_id', $data['journal_id'])->where('state', 0)->whereRaw('(is_show IS NULL OR is_show <> 0)')->order('sort')->select(); $stageList = $this->journal_stage_obj->where('journal_id', $data['journal_id'])->where('is_publish', 1)->where('state', 0)->order('stage_year desc,stage_no desc')->select(); //获取期刊主编辑 chengxiaoling start 20250626 @@ -498,7 +498,7 @@ class Journal extends Controller ->select(); foreach ($list as $k => $v) { - $abs = $this->journal_abs_obj->where('journal_id', $v['journal_id'])->where('state', 0)->order('sort')->limit(4)->select(); + $abs = $this->journal_abs_obj->where('journal_id', $v['journal_id'])->where('state', 0)->whereRaw('(is_show IS NULL OR is_show <> 0)')->order('sort')->limit(4)->select(); $list[$k]['abs'] = $abs; } @@ -555,7 +555,7 @@ class Journal extends Controller } //获取期刊主编辑 chengxiaoling end 20251210 foreach ($list as $k => $v) { - $abs = $this->journal_abs_obj->where('journal_id', $v['journal_id'])->where('state', 0)->order('sort')->limit(4)->select(); + $abs = $this->journal_abs_obj->where('journal_id', $v['journal_id'])->where('state', 0)->whereRaw('(is_show IS NULL OR is_show <> 0)')->order('sort')->limit(4)->select(); $list[$k]['abs'] = $abs; //获取期刊主编辑 chengxiaoling start 20251210 $sIssn = empty($v['issn']) ? '' : $v['issn']; diff --git a/application/api/job/dbpush.php b/application/api/job/dbpush.php new file mode 100644 index 0000000..45fc016 --- /dev/null +++ b/application/api/job/dbpush.php @@ -0,0 +1,37 @@ +executeAndLog( + $data['log_id'], + $data['stage_id'], + $data['pusher'], + isset($data['options']) ? $data['options'] : [] + ); + + if ($ok) { + $job->delete(); + return; + } + + // 失败重试 + $max = (int) config('dbpush.max_attempt'); + if ($job->attempts() >= $max) { + $job->delete(); + } else { + $job->release(30); // 30 秒后重试 + } + } +} diff --git a/application/api/job/mysftp.php b/application/api/job/mysftp.php index 4903f1d..c477767 100644 --- a/application/api/job/mysftp.php +++ b/application/api/job/mysftp.php @@ -8,11 +8,12 @@ class mysftp { public function push(Job $job, $data){ $job->delete(); - //链接sftp服务器 - $config['host'] = 'ftp.portico.org'; - $config['port'] = 22; - $config['username'] = "TMR"; - $config['password'] = "h6EHD8"; + //链接sftp服务器(凭证走 dbpush 配置,历史值兜底) + $cred = config('dbpush.credentials.portico'); + $config['host'] = isset($cred['host']) ? $cred['host'] : 'ftp.portico.org'; + $config['port'] = isset($cred['port']) ? $cred['port'] : 22; + $config['username'] = isset($cred['user']) ? $cred['user'] : "TMR"; + $config['password'] = isset($cred['pass']) ? $cred['pass'] : "h6EHD8"; $sftp_obj = new Sftp($config); $sftp_obj->upftp($data['local'],$data['remote']); } diff --git a/application/common.php b/application/common.php index 811d91c..c54627d 100644 --- a/application/common.php +++ b/application/common.php @@ -316,16 +316,17 @@ function getAuthor($article) { function pushSFTPForScopus($file,$pdf_file,$stage_info) { - $host = 'sftp-opsbank2.elsevier.com'; - $port = 22; // SFTP 默认端口号 - $username = 'pdf-e'; - $password = '?b#W5=T4'; + $cred = config('dbpush.credentials.scopus'); + $host = isset($cred['host']) ? $cred['host'] : 'sftp-opsbank2.elsevier.com'; + $port = isset($cred['port']) ? $cred['port'] : 22; // SFTP 默认端口号 + $username = isset($cred['user']) ? $cred['user'] : 'pdf-e'; + $password = isset($cred['pass']) ? $cred['pass'] : '?b#W5=T4'; // 创建 SFTP 连接 $sftp = new SFTP($host, $port); if (!$sftp->login($username, $password)) { - exit('SFTP 登录失败'); + throw new \Exception('Scopus SFTP 登录失败'); } // 列出远程服务器的文件 @@ -336,7 +337,7 @@ function pushSFTPForScopus($file,$pdf_file,$stage_info) // if($stage_info !=null){ // $bb = '/data/incoming/tmr/'.$stage_info['stage_year']."tmr-".$stage_info['stage_vol']."-".$stage_info['stage_no']."/"; // }else{ - $bb = '/data/incoming/tmr/'; + $bb = isset($cred['base']) ? $cred['base'] : '/data/incoming/tmr/'; // } $localFile = ROOT_PATH."public/articleXML/".$file; $remoteFile = $bb.$stage_info['stage_year']."v".$stage_info['stage_vol']."n".$stage_info['stage_no']."-".$file; @@ -349,13 +350,10 @@ function pushSFTPForScopus($file,$pdf_file,$stage_info) $remoteFilePdf = $bb.$stage_info['stage_year']."v".$stage_info['stage_vol']."n".$stage_info['stage_no']."-".str_replace(".xml",".pdf",$file); $res1 = $sftp->put($remoteFilePdf,$localPdf,SFTP::SOURCE_LOCAL_FILE); - if ($res&$res1) { - echo "文件上传成功!"; - } else { -// echo $sftp->getErrors(); - var_dump($sftp->getErrors()); -// return $sftp; + if (!($res && $res1)) { + throw new \Exception('Scopus SFTP 上传失败: ' . implode('; ', (array) $sftp->getErrors())); } + return true; } diff --git a/application/extra/dbpush.php b/application/extra/dbpush.php new file mode 100644 index 0000000..65db76e --- /dev/null +++ b/application/extra/dbpush.php @@ -0,0 +1,134 @@ + [ + // Portico:Excel + PDF 走 SFTP + 'portico' => [ + 'host' => Env::get('portico.host', 'ftp.portico.org'), + 'port' => (int) Env::get('portico.port', 22), + 'user' => Env::get('portico.user', 'TMR'), + 'pass' => Env::get('portico.pass', 'h6EHD8'), + ], + // Scopus / Elsevier:XML + PDF 走 SFTP + 'scopus' => [ + 'host' => Env::get('scopus.host', 'sftp-opsbank2.elsevier.com'), + 'port' => (int) Env::get('scopus.port', 22), + 'user' => Env::get('scopus.user', 'pdf-e'), + 'pass' => Env::get('scopus.pass', '?b#W5=T4'), + 'base' => Env::get('scopus.base', '/data/incoming/tmr/'), + ], + // EBSCO:XML + PDF 走 FTP + 'ebsco' => [ + 'host' => Env::get('ebsco.host', 'ftp.epnet.com'), + 'port' => (int) Env::get('ebsco.port', 21), + 'user' => Env::get('ebsco.user', '56704'), + 'pass' => Env::get('ebsco.pass', 'o3Mys"(S'), + ], + // CNKI 中国知网:Excel zip 走 FTP + 'cnki' => [ + 'host' => Env::get('cnki.host', 'FTP.cnki.net'), + 'port' => (int) Env::get('cnki.port', 21), + 'user' => Env::get('cnki.user', 'glo616cnki'), + 'pass' => Env::get('cnki.pass', 'glo616cnki'), + ], + ], + + // --------------------------------------------------------------- + // 通用数据库邮件送交的默认收件人(email_data 渠道使用) + // --------------------------------------------------------------- + 'data_recipients' => [ + 'jgatelicensing@informaticsglobal.com', + '3097953993@qq.com', + '751475802@qq.com', + ], + + // 相关外部服务地址(保持与历史代码一致) + 'ts_base_url' => Env::get('dbpush.ts_base_url', 'http://ts.tmrjournals.com/'), + 'article_pdf' => Env::get('dbpush.article_pdf', 'http://journalapi.tmrjournals.com/public/articlePDF/'), + 'article_sub' => Env::get('dbpush.article_sub', 'http://journalapi.tmrjournals.com/public/articleSUB/'), + 'public_base' => Env::get('dbpush.public_base', 'http://journalapi.tmrjournals.com/public/'), + + // 推送执行方式: + // false = 同步执行(默认,行为与历史一致:Portico/邮件仍走既有 mail/mysftp 队列) + // true = 各渠道整体异步入队,需额外常驻 `php think queue:work --queue dbpush` + 'async' => (bool) Env::get('dbpush.async', false), + + // 失败自动重试次数(队列内) + 'max_attempt' => (int) Env::get('dbpush.max_attempt', 3), + + // --------------------------------------------------------------- + // 默认渠道配置(仅当 j_db_channel 表为空时作为回退) + // journal_ids 为空数组表示适用全部期刊 + // --------------------------------------------------------------- + 'default_channels' => [ + [ + 'channel_key' => 'portico', + 'name' => 'Portico', + 'transport' => 'sftp', + 'pusher' => 'portico', + 'journal_ids' => [], + 'is_enable' => 1, + 'config' => [], + ], + [ + 'channel_key' => 'scopus', + 'name' => 'Scopus (Elsevier)', + 'transport' => 'sftp', + 'pusher' => 'scopus', + 'journal_ids' => [1, 11], + 'is_enable' => 1, + 'config' => [], + ], + [ + 'channel_key' => 'ebsco', + 'name' => 'EBSCO', + 'transport' => 'ftp', + 'pusher' => 'ebsco', + 'journal_ids' => [2, 8, 20, 15, 9, 10, 3, 1, 14, 11, 4, 18], + 'is_enable' => 1, + 'config' => [], + ], + [ + 'channel_key' => 'cnki', + 'name' => 'CNKI 中国知网', + 'transport' => 'ftp', + 'pusher' => 'cnki', + 'journal_ids' => [], + 'is_enable' => 0, // 历史代码中已注释,默认关闭 + 'config' => [], + ], + [ + 'channel_key' => 'email_data', + 'name' => '通用数据库邮件送交', + 'transport' => 'email', + 'pusher' => 'email_data', + 'journal_ids' => [], + 'is_enable' => 1, + 'config' => [], + ], + [ + 'channel_key' => 'email_wanfang', + 'name' => '万方数据库', + 'transport' => 'email', + 'pusher' => 'email_data', + 'journal_ids' => [1], + 'is_enable' => 1, + 'config' => ['recipients' => ['958518573@qq.com', 'swyy2@wanfangdata.com.cn']], + ], + ], +]; diff --git a/application/master/controller/Datebase.php b/application/master/controller/Datebase.php index f8d8a8d..f422fe5 100644 --- a/application/master/controller/Datebase.php +++ b/application/master/controller/Datebase.php @@ -7,6 +7,13 @@ use think\Db; use think\Queue; use sftp\Sftp; use think\Validate; +use app\master\service\DbPushManager; +use app\master\service\DbPushLogger; +use app\master\service\XmlBuilder; +use app\master\service\push\PorticoPusher; +use app\master\service\push\ScopusPusher; +use app\master\service\push\EbscoPusher; +use app\master\service\push\CnkiPusher; /** * @title 数据库接口 @@ -774,66 +781,159 @@ class Datebase extends Controller public function dataPush() { $data = $this->request->post(); - $stage_info = $this->journal_stage_obj->where("journal_stage_id", $data["journal_stage_id"])->find(); - $this->createEmailFile($data["journal_stage_id"]); - - $journal_info = $this->journal_obj->where("journal_id", $stage_info["journal_id"])->find(); - $zipfile = ROOT_PATH . "public/dataFile/" . $data["journal_stage_id"] . '/' . $data["journal_stage_id"] . '.zip'; - $tt = "Dear Database,

"; - $tt .= "Please find the PDF files in attachment of published issue (Vol. " . $stage_info['stage_vol'] . ", No." . $stage_info['stage_no'] . ") of " . $journal_info['title'] . ".

"; - $tt .= "Yours Sincerely
"; - $tt .= "Dan Chen
"; - $tt .= "Manager
"; - $tt .= "TMR Publishing Group | Editorial Office | New Zealand
"; - $tt .= "Telephone: +64 02108293806
"; - $tt .= "Email: publisher@tmrjournals.com
"; - $tt .= "Website:www.tmrjournals.com"; - - $elist = []; - $elist[] = "jgatelicensing@informaticsglobal.com"; -// $elist[] = "3601240974@qq.com"; - if ($journal_info["journal_id"] == 1) { - $elist[] = "958518573@qq.com"; - $elist[] = "swyy2@wanfangdata.com.cn";//万方数据库 + $rule = new Validate([ + "journal_stage_id" => "require" + ]); + if (!$rule->check($data)) { + return jsonError($rule->getError()); } - $elist[] = "3097953993@qq.com"; -// $elist[] = "849192806@qq.com"; //皮皮 - $elist[] = "751475802@qq.com"; //我 -// $elist[] = "ELD@dia.govt.nz"; - - foreach ($elist as $v) { - $cdata['email'] = $v; - $cdata['title'] = "Data submitted-TMR Publishing Group"; - $cdata["fromname"] = "Data submitted-TMR Publishing Group"; - $cdata["content"] = $tt; - $cdata["temail"] = $journal_info['email']; - $cdata["tpassword"] = $journal_info['epassword']; - $cdata["fj"] = $zipfile; - Queue::push('app\api\job\mail@puchAndFJ', $cdata, "mail"); - // sendEmail_data($v, "Data submitted-TMR Publishing Group", "Data submitted-TMR Publishing Group", $tt, $journal_info['email'], $journal_info['epassword'],$zipfile); - } - //发送sftp - $this->bf_db_push($data["journal_stage_id"]); - // 发送ftp -// $this->createFtpFileInfo($data["journal_stage_id"]);//cnki数据库 - - if($journal_info['journal_id']==1||$journal_info['journal_id']==11){ - $this->scopusPushXML($data["journal_stage_id"]);//scopus数据库 - } - // if(in_array($journal_info["journal_id"],[2,8,20,15,9,10,3,1])){//ep数据库 - // $this->pushXmlToEp($data["journal_stage_id"]); - // } - - //EBSCO上传XML新增期刊ID[14,11,4,18,8,1,2] chengxiaoling 20250402 start - if(in_array($journal_info["journal_id"],[2,8,20,15,9,10,3,1,14,11,4,18,8])){//ep数据库2,8,20,15,9,10,3,1 //EBSCO上传XML - $this->pushXmlToEp($data["journal_stage_id"]); - } - //EBSCO上传XML新增期刊ID[14,11,4,18,8,1,2] chengxiaoling 20250402 end - return jsonSuccess([]); + // 配置驱动 + 全程留痕 + 可异步的统一推送(渠道由 j_db_channel / 默认配置决定) + $operator = session('admin.realname') ?: (session('admin.username') ?: ''); + $manager = new DbPushManager(); + $summary = $manager->run($data["journal_stage_id"], $operator); + return jsonSuccess(['summary' => $summary]); } + /** + * @title 获取某期推送情况 + * @description 查看指定分期在各数据库的推送记录/状态 + * @url /master/Datebase/getPushStatus + * @method POST + * + * @param name:journal_stage_id type:int require:1 desc:分期id + * @return list:推送日志array# + */ + public function getPushStatus() + { + $data = $this->request->post(); + $rule = new Validate([ + "journal_stage_id" => "require" + ]); + if (!$rule->check($data)) { + return jsonError($rule->getError()); + } + $logger = new DbPushLogger(); + $list = $logger->stageStatus($data['journal_stage_id']); + $statusMap = [0 => '待推送/排队中', 1 => '成功', 2 => '失败']; + foreach ($list as $k => $v) { + $list[$k]['status_text'] = isset($statusMap[$v['status']]) ? $statusMap[$v['status']] : '未知'; + $list[$k]['ctime_text'] = $v['ctime'] ? date('Y-m-d H:i:s', $v['ctime']) : ''; + $list[$k]['utime_text'] = $v['utime'] ? date('Y-m-d H:i:s', $v['utime']) : ''; + } + return jsonSuccess(['list' => $list]); + } + + /** + * @title 重推 + * @description 针对某条推送日志重新推送(用于失败补推) + * @url /master/Datebase/retryPush + * @method POST + * + * @param name:log_id type:int require:1 desc:推送日志id + */ + public function retryPush() + { + $data = $this->request->post(); + $rule = new Validate([ + "log_id" => "require" + ]); + if (!$rule->check($data)) { + return jsonError($rule->getError()); + } + $manager = new DbPushManager(); + $res = $manager->retry($data['log_id']); + if (empty($res['ok'])) { + return jsonError(isset($res['msg']) ? $res['msg'] : '重推失败'); + } + return jsonSuccess($res); + } + + /** + * @title 获取推送渠道配置 + * @description 获取数据库推送渠道配置列表 + * @url /master/Datebase/getChannels + * @method POST + * @return list:渠道配置array# + */ + public function getChannels() + { + $list = Db::name('db_channel')->where('state', 0)->order('sort desc')->select(); + return jsonSuccess(['list' => $list]); + } + + /** + * @title 保存推送渠道配置 + * @description 新增或编辑数据库推送渠道(channel_id 存在则编辑) + * @url /master/Datebase/saveChannel + * @method POST + * + * @param name:channel_id type:int require:0 desc:渠道id(编辑时传) + * @param name:channel_key type:string require:1 desc:渠道标识 + * @param name:name type:string require:1 desc:展示名 + * @param name:transport type:string require:1 desc:传输方式 sftp/ftp/email + * @param name:pusher type:string require:0 desc:推送器标识(留空同channel_key) + * @param name:journal_ids type:string require:0 desc:适用期刊id逗号分隔(留空=全部) + * @param name:config type:string require:0 desc:JSON额外配置 + * @param name:is_enable type:int require:0 default:1 desc:是否启用 + * @param name:sort type:int require:0 default:0 desc:排序 + */ + public function saveChannel() + { + $data = $this->request->post(); + $rule = new Validate([ + "channel_key" => "require", + "name" => "require", + "transport" => "require", + ]); + if (!$rule->check($data)) { + return jsonError($rule->getError()); + } + if (!empty($data['config']) && json_decode($data['config'], true) === null && strtolower(trim($data['config'])) !== 'null') { + return jsonError('config 必须为合法 JSON'); + } + $save = [ + 'channel_key' => $data['channel_key'], + 'name' => $data['name'], + 'transport' => $data['transport'], + 'pusher' => isset($data['pusher']) ? $data['pusher'] : '', + 'journal_ids' => isset($data['journal_ids']) ? $data['journal_ids'] : '', + 'config' => isset($data['config']) ? $data['config'] : '', + 'is_enable' => isset($data['is_enable']) ? intval($data['is_enable']) : 1, + 'sort' => isset($data['sort']) ? intval($data['sort']) : 0, + 'utime' => time(), + ]; + if (!empty($data['channel_id'])) { + Db::name('db_channel')->where('channel_id', $data['channel_id'])->update($save); + } else { + $save['ctime'] = time(); + Db::name('db_channel')->insert($save); + } + return jsonSuccess([]); + } + + /** + * @title 删除推送渠道配置 + * @description 删除数据库推送渠道 + * @url /master/Datebase/delChannel + * @method POST + * + * @param name:channel_id type:int require:1 desc:渠道id + */ + public function delChannel() + { + $data = $this->request->post(); + $rule = new Validate([ + "channel_id" => "require" + ]); + if (!$rule->check($data)) { + return jsonError($rule->getError()); + } + Db::name('db_channel')->where('channel_id', $data['channel_id'])->update(['state' => 1, 'utime' => time()]); + return jsonSuccess([]); + } + public function scopusTest(){ $data = $this->request->post(); $rule = new Validate([ diff --git a/application/master/controller/Journal.php b/application/master/controller/Journal.php index 6d3151f..1aa09b1 100644 --- a/application/master/controller/Journal.php +++ b/application/master/controller/Journal.php @@ -1708,11 +1708,20 @@ class Journal extends Controller public function addJournalAbs() { $data = $this->request->post(); + $rule = new Validate([ + 'journal_id' => 'require', + 'title' => 'require', + 'url' => 'require', + ]); + if (!$rule->check($data)) { + return json(['code' => 1, 'msg' => $rule->getError()]); + } $add_data['journal_id'] = $data['journal_id']; $add_data['title'] = $data['title']; $add_data['url'] = $data['url']; - $add_data['sort'] = $data['sort']; - $add_data['is_show'] = $data['is_show']; + $add_data['sort'] = isset($data['sort']) ? intval($data['sort']) : 0; + $add_data['is_show'] = isset($data['is_show']) ? intval($data['is_show']) : 1; + $add_data['state'] = 0; $res = $this->journal_abs_obj->insert($add_data); if ($res) { return json(['code' => 0, 'msg' => 'success']); @@ -1754,7 +1763,46 @@ class Journal extends Controller public function editJournalAbs() { $data = $this->request->post(); - $this->journal_abs_obj->update($data); + $rule = new Validate([ + 'journal_abstracting_id' => 'require', + ]); + if (!$rule->check($data)) { + return json(['code' => 1, 'msg' => $rule->getError()]); + } + // 白名单,避免误更新未知字段(数据库开启了 fields_strict) + $update = []; + foreach (['journal_id', 'title', 'url', 'sort', 'is_show'] as $f) { + if (isset($data[$f])) { + $update[$f] = $data[$f]; + } + } + if (empty($update)) { + return json(['code' => 1, 'msg' => 'nothing to update']); + } + $this->journal_abs_obj->where('journal_abstracting_id', $data['journal_abstracting_id'])->update($update); + return json(['code' => 0, 'msg' => 'success']); + } + + /** + * @title 批量排序期刊外链 + * @description 拖拽排序后批量保存收录数据库外链顺序 + * @url /master/Journal/sortJournalAbs + * @method POST + * + * @param name:ids type:string require:1 desc:journal_abstracting_id 顺序数组(从大到小排序权重递减) + */ + public function sortJournalAbs() + { + $data = $this->request->post(); + $ids = isset($data['ids']) ? $data['ids'] : []; + if (!is_array($ids) || empty($ids)) { + return json(['code' => 1, 'msg' => 'ids 不能为空']); + } + // 数组顺序即展示顺序:越靠前 sort 越大 + $total = count($ids); + foreach ($ids as $index => $id) { + $this->journal_abs_obj->where('journal_abstracting_id', $id)->update(['sort' => $total - $index]); + } return json(['code' => 0, 'msg' => 'success']); } diff --git a/application/master/service/DbPushLogger.php b/application/master/service/DbPushLogger.php new file mode 100644 index 0000000..678f392 --- /dev/null +++ b/application/master/service/DbPushLogger.php @@ -0,0 +1,89 @@ +obj = Db::name('db_push_log'); + } + + /** + * 创建一条待推送日志,返回 log_id + */ + public function create($journalId, $stageId, $channelKey, $channelName, $operator = '', $articleId = 0) + { + $now = time(); + try { + return $this->obj->insertGetId([ + 'journal_id' => (int) $journalId, + 'journal_stage_id' => (int) $stageId, + 'article_id' => (int) $articleId, + 'channel' => $channelKey, + 'channel_name' => $channelName, + 'status' => self::STATUS_PENDING, + 'attempt' => 0, + 'message' => '', + 'operator' => $operator, + 'ctime' => $now, + 'utime' => $now, + ]); + } catch (\Throwable $e) { + // 日志表尚未创建时,不影响推送本身 + return 0; + } + } + + /** + * 标记结果 + */ + public function finish($logId, $ok, $message = '') + { + if (empty($logId)) { + return; + } + try { + $this->obj->where('log_id', $logId)->update([ + 'status' => $ok ? self::STATUS_SUCCESS : self::STATUS_FAIL, + 'message' => mb_substr((string) $message, 0, 2000), + 'utime' => time(), + ]); + $this->obj->where('log_id', $logId)->setInc('attempt'); + } catch (\Throwable $e) { + // ignore + } + } + + public function find($logId) + { + try { + return $this->obj->where('log_id', $logId)->find(); + } catch (\Throwable $e) { + return null; + } + } + + /** + * 某一期的推送情况 + */ + public function stageStatus($stageId) + { + try { + return $this->obj->where('journal_stage_id', $stageId)->order('log_id desc')->select(); + } catch (\Throwable $e) { + return []; + } + } +} diff --git a/application/master/service/DbPushManager.php b/application/master/service/DbPushManager.php new file mode 100644 index 0000000..a01d61c --- /dev/null +++ b/application/master/service/DbPushManager.php @@ -0,0 +1,202 @@ + 逐个执行(或入队) -> 写推送日志。 + */ +class DbPushManager +{ + /** 推送器标识 => 实现类 */ + protected static $registry = [ + 'portico' => PorticoPusher::class, + 'scopus' => ScopusPusher::class, + 'ebsco' => EbscoPusher::class, + 'cnki' => CnkiPusher::class, + 'email_data' => EmailDataPusher::class, + ]; + + /** @var DbPushLogger */ + protected $logger; + + public function __construct() + { + $this->logger = new DbPushLogger(); + } + + /** + * 实例化推送器 + * @return \app\master\service\push\PusherInterface|null + */ + public static function makePusher($code) + { + if (!isset(self::$registry[$code])) { + return null; + } + $class = self::$registry[$code]; + return new $class(); + } + + /** + * 解析某期刊应推送的渠道列表(配置驱动,数据表优先,回退默认配置) + * @return array 每项 [channel_key, name, pusher, options] + */ + public function resolveChannels($journalId) + { + try { + $rows = Db::name('db_channel')->where('state', 0)->where('is_enable', 1)->order('sort desc')->select(); + } catch (\Throwable $e) { + // 渠道表尚未创建时回退到默认配置 + $rows = []; + } + + $channels = []; + if (!empty($rows)) { + foreach ($rows as $r) { + $journalIds = trim($r['journal_ids']) === '' ? [] : array_map('intval', explode(',', $r['journal_ids'])); + $options = empty($r['config']) ? [] : (json_decode($r['config'], true) ?: []); + $channels[] = [ + 'channel_key' => $r['channel_key'], + 'name' => $r['name'], + 'pusher' => $r['pusher'] !== '' ? $r['pusher'] : $r['channel_key'], + 'journal_ids' => $journalIds, + 'options' => $options, + ]; + } + } else { + foreach ((array) config('dbpush.default_channels') as $c) { + if (empty($c['is_enable'])) { + continue; + } + $channels[] = [ + 'channel_key' => $c['channel_key'], + 'name' => $c['name'], + 'pusher' => !empty($c['pusher']) ? $c['pusher'] : $c['channel_key'], + 'journal_ids' => isset($c['journal_ids']) ? $c['journal_ids'] : [], + 'options' => isset($c['config']) ? $c['config'] : [], + ]; + } + } + + // 按期刊过滤:journal_ids 为空表示全部 + $result = []; + foreach ($channels as $c) { + if (empty($c['journal_ids']) || in_array((int) $journalId, $c['journal_ids'])) { + $result[] = $c; + } + } + return $result; + } + + /** + * 推送整期到所有匹配渠道 + * @param int $stageId + * @param string $operator 触发人 + * @param bool|null $async 是否异步,null 用配置默认 + * @return array 各渠道执行概况 + */ + public function run($stageId, $operator = '', $async = null) + { + if ($async === null) { + $async = (bool) config('dbpush.async'); + } + $stage_info = Db::name('journal_stage')->where('journal_stage_id', $stageId)->find(); + if (!$stage_info) { + return []; + } + $journalId = $stage_info['journal_id']; + $channels = $this->resolveChannels($journalId); + + $summary = []; + foreach ($channels as $c) { + $logId = $this->logger->create($journalId, $stageId, $c['channel_key'], $c['name'], $operator); + if ($async) { + Queue::push('app\api\job\dbpush@handle', [ + 'log_id' => $logId, + 'stage_id' => $stageId, + 'pusher' => $c['pusher'], + 'options' => $c['options'], + ], 'dbpush'); + $summary[] = ['channel' => $c['channel_key'], 'log_id' => $logId, 'queued' => true]; + } else { + $ok = $this->executeAndLog($logId, $stageId, $c['pusher'], $c['options']); + $summary[] = ['channel' => $c['channel_key'], 'log_id' => $logId, 'ok' => $ok]; + } + } + return $summary; + } + + /** + * 执行单个推送器并写入日志结果(供同步执行 / 队列任务调用) + * @return bool 是否成功 + */ + public function executeAndLog($logId, $stageId, $pusherCode, array $options = []) + { + try { + $pusher = self::makePusher($pusherCode); + if (!$pusher) { + $this->logger->finish($logId, false, '未知的推送器: ' . $pusherCode); + return false; + } + $result = $pusher->pushStage($stageId, $options); + if (!$result instanceof PushResult) { + $result = PushResult::success(); + } + $this->logger->finish($logId, $result->ok, $result->message); + return $result->ok; + } catch (\Throwable $e) { + $this->logger->finish($logId, false, $e->getMessage()); + return false; + } + } + + /** + * 重推某条日志 + * @return array [ok=>bool, queued=>bool] + */ + public function retry($logId, $async = null) + { + if ($async === null) { + $async = (bool) config('dbpush.async'); + } + $log = $this->logger->find($logId); + if (!$log) { + return ['ok' => false, 'queued' => false, 'msg' => '日志不存在']; + } + // 依据日志里的渠道标识重新解析推送器与配置 + $channels = $this->resolveChannels($log['journal_id']); + $pusher = $log['channel']; + $options = []; + foreach ($channels as $c) { + if ($c['channel_key'] === $log['channel']) { + $pusher = $c['pusher']; + $options = $c['options']; + break; + } + } + // 重置为待推送 + Db::name('db_push_log')->where('log_id', $logId)->update(['status' => DbPushLogger::STATUS_PENDING, 'utime' => time()]); + + if ($async) { + Queue::push('app\api\job\dbpush@handle', [ + 'log_id' => $logId, + 'stage_id' => $log['journal_stage_id'], + 'pusher' => $pusher, + 'options' => $options, + ], 'dbpush'); + return ['ok' => true, 'queued' => true]; + } + $ok = $this->executeAndLog($logId, $log['journal_stage_id'], $pusher, $options); + return ['ok' => $ok, 'queued' => false]; + } +} diff --git a/application/master/service/XmlBuilder.php b/application/master/service/XmlBuilder.php new file mode 100644 index 0000000..2f20075 --- /dev/null +++ b/application/master/service/XmlBuilder.php @@ -0,0 +1,162 @@ +where("article_id", $article_id)->find(); + $journal_info = $journal_obj->where("journal_id", $article_info['journal_id'])->find(); + + $file = ROOT_PATH . "public/articleXML/" . substr($article_info['doi'], strpos($article_info['doi'], "/") + 1) . ".xml"; + if (is_file($file)) { + return $file; + } + + $xml = new \SimpleXMLElement('
'); + // 设置命名空间 + $xml->addAttribute('xmlns:mml', 'http://www.w3.org/1998/Math/MathML'); + $xml->addAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink'); + $xml->addAttribute('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance'); + $xml->addAttribute('article-type', $article_info['type']); + + // 创建 元素 + $front = $xml->addChild('front'); + + // 创建 元素并添加子元素 + $journalMeta = $front->addChild('journal-meta'); + $journalMeta->addChild('journal-id', $journal_info['usx']); + $journalTitleGroup = $journalMeta->addChild('journal-title-group'); + $journalTitleGroup->addChild('journal-title', $journal_info['title'])->addAttribute('xml:lang', 'en'); + $journalTitleGroup->addChild('abbrev-journal-title', $journal_info['jabbr']); + $journalMeta->addChild('issn', $journal_info['issn'])->addAttribute('pub-type', 'ppub'); + $publisher = $journalMeta->addChild('publisher'); + $publisher->addChild('publisher-name', 'TMR publisher group'); + $publisher->addChild('publisher-name', 'TMR')->addAttribute('specific-use', 'short-name'); + + // 创建 元素并添加子元素 + $articleMeta = $front->addChild('article-meta'); + $articleMeta->addChild('article-id', $article_info['article_id']); + $articleMeta->addChild('article-id', $article_info['doi'])->addAttribute('pub-id-type', 'doi'); + + $articleCategories = $articleMeta->addChild('article-categories'); + $subjGroup = $articleCategories->addChild('subj-group'); + $subjGroup->addChild('subject', 'subject')->addAttribute('xml:lang', 'en'); + + $articleTypeGroup = $articleCategories->addChild('subj-group'); + $articleTypeGroup->addAttribute('id', 'article-type'); + $articleTypeGroup->addChild('subject', $article_info['type'])->addAttribute('xml:lang', 'en'); + + // 添加 元素 + $titleGroup = $articleMeta->addChild('title-group'); + $titleGroup->addChild('article-title', $article_info['title'])->addAttribute('xml:lang', 'en'); + $transTitleGroup = $titleGroup->addChild('trans-title-group'); + $transTitleGroup->addAttribute('xml:lang', 'en'); + $transTitleGroup->addChild('trans-title', $article_info['title'])->addAttribute('xml:lang', 'en'); + + // 添加 和相关的 元素 + $contribGroup = $articleMeta->addChild('contrib-group'); + $authors = $article_author_obj->where("article_id", $article_id)->where("state", 0)->select(); + $organs = $article_organ_obj->where("article_id", $article_id)->select(); + $os = []; + foreach ($organs as $k => $v) { + $os[$v['article_organ_id']] = $k + 1; + } + foreach ($authors as $v) { + $contrib_cache = $contribGroup->addChild('contrib'); + $contrib_cache->addAttribute('contrib-type', 'author'); + $nameAlternatives_cache = $contrib_cache->addChild('name-alternatives'); + $name_cache = $nameAlternatives_cache->addChild("name"); + $name_cache->addAttribute("name-style", "western"); + $name_cache->addAttribute("specific-use", "en"); + $name_cache->addAttribute("xml:lang", "en"); + $name_cache->addChild("surname", $v['first_name']); + $name_cache->addChild("given-names", $v['last_name']); + $l_c = $article_author_to_organ_obj->where("article_id", $article_id)->where("article_author_id", $v['article_author_id'])->where("state", 0)->select(); + foreach ($l_c as $val) { + $xref_cache = $contrib_cache->addChild("xref"); + $xref_cache->addAttribute("rid", "aff" . $os[$val['article_organ_id']]); + $xref_cache->addAttribute("xml:base", "aff"); + $xref_cache->addAttribute("xml:lang", "en"); + $xref_cache->addChild("sup", $os[$val['article_organ_id']]); + } + } + foreach ($organs as $v) { + $affAl_cache = $contribGroup->addChild("aff-alternatives"); + $affAl_cache->addAttribute("id", "aff" . $os[$v['article_organ_id']]); + $aff_cache = $affAl_cache->addChild("aff"); + $aff_cache->addAttribute("xml:lang", "en"); + $aff_cache->addChild("label", htmlspecialchars($os[$v['article_organ_id']] . " " . $v['organ_name'])); + $ins_cache = $aff_cache->addChild("institution ", htmlspecialchars($v['organ_name'])); + $ins_cache->addAttribute("content-type", 'orgname'); + } + + // pub-date + $stage_info = $journal_stage_obj->where("journal_stage_id", $article_info['journal_stage_id'])->find(); + if ($article_info['pub_date'] != "") { + $pubDate = $articleMeta->addChild("pub_date"); + $pub_date_list = explode(" ", $article_info['pub_date']); + $pubDate->addChild("year", $pub_date_list[2]); + $pubDate->addChild("month", $pub_date_list[1]); + } + $vol = $articleMeta->addChild("volume", $stage_info['stage_vol']); + $vol->addAttribute("content-type", "Vol."); + $issue = $articleMeta->addChild("issue", $article_info['npp']); + $issue->addAttribute("content-type", "No."); + + // abstract + $abstract = $articleMeta->addChild("abstract"); + $abstract->addAttribute("xml:lang", "en"); + $abs_p = $abstract->addChild("p", htmlspecialchars($article_info['abstract'], ENT_QUOTES | ENT_XML1, 'UTF-8')); + $abs_p->addAttribute("indent", "0mm"); + + // keywords + $kwd = $articleMeta->addChild("kwd-group"); + $kwd->addAttribute("kwd-group-type", "inspec"); + $kwd->addAttribute("xml:lang", "en"); + $keywords_list = explode(";", $article_info['keywords']); + foreach ($keywords_list as $v) { + if ($v == "") { + continue; + } + $kwd->addChild("kwd", trim($v)); + } + + // notes + $notes = $front->addChild("notes"); + $notes_p = $notes->addChild("p"); + $notes_p->addAttribute("ontent-type", "qrcode"); + $notes_p->addAttribute("xml:specific-use", "website"); + $url = "https://www.tmrjournals.com/article.html?J_num=" . $journal_info['journal_id'] . "&a_id=" . $article_info['article_id']; + $notes_p_uri = $notes_p->addChild("uri", htmlspecialchars($url)); + $notes_p_uri->addAttribute("xlink:href", htmlspecialchars($url)); + $notes_p1 = $notes->addChild("p"); + $notes_p1->addAttribute("xml:specific-use", "crossmark"); + $notes_p1_uri = $notes_p1->addChild("uri"); + $notes_p1_uri->addAttribute("xlink:href", "http://crossmark.crossref.org/dialog/?doi=" . $article_info['doi']); + + $x = $xml->asXML(); + + file_put_contents($file, $x); + return $file; + } +} diff --git a/application/master/service/push/AbstractPusher.php b/application/master/service/push/AbstractPusher.php new file mode 100644 index 0000000..6b618c7 --- /dev/null +++ b/application/master/service/push/AbstractPusher.php @@ -0,0 +1,53 @@ +journal_obj = Db::name('journal'); + $this->journal_stage_obj = Db::name('journal_stage'); + $this->article_obj = Db::name('article'); + $this->article_author_obj = Db::name('article_author'); + } + + /** + * 读取某个数据库的凭证配置 + * @param string $key credentials. + * @return array + */ + protected function cred($key) + { + $all = config('dbpush.credentials'); + return isset($all[$key]) ? $all[$key] : []; + } + + /** + * 读取顶层 dbpush 配置项 + */ + protected function conf($key, $default = null) + { + $val = config('dbpush.' . $key); + return $val === null ? $default : $val; + } + + /** + * 组合文章作者名(逗号分隔) + */ + protected function getAuthorNames($articleId) + { + $list = $this->article_author_obj->where('article_id', $articleId)->where('state', 0)->column('author_name'); + return implode(', ', $list); + } +} diff --git a/application/master/service/push/CnkiPusher.php b/application/master/service/push/CnkiPusher.php new file mode 100644 index 0000000..9ae7cbf --- /dev/null +++ b/application/master/service/push/CnkiPusher.php @@ -0,0 +1,146 @@ +journal_stage_obj->where("journal_stage_id", $journal_stage_id)->find(); + $journalInfo = $this->journal_obj->where('journal_id', $stage_info['journal_id'])->find(); + $articles = $this->article_obj->where(['journal_stage_id' => $journal_stage_id, 'state' => 0]) + ->field('title as articleTitle,abbr as author,pub_date as date,keywords as keyWords,doi,abstract,file_pdf as pdf,article_id,journal_id') + ->select(); + + foreach ($articles as $k => $v) { + $articles[$k]['abstract'] = strip_tags($v['abstract']); + $articles[$k]['journalTitle'] = $journalInfo['title']; + $articles[$k]['issn'] = $journalInfo['issn']; + $articles[$k]['vol'] = $stage_info['stage_vol']; + $articles[$k]['issue'] = $stage_info['stage_no']; + $articles[$k]['pdf'] = 'https://www.tmrjournals.com/public/articlePDF/' . $v['pdf']; + $articles[$k]['linkToAbstract'] = 'https://www.tmrjournals.com/article.html?J_num=' . $v['journal_id'] . '&a_id=' . $v['article_id']; + $articles[$k]['publisher'] = 'TMR publishing group'; + $articles[$k]['reference'] = ''; + $articles[$k]['eissn'] = ''; + } + $baseDir = ROOT_PATH . 'public' . DS . 'ftpFile' . DS; + $outfileTitle = $journalInfo['title'] . "-" . $stage_info['stage_vol'] . "卷" . $stage_info['stage_no'] . "期"; + + $res = $this->createZip($articles, $baseDir, $outfileTitle); + $resJosn = json_decode($res, true); + if ($resJosn['code'] != 0) { + throw new \Exception('CNKI zip 生成失败: ' . $resJosn['msg']); + } + $url = $baseDir . $outfileTitle . ".zip"; + $this->sendFtp($url, $outfileTitle); + + return PushResult::success('CNKI zip 已生成并上传'); + } + + private function createZip($articles, $baseDir, $outfileTitle) + { + Vendor('PHPExcel.PHPExcel'); + Vendor('PHPExcel.PHPExcel.Worksheet.Drawing'); + Vendor('PHPExcel.PHPExcel.Writer.Excel2007'); + $objExcel = new \PHPExcel(); + $objWriter = \PHPExcel_IOFactory::createWriter($objExcel, 'Excel2007'); + + $objActSheet = $objExcel->getActiveSheet(); + $letter = explode(',', "A,B,C,D,E,F,G,H,I,J,K,L,M,N,O"); + $arrHeader = array('Article Title', 'Author', 'Publishing Date', 'Key Words', 'Journal Title', 'ISSN', 'EISSN', 'Volume', 'Issue', 'Link to Full-Text Articles (PDF)', 'Abstract', 'DOI', 'Link to Abstract', 'Publisher', 'Reference'); + $lenth = count($arrHeader); + for ($i = 0; $i < $lenth; $i++) { + $objActSheet->setCellValue("$letter[$i]1", "$arrHeader[$i]"); + $objActSheet->getRowDimension('1')->setRowHeight(25); + } + foreach ($articles as $k => $v) { + $k += 2; + $objActSheet->setCellValue('A' . $k, $v['articleTitle']); + $objActSheet->setCellValue('B' . $k, $v['author']); + $objActSheet->setCellValue('C' . $k, $v['date']); + $objActSheet->setCellValue('D' . $k, $v['keyWords']); + $objActSheet->setCellValue('E' . $k, $v['journalTitle']); + $objActSheet->setCellValue('F' . $k, $v['issn']); + $objActSheet->setCellValue('G' . $k, $v['eissn']); + $objActSheet->setCellValue('H' . $k, $v['vol']); + $objActSheet->setCellValue('I' . $k, $v['issue']); + $objActSheet->setCellValue('J' . $k, $v['pdf']); + $objActSheet->setCellValue('K' . $k, $v['abstract']); + $objActSheet->setCellValue('L' . $k, $v['doi']); + $objActSheet->setCellValue('M' . $k, $v['linkToAbstract']); + $objActSheet->setCellValue('N' . $k, $v['publisher']); + $objActSheet->setCellValue('O' . $k, $v['reference']); + $objActSheet->getRowDimension($k)->setRowHeight(25); + } + $width = array(10, 15, 20, 25, 30); + $objActSheet->getColumnDimension('A')->setWidth($width[4]); + $objActSheet->getColumnDimension('B')->setWidth($width[2]); + $objActSheet->getColumnDimension('C')->setWidth($width[1]); + $objActSheet->getColumnDimension('D')->setWidth($width[4]); + $objActSheet->getColumnDimension('E')->setWidth($width[4]); + $objActSheet->getColumnDimension('F')->setWidth($width[2]); + $objActSheet->getColumnDimension('G')->setWidth($width[3]); + $objActSheet->getColumnDimension('H')->setWidth($width[1]); + $objActSheet->getColumnDimension('I')->setWidth($width[1]); + $objActSheet->getColumnDimension('J')->setWidth($width[4]); + $objActSheet->getColumnDimension('K')->setWidth($width[3]); + $objActSheet->getColumnDimension('L')->setWidth($width[3]); + $objActSheet->getColumnDimension('M')->setWidth($width[4]); + $objActSheet->getColumnDimension('N')->setWidth($width[2]); + $objActSheet->getColumnDimension('O')->setWidth($width[1]); + if (!is_dir($baseDir)) { + @mkdir($baseDir, 0777, true); + } + $objWriter->save($baseDir . $outfileTitle . ".xlsx"); + + $path = $baseDir . $outfileTitle . ".xlsx"; + $fileName = $outfileTitle . ".zip"; + $zip = new \ZipArchive; + $zip->open($baseDir . $fileName, \ZipArchive::CREATE); + $zip->addFile($path, basename($path)); + $zip->close(); + unlink($baseDir . $outfileTitle . ".xlsx"); + + if (!file_exists($baseDir . $fileName)) { + return json_encode(['code' => 1, 'msg' => '无法找到文件']); + } + return json_encode(['code' => 0, 'msg' => '成功', 'data' => $baseDir . $fileName]); + } + + private function sendFtp($url, $title) + { + $cred = $this->cred('cnki'); + $con = ftp_connect($cred['host'], $cred['port']); + if (!$con) { + throw new \Exception('CNKI FTP 连接失败'); + } + $res = ftp_login($con, $cred['user'], $cred['pass']); + if (!$res) { + ftp_close($con); + throw new \Exception('CNKI FTP 登录失败'); + } + $name = iconv("UTF-8", "GBK", $title . '.zip'); + ftp_pasv($con, true); + $data = ftp_put($con, '/' . $name, $url, FTP_BINARY); + ftp_close($con); + if (!$data) { + throw new \Exception('CNKI FTP 上传失败'); + } + } +} diff --git a/application/master/service/push/EbscoPusher.php b/application/master/service/push/EbscoPusher.php new file mode 100644 index 0000000..fcb84f2 --- /dev/null +++ b/application/master/service/push/EbscoPusher.php @@ -0,0 +1,62 @@ +journal_stage_obj->where("journal_stage_id", $stage_id)->find(); + $list = $this->article_obj->where("journal_stage_id", $stage_id)->select(); + $journal_info = $this->journal_obj->where("journal_id", $stage_info['journal_id'])->find(); + $count = 0; + foreach ($list as $v) { + $local_file = XmlBuilder::forArticle($v['article_id']); + $file = substr($v['doi'], strpos($v['doi'], "/") + 1) . ".xml"; + $remote_file = "/" . $journal_info['title'] . "/" . $stage_info['stage_year'] . "v" . $stage_info['stage_vol'] . "n" . $stage_info['stage_no'] . "-" . $file; + $this->pushFtp($remote_file, $local_file); + $localPdf = ROOT_PATH . "public/articlePDF/" . $v['file_pdf']; + $remotePdf = "/" . $journal_info['title'] . "/" . $stage_info['stage_year'] . "v" . $stage_info['stage_vol'] . "n" . $stage_info['stage_no'] . "-" . substr($v['doi'], strpos($v['doi'], "/") + 1) . ".pdf"; + $this->pushFtp($remotePdf, $localPdf); + $count++; + } + return PushResult::success("EBSCO 已上传 {$count} 篇文章的 XML/PDF"); + } + + private function pushFtp($remote_file, $local_file) + { + $cred = $this->cred('ebsco'); + $con = ftp_connect($cred['host'], $cred['port']); + if (!$con) { + throw new \Exception('EBSCO FTP 连接失败'); + } + $res = ftp_login($con, $cred['user'], $cred['pass']); + if (!$res) { + ftp_close($con); + throw new \Exception('EBSCO FTP 登录失败'); + } + ftp_pasv($con, true); + $data = ftp_put($con, $remote_file, $local_file, FTP_BINARY); + ftp_close($con); + if (!$data) { + throw new \Exception('EBSCO FTP 上传失败: ' . $remote_file); + } + } +} diff --git a/application/master/service/push/EmailDataPusher.php b/application/master/service/push/EmailDataPusher.php new file mode 100644 index 0000000..2b14c72 --- /dev/null +++ b/application/master/service/push/EmailDataPusher.php @@ -0,0 +1,106 @@ +journal_stage_obj->where("journal_stage_id", $journal_stage_id)->find(); + $journal_info = $this->journal_obj->where("journal_id", $stage_info["journal_id"])->find(); + + // 幂等构建整期附件 zip + $zipfile = ROOT_PATH . "public/dataFile/" . $journal_stage_id . '/' . $journal_stage_id . '.zip'; + if (!file_exists($zipfile)) { + $this->buildIssueZip($journal_stage_id, $stage_info, $journal_info); + } + + // 收件人:渠道配置优先,否则用默认通用收件人 + $recipients = !empty($options['recipients']) ? $options['recipients'] : $this->conf('data_recipients', []); + if (empty($recipients)) { + return PushResult::fail('未配置收件人'); + } + + $tt = "Dear Database,

"; + $tt .= "Please find the PDF files in attachment of published issue (Vol. " . $stage_info['stage_vol'] . ", No." . $stage_info['stage_no'] . ") of " . $journal_info['title'] . ".

"; + $tt .= "Yours Sincerely
"; + $tt .= "Dan Chen
"; + $tt .= "Manager
"; + $tt .= "TMR Publishing Group | Editorial Office | New Zealand
"; + $tt .= "Telephone: +64 02108293806
"; + $tt .= "Email: publisher@tmrjournals.com
"; + $tt .= "Website:www.tmrjournals.com"; + + foreach ($recipients as $v) { + $cdata['email'] = $v; + $cdata['title'] = "Data submitted-TMR Publishing Group"; + $cdata["fromname"] = "Data submitted-TMR Publishing Group"; + $cdata["content"] = $tt; + $cdata["temail"] = $journal_info['email']; + $cdata["tpassword"] = $journal_info['epassword']; + $cdata["fj"] = $zipfile; + Queue::push('app\api\job\mail@puchAndFJ', $cdata, "mail"); + } + + return PushResult::success('已向 ' . count($recipients) . ' 个收件人加入邮件队列'); + } + + /** + * 构建整期 zip(Contents.docx + 各文章 PDF + 封面) + */ + private function buildIssueZip($journal_stage_id, $stage_info, $journal_info) + { + $ts_base_url = $this->conf('ts_base_url'); + $pdfBase = $this->conf('article_pdf'); + $publicBase = $this->conf('public_base'); + + $url = $ts_base_url . "api/dataApi/createTemplate"; + $re['issue'] = $stage_info['issue_date'] . ", Volume " . $stage_info['stage_vol'] . " Issue " . $stage_info["stage_no"]; + $adate = []; + $alist = $this->article_obj->where("journal_stage_id", $journal_stage_id)->where("state", 0)->orderRaw("npp + 0")->select(); + foreach ($alist as $v) { + $cache["type"] = $v["type"]; + $cache["no"] = $v["npp"]; + $cache["title"] = $v["title"]; + $cache["author"] = $this->getAuthorNames($v['article_id']); + $adate[] = $cache; + } + $re["date_list"] = json_encode($adate); + $res = myPost($url, $re); + $r = object_to_array(json_decode($res)); + $file = $r['data']["file"]; + + $base_dir = ROOT_PATH . "public/dataFile/" . $stage_info["journal_stage_id"]; + if (!is_dir($base_dir)) { + @mkdir($base_dir, 0777, true); + } + $zip = new \ZipArchive; + $zip->open($base_dir . '/' . $stage_info["journal_stage_id"] . '.zip', \ZipArchive::CREATE); + copy($ts_base_url . 'upload/' . $file, $base_dir . '/' . $stage_info["journal_stage_id"] . ".docx"); + $zip->addFile($base_dir . '/' . $stage_info["journal_stage_id"] . ".docx", "Contents.docx"); + foreach ($alist as $v) { + copy($pdfBase . $v['file_pdf'], $base_dir . '/' . $v["npp"] . ".pdf"); + $zip->addFile($base_dir . '/' . $v["npp"] . ".pdf", $v["npp"] . ".pdf"); + } + copy($publicBase . $stage_info["stage_icon"], $base_dir . "/journal.jpg"); + $zip->addFile($base_dir . "/journal.jpg", "journal.jpg"); + $zip->close(); + } +} diff --git a/application/master/service/push/PorticoPusher.php b/application/master/service/push/PorticoPusher.php new file mode 100644 index 0000000..5dce2c5 --- /dev/null +++ b/application/master/service/push/PorticoPusher.php @@ -0,0 +1,158 @@ +conf('article_pdf'); + $subBase = $this->conf('article_sub'); + + $baseDir = ROOT_PATH . 'public' . DS . 'davExcel' . DS . $stage . DS; + if (!is_dir($baseDir)) { + @mkdir($baseDir, 0777, true); + } + $objExcel = new \PHPExcel(); + $objWriter = \PHPExcel_IOFactory::createWriter($objExcel, 'Excel2007'); + + $objActSheet = $objExcel->getActiveSheet(); + $letter = explode(',', "A,B,C,D,E,F,G,H,I,J,K,L,M,N"); + $arrHeader = array('Journal Title', 'Journal P-ISSN', 'Journal E-ISSN', 'Article DOI/ID', 'PDF File Name', 'Article Title', 'Authors', 'Publication Date', 'Volume', 'Issue', 'Supplement', 'First Page', 'Last Page', 'Copyright Statement'); + $lenth = count($arrHeader); + for ($i = 0; $i < $lenth; $i++) { + $objActSheet->setCellValue("$letter[$i]1", "$arrHeader[$i]"); + $objActSheet->getRowDimension('1')->setRowHeight(25); + } + + $stage_info = $this->journal_stage_obj->where('journal_stage_id', $stage)->find(); + $journal_info = $this->journal_obj->where('journal_id', $stage_info['journal_id'])->find(); + $articles = $this->article_obj + ->field('j_article.*,j_journal.title journalTitle,j_journal.issn') + ->join("j_journal", "j_article.journal_id = j_journal.journal_id", 'left') + ->where('j_article.journal_stage_id', $stage) + ->where('j_article.state', 0) + ->select(); + + foreach ($articles as $k => $v) { + $k += 2; + $objActSheet->setCellValue('A' . $k, choiseti1($v['article_id'], $v['journalTitle'])); + $objActSheet->setCellValue('B' . $k, $v['issn']); + $objActSheet->setCellValue('C' . $k, ''); + $objActSheet->setCellValue('D' . $k, $v['doi']); + $fpdf = explode("/", $v['file_pdf']); + $objActSheet->setCellValue('E' . $k, $fpdf[1]); + copy($pdfBase . $v['file_pdf'], $baseDir . $fpdf[1]); + $objActSheet->setCellValue('F' . $k, $v['title']); + + $cache_author = $this->article_author_obj->where('article_id', $v['article_id'])->where('state', 0)->column('author_name'); + $objActSheet->setCellValue('G' . $k, implode(",", $cache_author)); + $objActSheet->setCellValue('H' . $k, $v['pub_date']); + $objActSheet->setCellValue('I' . $k, $stage_info['stage_vol']); + if (($journal_info['journal_id'] == 2 && $stage_info['stage_year'] >= 2022) || ($journal_info['journal_id'] == 3 && $stage_info['stage_year'] >= 2022) || ($journal_info['journal_id'] == 17 && $stage_info['stage_year'] >= 2019)) { + $objActSheet->setCellValue('J' . $k, ''); + } else { + $objActSheet->setCellValue('J' . $k, $stage_info['issue_date']); + } + + if ($v['file_sub'] != '') { + $spdf = explode("/", $v['file_sub']); + copy($subBase . $v['file_sub'], $baseDir . $spdf[1]); + $objActSheet->setCellValue('K' . $k, $spdf[1]); + } else { + $objActSheet->setCellValue('K' . $k, ''); + } + + $npp = trim($v['npp']); + $npp = str_replace('–', '-', $npp); + $npp = str_replace("—", '-', $npp); + if (strpos($npp, '-') == false) { + $objActSheet->setCellValue('L' . $k, ''); + $objActSheet->setCellValue('M' . $k, ''); + } else { + $cc = explode('-', trim($npp)); + $objActSheet->setCellValue('L' . $k, $cc[0]); + $objActSheet->setCellValue('M' . $k, $cc[1]); + } + $objActSheet->setCellValue('N' . $k, "© " . $stage_info['stage_year'] . " By Author(s). Published by TMR Publishing Group Limited. This is an open access article under the CC-BY license. (http://creativecommons.org/licenses/BY/4.0/)"); + $objActSheet->getRowDimension($k)->setRowHeight(25); + } + $width = array(10, 15, 20, 25, 30); + $objActSheet->getColumnDimension('A')->setWidth($width[2]); + $objActSheet->getColumnDimension('B')->setWidth($width[1]); + $objActSheet->getColumnDimension('C')->setWidth($width[1]); + $objActSheet->getColumnDimension('D')->setWidth($width[2]); + $objActSheet->getColumnDimension('E')->setWidth($width[4]); + $objActSheet->getColumnDimension('F')->setWidth($width[4]); + $objActSheet->getColumnDimension('G')->setWidth($width[4]); + $objActSheet->getColumnDimension('H')->setWidth($width[2]); + $objActSheet->getColumnDimension('I')->setWidth($width[1]); + $objActSheet->getColumnDimension('J')->setWidth($width[1]); + $objActSheet->getColumnDimension('K')->setWidth($width[4]); + $objActSheet->getColumnDimension('L')->setWidth($width[1]); + $objActSheet->getColumnDimension('M')->setWidth($width[1]); + $objActSheet->getColumnDimension('N')->setWidth($width[4]); + + $outfileTitle = $stage_info['journal_stage_id'] . "dav"; + $objWriter->save($baseDir . $outfileTitle . ".xlsx"); + + $this->pushSftp($stage, $stage_info, $journal_info); + + return PushResult::success('Portico 文件已生成并加入 SFTP 上传队列'); + } + + /** + * 创建远程目录并将目录内文件通过队列上传到 Portico SFTP + */ + private function pushSftp($stage, $stage_info, $journal_info) + { + $cred = $this->cred('portico'); + // 连接 sftp 服务器,确保远程目录存在 + $config['host'] = $cred['host']; + $config['port'] = $cred['port']; + $config['username'] = $cred['user']; + $config['password'] = $cred['pass']; + $sftp_obj = new Sftp($config); + + $j_base = $sftp_obj->ssh2_dir_exits("/SPREADSHEET/" . $journal_info['issn']); + if (!$j_base) { + $sftp_obj->ssh2_sftp_mchkdir("/SPREADSHEET/" . $journal_info['issn']); + } + $m_base = $sftp_obj->ssh2_dir_exits("/SPREADSHEET/" . $journal_info['issn'] . '/' . $stage_info['stage_year'] . '-' . $stage_info['stage_vol'] . '-' . $stage_info['stage_no']); + if (!$m_base) { + $sftp_obj->ssh2_sftp_mchkdir("/SPREADSHEET/" . $journal_info['issn'] . '/' . $stage_info['stage_year'] . '-' . $stage_info['stage_vol'] . '-' . $stage_info['stage_no']); + } + + $files = scandir(ROOT_PATH . 'public' . DS . 'davExcel' . DS . $stage); + foreach ($files as $v) { + if ($v == '.' || $v == '..') { + continue; + } + $cdata['local'] = ROOT_PATH . 'public' . DS . 'davExcel' . DS . $stage . DS . $v; + $cdata['remote'] = "/SPREADSHEET/" . $journal_info['issn'] . '/' . $stage_info['stage_year'] . '-' . $stage_info['stage_vol'] . '-' . $stage_info['stage_no'] . "/" . $v; + Queue::push('app\api\job\mysftp@push', $cdata, "mysftp"); + } + } +} diff --git a/application/master/service/push/PushResult.php b/application/master/service/push/PushResult.php new file mode 100644 index 0000000..aab417c --- /dev/null +++ b/application/master/service/push/PushResult.php @@ -0,0 +1,30 @@ +ok = (bool) $ok; + $this->message = (string) $message; + } + + public static function success($message = 'success') + { + return new self(true, $message); + } + + public static function fail($message) + { + return new self(false, $message); + } +} diff --git a/application/master/service/push/PusherInterface.php b/application/master/service/push/PusherInterface.php new file mode 100644 index 0000000..505aecd --- /dev/null +++ b/application/master/service/push/PusherInterface.php @@ -0,0 +1,30 @@ +article_obj->where("journal_stage_id", $stage_id)->where("state", 0)->select(); + $stage_info = $this->journal_stage_obj->where("journal_stage_id", $stage_id)->find(); + $count = 0; + foreach ($list as $v) { + XmlBuilder::forArticle($v['article_id']); + $file = substr($v['doi'], strpos($v['doi'], "/") + 1) . ".xml"; + pushSFTPForScopus($file, $v['file_pdf'], $stage_info); + $count++; + } + return PushResult::success("Scopus 已上传 {$count} 篇文章的 XML/PDF"); + } +} diff --git a/sql/db_module_enhance.sql b/sql/db_module_enhance.sql new file mode 100644 index 0000000..8f384ac --- /dev/null +++ b/sql/db_module_enhance.sql @@ -0,0 +1,69 @@ +-- ===================================================================== +-- 数据库收录模块增强 - 迁移脚本 +-- 用途:新增 “推送日志表” 与 “推送渠道配置表” +-- 表前缀:j_ (与 application/database.php 的 prefix 一致) +-- 执行方式:在 phpMyAdmin / Navicat 选中 journal 库后直接执行本文件 +-- 可重复执行(IF NOT EXISTS),不会影响已有数据 +-- ===================================================================== + +SET NAMES utf8; + +-- --------------------------------------------------------------------- +-- 推送日志表:记录每一期 / 每一篇文章推送到各数据库的结果,支持重推 +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `j_db_push_log` ( + `log_id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + `journal_id` INT NOT NULL DEFAULT 0 COMMENT '期刊id', + `journal_stage_id` INT NOT NULL DEFAULT 0 COMMENT '分期id', + `article_id` INT NOT NULL DEFAULT 0 COMMENT '文章id,0表示整期推送', + `channel` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '渠道标识 portico/scopus/ebsco/cnki/email_data...', + `channel_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '渠道中文/展示名', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待推送/排队中 1成功 2失败', + `attempt` INT NOT NULL DEFAULT 0 COMMENT '已尝试次数', + `message` TEXT NULL COMMENT '返回信息 / 失败原因', + `operator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '触发人', + `ctime` INT NOT NULL DEFAULT 0 COMMENT '创建时间戳', + `utime` INT NOT NULL DEFAULT 0 COMMENT '更新时间戳', + PRIMARY KEY (`log_id`), + KEY `idx_stage_channel` (`journal_stage_id`, `channel`), + KEY `idx_journal` (`journal_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='数据库收录推送日志'; + +-- --------------------------------------------------------------------- +-- 推送渠道配置表:把 “哪些期刊推给哪些数据库” 从代码硬编码改为可配置 +-- 若该表为空,程序会回退到 application/extra/dbpush.php 里的默认配置, +-- 因此本表可以逐步维护、不影响现有行为。 +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `j_db_channel` ( + `channel_id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + `channel_key` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '渠道唯一标识(与推送器对应)', + `name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '展示名', + `transport` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '传输方式 sftp/ftp/email', + `pusher` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '推送器标识,留空则同 channel_key', + `journal_ids` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '适用期刊id,逗号分隔;留空=全部期刊', + `config` TEXT NULL COMMENT 'JSON 额外配置(如邮件收件人)', + `is_enable` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用 1是0否', + `sort` INT NOT NULL DEFAULT 0 COMMENT '排序(大在前)', + `ctime` INT NOT NULL DEFAULT 0 COMMENT '创建时间戳', + `utime` INT NOT NULL DEFAULT 0 COMMENT '更新时间戳', + `state` TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1删除', + PRIMARY KEY (`channel_id`), + KEY `idx_key` (`channel_key`), + KEY `idx_state` (`state`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='数据库收录推送渠道配置'; + +-- --------------------------------------------------------------------- +-- 初始化渠道配置:与当前代码写死的行为保持一致,方便直接切换到配置驱动 +-- journal_ids 为空表示全部期刊 +-- --------------------------------------------------------------------- +INSERT INTO `j_db_channel` (`channel_key`, `name`, `transport`, `pusher`, `journal_ids`, `config`, `is_enable`, `sort`, `ctime`) +SELECT * FROM ( + SELECT 'portico' AS channel_key, 'Portico' AS name, 'sftp' AS transport, 'portico' AS pusher, '' AS journal_ids, NULL AS config, 1 AS is_enable, 90 AS sort, UNIX_TIMESTAMP() AS ctime + UNION ALL SELECT 'scopus', 'Scopus (Elsevier)', 'sftp', 'scopus', '1,11', NULL, 1, 80, UNIX_TIMESTAMP() + UNION ALL SELECT 'ebsco', 'EBSCO', 'ftp', 'ebsco', '2,8,20,15,9,10,3,1,14,11,4,18', NULL, 1, 70, UNIX_TIMESTAMP() + UNION ALL SELECT 'cnki', 'CNKI 中国知网', 'ftp', 'cnki', '', NULL, 0, 60, UNIX_TIMESTAMP() + UNION ALL SELECT 'email_data', '通用数据库邮件送交', 'email', 'email_data', '', NULL, 1, 50, UNIX_TIMESTAMP() + UNION ALL SELECT 'email_wanfang','万方数据库', 'email', 'email_data', '1', '{"recipients":["958518573@qq.com","swyy2@wanfangdata.com.cn"]}', 1, 40, UNIX_TIMESTAMP() +) AS seed +WHERE NOT EXISTS (SELECT 1 FROM `j_db_channel` LIMIT 1);