Merge remote-tracking branch 'origin/master'

This commit is contained in:
wangjinlei
2026-07-15 13:19:04 +08:00
31 changed files with 10702 additions and 204 deletions

View File

@@ -0,0 +1,299 @@
<?php
/**
* AI 工具接口 — 编辑辅助AI 辅助写作风险分析
*/
namespace app\api\controller;
use app\common\ArticleParserService;
use app\common\mq\AiWritingRiskMqPublisher;
use app\common\PlagiarismService;
use app\common\service\AiWritingRiskTaskService;
use think\Validate;
class Aitools extends Base
{
public function __construct(\think\Request $request = null)
{
parent::__construct($request);
}
/**
* @title 写作风险检测可视化页面(前后端分离入口)
* @description 仅渲染 HTML检测与进度走 JSON 接口
*/
public function index()
{
return $this->renderRiskPage();
}
/**
* @title 开始 AI 辅助写作风险检测(异步入队)
* @description 立即返回 task_id后台 RabbitMQ 消费执行检测
* @param url 稿件链接地址(.docx
* @param p_article_id 可选业务稿件ID
*/
public function realtimeWritingRiskDetect()
{
$data = $this->request->param();
$rule = new Validate([
'url' => 'require',
]);
if (!$rule->check($data)) {
return $this->detectJsonError($rule->getError());
}
$url = trim((string) $data['url']);
$pArticleId = intval($data['p_article_id'] ?? 0);
try {
$taskService = new AiWritingRiskTaskService();
$created = $taskService->createTask($url, $pArticleId);
// 新任务,或复用但仍在排队(可能丢消息)时重新投递
if (empty($created['reused']) || intval($created['status']) === AiWritingRiskTaskService::STATUS_PENDING) {
(new AiWritingRiskMqPublisher())->publishTaskStart(intval($created['task_id']), 'enqueue');
}
return json([
'code' => 200,
'msg' => !empty($created['reused']) ? '已有进行中的检测任务' : '已开始检测',
'data' => [
'task_id' => $created['task_id'],
'task_no' => $created['task_no'],
'status' => $created['status'],
'status_text' => intval($created['status']) === 1 ? 'running' : 'queued',
'progress_percent' => 0,
'message' => $created['message'],
'reused' => !empty($created['reused']),
],
]);
} catch (\Exception $e) {
return $this->detectJsonError($e->getMessage());
}
}
/**
* @title 刷新 AI 辅助写作风险检测进度
* @description
* - 浏览器 GET无 format=json→ 可视化页面:未完成显示进度,完成后显示结果
* - POST 或 format=json → JSON供前端轮询
* @param task_id 任务ID与 task_no 二选一)
* @param task_no 任务号(与 task_id 二选一)
*/
public function realtimeWritingRiskProgress()
{
$data = $this->request->param();
$taskId = intval($data['task_id'] ?? 0);
$taskNo = trim((string) ($data['task_no'] ?? ''));
if ($taskId <= 0 && $taskNo === '') {
if ($this->wantsProgressJson()) {
return $this->detectJsonError('task_id 或 task_no 必填');
}
return $this->renderRiskPage();
}
try {
$progress = (new AiWritingRiskTaskService())->getProgress($taskNo, $taskId);
} catch (\Exception $e) {
if ($this->wantsProgressJson()) {
return $this->detectJsonError($e->getMessage());
}
return $this->renderRiskPage([
'page_error' => $e->getMessage(),
'task_id' => $taskId,
'task_no' => $taskNo,
]);
}
if ($this->wantsProgressJson()) {
return json([
'code' => 200,
'msg' => 'success',
'data' => $progress,
]);
}
return $this->renderRiskPage([
'task_id' => intval($progress['task_id'] ?? $taskId),
'task_no' => (string) ($progress['task_no'] ?? $taskNo),
'initial_data' => $progress,
]);
}
/**
* 是否返回 JSONPOST、format=json、XHR/fetch 显式要 JSON
*/
private function wantsProgressJson(): bool
{
if (strtolower((string) $this->request->param('format', '')) === 'json') {
return true;
}
if ($this->request->isAjax()) {
return true;
}
$accept = strtolower((string) $this->request->header('accept', ''));
if (strpos($accept, 'application/json') !== false) {
return true;
}
// 页面内 fetch 使用 POST
if ($this->request->isPost()) {
return true;
}
return false;
}
/**
* 渲染可视化页
* @param array $opts task_id/task_no/initial_data/page_error
*/
private function renderRiskPage(array $opts = [])
{
$apiStart = (string) url('api/aitools/realtimeWritingRiskDetect');
$apiProgress = (string) url('api/aitools/realtimeWritingRiskProgress');
$initial = isset($opts['initial_data']) && is_array($opts['initial_data'])
? $opts['initial_data']
: null;
$this->assign([
'api_start_js' => json_encode($apiStart, JSON_UNESCAPED_SLASHES),
'api_progress_js' => json_encode($apiProgress, JSON_UNESCAPED_SLASHES),
'init_task_id_js' => json_encode(intval($opts['task_id'] ?? 0)),
'init_task_no_js' => json_encode((string) ($opts['task_no'] ?? ''), JSON_UNESCAPED_UNICODE),
'init_data_js' => json_encode($initial, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'page_error_js' => json_encode((string) ($opts['page_error'] ?? ''), JSON_UNESCAPED_UNICODE),
]);
return $this->fetch('aiTools/index');
}
/**
* @title AI 辅助写作风险分析(编辑辅助)
* @description 解析稿件结构,并从模版句、重复短语、句长特征等维度输出风险报告
* @param url 稿件链接地址(.docx
*/
public function writingRiskAnalysis()
{
$data = $this->request->param();
$rule = new Validate([
'url' => 'require',
]);
if (!$rule->check($data)) {
return jsonError($rule->getError());
}
try {
$filePath = $this->resolveManuscriptUrl($data['url']);
} catch (\Exception $e) {
return jsonError($e->getMessage());
}
$result = ArticleParserService::analyzeWritingRisk($filePath);
if (empty($result['status']) || intval($result['status']) !== 1) {
return jsonError(empty($result['msg']) ? '稿件分析失败' : $result['msg']);
}
return jsonSuccess([
'manuscript' => $this->pickManuscriptFields($result['data']),
'risk_analysis' => $result['data']['risk_analysis'] ?? [],
'metrics' => $this->pickMetricFields($result['data']),
]);
}
/**
* @title 根据稿件链接解析稿件结构
* @description 从稿件地址解析标题、摘要、关键词及 IMRaD 正文分节(不含风险综述)
* @param url 稿件链接地址(.docx
*/
public function parseManuscript()
{
$data = $this->request->param();
$rule = new Validate([
'url' => 'require',
]);
if (!$rule->check($data)) {
return jsonError($rule->getError());
}
try {
$filePath = $this->resolveManuscriptUrl($data['url']);
} catch (\Exception $e) {
return jsonError($e->getMessage());
}
$result = ArticleParserService::parseManuscriptStructure($filePath);
if (empty($result['status']) || intval($result['status']) !== 1) {
return jsonError(empty($result['msg']) ? '稿件解析失败' : $result['msg']);
}
return jsonSuccess($result['data']);
}
private function detectJsonError($msg)
{
return json(['code' => 500, 'msg' => (string) $msg, 'data' => []]);
}
private function pickManuscriptFields(array $data): array
{
$keys = ['title', 'abstract', 'keywords', 'Introduction', 'Methods', 'Results', 'Discussion', 'Conclusion', 'References'];
$out = [];
foreach ($keys as $key) {
if (array_key_exists($key, $data)) {
$out[$key] = $data[$key];
}
}
return $out;
}
private function pickMetricFields(array $data): array
{
$keys = [
'sentence_stats',
'sentence_stats_by_section',
'repeated_phrases',
'repeated_phrases_by_section',
'template_sentence_stats',
];
$out = [];
foreach ($keys as $key) {
if (array_key_exists($key, $data)) {
$out[$key] = $data[$key];
}
}
return $out;
}
/**
* 将稿件链接解析为本地可读文件路径
*/
private function resolveManuscriptUrl($url)
{
$url = trim((string)$url);
if ($url === '') {
throw new \Exception('稿件地址不能为空');
}
if (preg_match('/^([a-zA-Z]:[\\\\\/]|\/)/', $url) && is_file($url)) {
return $url;
}
if (preg_match('#^https?://#i', $url)) {
try {
return (new PlagiarismService())->resolveFileUrlToLocal($url);
} catch (\Exception $e) {
$path = parse_url($url, PHP_URL_PATH);
if ($path) {
$local = rtrim(ROOT_PATH, '/') . $path;
if (is_file($local)) {
return $local;
}
}
throw $e;
}
}
$local = rtrim(ROOT_PATH, '/') . '/public/' . ltrim(ltrim($url, '/'), 'public');
if (is_file($local)) {
return $local;
}
throw new \Exception('无法解析稿件文件路径: ' . $url);
}
}

View File

@@ -272,6 +272,14 @@ class Base extends Controller
$this->production_article_refer_obj->where('p_article_id', $refer_info['p_article_id'])->where('index', ">", $refer_info['index'])->where('state', 0)->setDec('index');
$this->production_article_refer_obj->where('p_refer_id', $p_refer_id)->update(['state' => 1]);
try {
Db::name('production_article_refer_author')
->where('p_refer_id', intval($p_refer_id))
->delete();
} catch (\Exception $e) {
\think\Log::error('delOneRefer delete refer_author p_refer_id=' . $p_refer_id . ' ' . $e->getMessage());
}
// 文献集合已变更,原校对结果的 reference_no 已全部错位,整篇标记为未校对
try {
(new \app\common\ReferenceCheckService())

View File

@@ -7,6 +7,8 @@ use think\Env;
use think\Queue;
use think\Validate;
use app\common\CrossrefService;
use app\common\ReferenceCheckService;
use app\common\ReferenceReferAuthorService;
use app\common\ReferenceRelevanceCheckService;
class Preaccept extends Base
@@ -36,6 +38,78 @@ class Preaccept extends Base
}
}
/**
* 新增参考文献后同步作者明细到 t_production_article_refer_author
*/
private function syncReferAuthorsAfterInsert($pReferId, $pArticleId, array $refer)
{
$pReferId = intval($pReferId);
$pArticleId = intval($pArticleId);
if ($pReferId <= 0 || $pArticleId <= 0) {
return;
}
try {
(new ReferenceReferAuthorService())->syncOneRefer($pReferId, $pArticleId, $refer);
} catch (\Exception $e) {
\think\Log::error(
'syncReferAuthorsAfterInsert p_refer_id=' . $pReferId
. ' p_article_id=' . $pArticleId . ' ' . $e->getMessage()
);
}
}
/**
* 编辑参考文献后同步作者明细refer_doi 变化时先删后重新抓取入库
*/
private function syncReferAuthorsAfterUpdate($pReferId, $pArticleId, array $oldRefer, array $updata)
{
$pReferId = intval($pReferId);
$pArticleId = intval($pArticleId);
if ($pReferId <= 0 || $pArticleId <= 0 || empty($oldRefer)) {
return;
}
$newRefer = array_merge($oldRefer, $updata);
$refUtil = new ReferenceCheckService();
$oldDoi = $this->normalizeReferDoi($refUtil->extractDoiFromRefer($oldRefer));
$newDoi = $this->normalizeReferDoi($refUtil->extractDoiFromRefer($newRefer));
$doiChanged = $oldDoi !== $newDoi;
$authorChanged = false;
if (array_key_exists('author', $updata)) {
$authorChanged = trim((string)($oldRefer['author'] ?? '')) !== trim((string)$updata['author']);
}
if (!$doiChanged && !$authorChanged) {
return;
}
try {
$svc = new ReferenceReferAuthorService();
if ($doiChanged) {
Db::name('production_article_refer_author')->where('p_refer_id', $pReferId)->delete();
$svc->syncOneRefer($pReferId, $pArticleId, $newRefer);
} elseif ($authorChanged) {
$svc->syncFromReferAuthorField(
$pReferId,
$pArticleId,
(string)($newRefer['author'] ?? '')
);
}
} catch (\Exception $e) {
\think\Log::error(
'syncReferAuthorsAfterUpdate p_refer_id=' . $pReferId
. ' p_article_id=' . $pArticleId . ' ' . $e->getMessage()
);
}
}
private function normalizeReferDoi($doi)
{
$doi = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', trim((string)$doi));
return strtolower(trim($doi, " \t\n\r\0\x0B/"));
}
/**获取文章参考文献列表
* @return \think\response\Json
@@ -165,6 +239,11 @@ class Preaccept extends Base
$adId= $this->production_article_refer_obj->insertGetId($insert);
$this->production_article_refer_obj->where('p_article_id', $p_info['p_article_id'])->where("p_refer_id", "<>", $adId)->where("index", ">", $pre_refer['index'])->where('state', 0)->setInc('index');
$this->resetArticleChecksOnReferChange(intval($p_info['p_article_id']), 'addRefer');
$this->syncReferAuthorsAfterInsert(
$adId,
intval($p_info['p_article_id']),
array_merge($insert, ['p_refer_id' => $adId, 'p_article_id' => $p_info['p_article_id']])
);
return jsonSuccess([]);
@@ -222,6 +301,12 @@ class Preaccept extends Base
$adId= $this->production_article_refer_obj->insertGetId($insert);
$this->production_article_refer_obj->where('p_article_id', $p_info['p_article_id'])->where("p_refer_id", "<>", $adId)->where("index", ">", $pre_refer['index'])->where('state', 0)->setInc('index');
$this->resetArticleChecksOnReferChange(intval($p_info['p_article_id']), 'addReferByParticleid');
$this->syncReferAuthorsAfterInsert(
$adId,
intval($p_info['p_article_id']),
array_merge($insert, ['p_refer_id' => $adId, 'p_article_id' => $p_info['p_article_id']])
);
return jsonSuccess([]);
}
@@ -498,6 +583,13 @@ class Preaccept extends Base
\think\Log::error('editRefer enqueueRecheckByPReferId p_refer_id=' . $data['p_refer_id'] . ' ' . $e->getMessage());
}
$this->syncReferAuthorsAfterUpdate(
intval($data['p_refer_id']),
intval($old_refer_info['p_article_id']),
$old_refer_info,
$updata
);
return jsonSuccess([]);
}

View File

@@ -13,6 +13,8 @@ use think\Env;
use think\Queue;
use app\common\ReferenceCheckService;
use app\common\ReferenceRelevanceCheckService;
use app\common\ReferenceStackingStatsService;
use app\common\ReferenceReferAuthorService;
use app\common\DbReconnectHelper;
/**
* @title 参考文献
@@ -1818,5 +1820,117 @@ class References extends Base
return $frag;
}
/**
* 参考文献引用堆叠统计(同作者>15%、同刊>20%、自引>10%,实时计算)
*
* POST/GET: p_article_id必填
*/
public function referenceStackingStats()
{
$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->getThresholdStackingByPArticleId($iPArticleId);
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可选默认 01 返回作者明细), sleep_ms可选默认 120
*/
public function referenceReferAuthorSyncTest()
{
$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'));
}
$iPReferId = empty($aParam['p_refer_id']) ? 0 : intval($aParam['p_refer_id']);
$includeAuthors = !empty($aParam['include_authors']) && intval($aParam['include_authors']) === 1;
$sleepMs = isset($aParam['sleep_ms']) ? max(0, intval($aParam['sleep_ms'])) : 120;
try {
$svc = new ReferenceReferAuthorService();
if ($iPReferId > 0) {
$refer = Db::name('production_article_refer')
->where('p_refer_id', $iPReferId)
->where('p_article_id', $iPArticleId)
->where('state', 0)
->find();
if (empty($refer)) {
return jsonError('Reference not found');
}
$refUtil = new ReferenceCheckService();
$doi = $refUtil->extractDoiFromRefer($refer);
$count = 0;
if ($doi !== '') {
$crossref = new CrossrefService([
'mailto' => trim((string)Env::get('crossref_mailto', '')),
]);
$summary = $crossref->fetchWorkSummary($doi);
if ($summary === null || empty($summary['doi'])) {
$summary = ['doi' => $doi, 'raw' => []];
}
$count = $svc->syncFromWorkSummary($iPReferId, $iPArticleId, $doi, $summary);
}
if ($count <= 0) {
$count = $svc->syncFromReferAuthorField($iPReferId, $iPArticleId, (string)($refer['author'] ?? ''));
}
$result = [
'p_article_id' => $iPArticleId,
'p_refer_id' => $iPReferId,
'reference_no' => intval($refer['index']) + 1,
'doi' => $doi,
'authors' => $count,
'synced' => $count > 0 ? 1 : 0,
'failed' => $count > 0 ? 0 : 1,
];
} else {
$result = $svc->syncByPArticleId($iPArticleId, ['sleep_ms' => $sleepMs]);
}
if ($includeAuthors) {
$query = Db::name('production_article_refer_author')
->alias('a')
->join('production_article_refer r', 'r.p_refer_id = a.p_refer_id', 'LEFT')
->field('a.p_refer_id,r.index,a.author_seq,a.display_name,a.citation_name,a.orcid,a.openalex_id,a.identity_source')
->where('a.p_article_id', $iPArticleId);
if ($iPReferId > 0) {
$query->where('a.p_refer_id', $iPReferId);
}
$rows = $query->order('r.index asc, a.author_seq asc')->select();
foreach ($rows as &$row) {
$row['reference_no'] = intval($row['index']) + 1;
unset($row['index']);
}
unset($row);
$result['author_rows'] = $rows;
}
return jsonSuccess($result);
} catch (\Exception $e) {
return jsonError($e->getMessage());
}
}
}