质控任务自动化改造方案.md 43 KB

质控任务自动化改造需求与实现方案

一、需求背景

1.1 业务需求

当前质控任务只支持手动执行,需要增加周期性自动执行功能:

  • 自动任务:通过定时任务周期执行(如每周一执行一次),使用Quartz调度
  • 手动任务:保持现有功能,手动选择数据后执行

1.2 核心差异

对比项 自动任务 手动任务
执行方式 定时自动执行 手动触发执行
数据选择 按规则自动抽取 手动选择具体检查数据
数据范围 指定数量+抽取方式 指定时间范围+手动选择
结果查看 查看多次执行的历史记录 查看单次执行结果
技术实现 Quartz定时调度 即时执行

二、现状分析

2.1 现有表结构

qc_task: 质控任务表
- id: 任务ID
- task_name: 任务名称
- task_type: 任务类型
- institution_id: 机构ID
- standard_id: 质控标准ID
- plan_count: 计划数量
- sample_type: 抽样类型
- data_range_type: 数据范围类型(1时间/2患者)
- start_date/ end_date: 时间范围
- patient_ids: 患者ID列表
- exam_ids: 检查ID列表(逗号分隔)
- status: 任务状态(0待执行/1执行中/2已完成/3失败)
- create_time/ update_time

qc_task_exam: 任务检查关联表
- id
- task_id: 任务ID
- study_id: 检查ID
- create_time

qc_task_image_result: 图像质控结果表
- id
- task_id
- study_id
- image_index
- sop_instance_uid
- is_qualified
- total_score
- ...其他质控字段

2.2 现有页面

  • QcTask.vue: 质控任务列表页
  • QcResult.vue: 质控结果列表页
  • QcResultDetail.vue: 质控结果详情页

三、功能需求

3.1 任务创建表单差异化

3.1.1 共有字段

- 任务名称(必填,支持手动修改,自动生成默认值:qc+日期+4位字母+4位数字)
- 任务类型(单选:自动/手动)

3.1.2 自动任务专属字段

- 机构(必选,下拉选择)
- 时间周期(必填,Cron表达式生成器)
  - 快捷选项:每天/每周/每月
  - 高级配置:Cron表达式编辑器
  - 预览下次执行时间
- 数据数量(必填,数字输入,1-10000)
- 数据抽取方式(单选:随机抽取/按时间顺序)

3.1.3 手动任务专属字段

- 机构(必选,下拉选择)
- 时间范围(必填,日期范围选择器)
  - 开始日期
  - 结束日期
- 检查数据(必选,弹出选择器)
  - 显示已选择数量
  - 支持查看已选数据列表
  - 支持清空选择
- 数据数量(自动计算=已选数量,只读)

3.2 任务列表展示

3.2.1 列表字段

序号 | 任务名称 | 任务类型 | 机构名称 | 计划数量 | 执行统计 | 最近执行 | 下次执行 | 状态 | 操作

3.2.2 字段展示规则

任务类型列

  • 自动任务:蓝色tag "自动"
  • 手动任务:橙色tag "手动"

执行统计列

  • 自动任务:

    已执行12次 | 平均通过率85%
    
  • 手动任务:

    一次性 | 已完成
    

最近执行列

  • 自动任务:显示最近一次执行时间和状态

    02-03 10:00 成功
    
  • 手动任务:显示执行时间

    02-03 15:30
    

下次执行列

  • 自动任务:显示下次执行时间

    02-10 10:00
    
  • 手动任务:显示 "-"

操作列差异

  • 自动任务:
    • 查看历史
    • 立即执行(手动触发一次)
    • 编辑
    • 启用/禁用
    • 删除
  • 手动任务:
    • 开始执行(待执行状态)
    • 查看进度(执行中状态)
    • 查看结果(已完成状态)
    • 编辑
    • 删除

3.3 自动任务执行历史

3.3.1 执行历史列表页

入口:点击自动任务的"查看历史"按钮

页面结构

┌──────────────────────────────────────┐
│ 返回  执行历史 - [任务名称]          │
├──────────────────────────────────────┤
│ 统计卡片(4个)                       │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐     │
│ │总数 │ │成功率│ │平均数│ │通过率│     │
│ │ 12  │ │91.7%│ │ 98  │ │86.5%│     │
│ └─────┘ └─────┘ └─────┘ └─────┘     │
├──────────────────────────────────────┤
│ 执行历史表格                         │
│ 序号|执行时间|状态|数量|通过率|操作   │
│ 12 |02-03 10:00|✓|100|85%|[查看结果]│
│ 11 |01-27 10:00|✓|98 |88%|[查看结果]│
│ 10 |01-20 10:00|✗|95 |- |[查看错误]│
└──────────────────────────────────────┘

统计指标

  • 总执行次数
  • 成功率(成功次数/总次数)
  • 平均检查数量(所有执行的总数/次数)
  • 平均通过率

表格列

  • 执行编号:第N次
  • 执行时间:yyyy-MM-dd HH:mm
  • 状态:成功(✓)/失败(✗)/执行中/待执行
  • 检查数量:实际检查的数量
  • 通过率:合格检查数/总数
  • 操作:查看结果、查看错误、重试

3.4 质控结果查看

3.4.1 自动任务的质控结果

入口:执行历史页 → 点击某次执行的"查看结果"

展示内容

  • 与现有QcResultDetail.vue一致
  • 标题显示:"执行历史详情 - 第N次执行"
  • 查询参数基于execution_id

3.4.2 手动任务的质控结果

入口:任务列表 → 点击"查看结果"

展示内容

  • 保持现有功能不变

四、技术方案

4.1 技术架构

┌─────────────────────────────────────────┐
│           前端(Vue3)                     │
│  - QcTask.vue(任务列表)                  │
│  - ExecutionHistory.vue(执行历史)         │
│  - QcResultDetail.vue(结果详情-复用)      │
└──────────────┬──────────────────────────┘
               │ HTTP API
┌──────────────▼──────────────────────────┐
│           后端                           │
│  - QcTaskController                     │
│  - QcTaskService                        │
│  - QuartzScheduler(定时调度)             │
│  - QcExecutionService(执行服务)          │
└──────────────┬──────────────────────────┘
               │
┌──────────────▼──────────────────────────┐
│           Quartz                        │
│  - Job: AutoQcJob                       │
│  - Trigger: CronTrigger                 │
│  - Scheduler: StdScheduler               │
└──────────────┬──────────────────────────┘
               │
┌──────────────▼──────────────────────────┐
│           数据库层                       │
│  - qc_task(任务模板)                     │
│  - qc_task_execution(执行记录)           │
│  - qc_task_exam(检查关联)                │
│  - qc_task_image_result(质控结果)        │
└─────────────────────────────────────────┘

4.2 技术选型

4.2.1 后端技术栈

  • Spring Boot 2.7.18(现有)
  • Quartz 2.3.x(定时调度)
  • MyBatis-Plus(现有ORM)
  • MySQL(现有数据库)

4.2.2 前端技术栈

  • Vue 3 + Composition API(现有)
  • Element Plus(现有UI库)
  • vue-cron-generator(Cron表达式生成器组件)

五、数据库设计

5.1 新增表:qc_task_execution(任务执行记录表)

CREATE TABLE `qc_task_execution` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '执行记录ID',
  `task_id` bigint NOT NULL COMMENT '关联任务ID',
  `task_name` varchar(255) NOT NULL COMMENT '任务名称(快照)',
  `execute_no` int NOT NULL COMMENT '执行编号(第N次执行)',
  `plan_execute_time` datetime NOT NULL COMMENT '计划执行时间',
  `actual_execute_time` datetime DEFAULT NULL COMMENT '实际执行时间',
  `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0待执行/1执行中/2已完成/3失败',

  -- 执行结果统计
  `plan_count` int DEFAULT 0 COMMENT '计划数量',
  `total_count` int DEFAULT 0 COMMENT '实际检查数量',
  `completed_count` int DEFAULT 0 COMMENT '已完成数量',
  `pass_count` int DEFAULT 0 COMMENT '通过数量',
  `fail_count` int DEFAULT 0 COMMENT '不通过数量',
  `pass_rate` decimal(5,2) DEFAULT 0.00 COMMENT '通过率(%)',

  -- 时间信息
  `start_time` datetime DEFAULT NULL COMMENT '开始执行时间',
  `end_time` datetime DEFAULT NULL COMMENT '结束执行时间',
  `duration` int DEFAULT NULL COMMENT '执行耗时(秒)',

  -- 错误信息
  `error_message` text COMMENT '失败原因',
  `stack_trace` text COMMENT '错误堆栈',

  -- 基础字段
  `creator` varchar(64) DEFAULT NULL COMMENT '创建人',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  `update_time` datetime NOT NULL COMMENT '修改时间',
  `is_deleted` tinyint NOT NULL DEFAULT 0 COMMENT '删除标记',

  PRIMARY KEY (`id`),
  KEY `idx_task_id` (`task_id`),
  KEY `idx_execute_time` (`plan_execute_time`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='质控任务执行记录表';

5.2 修改表:qc_task(任务表)

ALTER TABLE `qc_task`
ADD COLUMN `schedule_config` json DEFAULT NULL COMMENT '定时配置' AFTER `task_type`,
ADD COLUMN `is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '是否启用:0禁用/1启用' AFTER `schedule_config`;

-- schedule_config JSON结构示例
{
  "schedule_type": "weekly",      // daily/weekly/monthly/custom
  "cron_expression": "0 0 10 ? * MON",  // Cron表达式
  "next_execute_time": "2026-02-10 10:00:00",
  "last_execute_time": "2026-02-03 10:00:00",
  "sample_type": "random",          // random/sequential
  "sample_count": 100
}

5.3 修改表:qc_task_exam(任务检查关联表)

ALTER TABLE `qc_task_exam`
ADD COLUMN `execution_id` bigint DEFAULT NULL COMMENT '执行记录ID(自动任务必填,手动任务为空)' AFTER `task_id`,
ADD INDEX `idx_execution_id` (`execution_id`);

5.4 修改表:qc_task_image_result(质控结果表)

ALTER TABLE `qc_task_image_result`
ADD COLUMN `execution_id` bigint DEFAULT NULL COMMENT '执行记录ID(自动任务必填)' AFTER `task_id`,
ADD INDEX `idx_execution_id` (`execution_id`);

六、接口设计

6.1 任务管理接口

6.1.1 创建/更新任务

POST /api/qc/task/create
PUT /api/qc/task/update

请求体:
{
  "id": "编辑时必填",
  "taskName": "qc20260203ABCD1234",
  "taskType": "auto",  // auto/manual
  "institutionId": "机构ID",

  // 自动任务字段
  "scheduleConfig": {
    "scheduleType": "weekly",
    "cronExpression": "0 0 10 ? * MON",
    "sampleCount": 100,
    "sampleType": "random"  // random/sequential
  },

  // 手动任务字段
  "dataRangeType": 1,
  "startDate": "2026-01-01",
  "endDate": "2026-01-31",
  "examIds": "studyId1,studyId2,studyId3",  // 手动选择的检查ID
  "planCount": 3  // 自动=已选数量
}

响应:
{
  "code": 200,
  "message": "创建成功",
  "data": {
    "taskId": 123
  }
}

6.1.2 任务列表

GET /api/qc/task/list?pageNum=1&pageSize=10&taskName=&status=

响应:
{
  "code": 200,
  "data": {
    "records": [
      {
        "id": 123,
        "taskName": "qc20260203ABCD1234",
        "taskType": "auto",
        "institutionName": "某某医院",
        "planCount": 100,
        "status": 1,  // 0待执行/1执行中/2已完成/3失败
        "isEnabled": 1,
        "createTime": "2026-01-01 10:00:00",

        // 执行统计(自动任务)
        "executionStats": {
          "totalCount": 12,
          "successCount": 11,
          "failCount": 1,
          "avgPassRate": 85.5
        },

        // 最近执行(自动任务)
        "lastExecution": {
          "executeNo": 12,
          "executeTime": "2026-02-03 10:00:00",
          "status": 2,
          "passRate": 85.0
        },

        // 下次执行(自动任务)
        "nextExecuteTime": "2026-02-10 10:00:00"
      }
    ],
    "total": 100
  }
}

6.2 执行历史接口

6.2.1 执行历史列表

GET /api/qc/task/execution-history?taskId=123&pageNum=1&pageSize=20

响应:
{
  "code": 200,
  "data": {
    "records": [
      {
        "id": 456,
        "taskId": 123,
        "taskName": "qc20260203ABCD1234",
        "executeNo": 12,
        "planExecuteTime": "2026-02-03 10:00:00",
        "actualExecuteTime": "2026-02-03 10:00:05",
        "status": 2,  // 0待执行/1执行中/2已完成/3失败
        "planCount": 100,
        "totalCount": 98,
        "completedCount": 98,
        "passCount": 83,
        "failCount": 15,
        "passRate": 85.0,
        "duration": 125,  // 秒
        "errorMessage": null,
        "createTime": "2026-02-03 10:00:00"
      }
    ],
    "total": 12
  }
}

6.2.2 执行统计汇总

GET /api/qc/task/execution-stats?taskId=123

响应:
{
  "code": 200,
  "data": {
    "totalCount": 12,
    "successCount": 11,
    "failCount": 1,
    "avgPlanCount": 100,
    "avgTotalCount": 98,
    "avgPassRate": 85.5,
    "recentExecutions": [...]  // 最近5次
  }
}

6.3 执行操作接口

6.3.1 手动触发执行(自动任务)

POST /api/qc/task/trigger-manual/{taskId}

响应:
{
  "code": 200,
  "message": "已加入执行队列",
  "data": {
    "executionId": 456
  }
}

6.3.2 启用/禁用任务

PUT /api/qc/task/toggle/{taskId}
参数: { "isEnabled": 0/1 }

响应:标准响应

6.3.3 失败重试

POST /api/qc/task/retry/{executionId}

响应:
{
  "code": 200,
  "message": "重新执行成功",
  "data": {
    "executionId": 457
  }
}

6.4 质控结果接口(改造)

6.4.1 获取执行记录的质控结果

GET /api/qc/execution/result/{executionId}

# 注意:复用现有的质控结果接口逻辑
# 区别:查询条件基于 execution_id 而非 task_id

6.4.2 获取执行记录的检查列表

GET /api/qc/execution/exams/{executionId}

响应:
{
  "code": 200,
  "data": {
    "executionInfo": {
      "id": 456,
      "taskName": "qc20260203ABCD1234",
      "executeNo": 12,
      "status": 2,
      "totalCount": 98,
      "passCount": 83
    },
    "exams": [
      {
        "studyId": "study-001",
        "patientName": "**",
        "modality": "CT",
        "studyDate": "2026-02-03",
        "examItemName": "CT胸部平扫",
        "isQualified": 1,
        "totalScore": 85.5
      }
    ]
  }
}

七、前端实现方案

7.1 Cron表达式生成器

7.1.1 组件选择

推荐使用 vue-cron-generator 或自建简化版:

安装

npm install vue-cron-generator-quartz

使用示例

<template>
  <el-form-item label="执行周期" required>
    <cron-selector-quartz
      v-model="form.scheduleConfig.cronExpression"
      :locale="locale"
      @change="handleCronChange"
    />
    <div class="cron-preview">
      <el-tag type="info">下次执行: {{ nextExecuteTime }}</el-tag>
    </div>
  </el-form-item>
</template>

快捷方案(自建简化版):

<el-form-item label="执行周期">
  <el-radio-group v-model="scheduleType">
    <el-radio value="daily">每天</el-radio>
    <el-radio value="weekly">每周</el-radio>
    <el-radio value="monthly">每月</el-radio>
  </el-radio-group>

  <!-- 每周选择 -->
  <el-checkbox-group v-if="scheduleType === 'weekly'" v-model="weekdays">
    <el-checkbox value="MON">周一</el-checkbox>
    <el-checkbox value="TUE">周二</el-checkbox>
    <el-checkbox value="WED">周三</el-checkbox>
    <el-checkbox value="THU">周四</el-checkbox>
    <el-checkbox value="FRI">周五</el-checkbox>
    <el-checkbox value="SAT">周六</el-checkbox>
    <el-checkbox value="SUN">周日</el-checkbox>
  </el-checkbox-group>

  <!-- 时间选择 -->
  <el-time-picker v-model="executeTime" format="HH:mm" />
</el-form-item>

7.2 页面结构

7.2.1 QcTask.vue 改造

创建表单(条件渲染)

<el-form ref="formRef" :model="form" :rules="rules">
  <!-- 共有字段 -->
  <el-form-item label="任务名称" prop="taskName" required>
    <el-input v-model="form.taskName" />
  </el-form-item>

  <el-form-item label="任务类型" prop="taskType">
    <el-radio-group v-model="form.taskType" @change="handleTaskTypeChange">
      <el-radio value="auto">自动任务</el-radio>
      <el-radio value="manual">手动任务</el-radio>
    </el-radio-group>
  </el-form-item>

  <!-- 自动任务字段 -->
  <template v-if="form.taskType === 'auto'">
    <el-form-item label="质控机构" prop="institutionId" required>
      <el-select v-model="form.institutionId">
        <!-- 机构选项 -->
      </el-select>
    </el-form-item>

    <el-form-item label="执行周期" prop="scheduleConfig" required>
      <!-- Cron生成器 -->
    </el-form-item>

    <el-form-item label="数据数量" prop="planCount" required>
      <el-input-number v-model="form.planCount" :min="1" :max="10000" />
    </el-form-item>

    <el-form-item label="抽取方式" prop="sampleType" required>
      <el-radio-group v-model="form.sampleType">
        <el-radio value="random">随机抽取</el-radio>
        <el-radio value="sequential">按时间顺序</el-radio>
      </el-radio-group>
    </el-form-item>
  </template>

  <!-- 手动任务字段 -->
  <template v-else>
    <el-form-item label="质控机构" prop="institutionId" required>
      <el-select v-model="form.institutionId">
        <!-- 机构选项 -->
      </el-select>
    </el-form-item>

    <el-form-item label="时间范围" prop="dateRange" required>
      <el-date-picker
        v-model="dateRange"
        type="daterange"
        value-format="YYYY-MM-DD"
      />
    </el-form-item>

    <el-form-item label="检查数据" prop="examIds" required>
      <el-button @click="showExamSelector">选择检查数据</el-button>
      <el-tag>已选择 {{ selectedExams.length }} 条</el-tag>
    </el-form-item>

    <el-form-item label="数据数量">
      <el-input v-model="selectedExams.length" disabled />
    </el-form-item>
  </template>
</el-form>

任务列表(差异展示)

<el-table-column label="执行统计">
  <template #default="{ row }">
    <span v-if="row.taskType === 'manual'">
      一次性
    </span>
    <span v-else>
      已执行{{ row.executionStats.totalCount }}次 |
      平均通过率{{ row.executionStats.avgPassRate }}%
    </span>
  </template>
</el-table-column>

<el-table-column label="操作">
  <template #default="{ row }">
    <!-- 自动任务操作 -->
    <template v-if="row.taskType === 'auto'">
      <el-button @click="viewHistory(row)">查看历史</el-button>
      <el-button @click="triggerManual(row)">立即执行</el-button>
      <el-button @click="toggleTask(row)">
        {{ row.isEnabled ? '禁用' : '启用' }}
      </el-button>
    </template>

    <!-- 手动任务操作 -->
    <template v-else>
      <el-button v-if="row.status === 0" @click="execute(row)">开始执行</el-button>
      <el-button v-if="row.status === 2" @click="viewResult(row)">查看结果</el-button>
    </template>

    <el-button @click="handleEdit(row)">编辑</el-button>
    <el-button @click="handleDelete(row)">删除</el-button>
  </template>
</el-table-column>

7.2.2 ExecutionHistory.vue(新建)

页面结构

<template>
  <div class="execution-history">
    <!-- 页面头部 -->
    <el-page-header @back="goBack">
      <template #content>
        执行历史 - {{ taskInfo.taskName }}
      </template>
    </el-page-header>

    <!-- 统计卡片 -->
    <el-row :gutter="16" class="stats-cards">
      <el-col :span="6">
        <el-card>
          <el-statistic title="总执行次数" :value="stats.totalCount" />
        </el-card>
      </el-col>
      <el-col :span="6">
        <el-card>
          <el-statistic
            title="成功率"
            :value="stats.successRate"
            suffix="%"
            :value-style="{ color: stats.successRate >= 80 ? '#67C23A' : '#F56C6C' }"
          />
        </el-card>
      </el-col>
      <el-col :span="6">
        <el-card>
          <el-statistic title="平均检查数" :value="stats.avgCount" />
        </el-card>
      </el-col>
      <el-col :span="6">
        <el-card>
          <el-statistic
            title="平均通过率"
            :value="stats.avgPassRate"
            suffix="%"
          />
        </el-card>
      </el-col>
    </el-row>

    <!-- 执行历史表格 -->
    <el-table :data="executionList" v-loading="loading">
      <el-table-column type="index" label="序号" width="60" />
      <el-table-column prop="executeNo" label="执行编号" width="100">
        <template #default="{ row }">
          第{{ row.executeNo }}次
        </template>
      </el-table-column>
      <el-table-column prop="planExecuteTime" label="计划执行时间" width="180" />
      <el-table-column prop="actualExecuteTime" label="实际执行时间" width="180" />
      <el-table-column label="状态" width="100" align="center">
        <template #default="{ row }">
          <el-tag v-if="row.status === 2" type="success">成功</el-tag>
          <el-tag v-else-if="row.status === 3" type="danger">失败</el-tag>
          <el-tag v-else-if="row.status === 1" type="warning">执行中</el-tag>
          <el-tag v-else type="info">待执行</el-tag>
        </template>
      </el-table-column>
      <el-table-column prop="totalCount" label="检查数量" width="100" align="center" />
      <el-table-column prop="passRate" label="通过率" width="100" align="center">
        <template #default="{ row }">
          {{ row.passRate }}%
        </template>
      </el-table-column>
      <el-table-column label="操作" width="150" align="center">
        <template #default="{ row }">
          <el-button
            v-if="row.status === 2"
            type="primary"
            link
            @click="viewResult(row)"
          >
            查看结果
          </el-button>
          <el-button
            v-if="row.status === 3"
            type="primary"
            link
            @click="viewError(row)"
          >
            查看错误
          </el-button>
          <el-button
            v-if="row.status === 3"
            type="warning"
            link
            @click="retry(row)"
          >
            重试
          </el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getExecutionHistory, getExecutionStats } from '@/api/qc'

const route = useRoute()
const router = useRouter()
const taskId = route.params.taskId

const taskInfo = ref({})
const stats = ref({})
const executionList = ref([])
const loading = ref(false)

const loadData = async () => {
  loading.value = true
  try {
    // 加载统计
    const statsRes = await getExecutionStats(taskId)
    stats.value = statsRes.data

    // 加载历史列表
    const listRes = await getExecutionHistory({
      taskId,
      pageNum: 1,
      pageSize: 100
    })
    executionList.value = listRes.data.records
  } finally {
    loading.value = false
  }
}

const viewResult = (row) => {
  // 跳转到质控结果页,传递 execution_id
  router.push(`/qc/execution/result/${row.id}`)
}

onMounted(() => {
  loadData()
})
</script>

7.2.3 QcResultDetail.vue 改造

路由适配

// router/index.ts 新增路由
{
  path: '/qc/execution/result/:executionId',
  name: 'ExecutionResultDetail',
  component: () => import('@/views/QcResultDetail.vue'),
  meta: { title: '质控结果详情', requiresAuth: true }
}

数据加载适配

// QcResultDetail.vue
const executionId = route.params.executionId
const taskId = route.query.taskId  // 兼容手动任务

const loadDetail = async () => {
  if (executionId) {
    // 自动任务:加载执行记录的质控结果
    const res = await getExecutionResult(executionId)
    detailData.value = res.data
  } else if (taskId) {
    // 手动任务:加载任务的质控结果
    const res = await getTaskResult(taskId)
    detailData.value = res.data
  }
}

7.3 路由设计

// router/index.ts
const routes: RouteRecordRaw[] = [
  // ... 现有路由

  // 新增:执行历史
  {
    path: '/qc/task/execution-history/:taskId',
    name: 'ExecutionHistory',
    component: () => import('@/views/ExecutionHistory.vue'),
    meta: { title: '执行历史', requiresAuth: true }
  },

  // 改造:质控结果详情(兼容executionId)
  {
    path: '/qc/result',
    name: 'QcResult',
    component: () => import('@/views/QcResult.vue'),
    meta: { title: '质控结果', requiresAuth: true }
  },

  {
    path: '/qc/execution/result/:executionId',
    name: 'ExecutionResultDetail',
    component: () => import('@/views/QcResultDetail.vue'),
    meta: { title: '质控结果详情', requiresAuth: true }
  }
]

八、后端实现方案

8.1 Quartz集成

8.1.1 Maven依赖

<dependency>
    <groupId>org.quartz-scheduler</groupId>
    <artifactId>quartz</artifactId>
    <version>2.3.2</version>
</dependency>
<dependency>
    <groupId>org.quartz-scheduler</groupId>
    <artifactId>quartz-jobs</artifactId>
    <version>2.3.2</version>
</dependency>

8.1.2 Quartz配置类

@Configuration
@ComponentScan(basePackages = "com.zskk.qcns.modules.qc.quartz")
public class QuartzConfig {

    @Bean
    public SchedulerFactoryBean schedulerFactoryBean(DataSource dataSource) {
        SchedulerFactoryBean factory = new SchedulerFactoryBean();
        factory.setDataSource(dataSource);
        factory.setJobFactory(springBeanJobFactory);
        factory.setApplicationContextSchedulerContextKey("applicationContext");
        // 持久化配置
        factory.setQuartzProperties(quartzProperties());
        return factory;
    }

    @Bean
    public SpringBeanJobFactory springBeanJobFactory(ApplicationContext applicationContext) {
        AutowiringSpringBeanJobFactory jobFactory = new AutowiringSpringBeanJobFactory();
        jobFactory.setApplicationContext(applicationContext);
        return jobFactory;
    }

    private Properties quartzProperties() {
        Properties prop = new Properties();
        prop.put("org.quartz.scheduler.instanceName", "QcScheduler");
        prop.put("org.quartz.scheduler.instanceId", "AUTO");
        prop.put("org.quartz.jobStore.class", "org.quartz.impl.jdbcjobstore.JobStoreTX");
        prop.put("org.quartz.jobStore.driverDelegateClass", "org.quartz.impl.jdbcjobstore.StdJDBCDelegate");
        prop.put("org.quartz.jobStore.tablePrefix", "QRTZ_");
        prop.put("org.quartz.threadPool.class", "org.quartz.simpl.SimpleThreadPool");
        prop.put("org.quartz.threadPool.threadCount", "5");
        return prop;
    }
}

8.1.3 Quartz表初始化

Quartz需要创建以下表(在数据库中执行):

  • QRTZ_JOB_DETAILS
  • QRTZ_TRIGGERS
  • QRTZ_SIMPLE_TRIGGERS
  • QRTZ_CRON_TRIGGERS
  • QRTZ_BLOB_TRIGGERS
  • QRTZ_CALENDARS
  • QRTZ_FIRED_TRIGGERS
  • QRTZ_PAUSED_TRIGGER_GRPS
  • QRTZ_SCHEDULER_STATE
  • QRTZ_LOCKS
  • QRTZ_SIMPROP_TRIGGERS

SQL脚本位置:quartz-2.3.2/docs/dbTables/

8.2 Job实现

8.2.1 自动质控Job

@Component
@DisallowConcurrentExecution
public class AutoQcJob extends QuartzJobBean {

    @Resource
    private QcExecutionService qcExecutionService;

    @Resource
    private QcTaskService qcTaskService;

    @Override
    protected void execute(JobExecutionContext context) throws JobExecutionException {
        Long taskId = context.getJobDetail().getJobDataMap().getLong("taskId");

        log.info("自动质控任务开始执行,taskId={}", taskId);

        try {
            // 1. 创建执行记录
            QcTaskExecution execution = qcExecutionService.createExecution(taskId);

            // 2. 根据配置抽取检查数据
            List<QcTaskExam> exams = qcExecutionService.extractExams(taskId, execution.getId());

            // 3. 执行质控
            qcExecutionService.executeQc(execution.getId(), exams);

            // 4. 更新执行记录状态
            qcExecutionService.completeExecution(execution.getId());

            log.info("自动质控任务执行完成,executionId={}", execution.getId());

        } catch (Exception e) {
            log.error("自动质控任务执行失败,taskId={}", taskId, e);
            throw new JobExecutionException(e);
        }
    }
}

8.2.2 Job管理服务

@Service
public class QcJobService {

    @Resource
    private Scheduler scheduler;

    /**
     * 创建定时任务
     */
    public void scheduleJob(Long taskId, QcTask task) {
        try {
            // 1. 创建JobDetail
            JobDetail jobDetail = JobBuilder.newJob(AutoQcJob.class)
                .withIdentity("JOB_" + taskId, "QC_TASK_GROUP")
                .usingJobData("taskId", taskId)
                .build();

            // 2. 创建Trigger(Cron表达式)
            CronTrigger trigger = TriggerBuilder.newTrigger()
                .withIdentity("TRIGGER_" + taskId, "QC_TASK_GROUP")
                .withSchedule(CronScheduleBuilder.cronSchedule(task.getCronExpression()))
                .build();

            // 3. 调度任务
            scheduler.scheduleJob(jobDetail, trigger);
            scheduler.start();

            log.info("定时任务创建成功,taskId={}", taskId);

        } catch (Exception e) {
            log.error("定时任务创建失败,taskId={}", taskId, e);
            throw new ServiceException("定时任务创建失败");
        }
    }

    /**
     * 暂停任务
     */
    public void pauseJob(Long taskId) {
        try {
            JobKey jobKey = JobKey.jobKey("JOB_" + taskId, "QC_TASK_GROUP");
            scheduler.pauseJob(jobKey);
        } catch (SchedulerException e) {
            throw new ServiceException("暂停任务失败");
        }
    }

    /**
     * 恢复任务
     */
    public void resumeJob(Long taskId) {
        try {
            JobKey jobKey = JobKey.jobKey("JOB_" + taskId, "QC_TASK_GROUP");
            scheduler.resumeJob(jobKey);
        } catch (SchedulerException e) {
            throw new ServiceException("恢复任务失败");
        }
    }

    /**
     * 删除任务
     */
    public void deleteJob(Long taskId) {
        try {
            JobKey jobKey = JobKey.jobKey("JOB_" + taskId, "QC_TASK_GROUP");
            scheduler.deleteJob(jobKey);
        } catch (SchedulerException e) {
            throw new ServiceException("删除任务失败");
        }
    }

    /**
     * 手动触发执行
     */
    public Long triggerJob(Long taskId) {
        try {
            // 1. 创建执行记录
            QcTaskExecution execution = qcExecutionService.createExecution(taskId);

            // 2. 手动触发Job
            JobKey jobKey = JobKey.jobKey("JOB_" + taskId, "QC_TASK_GROUP");
            scheduler.triggerJob(jobKey);

            return execution.getId();

        } catch (SchedulerException e) {
            throw new ServiceException("触发任务失败");
        }
    }
}

8.3 服务层改造

8.3.1 QcTaskService 改造

@Service
public class QcTaskServiceImpl implements QcTaskService {

    @Resource
    private QcJobService qcJobService;

    /**
     * 创建任务(区分自动/手动)
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createTask(QcTaskVO taskVO) {
        // 1. 保存任务基本信息
        QcTask task = new QcTask();
        // ... 设置字段
        task.setTaskType(taskVO.getTaskType());
        task.setIsEnabled(1);
        taskMapper.insert(task);

        // 2. 自动任务:创建定时任务
        if ("auto".equals(taskVO.getTaskType())) {
            // 保存定时配置
            task.setScheduleConfig(JSON.toJSONString(taskVO.getScheduleConfig()));
            taskMapper.updateById(task);

            // 创建Quartz Job
            qcJobService.scheduleJob(task.getId(), task);
        }

        // 3. 手动任务:保存检查数据关联
        else {
            List<QcTaskExam> exams = buildTaskExams(task.getId(), taskVO.getExamIds());
            qcTaskExamMapper.saveBatch(exams);
        }

        return task.getId();
    }

    /**
     * 启用/禁用任务
     */
    @Override
    public void toggleTask(Long taskId, Boolean isEnabled) {
        QcTask task = taskMapper.selectById(taskId);
        task.setIsEnabled(isEnabled ? 1 : 0);
        taskMapper.updateById(task);

        // 自动任务:控制Quartz Job
        if ("auto".equals(task.getTaskType())) {
            if (isEnabled) {
                qcJobService.resumeJob(taskId);
            } else {
                qcJobService.pauseJob(taskId);
            }
        }
    }

    /**
     * 删除任务
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteTask(Long taskId) {
        QcTask task = taskMapper.selectById(taskId);

        // 自动任务:删除Quartz Job
        if ("auto".equals(task.getTaskType())) {
            qcJobService.deleteJob(taskId);
        }

        // 删除任务数据
        taskMapper.deleteById(taskId);
    }
}

8.3.2 QcExecutionService(新增)

@Service
public class QcExecutionServiceImpl implements QcExecutionService {

    @Resource
    private QcTaskExecutionMapper executionMapper;

    @Resource
    private StudyInfoMapper studyInfoMapper;

    @Resource
    private QcTaskImageResultMapper imageResultMapper;

    /**
     * 创建执行记录
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public QcTaskExecution createExecution(Long taskId) {
        QcTask task = qcTaskMapper.selectById(taskId);

        // 1. 获取下次执行时间(从Quartz Trigger)
        Date nextExecuteTime = getNextExecuteTime(taskId);

        // 2. 创建执行记录
        QcTaskExecution execution = new QcTaskExecution();
        execution.setTaskId(taskId);
        execution.setTaskName(task.getTaskName());
        execution.setPlanExecuteTime(nextExecuteTime);
        execution.setActualExecuteTime(new Date());
        execution.setStatus(1);  // 执行中
        execution.setPlanCount(task.getPlanCount());
        executionMapper.insert(execution);

        return execution;
    }

    /**
     * 抽取检查数据(自动任务)
     */
    @Override
    public List<QcTaskExam> extractExams(Long taskId, Long executionId) {
        QcTask task = qcTaskMapper.selectById(taskId);
        JSONObject config = JSON.parseObject(task.getScheduleConfig());

        // 1. 根据配置查询符合条件的检查数据
        LambdaQueryWrapper<StudyInfo> query = new LambdaQueryWrapper<>();
        query.eq(StudyInfo::getInstitutionId, task.getInstitutionId());
        query.eq(StudyInfo::getIsDeleted, 0);
        // 时间范围:使用上次执行时间到当前时间
        query.ge(StudyInfo::getUploadTime, config.getDate("lastExecuteTime"));
        query.le(StudyInfo::getUploadTime, new Date());

        List<StudyInfo> allStudies = studyInfoMapper.selectList(query);

        // 2. 根据抽取方式选择数据
        int sampleCount = config.getIntValue("sampleCount");
        String sampleType = config.getString("sampleType");

        List<StudyInfo> selectedStudies;
        if ("random".equals(sampleType)) {
            // 随机抽取
            Collections.shuffle(allStudies);
            selectedStudies = allStudies.subList(0, Math.min(sampleCount, allStudies.size()));
        } else {
            // 按时间顺序
            allStudies.sort(Comparator.comparing(StudyInfo::getUploadTime).reversed());
            selectedStudies = allStudies.subList(0, Math.min(sampleCount, allStudies.size()));
        }

        // 3. 保存到qc_task_exam表
        List<QcTaskExam> exams = selectedStudies.stream().map(study -> {
            QcTaskExam exam = new QcTaskExam();
            exam.setTaskId(taskId);
            exam.setExecutionId(executionId);
            exam.setStudyId(study.getStudyId());
            return exam;
        }).collect(Collectors.toList());

        qcTaskExamMapper.saveBatch(exams);

        return exams;
    }

    /**
     * 执行质控
     */
    @Override
    @Async
    public void executeQc(Long executionId, List<QcTaskExam> exams) {
        try {
            // 执行质控逻辑(复用现有代码)
            for (QcTaskExam exam : exams) {
                // 执行图像质控
                executeImageQc(executionId, exam.getStudyId());
            }

        } catch (Exception e) {
            log.error("质控执行失败,executionId={}", executionId, e);
            throw e;
        }
    }

    /**
     * 完成执行
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void completeExecution(Long executionId) {
        QcTaskExecution execution = executionMapper.selectById(executionId);

        // 统计执行结果
        int total = imageResultMapper.selectCount(
            new LambdaQueryWrapper<QcTaskImageResult>()
                .eq(QcTaskImageResult::getExecutionId, executionId)
        );

        long pass = imageResultMapper.selectCount(
            new LambdaQueryWrapper<QcTaskImageResult>()
                .eq(QcTaskImageResult::getExecutionId, executionId)
                .eq(QcTaskImageResult::getIsQualified, 1)
        );

        // 更新执行记录
        execution.setStatus(2);  // 已完成
        execution.setTotalCount(total);
        execution.setCompletedCount(total);
        execution.setPassCount(pass.intValue());
        execution.setFailCount(total - pass.intValue());
        execution.setPassRate(total > 0 ? (pass * 100.0 / total) : 0);
        execution.setEndTime(new Date());
        execution.setDuration((int)((execution.getEndTime().getTime() - execution.getStartTime().getTime()) / 1000));
        executionMapper.updateById(execution);

        // 更新任务的last_execute_time和next_execute_time
        updateTaskScheduleInfo(execution.getTaskId());
    }
}

九、实现步骤

9.1 第一阶段:数据库和基础架构(1-2天)

  1. 数据库表创建

    • 执行Quartz表初始化脚本
    • 创建qc_task_execution表
    • 修改qc_task、qc_task_exam、qc_task_image_result表
  2. Quartz集成

    • 添加Maven依赖
    • 创建QuartzConfig配置类
    • 创建Quartz表

9.2 第二阶段:后端服务层(2-3天)

  1. 实体类和Mapper

    • QcTaskExecution实体类
    • QcTaskExecutionMapper
  2. 服务层实现

    • QcJobService:Job管理(创建、暂停、恢复、删除、触发)
    • QcExecutionService:执行记录管理
    • AutoQcJob:定时任务Job
    • QcTaskService改造:支持自动/手动任务区分
  3. Controller层

    • QcTaskController增加执行历史相关接口
    • 响应DTO改造

9.3 第三阶段:前端页面(2-3天)

  1. 任务创建表单改造

    • 条件渲染(自动/手动)
    • Cron表达式生成器集成
    • 表单验证规则调整
  2. 任务列表改造

    • 差异化展示字段
    • 操作按钮差异化
  3. 执行历史页面新建

    • ExecutionHistory.vue
    • 统计卡片
    • 执行历史表格
  4. 路由和API

    • 路由配置
    • API函数封装

9.4 第四阶段:联调测试(1-2天)

  1. 功能测试

    • 创建自动任务
    • 等待定时执行
    • 手动触发执行
    • 查看执行历史
    • 查看质控结果
  2. 异常测试

    • Quartz停止后重启
    • 执行失败重试
    • 并发执行

9.5 第五阶段:优化上线(1天)

  1. 性能优化

    • 异步执行优化
    • 数据查询优化
  2. 用户体验优化

    • 加载状态
    • 错误提示
    • 操作反馈

十、注意事项和风险点

10.1 技术风险

  1. Quartz持久化

    • 确保数据库连接池配置正确
    • Quartz表锁机制可能导致死锁
    • 集群环境需要配置集群节点
  2. 数据一致性

    • 执行记录快照避免历史数据被修改
    • 并发执行时的数据隔离
    • 事务边界控制
  3. 性能问题

    • 定时任务执行时间可能较长,需要异步处理
    • 大量数据的抽取和质控可能影响系统性能
    • 需要合理的超时和重试机制

10.2 业务风险

  1. 任务冲突

    • 同一任务的多次执行可能重叠
    • 需要配置@DisallowConcurrentExecution
  2. 数据量控制

    • 自动任务抽取数量需要合理限制
    • 避免一次性处理过多数据
  3. 失败处理

    • 定时任务执行失败需要告警
    • 提供重试机制
    • 记录详细错误日志

10.3 兼容性

  1. 现有功能不受影响

    • 手动任务功能保持不变
    • 现有质控结果页面兼容executionId参数
  2. 数据迁移

    • 现有质控结果数据execution_id为空
    • 新增逻辑要兼容空值情况

十一、成功标准

11.1 功能完整性

  • ✅ 自动任务可以创建、编辑、删除
  • ✅ 自动任务按配置周期执行
  • ✅ 可以查看执行历史记录
  • ✅ 可以查看每次执行的质控结果
  • ✅ 手动任务功能不受影响

11.2 性能指标

  • 定时任务执行延迟 < 5秒
  • 单次质控执行时间 < 30分钟(100条数据)
  • 执行历史列表查询 < 1秒

11.3 稳定性

  • Quartz任务调度成功率 > 99%
  • 自动任务执行失败率 < 1%
  • 系统支持至少100个并发自动任务

附录

附录A:Cron表达式示例

# 每天上午10点执行
0 0 10 * * ?

# 每周一上午10点执行
0 0 10 ? * MON

# 每月1号上午10点执行
0 0 10 1 * ?

# 每周一、三、五上午10点执行
0 0 10 ? * MON,WED,FRI

# 每2小时执行一次
0 0 */2 * * ?

附录B:关键文件清单

后端文件

新增:
- QcTaskExecution.java (实体)
- QcTaskExecutionMapper.java
- QcTaskExecutionService.java
- QcJobService.java
- AutoQcJob.java
- QuartzConfig.java

修改:
- QcTaskService.java
- QcTaskController.java
- QcTaskExam.java
- QcTaskImageResult.java

前端文件

新增:
- ExecutionHistory.vue
- CronSelector.vue (Cron生成器组件)

修改:
- QcTask.vue
- QcResultDetail.vue
- router/index.ts
- api/qc.ts

附录C:测试用例

  1. 自动任务创建

    • 输入:任务名称、机构、每周一10点、100条、随机抽取
    • 预期:任务创建成功,Quartz Job创建成功
  2. 定时执行验证

    • 操作:等待下次执行时间
    • 预期:任务自动执行,生成执行记录,生成质控结果
  3. 手动触发

    • 操作:点击"立即执行"
    • 预期:立即生成执行记录并执行质控
  4. 执行历史查看

    • 操作:点击"查看历史"
    • 预期:显示所有执行记录和统计信息
  5. 失败重试

    • 操作:执行失败的记录点击"重试"
    • 预期:重新执行该任务

文档版本:v1.0 创建日期:2026-02-03 最后更新:2026-02-03 文档状态:待评审