Răsfoiți Sursa

feat: 新增队列处理日志

runphp 1 săptămână în urmă
părinte
comite
ab0d1b3da8

+ 57 - 0
database/migrations/20260713000000_create_queue_job_log.php

@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+use Phinx\Migration\AbstractMigration;
+
+class CreateQueueJobLog extends AbstractMigration
+{
+    public function change(): void
+    {
+        $table = $this->table('queue_job_log', [
+            'comment' => '队列任务消费日志',
+            'engine' => 'InnoDB',
+            'collation' => 'utf8mb4_general_ci',
+            'id' => false,
+            'primary_key' => ['id'],
+        ]);
+
+        $table->addColumn('id', 'biginteger', [
+            'identity' => true,
+            'signed' => false,
+            'comment' => '主键'
+        ])->addColumn('name', 'string', [
+            'limit' => 200,
+            'null' => false,
+            'comment' => '任务类名'
+        ])->addColumn('connection', 'string', [
+            'limit' => 50,
+            'null' => false,
+            'comment' => '队列连接'
+        ])->addColumn('queue', 'string', [
+            'limit' => 255,
+            'null' => false,
+            'comment' => '队列名称'
+        ])->addColumn('attempts', 'integer', [
+            'default' => 1,
+            'signed' => false,
+            'comment' => '当前第几次尝试'
+        ])->addColumn('duration', 'decimal', [
+            'precision' => 10,
+            'scale' => 4,
+            'default' => 0,
+            'comment' => '执行耗时(秒)'
+        ])->addColumn('success', 'boolean', [
+            'default' => true,
+            'comment' => '是否成功'
+        ])->addColumn('exception', 'text', [
+            'null' => true,
+            'comment' => '异常信息'
+        ])->addTimestamps('create_time', false)
+            ->addIndex(['name', 'id'], [
+            'name' => 'idx_name_id'
+        ])->addIndex(['create_time'], [
+            'name' => 'idx_create_time'
+        ])->create();
+    }
+}

+ 34 - 0
frontend/admin/api.js

@@ -33,3 +33,37 @@ export function clearAllCronJobLog() {
     method: 'delete'
   })
 }
+
+// 获取队列任务日志列表
+export function getQueueJobLogList(params) {
+  return request({
+    url: '/system/queue_job_log',
+    method: 'get',
+    params
+  })
+}
+
+// 删除单条队列任务日志
+export function deleteQueueJobLog(id) {
+  return request({
+    url: `/system/queue_job_log/${id}`,
+    method: 'delete'
+  })
+}
+
+// 批量删除队列任务日志
+export function batchDeleteQueueJobLog(ids) {
+  return request({
+    url: '/system/queue_job_log/batch_delete',
+    method: 'delete',
+    data: { ids }
+  })
+}
+
+// 清空所有队列任务日志
+export function clearAllQueueJobLog() {
+  return request({
+    url: '/system/queue_job_log/clear_all',
+    method: 'delete'
+  })
+}

+ 14 - 0
frontend/admin/index.js

@@ -27,6 +27,15 @@ export default {
                     icon: 'Timer'
                 }
             },
+            {
+                path: 'queue-job-log',
+                name: 'QueueJobLog',
+                component: () => import('./views/QueueJobLog.vue'),
+                meta: {
+                    title: '队列任务日志',
+                    icon: 'List'
+                }
+            },
             {
                 path: 'plugin-config',
                 name: 'PluginConfig',
@@ -56,6 +65,11 @@ export default {
                     path: '/system/cron-job-log',
                     title: '定时任务日志',
                     icon: 'Timer'
+                },
+                {
+                    path: '/system/queue-job-log',
+                    title: '队列任务日志',
+                    icon: 'List'
                 }
             ]
         }

+ 398 - 0
frontend/admin/views/QueueJobLog.vue

@@ -0,0 +1,398 @@
+<template>
+  <div class="queue-job-log-container">
+    <!-- 搜索区域 -->
+    <el-card shadow="never" class="search-card">
+      <el-form :model="searchForm" inline>
+        <el-form-item label="任务名称">
+          <el-input
+            v-model="searchForm.name"
+            placeholder="请输入任务名称"
+            clearable
+            style="width: 240px"
+            @keyup.enter="handleSearch"
+          />
+        </el-form-item>
+        <el-form-item label="执行状态">
+          <el-select
+            v-model="searchForm.success"
+            placeholder="请选择状态"
+            clearable
+            style="width: 150px"
+          >
+            <el-option label="成功" :value="1" />
+            <el-option label="失败" :value="0" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="执行时间">
+          <el-date-picker
+            v-model="searchForm.date_range"
+            type="daterange"
+            range-separator="-"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            :default-time="[new Date(2000, 0, 1, 0, 0, 0), new Date(2000, 0, 1, 23, 59, 59)]"
+            style="width: 360px"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">
+            <el-icon><Search /></el-icon>
+            搜索
+          </el-button>
+          <el-button @click="handleReset">
+            <el-icon><Refresh /></el-icon>
+            重置
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- 统计卡片 -->
+    <el-row :gutter="16" class="stats-row">
+      <el-col :span="6">
+        <el-card shadow="never">
+          <div class="stat-item">
+            <div class="stat-label">总执行次数</div>
+            <div class="stat-value">{{ stats.total }}</div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="never">
+          <div class="stat-item">
+            <div class="stat-label">成功次数</div>
+            <div class="stat-value stat-success">{{ stats.success_count }}</div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="never">
+          <div class="stat-item">
+            <div class="stat-label">失败次数</div>
+            <div class="stat-value stat-fail">{{ stats.fail_count }}</div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="never">
+          <div class="stat-item">
+            <div class="stat-label">成功率</div>
+            <div class="stat-value">{{ stats.success_rate }}%</div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 表格区域 -->
+    <el-card shadow="never" class="table-card">
+      <template #header>
+        <div class="card-header">
+          <span>日志列表</span>
+          <div class="header-actions">
+            <el-button
+              type="danger"
+              :disabled="selectedIds.length === 0"
+              @click="handleBatchDelete"
+            >
+              批量删除
+            </el-button>
+            <el-button
+              type="danger"
+              plain
+              @click="handleClearAll"
+            >
+              清空所有日志
+            </el-button>
+          </div>
+        </div>
+      </template>
+
+      <el-table
+        v-loading="loading"
+        :data="tableData"
+        border
+        stripe
+        @selection-change="handleSelectionChange"
+      >
+        <el-table-column type="selection" width="50" align="center" />
+        <el-table-column prop="id" label="ID" width="80" align="center" />
+        <el-table-column prop="name" label="任务名称" min-width="240" show-overflow-tooltip />
+        <el-table-column prop="connection" label="连接" width="100" align="center" />
+        <el-table-column prop="queue" label="队列" width="100" align="center" />
+        <el-table-column prop="attempts" label="尝试次数" width="100" align="center" />
+        <el-table-column prop="duration" label="执行耗时" width="120" align="center">
+          <template #default="{ row }">
+            {{ row.duration ? row.duration + 's' : '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="success" label="执行结果" width="100" align="center">
+          <template #default="{ row }">
+            <el-tag :type="row.success ? 'success' : 'danger'" size="small">
+              {{ row.success ? '成功' : '失败' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="exception" label="异常信息" min-width="200" show-overflow-tooltip>
+          <template #default="{ row }">
+            <span v-if="row.exception" class="exception-text">{{ row.exception }}</span>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="create_time" label="执行时间" width="180" align="center" />
+        <el-table-column label="操作" width="100" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button
+              type="danger"
+              link
+              size="small"
+              @click="handleDelete(row)"
+            >
+              删除
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <!-- 分页 -->
+      <div class="pagination-wrapper">
+        <el-pagination
+          v-model:current-page="pagination.page"
+          v-model:page-size="pagination.list_rows"
+          :total="pagination.total"
+          :page-sizes="[10, 15, 30, 50, 100]"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handlePageChange"
+        />
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, reactive } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Search, Refresh } from '@element-plus/icons-vue'
+import { getQueueJobLogList, deleteQueueJobLog, batchDeleteQueueJobLog, clearAllQueueJobLog } from '../api'
+
+const loading = ref(false)
+const tableData = ref([])
+const selectedIds = ref([])
+
+const searchForm = reactive({
+  name: '',
+  success: null,
+  date_range: []
+})
+
+const pagination = reactive({
+  page: 1,
+  list_rows: 15,
+  total: 0
+})
+
+const stats = reactive({
+  total: 0,
+  success_count: 0,
+  fail_count: 0,
+  success_rate: 0
+})
+
+// 获取列表
+const getList = async () => {
+  loading.value = true
+  try {
+    const params = {
+      page: pagination.page,
+      list_rows: pagination.list_rows,
+      name: searchForm.name
+    }
+    if (searchForm.success !== null && searchForm.success !== '') {
+      params.success = searchForm.success
+    }
+    if (searchForm.date_range && searchForm.date_range.length === 2) {
+      params.start_time = searchForm.date_range[0]
+      params.end_time = searchForm.date_range[1]
+    }
+    const res = await getQueueJobLogList(params)
+    if (res.code === 200) {
+      tableData.value = res.page?.data || []
+      pagination.total = res.page?.total || 0
+      // 统计数据
+      const s = res.data || {}
+      stats.total = s.total || 0
+      stats.success_count = s.success_count || 0
+      stats.fail_count = s.fail_count || 0
+      stats.success_rate = stats.total > 0 ? parseFloat((stats.success_count / stats.total * 100).toFixed(4)) : 0
+    }
+  } catch (e) {
+    console.error('获取日志列表失败:', e)
+  } finally {
+    loading.value = false
+  }
+}
+
+// 搜索
+const handleSearch = () => {
+  pagination.page = 1
+  getList()
+}
+
+// 重置
+const handleReset = () => {
+  searchForm.name = ''
+  searchForm.success = null
+  searchForm.date_range = []
+  pagination.page = 1
+  getList()
+}
+
+// 分页
+const handlePageChange = () => {
+  getList()
+}
+
+const handleSizeChange = () => {
+  pagination.page = 1
+  getList()
+}
+
+// 选择变化
+const handleSelectionChange = (selection) => {
+  selectedIds.value = selection.map(item => item.id)
+}
+
+// 删除单条
+const handleDelete = (row) => {
+  ElMessageBox.confirm(
+    `确定要删除任务「${row.name}」的这条执行日志吗?`,
+    '删除确认',
+    {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning'
+    }
+  ).then(async () => {
+    try {
+      const res = await deleteQueueJobLog(row.id)
+      if (res.code === 200) {
+        ElMessage.success('删除成功')
+        getList()
+      }
+    } catch (e) {
+      console.error('删除失败:', e)
+    }
+  }).catch(() => {})
+}
+
+// 批量删除
+const handleBatchDelete = () => {
+  if (selectedIds.value.length === 0) return
+  ElMessageBox.confirm(
+    `确定要删除选中的 ${selectedIds.value.length} 条日志吗?`,
+    '批量删除确认',
+    {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning'
+    }
+  ).then(async () => {
+    try {
+      const res = await batchDeleteQueueJobLog(selectedIds.value)
+      if (res.code === 200) {
+        ElMessage.success('批量删除成功')
+        selectedIds.value = []
+        getList()
+      }
+    } catch (e) {
+      console.error('批量删除失败:', e)
+    }
+  }).catch(() => {})
+}
+
+// 清空所有
+const handleClearAll = () => {
+  ElMessageBox.confirm(
+    '确定要清空所有队列任务执行日志吗?此操作不可恢复!',
+    '清空确认',
+    {
+      confirmButtonText: '确定清空',
+      cancelButtonText: '取消',
+      type: 'warning',
+      confirmButtonClass: 'el-button--danger'
+    }
+  ).then(async () => {
+    try {
+      const res = await clearAllQueueJobLog()
+      if (res.code === 200) {
+        ElMessage.success('清空成功')
+        pagination.page = 1
+        getList()
+      }
+    } catch (e) {
+      console.error('清空失败:', e)
+    }
+  }).catch(() => {})
+}
+
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style scoped lang="scss">
+.queue-job-log-container {
+  padding: 0;
+}
+
+.stats-row {
+  margin-bottom: 16px;
+
+  .stat-item {
+    text-align: center;
+  }
+
+  .stat-label {
+    font-size: 14px;
+    color: #909399;
+    margin-bottom: 8px;
+  }
+
+  .stat-value {
+    font-size: 28px;
+    font-weight: 600;
+    color: #303133;
+  }
+
+  .stat-success {
+    color: #67c23a;
+  }
+
+  .stat-fail {
+    color: #f56c6c;
+  }
+}
+
+.search-card {
+  margin-bottom: 16px;
+}
+
+.table-card {
+  .card-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+  }
+}
+
+.pagination-wrapper {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 16px;
+}
+
+.exception-text {
+  color: #f56c6c;
+}
+</style>

+ 18 - 1
route/admin.php

@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-use SixShop\System\Controller\{CronJobLogController, ExtensionConfigController, ExtensionController};
+use SixShop\System\Controller\{CronJobLogController, ExtensionConfigController, ExtensionController, QueueJobLogController};
 use think\facade\Route;
 
 // Admin路由
@@ -61,3 +61,20 @@ Route::resource('cron_job_log', CronJobLogController::class)->only([
 ])->middleware([
     'auth'
 ]);
+
+// 队列任务日志
+Route::delete('queue_job_log/batch_delete', [QueueJobLogController::class, 'batchDelete'])
+    ->option(['name' => 'system:queue_job_log:batch_delete', 'description' => '批量删除队列任务日志'])
+    ->middleware(['auth']);
+Route::delete('queue_job_log/clear_all', [QueueJobLogController::class, 'clearAll'])
+    ->option(['name' => 'system:queue_job_log:clear_all', 'description' => '清空队列任务日志'])
+    ->middleware(['auth']);
+Route::resource('queue_job_log', QueueJobLogController::class)->only([
+    'index',
+    'delete',
+])->option([
+    'name' => 'system:queue_job_log',
+    'description' => '队列任务日志'
+])->middleware([
+    'auth'
+]);

+ 62 - 0
src/Controller/QueueJobLogController.php

@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SixShop\System\Controller;
+
+use SixShop\System\Entity\QueueJobLogEntity;
+use think\Request;
+use think\Response;
+
+use function SixShop\Core\page_response;
+use function SixShop\Core\success_response;
+
+class QueueJobLogController
+{
+    /**
+     * 队列任务日志列表
+     */
+    public function index(Request $request, QueueJobLogEntity $entity): Response
+    {
+        $params = $request->get([
+            'name/s',
+            'success/d',
+            'start_time/s',
+            'end_time/s'
+        ]);
+        $list = $entity->getList($params, $request->pageAndLimit());
+        $stats = $entity->getStats($params);
+        return page_response($list, $stats);
+    }
+
+    /**
+     * 删除单条日志
+     */
+    public function delete(int $id, QueueJobLogEntity $entity): Response
+    {
+        $entity->deleteById($id);
+        return success_response();
+    }
+
+    /**
+     * 批量删除日志
+     */
+    public function batchDelete(Request $request, QueueJobLogEntity $entity): Response
+    {
+        $ids = $request->post('ids', []);
+        if (empty($ids) || !is_array($ids)) {
+            return success_response([], 'ok', 200, '没有可删除的记录');
+        }
+        $entity->deleteByIds($ids);
+        return success_response([], 'ok', 200, '批量删除成功');
+    }
+
+    /**
+     * 清空所有日志
+     */
+    public function clearAll(QueueJobLogEntity $entity): Response
+    {
+        $entity->clearAll();
+        return success_response([], 'ok', 200, '清空成功');
+    }
+}

+ 93 - 0
src/Entity/QueueJobLogEntity.php

@@ -0,0 +1,93 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SixShop\System\Entity;
+
+use SixShop\Core\Entity\BaseEntity;
+use SixShop\System\Model\QueueJobLogModel;
+use think\Paginator;
+
+/**
+ * @mixin QueueJobLogModel
+ */
+class QueueJobLogEntity extends BaseEntity
+{
+    /**
+     * 查询统计数据,支持筛选条件
+     */
+    public function getStats(array $params = []): array
+    {
+        $query = QueueJobLogModel::field([
+            'COUNT(*) as total',
+            'SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count',
+            'SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as fail_count'
+        ]);
+
+        $this->applyFilters($query, $params);
+
+        $result = $query->select()->toArray();
+        $stats = $result[0] ?? [];
+
+        return [
+            'total' => (int)($stats['total'] ?? 0),
+            'success_count' => (int)($stats['success_count'] ?? 0),
+            'fail_count' => (int)($stats['fail_count'] ?? 0),
+        ];
+    }
+
+    /**
+     * 分页查询日志列表,支持筛选条件
+     */
+    public function getList(array $params, array $pageAndLimit = []): Paginator
+    {
+        $query = $this->order('id', 'desc');
+
+        $this->applyFilters($query, $params);
+
+        return $query->paginate($pageAndLimit);
+    }
+
+    /**
+     * 应用筛选条件
+     */
+    private function applyFilters($query, array $params): void
+    {
+        if (!empty($params['name'])) {
+            $query->whereLike('name', '%' . $params['name'] . '%');
+        }
+        if (isset($params['success']) && $params['success'] !== '') {
+            $query->where('success', intval($params['success']));
+        }
+        if (!empty($params['start_time'])) {
+            $query->where('create_time', '>=', $params['start_time']);
+        }
+        if (!empty($params['end_time'])) {
+            $query->where('create_time', '<=', $params['end_time']);
+        }
+    }
+
+    /**
+     * 删除单条日志
+     */
+    public function deleteById(int $id): void
+    {
+        $this->model()->destroy($id);
+    }
+
+    /**
+     * 批量删除日志
+     */
+    public function deleteByIds(array $ids): void
+    {
+        $this->model()->destroy($ids);
+    }
+
+    /**
+     * 清空所有日志
+     */
+    public function clearAll(): void
+    {
+        $this->model()->where('1=1')->delete();
+    }
+}

+ 3 - 1
src/Extension.php

@@ -11,6 +11,7 @@ use SixShop\System\Hook\CronJobHook;
 use SixShop\System\Hook\DebugHook;
 use SixShop\System\Hook\ExtensionStatusHook;
 use SixShop\System\Hook\GatheringCrontabEventHook;
+use SixShop\System\Hook\QueueJobHook;
 use think\db\exception\PDOException;
 
 use function SixShop\Core\extension_name_list;
@@ -26,7 +27,8 @@ class Extension extends ExtensionAbstract
         $hooks = [
             CronJobHook::class,
             ExtensionStatusHook::class,
-            GatheringCrontabEventHook::class
+            GatheringCrontabEventHook::class,
+            QueueJobHook::class,
         ];
 
         // Debug 模式下添加 DebugHook

+ 102 - 0
src/Hook/QueueJobHook.php

@@ -0,0 +1,102 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SixShop\System\Hook;
+
+use SixShop\Core\Attribute\Hook;
+use SixShop\System\Entity\QueueJobLogEntity;
+use think\queue\event\JobFailed;
+use think\queue\event\JobProcessed;
+use think\queue\event\JobProcessing;
+use think\queue\Job;
+
+class QueueJobHook
+{
+    /**
+     * 用 job ID 暂存执行开始时间,用于计算耗时(仅 async 模式有效)
+     * @var array<string, float>
+     */
+    private static array $startTimes = [];
+
+    /**
+     * 任务开始执行时记录开始时间
+     */
+    #[Hook(JobProcessing::class)]
+    public function onJobProcessing(JobProcessing $event): void
+    {
+        $key = $event->job instanceof Job ? $event->job->getJobId() : $event->job;
+        self::$startTimes[$key] = microtime(true);
+    }
+
+    /**
+     * 任务执行成功后记录日志
+     */
+    #[Hook(JobProcessed::class)]
+    public function onJobProcessed(JobProcessed $event): void
+    {
+        $key = $event->job instanceof Job ? $event->job->getJobId() : $event->job;
+        $duration = $this->calcDuration($key);
+
+        QueueJobLogEntity::create($this->buildLogData($event, $duration, true));
+    }
+
+    /**
+     * 任务执行失败后记录日志
+     */
+    #[Hook(JobFailed::class)]
+    public function onJobFailed(JobFailed $event): void
+    {
+        $key = $event->job instanceof Job ? $event->job->getJobId() : $event->job;
+        $duration = $this->calcDuration($key);
+
+        QueueJobLogEntity::create($this->buildLogData($event, $duration, false, $event->exception->getMessage()));
+    }
+
+    private function buildLogData(JobProcessed|JobFailed $event, float $duration, bool $success, ?string $exception = null): array
+    {
+        $isAsync = $event->job instanceof Job;
+
+        $data = [
+            'name'       => $isAsync ? $this->resolveJobName($event->job) : $event->job,
+            'connection' => $event->connection,
+            'queue'      => $isAsync ? $event->job->getQueue() : 'default',
+            'attempts'   => $isAsync ? $event->job->attempts() : 1,
+            'duration'   => $duration,
+            'success'    => $success,
+        ];
+
+        if ($exception !== null) {
+            $data['exception'] = $exception;
+        }
+
+        return $data;
+    }
+
+    private function resolveJobName(Job $job): string
+    {
+        $data = $job->payload('data');
+        if (is_array($data) && isset($data['__job_name__'])) {
+            return $data['__job_name__'];
+        }
+
+        return $job->getName();
+    }
+
+    /**
+     * 计算执行耗时,并清理暂存
+     */
+    private function calcDuration(string $jobId): float
+    {
+        $startTime = self::$startTimes[$jobId] ?? null;
+        if ($startTime !== null) {
+            $duration = microtime(true) - $startTime;
+            // 用完之后删掉,避免内存泄漏
+            unset(self::$startTimes[$jobId]);
+
+            return round($duration, 4);
+        }
+
+        return 0.0;
+    }
+}

+ 25 - 0
src/Model/QueueJobLogModel.php

@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SixShop\System\Model;
+
+use think\Model;
+
+class QueueJobLogModel extends Model
+{
+    protected function getOptions(): array
+    {
+        return [
+            'name' => 'queue_job_log',
+            'autoWriteTimestamp' => true,
+            'createTime' => 'create_time',
+            'updateTime' => false,
+            'type' => [
+                'success' => 'bool',
+                'duration' => 'float',
+                'attempts' => 'int',
+            ],
+        ];
+    }
+}