链盾shieldchain | 项目管理、DID操作、DID密钥更新消息定时提醒

项目数量统计+项目列表展示功能开发

前端

1. 前后端交互路径

javascript 复制代码
// src/api/project.js
import request from '@/utils/request' // 您的axios实例

// 获取项目概览统计
export const getProjectOverview = () => {
  return request.get('/projects/overview')
}

// 分页查询项目列表
export const getProjectList = (params) => {
  return request.get('/projects', { params })
}

// 获取项目详情
export const getProjectDetail = (id) => {
  return request.get(`/projects/${id}`)
}

// 删除项目
export const deleteProject = (id) => {
  return request.delete(`/projects/${id}`)
}

// 导出SBOM
export const exportSBOM = (projectId, standard, format) => {
  return request.get(`/api/projects/${projectId}/sbom`, {
    params: { standard, format },
    responseType: 'blob' // 重要:用于文件下载
  })
}

// 导出漏洞报告
export const exportVulnerabilityReport = (projectId, format) => {
  return request.get(`/api/projects/${projectId}/report`, {
    params: { format },
    responseType: 'blob'
  })
}

注意:获取项目统计和获取项目列表是同时请求API,这个需要在vue里面专门写一个函数,否则不会同时请求API

2. 风险分布表前端随机数生成

utils/RiskSimulator.js

javascript 复制代码
/**
 * 风险分布数据模拟器
 * 根据风险等级生成合理的随机分布数据
 */
class RiskSimulator {
  constructor() {
      this.distributionTemplates = {
          '高危': { high: { min: 30, max: 100 }, medium: { min: 50, max: 300 }, low: { min: 10, max: 70 }, info: { min: 0, max: 30 } },
          '中危': { high: { min: 20, max: 70 }, medium: { min: 30, max: 200 }, low: { min: 20, max: 60 }, info: { min: 5, max: 20 } },
          '低危': { high: { min: 0, max: 3 }, medium: { min: 50, max: 300 }, low: { min: 5, max: 30 }, info: { min: 10, max: 50 } },
          '无风险': { high: 0, medium: 0, low: 0, info: { min: 1, max: 10 } }
      };
      // 新增:兼容数字类型的 riskLevel(如 0=低危、1=中危、2=高危)
      this.riskLevelMap = {
          0: '低危',
          1: '中危',
          2: '高危',
          3: '无风险'
      };
  }

  /**
   * 生成指定范围内的随机数(兜底:确保 min ≤ max)
   */
  randomInRange(min, max) {
      // 修复:避免 min > max 导致的 NaN
      const realMin = Math.min(min, max);
      const realMax = Math.max(min, max);
      return Math.floor(Math.random() * (realMax - realMin + 1)) + realMin;
  }

  /**
   * 根据风险等级生成分布数据(核心修复)
   */
  generateDistribution(riskLevel, did) {
      // 修复1:处理 riskLevel 为数字的情况(如 0/1/2/3)
      const realRiskLevel = this.riskLevelMap[riskLevel] || riskLevel || '低危';
      // 修复2:确保模板存在,兜底为低危
      const template = this.distributionTemplates[realRiskLevel] || this.distributionTemplates['低危'];
      
      // 修复3:处理 did 为空的情况(避免 hashString 入参异常)
      const safeDid = did || 'default-seed-' + Date.now();
      const seed = this.hashString(safeDid);
      
      const distribution = {};

      // 为每种风险级别生成数据
      Object.keys(template).forEach(level => {
          const config = template[level];
          // 修复4:处理 config 为数字的情况(如无风险的 high=0)
          if (typeof config === 'number') {
              distribution[level] = Math.max(0, config); // 确保非负
              return;
          }
          // 修复5:确保 config 是包含 min/max 的对象
          if (typeof config !== 'object' || !config.min || !config.max) {
              distribution[level] = 0; // 异常配置时兜底为0
              return;
          }

          // 修复6:确保 seededRandom 返回有效数字(0-1之间)
          const seededRandom = this.seededRandom(seed + level);
          const validRandom = isNaN(seededRandom) ? Math.random() : seededRandom;
          
          // 计算最终值(确保非负)
          const value = Math.floor(validRandom * (config.max - config.min + 1)) + config.min;
          distribution[level] = Math.max(0, value); // 兜底:避免负数
      });

      return distribution;
  }

  /**
   * 简单的字符串哈希函数,用于生成种子(优化:避免 hash 为 0)
   */
  hashString(str) {
      let hash = 0;
      for (let i = 0; i < str.length; i++) {
          const char = str.charCodeAt(i);
          hash = ((hash << 5) - hash) + char;
          hash = hash & hash; // 转换为32位整数
      }
      // 修复:避免 hash 为 0 导致 sin(0) = 0,进而生成固定值
      return Math.abs(hash) || 1;
  }

  /**
   * 基于种子的伪随机数生成器(修复:避免返回 NaN)
   */
  seededRandom(seed) {
      // 修复:确保 seed 是有效数字,避免 sin(NaN)
      if (isNaN(seed)) seed = 1;
      // 优化:使用更大的种子偏移,避免重复
      const x = Math.sin(seed * 10000) * 10000;
      const result = x - Math.floor(x);
      // 修复:如果 result 是 NaN,返回随机数兜底
      return isNaN(result) ? Math.random() : result;
  }

  /**
   * 计算风险分数(用于排序和显示)
   */
  calculateRiskScore(distribution) {
      // 修复:处理 distribution 字段可能为 NaN 的情况
      const high = isNaN(distribution.high) ? 0 : distribution.high;
      const medium = isNaN(distribution.medium) ? 0 : distribution.medium;
      const low = isNaN(distribution.low) ? 0 : distribution.low;
      return high * 10 + medium * 3 + low * 1;
  }
}

export default new RiskSimulator();

3. 逻辑

src/api/project.js

javascript 复制代码
<template>
    <div class="project-dashboard">
        <!-- 页面标题区域 -->
        <div class="page-header">
            <div class="header-content">
                <div class="header-main">
                    <div class="header-text">
                        <h1>项目管理</h1>
                        <p class="subtitle">管理和监控项目的风险状态</p>
                    </div>
                </div>
            </div>
        </div>

        <!-- 项目总览 -->
        <div class="content-wrapper">
            <div class="overview-section">
                <el-card shadow="never" class="overview-card">
                    <el-row :gutter="24">
                        <!-- 左侧统计数据 -->
                        <el-col :xs="24" :lg="6">
                            <el-row :gutter="20">
                                <el-col :span="24">
                                    <el-card shadow="hover" class="stat-card">
                                        <div class="stat-content">
                                            <div class="stat-icon">
                                                <el-icon><DataLine /></el-icon>
                                            </div>
                                            <div class="stat-info">
                                                <span class="stat-label">项目总数</span>
                                                <div class="stat-value">{{ totalProjects }}</div>
                                            </div>
                                        </div>
                                    </el-card>
                                </el-col>
                                <el-col :span="24" style="margin-top: 20px;">
                                    <el-card shadow="hover" class="stat-card high-risk">
                                        <div class="stat-content">
                                            <div class="stat-icon">
                                                <el-icon><Warning /></el-icon>
                                            </div>
                                            <div class="stat-info">
                                                <span class="stat-label">高危项目</span>
                                                <div class="stat-value">{{ highRiskProjects }}</div>
                                            </div>
                                        </div>
                                    </el-card>
                                </el-col>
                            </el-row>
                        </el-col>

                        <!-- 中间饼图 -->
                        <el-col :xs="24" :lg="12">
                            <div class="chart-wrapper">
                                <div ref="riskChart" class="chart-container"></div>
                            </div>
                        </el-col>

                        <!-- 右侧统计数据 -->
                        <el-col :xs="24" :lg="6">
                            <el-row :gutter="20">
                                <el-col :span="24">
                                    <el-card shadow="hover" class="stat-card medium-risk">
                                        <div class="stat-content">
                                            <div class="stat-icon">
                                                <el-icon><Warning /></el-icon>
                                            </div>
                                            <div class="stat-info">
                                                <span class="stat-label">中危项目</span>
                                                <div class="stat-value">{{ mediumRiskProjects }}</div>
                                            </div>
                                        </div>
                                    </el-card>
                                </el-col>
                                <el-col :span="24" style="margin-top: 20px;">
                                    <el-card shadow="hover" class="stat-card low-risk">
                                        <div class="stat-content">
                                            <div class="stat-icon">
                                                <el-icon><Warning /></el-icon>
                                            </div>
                                            <div class="stat-info">
                                                <span class="stat-label">低危项目</span>
                                                <div class="stat-value">{{ lowRiskProjects }}</div>
                                            </div>
                                        </div>
                                    </el-card>
                                </el-col>
                            </el-row>
                        </el-col>
                    </el-row>
                </el-card>
            </div>

            <!-- 项目列表 -->
            <div class="list-section">
                <el-card shadow="never" class="table-card">
                    <template #header>
                        <div class="table-header">
                            <div class="header-left">
                                <el-icon :size="20"><Document /></el-icon>
                                <span class="header-title">项目列表</span>
                            </div>
                            <el-input
                                v-model="searchKeyword"
                                placeholder="搜索项目"
                                class="search-input"
                                :prefix-icon="Search"
                            />
                        </div>
                    </template>

                    <el-table 
                        :data="filteredProjects" 
                        v-loading="loading" 
                        style="width: 100%"
                        :header-cell-style="{ 
                            backgroundColor: '#f5f7fa', 
                            fontSize: '15px',
                            fontWeight: 600 
                        }"
                        :cell-style="{ fontSize: '14px' }"
                    >
                        <el-table-column 
                            prop="did" 
                            label="DID标识" 
                            width="290"
                            show-overflow-tooltip 
                        />
                        <el-table-column 
                            prop="name" 
                            label="项目名称" 
                            min-width="150"
                            show-overflow-tooltip
                        />
                        <el-table-column 
                            prop="riskLevel" 
                            label="风险等级" 
                            width="200"
                        >
                            <template #default="{ row }">
                                <el-tag 
                                    :type="getRiskTagType(row.riskLevel)"
                                    style="font-size: 14px; padding: 4px 8px;"
                                >
                                {{ getRiskLevelText(row.riskLevel) }}
                                </el-tag>
                            </template>
                        </el-table-column>
                        <el-table-column 
                            prop="riskScore" 
                            label="风险分布" 
                            width="400"
                        >
                            <template #default="{ row }">
                                <risk-distribution :data="row.riskDistribution || {}" />
                            </template>
                        </el-table-column>
                        <el-table-column 
                            prop="scanTime" 
                            label="扫描时间" 
                            width="220"
                        />
                        <el-table-column 
                            label="操作" 
                            width="200" 
                            fixed="right"
                        >
                            <template #default="{ row }">
                                <div class="operation-buttons">
                                    <el-tooltip content="查看详情" placement="top">
                                        <el-button type="primary" link @click="viewDetails(row)">
                                            <el-icon><View /></el-icon>
                                        </el-button>
                                    </el-tooltip>
                                    <el-tooltip content="导出SBOM" placement="top">
                                        <el-button type="primary" link @click="handleExportSBOM(row)">
                                            <el-icon><Document /></el-icon>
                                        </el-button>
                                    </el-tooltip>
                                    <el-tooltip content="导出报告" placement="top">
                                        <el-button type="primary" link @click="handleExportReport(row)">
                                            <el-icon><DocumentCopy /></el-icon>
                                        </el-button>
                                    </el-tooltip>
                                    <el-tooltip content="删除项目" placement="top">
                                        <el-button type="danger" link @click="deleteProject(row)">
                                            <el-icon><Delete /></el-icon>
                                        </el-button>
                                    </el-tooltip>
                                </div>
                            </template>
                        </el-table-column>
                    </el-table>
                </el-card>
            </div>
        </div>

        <!-- SBOM导出对话框 -->
        <el-dialog
            v-model="exportDialogVisible"
            title="导出SBOM"
            width="500px"
            align-center
            class="export-dialog"
        >
            <div class="format-selection">
                <div class="selection-section">
                    <h4>选择SBOM标准:</h4>
                    <el-radio-group v-model="selectedStandard">
                        <el-radio
                            v-for="standard in sbomStandards"
                            :key="standard.value"
                            :label="standard.value"
                            border
                            class="format-radio"
                        >
                            {{ standard.label }}
                        </el-radio>
                    </el-radio-group>
                </div>

                <el-divider>导出格式</el-divider>

                <div class="selection-section">
                    <h4>选择导出格式:</h4>
                    <el-radio-group v-model="selectedFormat">
                        <el-radio
                            v-for="format in exportFormats"
                            :key="format.value"
                            :label="format.value"
                            border
                            class="format-radio"
                        >
                            {{ format.label }}
                        </el-radio>
                    </el-radio-group>
                </div>
            </div>
            <template #footer>
                <span class="dialog-footer">
                    <el-button plain @click="exportDialogVisible = false">取消</el-button>
                    <el-button type="primary" @click="confirmExportSBOM">
                        确认导出
                    </el-button>
                </span>
            </template>
        </el-dialog>

        <!-- 漏洞报告导出对话框 -->
        <el-dialog
            v-model="exportReportDialogVisible"
            title="导出漏洞报告"
            width="500px"
            align-center
            class="export-dialog"
        >
            <div class="format-selection">
                <div class="selection-section">
                    <h4>选择导出格式:</h4>
                    <el-radio-group v-model="selectedReportFormat">
                        <el-radio
                            v-for="format in reportFormats"
                            :key="format.value"
                            :label="format.value"
                            border
                            class="format-radio"
                        >
                            {{ format.label }}
                        </el-radio>
                    </el-radio-group>
                </div>
            </div>
            <template #footer>
                <span class="dialog-footer">
                    <el-button plain @click="exportReportDialogVisible = false">取消</el-button>
                    <el-button type="primary" @click="confirmExportReport">
                        确认导出
                    </el-button>
                </span>
            </template>
        </el-dialog>
    </div>
</template>

<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import * as echarts from 'echarts'
import { Search, View, Document, DocumentCopy, Delete, ArrowDown, DataLine, Warning } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import RiskDistribution from '@/views/Function/Management/RiskDistribution.vue'
import { useRouter } from 'vue-router'
import { 
  getProjectOverview, 
  getProjectList, 
  deleteProject as apiDeleteProject,
  exportSBOM,
  exportVulnerabilityReport
} from '@/api/project'
import RiskSimulator from '@/utils/RiskSimulator'
//import RiskDistribution from '@/views/Function/Management/RiskDistribution.vue'

const router = useRouter()

// 统计数据
const totalProjects = ref(0)
const highRiskProjects = ref(0)
const mediumRiskProjects = ref(0)
const lowRiskProjects = ref(0)

const searchKeyword = ref('')
const loading = ref(false)
const projectList = ref([]) // 改为空数组,从后端获取


// 图表引用
const riskChart = ref(null)

// 导出相关的状态
const exportDialogVisible = ref(false)
const exportReportDialogVisible = ref(false)
const selectedStandard = ref('spdx')
const selectedFormat = ref('json')
const selectedReportFormat = ref('html')

// 当前操作的项目
const currentProject = ref(null)

// SBOM标准选项
const sbomStandards = [
    { label: 'SPDX', value: 'spdx' },
    { label: 'CycloneDX', value: 'cdx' },
    { label: 'SYFT', value: 'syft' }
]

// SBOM格式选项
const exportFormats = [
    { label: 'JSON', value: 'json' },
    { label: 'SPDX', value: 'spdx' },
    { label: 'XML', value: 'xml' }
]

// 报告格式选项
const reportFormats = [
    { label: 'JSON', value: 'json' },
    { label: 'XML', value: 'xml' },
    { label: 'HTML', value: 'html' },
    { label: 'CSV', value: 'csv' }
]

// 2. 饼图相关(完全独立,写死数据)
//const riskChart = ref(null);
let chartInstance = null; // 饼图实例

// 写死的饼图数据(可直接修改数值)
const fixedPieData = [
  { value: 15, name: '高危项目', itemStyle: { color: '#F56C6C' } },
  { value: 30, name: '中危项目', itemStyle: { color: '#E6A23C' } },
  { value: 60, name: '低危项目', itemStyle: { color: '#67C23A' } },
  { value: 25, name: '无风险项目', itemStyle: { color: '#909399' } }
];

// 初始化饼图(独立逻辑,不依赖任何后端数据)
const initChart = () => {
  // 确保DOM已挂载
  if (!riskChart.value) return;

  // 创建ECharts实例(避免重复创建)
  if (!chartInstance) {
    chartInstance = echarts.init(riskChart.value);
  }

  // 饼图配置项(用写死的数据)
  const option = {
    title: {
      text: '项目风险分布',
      left: 'center',
      top: '5%'
    },
    tooltip: {
      trigger: 'item',
      formatter: '{b}: {c} ({d}%)'
    },
    legend: {
      orient: 'horizontal',
      bottom: '5%',
      left: 'center',
      data: ['高危项目', '中危项目', '低危项目', '无风险项目']
    },
    series: [
      {
        type: 'pie',
        radius: ['35%', '55%'],
        center: ['50%', '50%'],
        avoidLabelOverlap: false,
        itemStyle: {
          borderRadius: 10,
          borderColor: '#fff',
          borderWidth: 2
        },
        label: {
          show: true,
          position: 'outside',
          formatter: '{b}: {c}'
        },
        emphasis: {
          label: {
            show: true,
            fontSize: 20,
            fontWeight: 'bold'
          }
        },
        data: fixedPieData // 直接使用写死的数据
      }
    ]
  };

  chartInstance.setOption(option);
};

// 模拟项目数据
/*const projectList = ref([
    {
        did: 'did:shieldchain:4e2d8c1f3a7b9e0d5f6c8a9b0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0',
        name: 'OpenCV',
        riskLevel: '高危',
        riskDistribution: {
            high: 32,
            medium: 250,
            low: 107,
            info: 57
        },
        scanTime: '2024-03-18 20:51:36'
    },
    {
        did: 'did:shieldchain:550e8400e29b41d4a716446655440000550e8400e29b41d4a716446655440000',
        name: '测试项目',
        riskLevel: '低危',
        riskDistribution: {
            high: 0,
            medium: 2,
            low: 15,
            info: 43
        },
        scanTime: '2024-03-18 16:50:04'
    }
])*/

// 过滤项目列表
const filteredProjects = computed(() => {
    if (!searchKeyword.value) return projectList.value
    const keyword = searchKeyword.value.toLowerCase()
    return projectList.value.filter(project => 
        project.name.toLowerCase().includes(keyword) ||
        project.did.toLowerCase().includes(keyword)
    )
})

// 并发加载数据函数
const loadAllData = async () => {
  const loadingInstance = ElLoading.service({
    lock: true,
    text: '正在加载数据...',
    background: 'rgba(0, 0, 0, 0.7)'
  })
  
  try {
    // 并发调用两个接口[3](@ref)
    const [overviewResponse, listResponse] = await Promise.all([
      getProjectOverview(),
      getProjectList({ 
        page: 1, 
        pageSize: 100, 
        keyword: searchKeyword.value 
      })
    ])
    
    // 处理响应数据
    await processOverviewResponse(overviewResponse)
    await processListResponse(listResponse)
    
    ElMessage.success('数据加载完成')
  } catch (error) {
    console.error('数据加载失败:', error)
    ElMessage.error(`数据加载失败: ${error.message}`)
  } finally {
    loadingInstance.close()
  }
}

// 风险等级映射配置
const RISK_LEVEL_MAP = {
  '0': { text: '低危', type: 'success', level: 0 },
  '1': { text: '中危', type: 'warning', level: 1 },
  '2': { text: '高危', type: 'danger', level: 2 },
  '3': { text: '严重', type: 'danger', level: 3 }
}

// 风险等级转换函数
const convertRiskLevel = (level) => {
  if (!level && level !== 0) return { text: '未知', type: 'info', level: -1 }
  
  const levelKey = String(level)
  const levelConfig = RISK_LEVEL_MAP[levelKey]
  
  return levelConfig || { text: '未知', type: 'info', level: -1 }
}

// 修改后的风险等级标签类型函数
const getRiskTagType = (level) => {
  const levelConfig = convertRiskLevel(level)
  return levelConfig.type
}

// 获取风险等级显示文本
const getRiskLevelText = (level) => {
  const levelConfig = convertRiskLevel(level)
  return levelConfig.text
}

// 3. 后端数据请求(只给统计框赋值,不影响饼图)
// 保留你原有获取后端数据的逻辑,示例:
const fetchBackendData = async () => {
  try {
    // 调用后端接口获取统计数据(你的原有逻辑)
    const res = await axios.get('/project/overview', {
      headers: { Authorization: 'Bearer ' + localStorage.getItem('token') }
    });
    // 给统计框变量赋值(饼图不使用这些变量)
    totalProjects.value = res.data.data.totalProjects;
    highRiskProjects.value = res.data.data.highRiskProjects;
    mediumRiskProjects.value = res.data.data.mediumRiskProjects;
    lowRiskProjects.value = res.data.data.lowRiskProjects;
    // projectList.value = res.data.data.projectList; // 按需保留
  } catch (error) {
    console.error('获取后端统计数据失败', error);
    // 失败时给统计框设置默认值,避免显示0
    totalProjects.value = 0;
    highRiskProjects.value = 0;
    mediumRiskProjects.value = 0;
    lowRiskProjects.value = 0;
  }
};


// 生命周期
onMounted(() => {
  initChart()
  loadProjectOverview()
  loadProjectList()
})

// 初始化图表
/*const initChart = () => {
  const chart = echarts.init(riskChart.value)
  
  // 监听数据变化更新图表
  watch([totalProjects, highRiskProjects, mediumRiskProjects, lowRiskProjects], () => {
    updateChart(chart)
  })
  
  window.addEventListener('resize', () => {
    chart.resize()
  })

  
}

// 更新图表数据
const updateChart = (chart) => {
 
 // 获取转换后的统计数据
 const highRiskCount = projectList.value.filter(project => 
    convertRiskLevel(project.originalRiskLevel).level === 2
  ).length
  
  const mediumRiskCount = projectList.value.filter(project => 
    convertRiskLevel(project.originalRiskLevel).level === 1
  ).length
  
  const lowRiskCount = projectList.value.filter(project => 
    convertRiskLevel(project.originalRiskLevel).level === 0
  ).length
  
  const noRiskCount = Math.max(0, totalProjects.value - highRiskCount - mediumRiskCount - lowRiskCount)
  const option = {
    title: {
      text: '项目风险分布',
      left: 'center',
      top: '5%'
    },
    tooltip: {
      trigger: 'item',
      formatter: '{b}: {c} ({d}%)'
    },
    legend: {
      orient: 'horizontal',
      bottom: '5%',
      left: 'center',
      data: ['高危项目', '中危项目', '低危项目', '无风险项目']
    },
    series: [
      {
        type: 'pie',
        radius: ['35%', '55%'],
        center: ['50%', '50%'],
        avoidLabelOverlap: false,
        itemStyle: {
          borderRadius: 10,
          borderColor: '#fff',
          borderWidth: 2
        },
        label: {
          show: true,
          position: 'outside',
          formatter: '{b}: {c}'
        },
        emphasis: {
          label: {
            show: true,
            fontSize: 20,
            fontWeight: 'bold'
          }
        },
        data: [
        { 
            value: highRiskCount, 
            name: '高危项目', 
            itemStyle: { color: '#F56C6C' } 
          },
          { 
            value: mediumRiskCount, 
            name: '中危项目', 
            itemStyle: { color: '#E6A23C' } 
          },
          { 
            value: lowRiskCount, 
            name: '低危项目', 
            itemStyle: { color: '#67C23A' } 
          },
          { 
            value: noRiskCount, 
            name: '无风险项目', 
            itemStyle: { color: '#909399' } 
          }
        ]
      }
    ]
  }
  chart.setOption(option)
}*/

// 获取项目概览数据
const loadProjectOverview = async () => {
  loading.value = true
  try {
    const response = await getProjectOverview()
    
    if (response.code === 1) {
      const data = response.data
      
      // 将后端数据绑定到前端响应式变量
      totalProjects.value = data.totalProjects || 0
      highRiskProjects.value = data.highRiskProjects || 0
      mediumRiskProjects.value = data.mediumRiskProjects || 0
      lowRiskProjects.value = data.lowRiskProjects || 0
      
      console.log('项目概览数据加载成功:', data)
    } else {
      ElMessage.error(response.msg || '获取数据失败')
    }
  } catch (error) {
    console.error('获取项目概览失败:', error)
    ElMessage.error('网络错误,获取数据失败')
  } finally {
    loading.value = false
  }
}

/*const loadProjectList = async () => {
  loading.value = true
  try {
    const params = {
      page: 1,
      pageSize: 100, // 根据实际需求调整
      keyword: searchKeyword.value
    }
    
    const response = await getProjectList(params)
    if (response.code === 1 && response.data && Array.isArray(response.data.records)) {
      // 为每个项目生成风险分布数据
      projectList.value = response.data.records.map(project => ({
        ...project,
        riskDistribution: RiskSimulator.generateDistribution(project.riskLevel, project.did)
      }))
    } else {
      ElMessage.error('返回数据格式不正确')
    }
  } catch (error) {
    console.error('获取项目列表失败:', error)
    ElMessage.error('获取项目列表失败')
  } finally {
    loading.value = false
  }
}*/
const loadProjectList = async () => {
  loading.value = true
  try {
    const params = { page: 1, pageSize: 100, keyword: searchKeyword.value }
    const response = await getProjectList(params)
    if (response.code === 1 && response.data?.records) {
      projectList.value = response.data.records.map(project => {
        console.log('项目原始数据:', { riskLevel: project.riskLevel, did: project.did }) // 打印入参
        const riskDistribution = RiskSimulator.generateDistribution(project.riskLevel, project.did)
        console.log('生成的风险分布:', riskDistribution) // 打印出参
        return { ...project, riskDistribution }
      })
    } else {
      ElMessage.error('返回数据格式不正确')
    }
  } catch (error) {
    console.error('获取项目列表失败:', error)
    ElMessage.error('获取项目列表失败')
  } finally {
    loading.value = false
  }
}



// 操作方法
const viewDetails = (row) => {
    router.push(`/detail/${row.id}`)// 使用ID而不是DID,更标准
}

// 修改导出相关方法
const simulateExport = (fileName, type) => {
    return new Promise((resolve) => {
        const loading = ElLoading.service({
            lock: true,
            text: `正在${type}...`,
            background: 'rgba(0, 0, 0, 0.7)'
        })

        const duration = Math.floor(Math.random() * 2000) + 1000 // 1-3秒随机时间
        setTimeout(() => {
            loading.close()
            resolve(duration / 1000) // 返回耗时(秒)
        }, duration)
    })
}

// 修改SBOM导出方法
const confirmExportSBOM = async () => {
    try {
        exportDialogVisible.value = false
        const duration = await simulateExport('sbom', '生成SBOM清单')

        // 加载测试数据
        const response = await fetch('/mock/Test.json')
        const mockData = await response.json()

        let content = ''
        let filename = ''
        let type = ''

        // 根据选择的标准和格式处理数据
        if (selectedStandard.value === 'spdx' && selectedFormat.value === 'json') {
            content = JSON.stringify(mockData, null, 2)
            filename = 'sbom-spdx.json'
            type = 'application/json'
        } else {
            // 处理其他格式...
            content = JSON.stringify(mockData, null, 2)
            filename = `sbom-${selectedStandard.value}.${selectedFormat.value}`
            type = 'application/json'
        }

        // 创建 Blob 对象并下载
        const blob = new Blob([content], { type: `${type};charset=utf-8` })
        const link = document.createElement('a')
        link.href = URL.createObjectURL(blob)
        link.download = filename
        
        document.body.appendChild(link)
        link.click()
        document.body.removeChild(link)
        URL.revokeObjectURL(link.href)

        ElMessage.success(`成功导出SBOM清单(${selectedStandard.value.toUpperCase()} ${selectedFormat.value.toUpperCase()}格式),共花费 ${duration} 秒`)
    } catch (error) {
        console.error('Export failed:', error)
        ElMessage.error('导出失败')
    }
}

// 修改漏洞报告导出方法
const confirmExportReport = async () => {
    try {
        exportReportDialogVisible.value = false
        const duration = await simulateExport('report', '生成漏洞报告')

        if (selectedReportFormat.value === 'html') {
            // 获取HTML模板
            const templateResponse = await fetch('/templates/Test.html')
            const template = await templateResponse.text()

            // 创建并下载文件
            const blob = new Blob([template], { type: 'text/html;charset=utf-8' })
            const link = document.createElement('a')
            link.href = URL.createObjectURL(blob)
            link.download = 'vulnerability-report.html'
            
            document.body.appendChild(link)
            link.click()
            document.body.removeChild(link)
            URL.revokeObjectURL(link.href)

            ElMessage.success(`成功导出漏洞报告(HTML格式),共花费 ${duration} 秒`)
        }
    } catch (error) {
        console.error('Export failed:', error)
        ElMessage.error('导出失败')
    }
}

const deleteProject = (row) => {
    ElMessageBox.confirm(
        `确定要删除项目 "${row.name}" 吗?`,
        '警告',
        {
            confirmButtonText: '确定',
            cancelButtonText: '取消',
            type: 'warning',
        }
    )
        .then(() => {
            // 实现删除逻辑
            ElMessage.success(`已删除项目 ${row.name}`)
        })
        .catch(() => {
            ElMessage.info('已取消删除')
        })
}

// 修改导出处理方法
const handleExportSBOM = (row) => {
    selectedStandard.value = 'spdx' // 重置默认选择
    selectedFormat.value = 'json'   // 重置默认选择
    exportDialogVisible.value = true
}

const handleExportReport = (row) => {
    selectedReportFormat.value = 'html' // 重置默认选择
    exportReportDialogVisible.value = true
}

// 初始化图表
onMounted(() => {
    const chart = echarts.init(riskChart.value)
    const option = {
        title: {
            text: '漏洞分布统计',
            left: 'center',
            top: '5%'
        },
        tooltip: {
            trigger: 'item',
            formatter: '{b}: {c} ({d}%)'
        },
        legend: {
            orient: 'horizontal',
            bottom: '5%',
            left: 'center',
            data: ['高危漏洞', '中危漏洞', '低危漏洞', '信息']
        },
        series: [
            {
                type: 'pie',
                radius: ['35%', '55%'],
                center: ['50%', '50%'],
                avoidLabelOverlap: false,
                itemStyle: {
                    borderRadius: 10,
                    borderColor: '#fff',
                    borderWidth: 2
                },
                label: {
                    show: true,
                    position: 'outside',
                    formatter: '{b}: {c}'
                },
                emphasis: {
                    label: {
                        show: true,
                        fontSize: 20,
                        fontWeight: 'bold'
                    }
                },
                data: [
                    { value: 32, name: '高危漏洞', itemStyle: { color: '#F56C6C' } },
                    { value: 252, name: '中危漏洞', itemStyle: { color: '#E6A23C' } },
                    { value: 122, name: '低危漏洞', itemStyle: { color: '#67C23A' } },
                    { value: 100, name: '信息', itemStyle: { color: '#909399' } }
                ]
            }
        ]
    }
    chart.setOption(option)
    
    // 添加响应式调整
    window.addEventListener('resize', () => {
        chart.resize()
    })
})
</script>

<style lang="scss" scoped>
.project-dashboard {
    min-height: 100vh;
    background-color: #f8fafc;
    padding: 0;
}

.page-header {
    background: white;
    padding: 24px 32px;
    margin-bottom: 24px;
    border-bottom: 1px solid #eaedf1;

    .header-content {
        max-width: 1400px;
        margin: 0;
        padding: 0;

        .header-main {
            display: flex;
            align-items: flex-start;
        }

        .header-text {
            h1 {
                font-size: 24px;
                color: #2c3e50;
                margin: 0 0 8px 0;
                font-weight: 600;
            }

            .subtitle {
                color: #6b7c93;
                font-size: 18px;
                margin: 0;
            }
        }
    }
}

.content-wrapper {
    max-width: 2000px;
    margin: 0 auto;
    padding: 0 24px;
}

.overview-section {
    margin: 0 auto;
    
    .overview-card {
        background: white;
        border-radius: 8px;
        box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
        margin-bottom: 24px;
        padding: 32px;

        .el-row {
            min-height: 360px;
        }
    }

    .chart-wrapper {
        height: 100%;
        display: flex;
        align-items: center;
        justify-content: center;
        padding: 0;
    }

    .chart-container {
        width: 100%;
        max-width: 800px;
        height: 360px;
        margin: 0 auto;
    }

    .stat-card {
        height: 175px;
        margin-bottom: 20px;
        transition: all 0.3s ease;
        border-radius: 12px;
        overflow: hidden;
        
        &:hover {
            transform: translateY(-4px);
            box-shadow: 0 12px 24px rgba(0, 0, 0, 0.06);
        }

        .stat-content {
            padding: 20px;
            text-align: center;
            display: flex;
            align-items: center;
            gap: 16px;
            
            .stat-icon {
                width: 56px;
                height: 56px;
                border-radius: 12px;
                display: flex;
                align-items: center;
                justify-content: center;
                background: rgba(64, 158, 255, 0.1);
                
                .el-icon {
                    font-size: 32px !important;
                    color: #409EFF;
                }
            }
            
            .stat-info {
                flex: 1;
            }

            .stat-label {
                font-size: 22px !important;
                color: #606266;
                display: block;
                margin-bottom: 8px;
            }

            .stat-value {
                font-size: 32px !important;
                font-weight: 600;
                color: #303133;
            }
        }

        &.high-risk .stat-icon {
            background: rgba(245, 108, 108, 0.1);
            .el-icon { color: #F56C6C; }
        }
        
        &.medium-risk .stat-icon {
            background: rgba(230, 162, 60, 0.1);
            .el-icon { color: #E6A23C; }
        }
        
        &.low-risk .stat-icon {
            background: rgba(103, 194, 58, 0.1);
            .el-icon { color: #67C23A; }
        }
    }
}

.table-card {
    margin-top: 24px;
    border-radius: 12px;
    
    .table-header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 16px 20px;

        .header-left {
            display: flex;
            align-items: center;
            gap: 8px;

            .el-icon {
                color: #409EFF;
                font-size: 24px;
            }

            .header-title {
                font-size: 20px;
                font-weight: 500;
                color: #303133;
            }
        }

        .search-input {
            width: 250px;
            :deep(.el-input__inner) {
                font-size: 18px;
            }
        }
    }

    :deep(.el-table) {
        font-size: 18px !important;

        .el-table__header-wrapper {
            th {
                font-size: 20px !important;
                font-weight: 600;
                padding: 18px 0;
            }
        }

        .el-table__body-wrapper {
            td {
                font-size: 18px !important;
                padding: 18px 0;
            }
        }

        .el-tag {
            font-size: 16px !important;
            padding: 8px 14px;
        }

        .cell {
            font-size: 18px !important;
            line-height: 1.5;
        }
    }
}

.operation-buttons {
    display: flex;
    align-items: center;
    gap: 8px;

    .el-button {
        padding: 4px;
        margin: 0;
        
        .el-icon {
            font-size: 18px;
        }

        &:hover {
            transform: scale(1.1);
            transition: transform 0.2s ease;
        }
    }
}

@media (max-width: 992px) {
    .overview-section {
        .chart-container {
            height: 350px;
            margin: 20px 0;
        }
    }
}

@media (max-width: 768px) {
    .page-header {
        padding: 24px 16px;

        .header-content {
            h1 {
                font-size: 1.8rem;
            }

            .subtitle {
                font-size: 1rem;
            }
        }
    }

    .overview-section {
        padding: 16px;
        
        .chart-container {
            height: 250px;
        }
    }
}

// 添加响应式阴影效果
.el-card {
    transition: box-shadow 0.3s ease;
    
    &:hover {
        box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);
    }
}

// 添加新的样式
.dialog-footer {
    display: flex;
    justify-content: flex-end;
    gap: 12px;
}

:deep(.el-radio) {
    height: 40px;
    display: flex;
    align-items: center;
    margin-left: 0;
    padding: 0 15px;
}

// 添加新的对话框样式
.export-dialog {
    .format-selection {
        padding: 24px;

        .selection-section {
            margin-bottom: 20px;

            &:last-child {
                margin-bottom: 0;
            }

            h4 {
                margin: 0 0 16px 0;
                color: #303133;
                font-size: 16px;
                font-weight: 500;
            }
        }

        .el-radio-group {
            display: flex;
            flex-direction: column;
            gap: 12px;

            .format-radio {
                margin: 0;
                padding: 12px 20px;
                width: 100%;
                text-align: center;
                
                &.is-bordered {
                    border-radius: 8px;
                }
            }
        }
    }

    .el-divider {
        margin: 24px 0;
        
        :deep(.el-divider__text) {
            font-size: 14px;
            color: #909399;
            background-color: #f7f9fc;
        }
    }
}

:deep(.el-dialog) {
    border-radius: 12px;
    box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
    
    .el-dialog__header {
        background: #f7f9fc;
        border-radius: 12px 12px 0 0;
        padding: 20px 24px;
        margin: 0;
        border-bottom: 1px solid #eaedf1;

        .el-dialog__title {
            font-size: 1.2rem;
            font-weight: 500;
            color: #303133;
        }
    }

    .el-dialog__body {
        padding: 0;
    }

    .el-dialog__footer {
        padding: 20px 24px;
        background: #ffffff;
        border-top: 1px solid #eaedf1;
        border-radius: 0 0 12px 12px;
    }
}

.dialog-footer {
    display: flex;
    justify-content: flex-end;
    gap: 12px;

    .el-button {
        padding: 8px 24px;
        font-size: 0.95rem;
    }
}
</style>

后端

1. 在WebMvcConfiguration注册新路径

java 复制代码
protected void addInterceptors(InterceptorRegistry registry){
        log.info("开始注册自定义拦截器...");
        registry.addInterceptor(jwtTokenInterceptor)
                .addPathPatterns("/user/**")
                .addPathPatterns("/software/**")
                .addPathPatterns("/message/**")
                .addPathPatterns("/sbom-vuln/**")
                .addPathPatterns("/projects/**")
                .excludePathPatterns("/user/login");
    }

2. VO和DTO

复制代码
ProjectOverviewVO
java 复制代码
package com.sky.vo;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ApiModel(description = "项目风险概览的数据格式")
public class ProjectOverviewVO implements Serializable {
    @ApiModelProperty("总项目数量")
    private Integer totalProjects;

    @ApiModelProperty("高风险项目数量")
    private Integer highRiskProjects;

    @ApiModelProperty("中风险项目数量")
    private Integer mediumRiskProjects;

    @ApiModelProperty("低风险项目数量")
    private Integer lowRiskProjects;
}
复制代码
RiskDistributionVO
java 复制代码
package com.sky.vo;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ApiModel(description = "项目风险值分布对象的数据格式")
public class RiskDistributionVO implements Serializable {
    @ApiModelProperty("高危漏洞数量")
    private Integer high;
    @ApiModelProperty("中危漏洞数量")
    private Integer medium;
    @ApiModelProperty("低危漏洞数量")
    private Integer low;
    private Integer info;
}
复制代码
ProjectVO
java 复制代码
package com.sky.vo;

import io.swagger.annotations.ApiModel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.Collections;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ApiModel(description = "项目视图对象的数据格式")
public class ProjectVO implements Serializable {
    private Integer id;
    private String did;
    private String name;
    private String riskLevel;
    // 风险分布: 前端随机数生成
    private RiskDistributionVO riskDistribution;
    private LocalDateTime scanTime;
    private String sbomLocalPath;
    private String vulnListLocalPath;
}

3. controller

修改Software,新增字段risk_level用于风险统计

java 复制代码
package com.sky.controller;

import com.sky.context.BaseContext;
import com.sky.dto.ProjectListPageQueryDTO;
import com.sky.exception.AccountNotFoundException;
import com.sky.result.PageResult;
import com.sky.result.Result;
import com.sky.service.ProjectService;
import com.sky.service.SbomVulnService;
import com.sky.vo.ProjectOverviewVO;
import com.sky.vo.ProjectVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/projects")
@Slf4j
@Api(tags = "项目管理接口")
public class ProjectController {

    @Autowired
    private ProjectService projectService;

    /**
     * 获取项目概览统计
     */
    @GetMapping("/overview")
    @ApiOperation(value = "获取项目概览统计")
    public Result<ProjectOverviewVO> getProjectOverview() {
        log.info("获取项目概览统计...");
        Integer currentId = BaseContext.getCurrentId();
        try {
            ProjectOverviewVO overview = projectService.getProjectOverview(currentId);
            return Result.success(overview);
        } catch (Exception e) {
            log.error("获取项目概览失败", e);
            return Result.error("获取项目概览失败: " + e.getMessage());
        }
    }

    /**
     * 获取项目列表(支持搜索和分页)
     */
    @GetMapping
    @ApiOperation("软件分页查询")
    public Result<PageResult> getProjectList(ProjectListPageQueryDTO projectListPageQueryDTO) {
        Integer currentId = BaseContext.getCurrentId();
        if (currentId == null){
            throw new AccountNotFoundException("用户未登录或登录信息无效");
        }
        projectListPageQueryDTO.setUserId(currentId);
        log.info("项目分页查询,参数为:{}", projectListPageQueryDTO);
        PageResult pageResult = projectService.pageQuery(projectListPageQueryDTO);
        return Result.success(pageResult);
    }

    
    
}

4. service

java 复制代码
package com.sky.service;

import com.sky.dto.ProjectListPageQueryDTO;
import com.sky.result.PageResult;
import com.sky.vo.ProjectOverviewVO;

public interface ProjectService {

    /**
     * 获取当前用户的项目概览统计--总、低中高危项目数量
     * @return
     */
    ProjectOverviewVO getProjectOverview(Integer currentId);

    /**
     * 软件项目分页查询
     * @param projectListPageQueryDTO
     * @return
     */
    PageResult pageQuery(ProjectListPageQueryDTO projectListPageQueryDTO);
}
java 复制代码
package com.sky.service.impl;

import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import com.sky.context.BaseContext;
import com.sky.dto.ProjectListPageQueryDTO;
import com.sky.mapper.ProjectMapper;
import com.sky.result.PageResult;
import com.sky.service.ProjectService;
import com.sky.vo.ProjectOverviewVO;
import com.sky.vo.ProjectVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.security.auth.login.AccountNotFoundException;
import java.util.List;


@Service
@Slf4j
public class ProjectServiceImpl implements ProjectService {

    @Autowired
    private ProjectMapper projectMapper;

    @Override
    public ProjectOverviewVO getProjectOverview(Integer currentId) {
        try {
            ProjectOverviewVO overview = projectMapper.selectProjectOverview(currentId);

            // 设置默认值,避免null
            if (overview.getTotalProjects() == null) overview.setTotalProjects(0);
            if (overview.getHighRiskProjects() == null) overview.setHighRiskProjects(0);
            if (overview.getMediumRiskProjects() == null) overview.setMediumRiskProjects(0);
            if (overview.getLowRiskProjects() == null) overview.setLowRiskProjects(0);

            return overview;
        } catch (Exception e) {
            log.error("获取项目概览失败", e);
            // 返回空数据而不是抛出异常
            ProjectOverviewVO emptyOverview = new ProjectOverviewVO();
            emptyOverview.setTotalProjects(0);
            emptyOverview.setHighRiskProjects(0);
            emptyOverview.setMediumRiskProjects(0);
            emptyOverview.setLowRiskProjects(0);
            return emptyOverview;
        }
    }

    /**
     * 软件项目分页查询
     * @param projectListPageQueryDTO
     * @return
     */
    public PageResult pageQuery(ProjectListPageQueryDTO projectListPageQueryDTO) {

        // 底层是基于mysql的limit关键字实现的分页查询
        // 开始分页查询,借助插件(底层借助Mybatis的拦截器,sql拼接)
        PageHelper.startPage(projectListPageQueryDTO.getPage(), projectListPageQueryDTO.getPageSize());

        Page<ProjectVO> page = projectMapper.pageQuery(projectListPageQueryDTO);

        long total = page.getTotal();
        List<ProjectVO> records = page.getResult();

        return new PageResult(total,records);
    }
}

5. mapper

java 复制代码
package com.sky.mapper;

import com.github.pagehelper.Page;
import com.sky.dto.ProjectListPageQueryDTO;
import com.sky.vo.ProjectOverviewVO;
import com.sky.vo.ProjectVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

@Mapper
public interface ProjectMapper {


    /**
     * 查询项目概览统计
     */
    @Select("SELECT " +
            "COUNT(*) as totalProjects, " +
            "SUM(CASE WHEN risk_level = 2 THEN 1 ELSE 0 END) as highRiskProjects, " +
            "SUM(CASE WHEN risk_level = 1 THEN 1 ELSE 0 END) as mediumRiskProjects, " +
            "SUM(CASE WHEN risk_level = 0 THEN 1 ELSE 0 END) as lowRiskProjects " +
            "FROM software WHERE task_status = 3 AND create_user = #{currentId}")
    ProjectOverviewVO selectProjectOverview(Integer currentId);

    /**
     * 软件项目分页查询
     * @param projectListPageQueryDTO
     * @return
     */
    Page<ProjectVO> pageQuery(ProjectListPageQueryDTO projectListPageQueryDTO);
}
XML 复制代码
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.ProjectMapper">

    <!-- 软件项目分页查询 -->
    <select id="pageQuery" parameterType="com.sky.dto.ProjectListPageQueryDTO" resultType="com.sky.vo.ProjectVO">
        SELECT DISTINCT  <!-- 关键:去重软件记录 -->
        s.id,
        s.did,
        s.name,
        s.risk_level,
        s.update_time as scanTime
        FROM software s
        INNER JOIN software_file_info sfi ON s.id = sfi.software_id
        <where>
            s.create_user = #{userId}
            <if test="keyword != null and keyword != ''">
                <!-- 修复:LIKE 语法错误(缺少闭合括号) -->
                AND (s.name LIKE CONCAT('%', #{keyword}, '%') OR s.did LIKE CONCAT('%', #{keyword}, '%'))
            </if>
        </where>
        ORDER BY s.update_time DESC
    </select>
</mapper>

调试

问题:项目列表会重复出现一个记录三次,原因是后端的mapper查找的时候是software表和software_file_info表联合查找,而一条software记录对应三条software_file_info记录,返回前端渲染的时候就会有三条项目记录

解决方法:直接把后端mapper的查找变成不select路径(简化)

XML 复制代码
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.ProjectMapper">

    <!-- 软件项目分页查询 -->
    <select id="pageQuery" parameterType="com.sky.dto.ProjectListPageQueryDTO" resultType="com.sky.vo.ProjectVO">
        SELECT DISTINCT  <!-- 关键:去重软件记录 -->
        s.id,
        s.did,
        s.name,
        s.risk_level,
        s.update_time as scanTime
        FROM software s
        INNER JOIN software_file_info sfi ON s.id = sfi.software_id
        <where>
            s.create_user = #{userId}
            <if test="keyword != null and keyword != ''">
                <!-- 修复:LIKE 语法错误(缺少闭合括号) -->
                AND (s.name LIKE CONCAT('%', #{keyword}, '%') OR s.did LIKE CONCAT('%', #{keyword}, '%'))
            </if>
        </where>
        ORDER BY s.update_time DESC
    </select>
</mapper>

DID操作接口开发

使用当前DID作为前后端传输参数

前端

1. 前后端交互路径

javascript 复制代码
// src/api/did.js
import request from '@/utils/request'

//冻结DID
export const freezeDID = (did) => {
    console.log('调用freezeDID API,DID:', did);
    return request({
        url: `/did/freeze/${did}`,
        method: 'put',
        timeout: 10000 // 设置超时
    });
};
// 注销DID
export const revokeDID = (did) => {
  return request.put(`/did/revoke/${did}`)
}

// 密钥轮换
export const rotateKey = (did) => {
  return request.post(`/did/key-rotation/${did}`)
}

// 设置密钥更新周期
export const setKeyRotationCycle = (did, cycle) => {
  return request.put(`/did/key-rotation-cycle/${did}/${cycle}`)
}

// 获取DID文档
export const getDIDDocument = (did) => {
  return request.get(`/did/document/${did}`)
}

2. 逻辑(有点问题在冻结的地方)

javascript 复制代码
<template>
    <div class="did-operations">
        <el-card shadow="never" class="table-card">
            <template #header>
                <div class="card-header">
                    <div class="header-left">
                        <span class="title">DID 操作</span>
                    </div>
                </div>
            </template>

            <div class="did-content">
                <!-- DID信息展示 -->
                <div class="did-info" v-if="didInfo">
                    <div class="did-header">
                        <div class="did-main">
                            <span class="did-id">{{ didInfo.id }}</span>
                            <el-tag 
                                :type="didInfo.status === 'active' ? 'success' : didInfo.status === 'frozen' ? 'warning' : 'danger'"
                                size="small"
                            >
                                {{ getStatusText(didInfo.status) }}
                            </el-tag>
                        </div>
                        <div class="did-time">
                            创建时间:{{ didInfo.createdAt }}
                        </div>
                    </div>
                    
                    <div class="did-actions">
                        <el-button-group>
                            <el-button 
                                class="btn-view"
                                @click="handleViewDocument"
                                :icon="Document"
                            >
                                查看DID文档
                            </el-button>
                            <el-button 
                                class="btn-freeze"
                                @click="handleFreeze"
                                :disabled="didInfo.status === 'frozen' || didInfo.status === 'revoked'"
                                :icon="Lock"
                            >
                                冻结DID
                            </el-button>
                            <el-button 
                                class="btn-revoke"
                                @click="handleRevoke"
                                :disabled="didInfo.status === 'revoked'"
                                :icon="Delete"
                            >
                                注销DID
                            </el-button>
                            <el-button 
                                class="btn-update-key"
                                @click="handleUpdateKey"
                                :disabled="didInfo.status !== 'active'"
                                :icon="RefreshRight"
                            >
                                更新密钥
                            </el-button>
                            <el-button 
                                class="btn-update-cycle"
                                @click="handleUpdateCycle"
                                :disabled="didInfo.status !== 'active'"
                                :icon="Timer"
                            >
                                更新周期
                            </el-button>
                        </el-button-group>
                    </div>
                </div>

                <!-- DID文档对话框 -->
                <el-dialog
                    v-model="documentDialogVisible"
                    title="DID文档"
                    width="800px"
                    class="document-dialog"
                >
                    <div class="document-content">
                        <el-descriptions :column="1" border>
                            <el-descriptions-item label="DID标识">
                                {{ didInfo.id }}
                            </el-descriptions-item>
                            <el-descriptions-item label="控制器">
                                {{ didInfo.controller }}
                            </el-descriptions-item>
                            <el-descriptions-item label="标识密钥">
                                <div class="key-info">
                                    <span class="key-id">{{ didInfo.publicKey.id }}</span>
                                    <el-tag size="small" type="info">{{ didInfo.publicKey.type }}</el-tag>
                                </div>
                                <div class="key-value">{{ didInfo.publicKey.publicKeyHex }}</div>
                            </el-descriptions-item>
                            <el-descriptions-item label="认证方式">
                                {{ didInfo.authentication.join(', ') }}
                            </el-descriptions-item>
                            <el-descriptions-item label="服务端点">
                                <div v-for="service in didInfo.service" :key="service.id" class="service-item">
                                    <div class="service-header">
                                        <span>{{ service.type }}</span>
                                        <el-tag size="small">{{ service.id }}</el-tag>
                                    </div>
                                    <div class="service-endpoint">{{ service.serviceEndpoint }}</div>
                                </div>
                            </el-descriptions-item>
                        </el-descriptions>
                    </div>
                </el-dialog>

                <!-- 更新密钥对话框 -->
                <el-dialog
                    v-model="keyUpdateDialogVisible"
                    title="更新标识密钥"
                    width="500px"
                    class="key-update-dialog"
                >
                    <el-form 
                        ref="keyUpdateFormRef"
                        :model="keyUpdateForm"
                        :rules="keyUpdateRules"
                        label-width="100px"
                    >
                        <el-form-item label="密钥类型" prop="keyType">
                            <el-select v-model="keyUpdateForm.keyType">
                                <el-option label="Ed25519" value="Ed25519" />
                                <el-option label="Secp256k1" value="Secp256k1" />
                            </el-select>
                        </el-form-item>
                        <el-form-item label="公钥" prop="publicKey">
                            <el-input 
                                v-model="keyUpdateForm.publicKey"
                                type="textarea"
                                rows="3"
                                placeholder="请输入新的公钥"
                            />
                        </el-form-item>
                    </el-form>
                    <template #footer>
                        <span class="dialog-footer">
                            <el-button @click="keyUpdateDialogVisible = false">取消</el-button>
                            <el-button type="primary" @click="submitKeyUpdate">
                                确认更新
                            </el-button>
                        </span>
                    </template>
                </el-dialog>

                <!-- 添加更新周期对话框 -->
                <el-dialog
                    v-model="cycleUpdateDialogVisible"
                    title="设置密钥更新周期"
                    width="400px"
                    class="cycle-update-dialog"
                >
                    <el-form 
                        ref="cycleUpdateFormRef"
                        :model="cycleUpdateForm"
                        :rules="cycleUpdateRules"
                        label-width="100px"
                    >
                        <el-form-item label="更新周期" prop="cycle">
                            <el-select v-model="cycleUpdateForm.cycle" placeholder="请选择更新周期">
                                <el-option label="1个月" value="1" />
                                <el-option label="3个月" value="3" />
                                <el-option label="6个月" value="6" />
                                <el-option label="12个月" value="12" />
                            </el-select>
                        </el-form-item>
                    </el-form>
                    <template #footer>
                        <span class="dialog-footer">
                            <el-button @click="cycleUpdateDialogVisible = false">取消</el-button>
                            <el-button type="primary" @click="submitCycleUpdate" :loading="cycleLoading" >
                                确认
                            </el-button>
                        </span>
                    </template>
                </el-dialog>
            </div>
        </el-card>
    </div>
</template>

<script setup>
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
    Document,
    Lock,
    Delete,
    RefreshRight,
    Timer
} from '@element-plus/icons-vue'
import { ElLoading } from 'element-plus'
import { freezeDID, revokeDID, rotateKey, setKeyRotationCycle, getDIDDocument } from '@/api/did'

// DID信息
const didInfo = ref({
    id: 'did:shieldchain:software:3a7f5c8e21d94b0a6e3f8c1b2d7a9f0e5c6b8a9d2f4e1c7b3d8a0e9f6c5d7b1a',
    status: 'active', // active, frozen, revoked
    createdAt: '2024-03-15 10:30:00',
    controller: 'did:shieldchain:controller',
    publicKey: {
        id: 'did:shieldchain:123456789abcdefghi#key-1',
        type: 'Ed25519VerificationKey2020',
        publicKeyHex: '0x1234567890abcdef...'
    },
    authentication: [
        'did:shieldchain:123456789abcdefghi#key-1'
    ],
    service: [
        {
            id: 'did:shieldchain:123456789abcdefghi#service-1',
            type: 'DIDCommMessaging',
            serviceEndpoint: 'https://shieldchain.com/endpoint'
        }
    ]
})

//const didIdentifier = 'did:shieldchain:software:3a7f5c8e21d94b0a6e3f8c1b2d7a9f0e5c6b8a9d2f4e1c7b3d8a0e9f6c5d7b1a';

// 状态文本映射
const getStatusText = (status) => {
    const statusMap = {
        'active': '活跃',
        'frozen': '已冻结',
        'revoked': '已注销'
    }
    return statusMap[status] || status
}

// 对话框控制
const documentDialogVisible = ref(false)
const keyUpdateDialogVisible = ref(false)
const cycleUpdateDialogVisible = ref(false)

// 密钥更新表单
const keyUpdateFormRef = ref(null)
const keyUpdateForm = ref({
    keyType: '',
    publicKey: ''
})

const keyUpdateRules = {
    keyType: [
        { required: true, message: '请选择密钥类型', trigger: 'change' }
    ],
    publicKey: [
        { required: true, message: '请输入公钥', trigger: 'blur' }
    ]
}

// 更新周期表单
const cycleUpdateFormRef = ref(null)
const cycleUpdateForm = ref({
    cycle: ''
})
const cycleLoading = ref(false) // 新增:初始化加载状态(关键!)

const cycleUpdateRules = {
    cycle: [
        { required: true, message: '请选择更新周期', trigger: 'change' }
    ]
}

// 处理函数
const handleViewDocument = () => {
    documentDialogVisible.value = true
}

const handleFreeze = async () => {
    console.log('测试:handleFreeze被调用');
    
    // 方法1:使用原生confirm测试
    const userConfirmed = confirm('确定要冻结该DID吗?');
    console.log('用户选择:', userConfirmed);
    
    if (userConfirmed) {
        // 方法2:直接显示成功消息,跳过API调用
        ElMessage.success('DID冻结成功');
        await freezeDID(didInfo.value.id);
        didInfo.value.status = 'frozen';
    }
};

const handleRevoke = async () => {
    const userConfirmed = confirm('确定要注销该DID吗?');
    console.log('用户选择:', userConfirmed);
    if (userConfirmed) {
        ElMessage.success('DID已注销');
        await revokeDID(didInfo.value.id);
        didInfo.value.status = 'revoked';
    }
}

const handleUpdateKey = async () => {
    let loading;
    try {
        // 直接创建 Loading(无弹窗确认,直接执行)
        loading = ElLoading.service({
            lock: true,
            text: '正在更新密钥...',
            background: 'rgba(0, 0, 0, 0.7)'
        });

        await rotateKey(didInfo.value.id);
        ElMessage.success('密钥更新成功');
        //await fetchDIDInfo();
    } catch (error) {
        ElMessage.error('密钥更新失败: ' + (error?.message || '未知错误'));
    } finally {
        // 安全关闭 Loading(同上)
        if (loading && typeof loading.close === 'function') {
            loading.close();
        }
    }
};
// 打开更新周期设置对话框
const handleUpdateCycle = () => {
  console.log('打开周期设置对话框');
  // 重置表单,清空可能的历史选择(可选,但建议有)
  // cycleUpdateForm.value.cycle = '';
  // 显示对话框
  cycleUpdateDialogVisible.value = true;
};

// 提交周期设置
const submitCycleUpdate = async () => {
  let loadingInstance; // 用于控制加载状态

  // 检查表单引用是否存在
  if (!cycleUpdateFormRef.value) {
    console.error('表单引用未找到');
    return;
  }

  try {
    // 显示加载状态,防止重复提交
    loadingInstance = ElLoading.service({
      lock: true,
      text: '正在设置更新周期...',
      background: 'rgba(0, 0, 0, 0.7)'
    });
    cycleLoading.value = true;

    // 1. 进行表单验证 [2,5](@ref)
    await cycleUpdateFormRef.value.validate();

    console.log('表单验证通过,周期为:', cycleUpdateForm.value.cycle);

    // 2. 调用后端API [1](@ref)
    // 假设 setKeyRotationCycle 是你引入的API方法
    await setKeyRotationCycle(didInfo.value.id, cycleUpdateForm.value.cycle);

    // 3. 成功后给出反馈
    ElMessage.success(`密钥更新周期已设置为 ${cycleUpdateForm.value.cycle} 个月`);

    // 4. 关闭对话框并重置表单
    cycleUpdateDialogVisible.value = false;
    cycleUpdateForm.value.cycle = ''; // 清空选择,方便下次设置

  } catch (error) {
    // 错误处理 [3](@ref)
    if (error.fields) {
      // 这是表单验证失败的错误,通常不需要额外提示,因为Element Plus会自动显示错误信息
      console.log('表单验证未通过:', error.fields);
      return; // 直接返回,不显示全局错误消息
    }
    // 其他错误(如网络错误、后端返回错误等)
    console.error('设置失败:', error);
    ElMessage.error('设置失败: ' + (error.message || '未知错误'));
  } finally {
    // 无论成功或失败,都关闭加载状态
    cycleLoading.value = false;
    if (loadingInstance) {
      loadingInstance.close();
    }
  }
};

后端

1. 注册路径

java 复制代码
  protected void addInterceptors(InterceptorRegistry registry){
        log.info("开始注册自定义拦截器...");
        registry.addInterceptor(jwtTokenInterceptor)
                .addPathPatterns("/user/**")
                .addPathPatterns("/software/**")
                .addPathPatterns("/message/**")
                .addPathPatterns("/sbom-vuln/**")
                .addPathPatterns("/projects/**")
                .addPathPatterns("/did/**")
                .excludePathPatterns("/user/login");
    }

2. controller

java 复制代码
package com.sky.controller;

import com.sky.result.Result;
import com.sky.service.DIDOperationService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/did")
@Slf4j
@Api(tags = "DID操作管理")
public class DIDController {

    @Autowired
    private DIDOperationService didOperationService;

    @PutMapping("/freeze/{did}")
    @ApiOperation("冻结DID")
    public Result<String> freezeDID(@PathVariable String did) {
        log.info("冻结DID: {}", did);
        didOperationService.freezeDID(did);
        return Result.success("DID冻结成功");
    }

    @PutMapping("/revoke/{did}")
    @ApiOperation("注销DID")
    public Result<String> revokeDID(@PathVariable String did) {
        log.info("注销DID: {}", did);
        didOperationService.revokeDID(did);
        return Result.success("DID注销成功");
    }

    @PostMapping("/key-rotation/{did}")
    @ApiOperation("密钥轮换")
    public Result<String> rotateKey(@PathVariable String did) {
        log.info("密钥轮换: {}", did);
        didOperationService.rotateKey(did);
        return Result.success("密钥轮换成功");
    }

    @PutMapping("/key-rotation-cycle/{did}/{cycle}")
    @ApiOperation("设置密钥更新周期")
    public Result<String> setKeyRotationCycle(@PathVariable String did, @PathVariable Integer cycle) {
        log.info("设置密钥更新周期: {}, 周期: {}个月", did, cycle);
        didOperationService.setKeyRotationCycle(did, cycle);
        return Result.success("密钥更新周期设置成功");
    }
}

3. service

java 复制代码
package com.sky.service;

public interface DIDOperationService {

    /**
     * 冻结DID
     * @param did
     */
    void freezeDID(String did);

    /**
     * 注销DID
     * @param did
     */
    void revokeDID(String did);

    /**
     * 秘钥轮换
     * @param did
     */
    void rotateKey(String did);

    /**
     * 设置密钥轮换周期
     * @param did
     * @param cycle
     */
    void setKeyRotationCycle(String did, Integer cycle);
}
java 复制代码
package com.sky.service.impl;

import com.sky.entity.Software;
import com.sky.mapper.SoftwareMapper;
import com.sky.service.DIDOperationService;
import com.sky.utils.DIDUtil;
import com.sky.utils.SimpleEncryptUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.time.LocalDateTime;
import java.util.Base64;

@Service
@Slf4j
public class DIDOperationServiceImpl implements DIDOperationService {

    @Autowired
    private SoftwareMapper softwareMapper;

    /**
     * 冻结DID
     * @param did
     */
    @Transactional
    public void freezeDID(String did) {
        Software software = softwareMapper.selectByDid(did);
        if (software == null) {
            throw new RuntimeException("DID不存在");
        }

        // 更新DID状态为冻结(2)
        software.setDidStatus(2);

        softwareMapper.updateSoftwareStatus(software);
        log.info("DID冻结成功: {}", did);
    }

    /**
     * 注销DID
     * @param did
     */
    @Transactional
    public void revokeDID(String did) {
        Software software = softwareMapper.selectByDid(did);
        if (software == null) {
            throw new RuntimeException("DID不存在");
        }
        // 更新DID状态为吊销(0)
        software.setDidStatus(0);

        softwareMapper.updateSoftwareStatus(software);
        log.info("DID注销成功: {}", did);
    }

    /**
     * 密钥更新
     * @param did
     */
    @Transactional
    public void rotateKey(String did) {
        Software software = softwareMapper.selectByDid(did);
        if (software == null) {
            throw new RuntimeException("DID不存在");
        }

        if (software.getDidStatus() != 1) {
            throw new RuntimeException("只有有效状态的DID才能更新密钥");
        }

        try {
            // 生成新的密钥对
            KeyPair keyPair = DIDUtil.generateECCKeyPair();
            PublicKey publicKey = keyPair.getPublic();
            PrivateKey privateKey = keyPair.getPrivate();

            // 加密私钥
            String encryptedPrivateKey = SimpleEncryptUtil.encryptForSoftware(
                    Base64.getEncoder().encodeToString(privateKey.getEncoded())
            );
            // 更新密钥信息
            software.setPublicKey(Base64.getEncoder().encodeToString(publicKey.getEncoded()));
            software.setPrivateKeyEncrypt(encryptedPrivateKey);
            software.setLastRotationTime(LocalDateTime.now());

            softwareMapper.updateKey(software);
            log.info("密钥轮换成功: {}", did);

        } catch (Exception e){

        }

    }

    /**
     * 设置密钥轮换周期
     * @param did
     * @param cycle
     */
    @Transactional
    public void setKeyRotationCycle(String did, Integer cycle) {
        Software software = softwareMapper.selectByDid(did);
        if (software == null) {
            throw new RuntimeException("DID不存在");
        }

        if (software.getDidStatus() != 1) {
            throw new RuntimeException("只有有效状态的DID才能设置更新周期");
        }

        software.setKeyRotationCycle(cycle);

        softwareMapper.updateKeyRotationCycle(software);
        log.info("密钥更新周期设置成功: {}, 周期: {}个月", did, cycle);
    }
}

4. mapper

java 复制代码
 /**
     * 更新软件DID状态
     * @param software
     */
    @AutoFill(value = OperationType.UPDATE)
    @Update("update software set did_status = #{didStatus}, update_time = #{updateTime}, update_user =#{updateUser} " +
            "where did = #{did}")
    void updateSoftwareStatus(Software software);


    /**
     * 更新密钥
     * @param software
     */
    @AutoFill(value = OperationType.UPDATE)
    @Update("update software set public_key = #{publicKey}, private_key_encrypt = #{privateKeyEncrypt}, " +
            "last_rotation_time = #{lastRotationTime}, update_time = #{updateTime}, update_user =#{updateUser} " +
            "where did = #{did}")
    void updateKey(Software software);


    /**
     * 设置密钥更新周期
     * @param software
     */
    @AutoFill(value = OperationType.UPDATE)
    @Update("update software set key_rotation_cycle = #{keyRotationCycle}, update_time = #{updateTime}, update_user =#{updateUser} " +
            "where did = #{did}")
    void updateKeyRotationCycle(Software software);

测试

DID密钥更新消息定时提醒

这部分没有真正实现,下面是AI的参考代码

  1. 数据库

    ALTER TABLE software ADD COLUMN (
    key_rotation_cycle INT DEFAULT 3 COMMENT '密钥更新周期(月)',
    last_rotation_time DATETIME COMMENT '最后密钥更新时间',
    next_reminder_time DATETIME COMMENT '下次提醒时间',
    reminder_sent BOOLEAN DEFAULT FALSE COMMENT '提醒是否已发送'
    );

  2. KeyRotationReminderTask

java 复制代码
// server/task/KeyRotationReminderTask.java
package com.software.identifier.server.task;

import com.software.identifier.server.service.KeyRotationService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
@Slf4j
public class KeyRotationReminderTask {
    
    @Autowired
    private KeyRotationService keyRotationService;
    
    /**
     * 每天凌晨1点检查需要提醒的密钥更新
     * cron表达式: 秒 分 时 日 月 周
     */
    @Scheduled(cron = "0 0 1 * * ?")
    public void checkKeyRotationReminders() {
        log.info("开始执行密钥更新提醒检查任务");
        try {
            int reminderCount = keyRotationService.checkAndSendReminders();
            log.info("密钥更新提醒任务执行完成,共发送 {} 条提醒", reminderCount);
        } catch (Exception e) {
            log.error("密钥更新提醒任务执行失败", e);
        }
    }
    
    /**
     * 每30分钟检查一次紧急提醒(距离到期时间小于3天)
     */
    @Scheduled(cron = "0 */30 * * * ?")
    public void checkUrgentReminders() {
        log.debug("检查紧急密钥更新提醒");
        try {
            int urgentCount = keyRotationService.checkUrgentReminders();
            if (urgentCount > 0) {
                log.info("发送 {} 条紧急密钥更新提醒", urgentCount);
            }
        } catch (Exception e) {
            log.error("紧急提醒检查任务执行失败", e);
        }
    }
}
  1. service
java 复制代码
// server/service/KeyRotationService.java
package com.software.identifier.server.service;

import com.software.identifier.common.entity.Software;
import com.software.identifier.server.mapper.SoftwareMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;

@Service
@Slf4j
public class KeyRotationService {
    
    @Autowired
    private SoftwareMapper softwareMapper;
    
    @Autowired
    private NotificationService notificationService;
    
    /**
     * 检查并发送密钥更新提醒
     */
    @Transactional
    public int checkAndSendReminders() {
        LocalDateTime now = LocalDateTime.now();
        List<Software> softwaresDueForRotation = softwareMapper.selectDueForRotation(now);
        
        int sentCount = 0;
        for (Software software : softwaresDueForRotation) {
            try {
                sendReminder(software);
                // 更新提醒状态
                software.setReminderSent(true);
                software.setUpdateTime(now);
                softwareMapper.update(software);
                sentCount++;
            } catch (Exception e) {
                log.error("发送密钥更新提醒失败,DID: {}", software.getDid(), e);
            }
        }
        return sentCount;
    }
    
    /**
     * 检查紧急提醒(3天内到期)
     */
    @Transactional
    public int checkUrgentReminders() {
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime urgentThreshold = now.plusDays(3);
        
        List<Software> urgentSoftwares = softwareMapper.selectUrgentForRotation(urgentThreshold);
        
        int sentCount = 0;
        for (Software software : urgentSoftwares) {
            if (!software.isReminderSent() || shouldResendUrgentReminder(software)) {
                try {
                    sendUrgentReminder(software);
                    software.setReminderSent(true);
                    software.setUpdateTime(now);
                    softwareMapper.update(software);
                    sentCount++;
                } catch (Exception e) {
                    log.error("发送紧急密钥更新提醒失败,DID: {}", software.getDid(), e);
                }
            }
        }
        return sentCount;
    }
    
    private boolean shouldResendUrgentReminder(Software software) {
        // 如果上次提醒是3天前发送的,可以重新发送紧急提醒
        return software.getUpdateTime().isBefore(LocalDateTime.now().minusDays(3));
    }
    
    /**
     * 发送普通提醒
     */
    private void sendReminder(Software software) {
        String title = "密钥更新提醒 - DID: " + software.getDid();
        String content = buildReminderContent(software, false);
        notificationService.sendNotification(software.getCreateUser(), title, content);
    }
    
    /**
     * 发送紧急提醒
     */
    private void sendUrgentReminder(Software software) {
        String title = "【紧急】密钥更新提醒 - DID: " + software.getDid();
        String content = buildReminderContent(software, true);
        notificationService.sendNotification(software.getCreateUser(), title, content);
    }
    
    private String buildReminderContent(Software software, boolean isUrgent) {
        StringBuilder content = new StringBuilder();
        if (isUrgent) {
            content.append("【紧急提醒】");
        }
        content.append("您的DID密钥即将到期,请及时更新。\n\n");
        content.append("DID标识: ").append(software.getDid()).append("\n");
        content.append("软件名称: ").append(software.getName()).append("\n");
        content.append("当前密钥周期: ").append(software.getKeyRotationCycle()).append("个月\n");
        content.append("最后更新时间: ").append(software.getLastRotationTime()).append("\n");
        content.append("建议更新时间: ").append(calculateNextRotationDate(software)).append("\n\n");
        content.append("请登录系统进行密钥更新操作。");
        
        return content.toString();
    }
    
    private LocalDateTime calculateNextRotationDate(Software software) {
        return software.getLastRotationTime().plusMonths(software.getKeyRotationCycle());
    }
}
java 复制代码
// server/service/NotificationService.java
package com.software.identifier.server.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;

@Service
@Slf4j
public class NotificationService {
    
    @Autowired(required = false)
    private JavaMailSender mailSender;
    
    @Autowired
    private WebSocketNotificationService webSocketService;
    
    /**
     * 发送通知(站内信 + 邮件)
     */
    public void sendNotification(Integer userId, String title, String content) {
        try {
            // 1. 发送站内信(WebSocket实时通知)
            webSocketService.sendNotificationToUser(userId, title, content);
            
            // 2. 发送邮件提醒
            sendEmailNotification(userId, title, content);
            
            log.info("成功向用户 {} 发送密钥更新提醒", userId);
        } catch (Exception e) {
            log.error("发送通知失败,用户ID: {}", userId, e);
        }
    }
    
    /**
     * 发送邮件通知
     */
    private void sendEmailNotification(Integer userId, String title, String content) {
        if (mailSender == null) {
            log.debug("邮件发送器未配置,跳过邮件通知");
            return;
        }
        
        try {
            // 根据用户ID查询邮箱地址
            String toEmail = getUserEmail(userId);
            if (toEmail == null) {
                log.warn("用户 {} 未设置邮箱地址,跳过邮件发送", userId);
                return;
            }
            
            MimeMessage message = mailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
            
            helper.setTo(toEmail);
            helper.setSubject(title);
            helper.setText(buildEmailContent(content), true); // true表示支持HTML
            
            mailSender.send(message);
            log.debug("密钥更新提醒邮件已发送至: {}", toEmail);
        } catch (MessagingException e) {
            log.error("发送邮件失败", e);
        }
    }
    
    private String buildEmailContent(String content) {
        return "<!DOCTYPE html>" +
                "<html>" +
                "<head><meta charset='UTF-8'></head>" +
                "<body>" +
                "<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>" +
                "<h2 style='color: #1890ff;'>软件标识管理系统 - 密钥更新提醒</h2>" +
                "<div style='background: #f5f5f5; padding: 20px; border-radius: 5px;'>" +
                content.replace("\n", "<br>") +
                "</div>" +
                "<p style='color: #666; font-size: 12px;'>此为系统自动发送邮件,请勿回复。</p>" +
                "</div>" +
                "</body>" +
                "</html>";
    }
    
    private String getUserEmail(Integer userId) {
        // 这里需要实现根据用户ID查询邮箱的逻辑
        // 可以从用户表或缓存中获取
        return "user@example.com"; // 示例返回值
    }
}
  1. mapper
java 复制代码
// server/mapper/SoftwareMapper.java
package com.software.identifier.server.mapper;

import com.software.identifier.common.entity.Software;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;

import java.time.LocalDateTime;
import java.util.List;

@Mapper
public interface SoftwareMapper {
    
    /**
     * 查询需要提醒密钥更新的软件
     */
    @Select("SELECT * FROM software WHERE " +
            "key_rotation_cycle IS NOT NULL AND " +
            "last_rotation_time IS NOT NULL AND " +
            "did_status = 1 AND " +  // 只查询活跃状态的DID
            "next_reminder_time <= #{currentTime} AND " +
            "reminder_sent = FALSE")
    List<Software> selectDueForRotation(@Param("currentTime") LocalDateTime currentTime);
    
    /**
     * 查询需要紧急提醒的软件(3天内到期)
     */
    @Select("SELECT * FROM software WHERE " +
            "key_rotation_cycle IS NOT NULL AND " +
            "last_rotation_time IS NOT NULL AND " +
            "did_status = 1 AND " +
            "last_rotation_time + INTERVAL key_rotation_cycle MONTH <= #{urgentThreshold}")
    List<Software> selectUrgentForRotation(@Param("urgentThreshold") LocalDateTime urgentThreshold);
    
    /**
     * 更新软件信息
     */
    int update(Software software);
}
  1. websocket实时通知
java 复制代码
// server/service/WebSocketNotificationService.java
package com.software.identifier.server.service;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;

import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Service
@Slf4j
public class WebSocketNotificationService {
    
    private final Map<Integer, WebSocketSession> userSessions = new ConcurrentHashMap<>();
    private final ObjectMapper objectMapper = new ObjectMapper();
    
    /**
     * 向指定用户发送通知
     */
    public void sendNotificationToUser(Integer userId, String title, String content) {
        WebSocketSession session = userSessions.get(userId);
        if (session != null && session.isOpen()) {
            try {
                Map<String, Object> message = Map.of(
                    "type", "keyRotationReminder",
                    "title", title,
                    "content", content,
                    "timestamp", System.currentTimeMillis()
                );
                
                String messageJson = objectMapper.writeValueAsString(message);
                session.sendMessage(new TextMessage(messageJson));
                
                log.debug("WebSocket通知已发送给用户: {}", userId);
            } catch (IOException e) {
                log.error("WebSocket消息发送失败,用户ID: {}", userId, e);
                // 移除无效的session
                userSessions.remove(userId, session);
            }
        }
    }
    
    /**
     * 注册用户WebSocket会话
     */
    public void registerUserSession(Integer userId, WebSocketSession session) {
        userSessions.put(userId, session);
        log.debug("用户 {} WebSocket会话已注册", userId);
    }
    
    /**
     * 移除用户WebSocket会话
     */
    public void removeUserSession(Integer userId) {
        userSessions.remove(userId);
        log.debug("用户 {} WebSocket会话已移除", userId);
    }
}
  1. application.yml
javascript 复制代码
# 定时任务配置
spring:
  task:
    scheduling:
      pool:
        size: 5
      thread-name-prefix: key-rotation-scheduler-
  
  # 邮件配置(可选)
  mail:
    host: smtp.qq.com
    username: your-email@qq.com
    password: your-auth-code
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true

# 自定义配置
app:
  key-rotation:
    reminder:
      enabled: true
      # 提前提醒天数
      advance-days: 7
      # 紧急提醒天数
      urgent-days: 3

前端

javascript 复制代码
<!-- frontend/src/components/NotificationCenter.vue -->
<template>
  <div class="notification-center">
    <el-badge :value="unreadCount" :max="99" class="notification-badge">
      <el-button circle @click="showNotifications = true">
        <el-icon><Bell /></el-icon>
      </el-button>
    </el-badge>
    
    <el-dialog v-model="showNotifications" title="通知中心" width="500px">
      <div class="notification-list">
        <div v-for="notification in notifications" :key="notification.id" 
             class="notification-item" :class="{ unread: !notification.read }">
          <div class="notification-header">
            <span class="title">{{ notification.title }}</span>
            <el-tag v-if="!notification.read" size="small" type="danger">新</el-tag>
            <span class="time">{{ formatTime(notification.timestamp) }}</span>
          </div>
          <div class="notification-content">{{ notification.content }}</div>
        </div>
      </div>
    </el-dialog>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Bell } from '@element-plus/icons-vue'

const showNotifications = ref(false)
const notifications = ref([])
const unreadCount = ref(0)
let websocket = null

// 连接到WebSocket
const connectWebSocket = () => {
  const userId = localStorage.getItem('userId') // 从本地存储获取用户ID
  if (!userId) return
  
  const wsUrl = `ws://localhost:8080/ws/notifications/${userId}`
  websocket = new WebSocket(wsUrl)
  
  websocket.onopen = () => {
    console.log('WebSocket连接已建立')
  }
  
  websocket.onmessage = (event) => {
    const message = JSON.parse(event.data)
    if (message.type === 'keyRotationReminder') {
      handleKeyRotationReminder(message)
    }
  }
  
  websocket.onclose = () => {
    console.log('WebSocket连接已关闭')
    // 3秒后重连
    setTimeout(connectWebSocket, 3000)
  }
}

// 处理密钥更新提醒
const handleKeyRotationReminder = (message) => {
  const newNotification = {
    id: Date.now(),
    title: message.title,
    content: message.content,
    timestamp: message.timestamp,
    read: false,
    type: 'keyRotation'
  }
  
  notifications.value.unshift(newNotification)
  unreadCount.value++
  
  // 显示桌面通知(如果浏览器支持)
  if ('Notification' in window && Notification.permission === 'granted') {
    new Notification(message.title, {
      body: message.content,
      icon: '/favicon.ico'
    })
  }
  
  ElMessage.warning('您有新的密钥更新提醒')
}

onMounted(() => {
  connectWebSocket()
  // 请求通知权限
  if ('Notification' in window && Notification.permission === 'default') {
    Notification.requestPermission()
  }
})

onUnmounted(() => {
  if (websocket) {
    websocket.close()
  }
})
</script>
相关推荐
寻星探路4 小时前
【深度长文】万字攻克网络原理:从 HTTP 报文解构到 HTTPS 终极加密逻辑
java·开发语言·网络·python·http·ai·https
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
曹牧6 小时前
Spring Boot:如何测试Java Controller中的POST请求?
java·开发语言
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
爬山算法7 小时前
Hibernate(90)如何在故障注入测试中使用Hibernate?
java·后端·hibernate
七夜zippoe7 小时前
CANN Runtime任务描述序列化与持久化源码深度解码
大数据·运维·服务器·cann