链盾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>
相关推荐
j***63081 小时前
【springboot】Spring 官方抛弃了 Java 8!新idea如何创建java8项目
java·spring boot·spring
用户21411832636021 小时前
dify案例分享-国内首发!手把手教你用Dify调用Nano Banana2AI画图
前端
wa的一声哭了1 小时前
Webase部署Webase-Web在合约IDE页面一直转圈
linux·运维·服务器·前端·python·区块链·ssh
han_2 小时前
前端性能优化之CSS篇
前端·javascript·性能优化
k***85842 小时前
【SpringBoot】【log】 自定义logback日志配置
android·前端·后端
小满zs2 小时前
Next.js第十章(Proxy)
前端
JIngJaneIL2 小时前
汽车租赁|汽车管理|基于Java+vue的汽车租赁系统(源码+数据库+文档)
java·vue.js·spring boot·汽车·论文·毕设·汽车租赁
曾经的三心草2 小时前
JavaEE初阶-多线程1
android·java·java-ee
m***56722 小时前
【Spring】Spring MVC案例
java·spring·mvc