From 10e75b9769f9be049e735f997da53a1163c80dbf Mon Sep 17 00:00:00 2001 From: dengqichen Date: Tue, 11 Nov 2025 14:06:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AF=86=E7=A0=81=E5=8A=A0?= =?UTF-8?q?=E5=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/frontend/部署流程图弹窗开发指南.md | 989 ++++++++++++++++++ .../impl/JenkinsServiceIntegrationImpl.java | 20 +- .../impl/ExternalSystemServiceImpl.java | 122 ++- .../framework/util/EncryptionUtils.java | 221 ++++ .../util/SensitiveDataEncryptor.java | 97 ++ .../src/main/resources/application-prod.yml | 11 + backend/src/main/resources/application.yml | 11 + .../db/changelog/changes/v1.0.0-data.sql | 11 +- .../db/changelog/changes/v1.0.0-schema.sql | 4 +- 9 files changed, 1478 insertions(+), 8 deletions(-) create mode 100644 backend/docs/frontend/部署流程图弹窗开发指南.md create mode 100644 backend/src/main/java/com/qqchen/deploy/backend/framework/util/EncryptionUtils.java create mode 100644 backend/src/main/java/com/qqchen/deploy/backend/framework/util/SensitiveDataEncryptor.java diff --git a/backend/docs/frontend/部署流程图弹窗开发指南.md b/backend/docs/frontend/部署流程图弹窗开发指南.md new file mode 100644 index 00000000..90337ce1 --- /dev/null +++ b/backend/docs/frontend/部署流程图弹窗开发指南.md @@ -0,0 +1,989 @@ +# 部署流程图弹窗开发指南 + +## 1. 概述 + +本指南用于指导前端开发部署流程图可视化弹窗,展示部署工作流的执行状态、节点详情和统计信息。 + +--- + +## 2. API 接口 + +### 2.1 接口信息 + +```typescript +GET /api/v1/deploy-records/{deployRecordId}/flow-graph +``` + +### 2.2 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| deployRecordId | Long | 是 | 部署记录ID | + +### 2.3 响应数据结构 + +```typescript +interface DeployRecordFlowGraphDTO { + // ============ 部署记录基本信息 ============ + deployRecordId: number; // 部署记录ID + businessKey: string; // 业务标识(UUID) + deployStatus: DeployStatus; // 部署状态 + deployBy: string; // 部署人 + deployRemark: string; // 部署备注 + deployStartTime: string; // 部署开始时间(ISO格式) + deployEndTime: string; // 部署结束时间(ISO格式) + deployDuration: number; // 部署总时长(秒) + + // ============ 业务上下文信息 ============ + applicationName: string; // 应用名称 + applicationCode: string; // 应用编码 + environmentName: string; // 环境名称 + teamName: string; // 团队名称 + + // ============ 工作流实例信息 ============ + workflowInstanceId: number; // 工作流实例ID + processInstanceId: string; // 流程实例ID(Flowable) + workflowStatus: WorkflowStatus; // 工作流实例状态 + workflowStartTime: string; // 工作流开始时间 + workflowEndTime: string; // 工作流结束时间 + workflowDuration: number; // 工作流总时长(秒) + + // ============ 执行统计信息 ============ + totalNodeCount: number; // 总节点数 + executedNodeCount: number; // 已执行节点数 + successNodeCount: number; // 成功节点数 + failedNodeCount: number; // 失败节点数 + runningNodeCount: number; // 运行中节点数 + + // ============ 流程图数据 ============ + graph: WorkflowDefinitionGraph; // 流程图结构 + nodeInstances: NodeInstanceDTO[]; // 节点执行状态列表 +} + +// 节点实例DTO +interface NodeInstanceDTO { + id: number | null; // 节点实例ID(未执行节点为null) + nodeId: string; // 节点ID + nodeName: string; // 节点名称 + nodeType: string; // 节点类型 + status: NodeStatus; // 节点状态 + startTime: string | null; // 开始时间 + endTime: string | null; // 结束时间 + duration: number | null; // 执行时长(秒) + errorMessage: string | null; // 错误信息(失败时) + processInstanceId: string; // 流程实例ID + createTime: string | null; // 创建时间 + updateTime: string | null; // 更新时间 +} + +// 流程图结构 +interface WorkflowDefinitionGraph { + nodes: GraphNode[]; // 节点列表 + edges: GraphEdge[]; // 边列表 +} + +interface GraphNode { + id: string; // 节点ID + nodeCode: string; // 节点编码 + nodeType: string; // 节点类型 + nodeName: string; // 节点名称 + position: { x: number; y: number }; // 节点位置 + configs: Record; // 节点配置 + inputMapping: Record; // 输入映射 + outputs: OutputField[]; // 输出字段定义 +} + +interface GraphEdge { + id: string; // 边ID + from: string; // 源节点ID + to: string; // 目标节点ID + name: string; // 边名称 + config: { + type: string; // 边类型 + condition: { + type: string; // 条件类型 + priority: number; // 优先级 + expression?: string; // 条件表达式 + }; + }; +} + +// 枚举类型 +enum DeployStatus { + CREATED = 'CREATED', // 已创建 + PENDING_APPROVAL = 'PENDING_APPROVAL', // 待审批 + RUNNING = 'RUNNING', // 运行中 + SUCCESS = 'SUCCESS', // 成功 + FAILED = 'FAILED', // 失败 + REJECTED = 'REJECTED', // 审批拒绝 + CANCELLED = 'CANCELLED', // 已取消 + TERMINATED = 'TERMINATED', // 已终止 + PARTIAL_SUCCESS = 'PARTIAL_SUCCESS' // 部分成功 +} + +enum NodeStatus { + NOT_STARTED = 'NOT_STARTED', // 未开始 + RUNNING = 'RUNNING', // 运行中 + SUSPENDED = 'SUSPENDED', // 已暂停 + COMPLETED = 'COMPLETED', // 已完成 + TERMINATED = 'TERMINATED', // 已终止 + FAILED = 'FAILED' // 执行失败 +} +``` + +--- + +## 3. UI 设计建议 + +### 3.1 整体布局 + +建议使用全屏或大尺寸弹窗(1200px+),分为三个主要区域: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 📋 头部信息区(部署基本信息 + 统计卡片) │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 🎨 流程图可视化区(Canvas绘制) │ +│ - 节点按状态着色 │ +│ - 支持缩放、拖拽 │ +│ - 点击节点查看详情 │ +│ │ +├─────────────────────────────────────────────────────────────┤ +│ 📊 底部详情区(选中节点的详细信息) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 3.2 头部信息区 + +显示关键信息和统计数据: + +```tsx +// 第一行:基本信息 +┌───────────────────────────────────────────────────────────┐ +│ 🚀 部署流程详情 [关闭 ✕] │ +├───────────────────────────────────────────────────────────┤ +│ 应用:应用名称 (应用编码) | 环境:生产环境 | 团队:运维组 │ +│ 部署人:张三 | 开始时间:2025-11-05 14:00:00 │ +│ 状态:🟢 运行中 | 总时长:5分30秒 │ +└───────────────────────────────────────────────────────────┘ + +// 第二行:统计卡片 +┌──────────┬──────────┬──────────┬──────────┬──────────┐ +│ 总节点 │ 已执行 │ 成功 │ 失败 │ 运行中 │ +│ 8 │ 5 │ 4 │ 0 │ 1 │ +│ 100% │ 62% │ 50% │ 0% │ 12% │ +└──────────┴──────────┴──────────┴──────────┴──────────┘ +``` + +### 3.3 流程图可视化区 + +#### 节点状态着色方案 + +```typescript +const nodeColorMap = { + NOT_STARTED: '#d9d9d9', // 未开始:灰色 + RUNNING: '#1890ff', // 运行中:蓝色(闪烁动画) + COMPLETED: '#52c41a', // 已完成:绿色 + FAILED: '#ff4d4f', // 失败:红色 + SUSPENDED: '#faad14', // 暂停:橙色 + TERMINATED: '#8c8c8c' // 终止:深灰 +}; + +const nodeIconMap = { + START_EVENT: '▶️', + END_EVENT: '⏹️', + USER_TASK: '👤', + SERVICE_TASK: '⚙️', + APPROVAL: '✓', + SCRIPT_TASK: '📝', + JENKINS_BUILD: '🔨', + NOTIFICATION: '🔔' +}; +``` + +#### 节点显示内容 + +``` +┌─────────────────┐ +│ 🔨 Jenkins构建 │ ← 节点图标 + 名称 +├─────────────────┤ +│ ✓ 已完成 │ ← 状态(带图标) +│ ⏱ 2分30秒 │ ← 执行时长 +└─────────────────┘ +``` + +失败节点特殊标记: +``` +┌─────────────────┐ +│ ⚙️ 部署服务 │ +├─────────────────┤ +│ ✗ 执行失败 │ ← 红色状态 +│ ⚠️ 点击查看错误 │ ← 错误提示 +└─────────────────┘ +``` + +### 3.4 底部详情区 + +选中节点后显示完整信息: + +``` +┌─────────────────────────────────────────────────────────┐ +│ 📌 节点详情 │ +├─────────────────────────────────────────────────────────┤ +│ 节点名称:Jenkins构建 │ +│ 节点类型:SERVICE_TASK (服务任务) │ +│ 执行状态:✓ 已完成 │ +│ │ +│ ⏱️ 时间信息 │ +│ 开始时间:2025-11-05 14:02:30 │ +│ 结束时间:2025-11-05 14:05:00 │ +│ 执行时长:2分30秒 │ +│ │ +│ 📊 执行结果(如果有) │ +│ 构建编号:#123 │ +│ 构建状态:SUCCESS │ +│ 构建产物:app-1.0.0.jar │ +│ │ +│ ⚠️ 错误信息(失败时显示) │ +│ 连接超时:无法连接到Jenkins服务器 (timeout: 30s) │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. 组件划分建议 + +### 4.1 组件层次结构 + +``` +DeployFlowGraphModal (弹窗容器) +├── FlowGraphHeader (头部信息) +│ ├── DeployBasicInfo (基本信息) +│ └── ExecutionStatistics (统计卡片) +├── FlowGraphCanvas (流程图画布) +│ ├── GraphNode (节点组件) +│ └── GraphEdge (边组件) +└── NodeDetailPanel (节点详情面板) +``` + +### 4.2 核心组件代码示例 + +#### 4.2.1 弹窗容器组件 + +```tsx +// DeployFlowGraphModal.tsx +import React, { useState, useEffect } from 'react'; +import { Modal, Spin, message } from 'antd'; +import { getDeployFlowGraph } from '@/services/deploy'; +import type { DeployRecordFlowGraphDTO, NodeInstanceDTO } from '@/types/deploy'; + +interface Props { + visible: boolean; + deployRecordId: number; + onClose: () => void; +} + +export const DeployFlowGraphModal: React.FC = ({ + visible, + deployRecordId, + onClose +}) => { + const [loading, setLoading] = useState(false); + const [data, setData] = useState(null); + const [selectedNode, setSelectedNode] = useState(null); + + useEffect(() => { + if (visible && deployRecordId) { + loadFlowGraph(); + } + }, [visible, deployRecordId]); + + const loadFlowGraph = async () => { + setLoading(true); + try { + const response = await getDeployFlowGraph(deployRecordId); + setData(response.data); + } catch (error) { + message.error('加载流程图失败'); + } finally { + setLoading(false); + } + }; + + return ( + + + {data && ( +
+ {/* 头部信息 */} + + + {/* 流程图画布 */} + + + {/* 节点详情面板 */} + {selectedNode && ( + setSelectedNode(null)} + /> + )} +
+ )} +
+
+ ); +}; +``` + +#### 4.2.2 头部信息组件 + +```tsx +// FlowGraphHeader.tsx +import React from 'react'; +import { Tag, Statistic, Card, Row, Col, Descriptions } from 'antd'; +import type { DeployRecordFlowGraphDTO } from '@/types/deploy'; +import { formatDuration, getStatusTag } from '@/utils/deploy'; + +interface Props { + data: DeployRecordFlowGraphDTO; +} + +export const FlowGraphHeader: React.FC = ({ data }) => { + return ( +
+ {/* 基本信息 */} + + + {data.applicationName} ({data.applicationCode}) + + + {data.environmentName} + + + {data.teamName} + + + {data.deployBy} + + + {data.deployStartTime} + + + {getStatusTag(data.deployStatus)} + + + {formatDuration(data.deployDuration)} + + {data.deployRemark && ( + + {data.deployRemark} + + )} + + + {/* 统计卡片 */} + + + + + + + + + +
+ {((data.executedNodeCount / data.totalNodeCount) * 100).toFixed(0)}% +
+
+ + + + + + + + + 0 ? '#ff4d4f' : undefined }} + /> + + + + + 0 ? '#1890ff' : undefined }} + /> + + +
+
+ ); +}; +``` + +#### 4.2.3 流程图画布组件(使用 AntV X6) + +```tsx +// FlowGraphCanvas.tsx +import React, { useEffect, useRef } from 'react'; +import { Graph } from '@antv/x6'; +import type { WorkflowDefinitionGraph, NodeInstanceDTO } from '@/types/deploy'; +import { getNodeColor, getNodeIcon } from '@/utils/workflow'; + +interface Props { + graph: WorkflowDefinitionGraph; + nodeInstances: NodeInstanceDTO[]; + selectedNode: NodeInstanceDTO | null; + onNodeClick: (node: NodeInstanceDTO) => void; +} + +export const FlowGraphCanvas: React.FC = ({ + graph, + nodeInstances, + selectedNode, + onNodeClick +}) => { + const containerRef = useRef(null); + const graphRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + // 创建画布 + const graphInstance = new Graph({ + container: containerRef.current, + width: 1100, + height: 500, + grid: true, + panning: true, + mousewheel: { + enabled: true, + modifiers: ['ctrl', 'meta'], + }, + }); + + graphRef.current = graphInstance; + + // 渲染节点 + renderNodes(graphInstance); + + // 渲染边 + renderEdges(graphInstance); + + // 监听节点点击 + graphInstance.on('node:click', ({ node }) => { + const nodeId = node.id; + const nodeInstance = nodeInstances.find(n => n.nodeId === nodeId); + if (nodeInstance) { + onNodeClick(nodeInstance); + } + }); + + return () => { + graphInstance.dispose(); + }; + }, [graph, nodeInstances]); + + const renderNodes = (graphInstance: Graph) => { + graph.nodes.forEach(node => { + const nodeInstance = nodeInstances.find(n => n.nodeId === node.id); + const status = nodeInstance?.status || 'NOT_STARTED'; + const color = getNodeColor(status); + const icon = getNodeIcon(node.nodeType); + + graphInstance.addNode({ + id: node.id, + x: node.position.x, + y: node.position.y, + width: 120, + height: 80, + label: `${icon} ${node.nodeName}`, + attrs: { + body: { + fill: color, + stroke: status === selectedNode?.nodeId ? '#1890ff' : '#d9d9d9', + strokeWidth: status === selectedNode?.nodeId ? 3 : 1, + rx: 6, + ry: 6, + }, + label: { + fill: '#ffffff', + fontSize: 12, + }, + }, + data: { + status, + duration: nodeInstance?.duration, + hasError: !!nodeInstance?.errorMessage, + }, + }); + + // 添加状态标签 + if (nodeInstance) { + graphInstance.addNode({ + id: `${node.id}-status`, + x: node.position.x + 5, + y: node.position.y + 60, + width: 110, + height: 18, + label: getStatusLabel(status, nodeInstance.duration), + attrs: { + body: { + fill: 'rgba(255, 255, 255, 0.9)', + stroke: 'none', + rx: 3, + ry: 3, + }, + label: { + fill: '#000000', + fontSize: 10, + }, + }, + }); + } + }); + }; + + const renderEdges = (graphInstance: Graph) => { + graph.edges.forEach(edge => { + graphInstance.addEdge({ + id: edge.id, + source: edge.from, + target: edge.to, + label: edge.name, + attrs: { + line: { + stroke: '#8c8c8c', + strokeWidth: 2, + targetMarker: { + name: 'classic', + size: 8, + }, + }, + label: { + fill: '#595959', + fontSize: 11, + }, + }, + }); + }); + }; + + return ( +
+ ); +}; +``` + +#### 4.2.4 节点详情面板 + +```tsx +// NodeDetailPanel.tsx +import React from 'react'; +import { Card, Descriptions, Tag, Alert } from 'antd'; +import type { NodeInstanceDTO } from '@/types/deploy'; +import { formatDuration, getStatusTag } from '@/utils/deploy'; + +interface Props { + node: NodeInstanceDTO; + onClose: () => void; +} + +export const NodeDetailPanel: React.FC = ({ node, onClose }) => { + return ( + 关闭} + style={{ marginTop: 16 }} + > + + + {node.nodeName} + + + {node.nodeType} + + + {getStatusTag(node.status)} + + + {node.startTime && ( + <> + + {node.startTime} + + + {node.endTime || '执行中...'} + + + {node.duration ? formatDuration(node.duration) : '计算中...'} + + + )} + + {node.status === 'NOT_STARTED' && ( + + 该节点尚未执行 + + )} + + + {/* 错误信息 */} + {node.errorMessage && ( + + )} + + {/* 执行结果(outputs)*/} + {node.outputs && Object.keys(node.outputs).length > 0 && ( +
+

📊 执行结果

+ + {Object.entries(node.outputs).map(([key, value]) => ( + + {typeof value === 'object' ? JSON.stringify(value) : String(value)} + + ))} + +
+ )} +
+ ); +}; +``` + +--- + +## 5. 工具函数 + +```typescript +// utils/deploy.ts + +/** + * 格式化时长 + */ +export function formatDuration(seconds: number | null): string { + if (seconds === null || seconds === undefined) { + return '-'; + } + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}小时${minutes}分${secs}秒`; + } else if (minutes > 0) { + return `${minutes}分${secs}秒`; + } else { + return `${secs}秒`; + } +} + +/** + * 获取状态标签 + */ +export function getStatusTag(status: string): React.ReactNode { + const statusConfig = { + NOT_STARTED: { color: 'default', text: '未开始' }, + RUNNING: { color: 'processing', text: '运行中' }, + COMPLETED: { color: 'success', text: '已完成' }, + FAILED: { color: 'error', text: '执行失败' }, + SUSPENDED: { color: 'warning', text: '已暂停' }, + TERMINATED: { color: 'default', text: '已终止' }, + + CREATED: { color: 'default', text: '已创建' }, + PENDING_APPROVAL: { color: 'warning', text: '待审批' }, + SUCCESS: { color: 'success', text: '成功' }, + REJECTED: { color: 'error', text: '审批拒绝' }, + CANCELLED: { color: 'default', text: '已取消' }, + PARTIAL_SUCCESS: { color: 'warning', text: '部分成功' }, + }; + + const config = statusConfig[status] || { color: 'default', text: status }; + return {config.text}; +} + +/** + * 获取节点颜色 + */ +export function getNodeColor(status: string): string { + const colorMap = { + NOT_STARTED: '#d9d9d9', + RUNNING: '#1890ff', + COMPLETED: '#52c41a', + FAILED: '#ff4d4f', + SUSPENDED: '#faad14', + TERMINATED: '#8c8c8c', + }; + return colorMap[status] || '#d9d9d9'; +} + +/** + * 获取节点图标 + */ +export function getNodeIcon(nodeType: string): string { + const iconMap = { + START_EVENT: '▶️', + END_EVENT: '⏹️', + USER_TASK: '👤', + SERVICE_TASK: '⚙️', + APPROVAL: '✓', + SCRIPT_TASK: '📝', + JENKINS_BUILD: '🔨', + NOTIFICATION: '🔔', + }; + return iconMap[nodeType] || '⚙️'; +} + +/** + * 获取状态文本 + */ +function getStatusLabel(status: string, duration: number | null): string { + const statusText = { + NOT_STARTED: '未开始', + RUNNING: '运行中', + COMPLETED: '已完成', + FAILED: '执行失败', + SUSPENDED: '已暂停', + TERMINATED: '已终止', + }; + + const text = statusText[status] || status; + const timeText = duration ? ` | ${formatDuration(duration)}` : ''; + + return `${text}${timeText}`; +} +``` + +--- + +## 6. 高级功能建议 + +### 6.1 实时刷新 + +对于运行中的部署,建议实现定时刷新: + +```typescript +useEffect(() => { + if (data?.runningNodeCount > 0) { + const timer = setInterval(() => { + loadFlowGraph(); // 每5秒刷新一次 + }, 5000); + + return () => clearInterval(timer); + } +}, [data?.runningNodeCount]); +``` + +### 6.2 节点动画 + +运行中的节点添加闪烁动画: + +```css +@keyframes pulse { + 0% { opacity: 1; } + 50% { opacity: 0.6; } + 100% { opacity: 1; } +} + +.node-running { + animation: pulse 2s infinite; +} +``` + +### 6.3 进度条展示 + +在头部添加整体进度条: + +```tsx + 0 ? 'exception' : 'active'} + format={(percent) => `${data.executedNodeCount} / ${data.totalNodeCount}`} +/> +``` + +### 6.4 节点搜索/过滤 + +添加节点名称搜索功能: + +```tsx +} + onChange={(e) => handleSearchNode(e.target.value)} +/> +``` + +### 6.5 导出功能 + +支持导出流程图为图片或PDF: + +```typescript +import html2canvas from 'html2canvas'; + +const exportFlowGraph = async () => { + const canvas = await html2canvas(containerRef.current); + const link = document.createElement('a'); + link.download = `deploy-flow-${deployRecordId}.png`; + link.href = canvas.toDataURL(); + link.click(); +}; +``` + +--- + +## 7. 注意事项 + +### 7.1 性能优化 + +1. **虚拟滚动**:节点数量超过50个时使用虚拟滚动 +2. **懒加载**:详情面板内容按需加载 +3. **防抖节流**:缩放、拖拽等操作使用防抖 + +### 7.2 异常处理 + +1. **空数据处理**:显示"暂无流程图数据"提示 +2. **加载失败**:提供重试按钮 +3. **超时处理**:超过30秒显示超时提示 + +### 7.3 用户体验 + +1. **加载状态**:使用骨架屏或加载动画 +2. **操作提示**:提供操作引导(缩放、拖拽说明) +3. **快捷键**:支持ESC关闭弹窗,Ctrl+滚轮缩放 + +### 7.4 移动端适配 + +如需移动端支持,建议: +- 使用响应式布局 +- 触摸手势支持 +- 简化节点显示信息 + +--- + +## 8. 测试用例 + +### 8.1 基本功能测试 + +- [ ] 弹窗正常打开和关闭 +- [ ] 数据正确加载和显示 +- [ ] 节点正确渲染(位置、颜色、图标) +- [ ] 边正确连接 +- [ ] 点击节点显示详情 + +### 8.2 状态测试 + +- [ ] 未开始节点显示为灰色 +- [ ] 运行中节点显示蓝色并闪烁 +- [ ] 成功节点显示绿色 +- [ ] 失败节点显示红色并显示错误信息 + +### 8.3 交互测试 + +- [ ] 画布拖拽功能正常 +- [ ] 画布缩放功能正常 +- [ ] 节点选中高亮正常 +- [ ] 统计数据准确 + +--- + +## 9. 参考资源 + +### 9.1 推荐库 + +- **流程图渲染**:[AntV X6](https://x6.antv.vision/) +- **UI组件**:[Ant Design](https://ant.design/) +- **图表统计**:[AntV G2Plot](https://g2plot.antv.vision/) + +### 9.2 设计参考 + +- Jenkins 构建详情页 +- GitLab CI/CD Pipeline 可视化 +- GitHub Actions 工作流可视化 + +--- + +## 10. 常见问题 + +**Q1: 节点太多时如何优化显示?** +A: 使用缩略图导航 + 局部放大;超过100个节点时考虑分页或折叠显示。 + +**Q2: 如何处理长时间运行的部署?** +A: 实现自动刷新(5-10秒间隔)+ WebSocket推送更新。 + +**Q3: 失败节点如何快速定位?** +A: 在头部添加"快速定位失败节点"按钮,点击自动滚动并高亮。 + +**Q4: 如何显示审批节点的审批人信息?** +A: 在节点 outputs 中包含 `approver`、`approvalTime` 等信息,详情面板中展示。 + +--- + +## 11. 迭代计划 + +### V1.0(基础版) +- ✅ 流程图基本渲染 +- ✅ 节点状态显示 +- ✅ 统计信息展示 +- ✅ 节点详情查看 + +### V2.0(增强版) +- 📋 实时刷新 +- 📋 节点搜索/过滤 +- 📋 导出功能 +- 📋 操作历史记录 + +### V3.0(高级版) +- 📋 时间轴视图 +- 📋 性能分析(节点耗时对比) +- 📋 历史部署对比 +- 📋 智能建议(性能优化建议) + +--- + +## 联系方式 + +如有问题,请联系后端开发团队或查阅API文档。 + diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/integration/impl/JenkinsServiceIntegrationImpl.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/integration/impl/JenkinsServiceIntegrationImpl.java index d980d5c9..a0d7e2b5 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/deploy/integration/impl/JenkinsServiceIntegrationImpl.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/integration/impl/JenkinsServiceIntegrationImpl.java @@ -62,7 +62,20 @@ public class JenkinsServiceIntegrationImpl implements IJenkinsServiceIntegration public boolean testConnection(ExternalSystem system) { try { String url = system.getUrl() + "/api/json"; + + // 详细日志:检查认证信息 + log.info("===== Jenkins连接测试调试信息 ====="); + log.info("URL: {}", url); + log.info("认证类型: {}", system.getAuthType()); + log.info("用户名: {}", system.getUsername()); + log.info("密码是否为空: {}", system.getPassword() == null || system.getPassword().isEmpty()); + HttpHeaders headers = createHeaders(system); + + // 打印实际发送的请求头 + log.info("Authorization头: {}", headers.getFirst("Authorization")); + log.info("==================================="); + HttpEntity entity = new HttpEntity<>(headers); ResponseEntity response = restTemplate.exchange( @@ -106,8 +119,11 @@ public class JenkinsServiceIntegrationImpl implements IJenkinsServiceIntegration headers.set("Authorization", authHeader); } case TOKEN -> { - // Token认证 - headers.set("Authorization", "Bearer " + jenkins.getToken()); + // Jenkins API Token认证也使用Basic Auth格式:username:apiToken + String auth = jenkins.getUsername() + ":" + jenkins.getToken(); + byte[] encodedAuth = Base64.getEncoder().encode(auth.getBytes()); + String authHeader = "Basic " + new String(encodedAuth); + headers.set("Authorization", authHeader); } default -> throw new RuntimeException("Unsupported authentication type: " + jenkins.getAuthType()); } diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/ExternalSystemServiceImpl.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/ExternalSystemServiceImpl.java index e1f7b3bd..679839c9 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/ExternalSystemServiceImpl.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/ExternalSystemServiceImpl.java @@ -21,6 +21,7 @@ import com.qqchen.deploy.backend.framework.enums.ResponseCode; import com.qqchen.deploy.backend.framework.exception.BusinessException; import com.qqchen.deploy.backend.framework.exception.UniqueConstraintException; import com.qqchen.deploy.backend.framework.service.impl.BaseServiceImpl; +import com.qqchen.deploy.backend.framework.util.SensitiveDataEncryptor; import com.qqchen.deploy.backend.deploy.integration.IExternalSystemIntegration; import com.qqchen.deploy.backend.system.model.ExternalSystemDTO; import com.qqchen.deploy.backend.deploy.repository.IExternalSystemRepository; @@ -88,6 +89,9 @@ public class ExternalSystemServiceImpl extends BaseServiceImpl findAll() { + List dtos = super.findAll(); + // 查询后处理:用掩码替换敏感数据 + dtos.forEach(this::maskSensitiveData); + return dtos; + } + @Override @Transactional public boolean testConnection(Long id) { @@ -144,12 +205,15 @@ public class ExternalSystemServiceImpl extends BaseServiceImpl= 4) { + // 使用指定的密钥和盐值 + String secretKey = args[2]; + String salt = args[3]; + + switch (operation) { + case "encrypt", "enc", "e" -> { + result = encrypt(input, secretKey, salt); + System.out.println("\n=== 加密成功 ==="); + System.out.println("明文: " + input); + System.out.println("密文: " + result); + System.out.println("长度: " + result.length()); + } + case "decrypt", "dec", "d" -> { + result = decrypt(input, secretKey, salt); + System.out.println("\n=== 解密成功 ==="); + System.out.println("密文: " + input); + System.out.println("明文: " + result); + } + default -> { + System.err.println("错误: 未知操作 '" + operation + "'"); + printUsage(); + System.exit(1); + } + } + } else { + // 使用默认配置 + switch (operation) { + case "encrypt", "enc", "e" -> { + result = encrypt(input); + System.out.println("\n=== 加密成功(使用默认配置) ==="); + System.out.println("明文: " + input); + System.out.println("密文: " + result); + System.out.println("长度: " + result.length()); + System.out.println("\n提示: 如需使用自定义配置,请提供密钥和盐值参数"); + } + case "decrypt", "dec", "d" -> { + result = decrypt(input); + System.out.println("\n=== 解密成功(使用默认配置) ==="); + System.out.println("密文: " + input); + System.out.println("明文: " + result); + System.out.println("\n提示: 如需使用自定义配置,请提供密钥和盐值参数"); + } + default -> { + System.err.println("错误: 未知操作 '" + operation + "'"); + printUsage(); + System.exit(1); + } + } + } + } catch (Exception e) { + System.err.println("\n操作失败: " + e.getMessage()); + e.printStackTrace(); + System.exit(1); + } + } + + /** + * 打印使用说明 + */ + private static void printUsage() { + System.out.println("\n=== 加密解密工具 ==="); + System.out.println("\n用法:"); + System.out.println(" 使用默认配置:"); + System.out.println(" java EncryptionUtils encrypt <明文>"); + System.out.println(" java EncryptionUtils decrypt <密文>"); + System.out.println(); + System.out.println(" 使用自定义配置:"); + System.out.println(" java EncryptionUtils encrypt <明文> <密钥> <盐值>"); + System.out.println(" java EncryptionUtils decrypt <密文> <密钥> <盐值>"); + System.out.println(); + System.out.println("示例:"); + System.out.println(" # 加密密码(默认配置)"); + System.out.println(" java EncryptionUtils encrypt \"mypassword123\""); + System.out.println(); + System.out.println(" # 解密密码(默认配置)"); + System.out.println(" java EncryptionUtils decrypt \"encrypted_text_here\""); + System.out.println(); + System.out.println(" # 加密密码(自定义配置)"); + System.out.println(" java EncryptionUtils encrypt \"mypassword\" \"my-secret-key-32-chars\" \"0123456789abcdef\""); + System.out.println(); + System.out.println("注意:"); + System.out.println(" - 盐值必须是16位十六进制字符串(只能包含0-9和a-f)"); + System.out.println(" - 密钥建议至少32位"); + System.out.println(" - 使用的密钥和盐值必须与application.yml中的配置一致"); + System.out.println(); + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/util/SensitiveDataEncryptor.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/util/SensitiveDataEncryptor.java new file mode 100644 index 00000000..e2cdf570 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/util/SensitiveDataEncryptor.java @@ -0,0 +1,97 @@ +package com.qqchen.deploy.backend.framework.util; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 敏感数据加密服务类(Spring组件) + * 从配置文件读取密钥和盐值,委托给 EncryptionUtils 执行加密解密 + * + * 设计说明: + * - 本类是Spring组件,可自动注入,从配置文件读取密钥 + * - 实际加密解密委托给 EncryptionUtils 静态工具类 + * - EncryptionUtils 可独立使用,无需Spring容器 + * + * @author qqchen + * @since 2025-11-11 + */ +@Slf4j +@Component +public class SensitiveDataEncryptor { + + /** + * 密码掩码(用于前端显示) + */ + public static final String PASSWORD_MASK = EncryptionUtils.PASSWORD_MASK; + + /** + * 从配置文件读取加密密钥 + */ + @Value("${deploy.encryption.secret-key}") + private String secretKey; + + /** + * 从配置文件读取加密盐值 + */ + @Value("${deploy.encryption.salt}") + private String salt; + + /** + * 加密明文 + * + * @param plainText 明文 + * @return 加密后的文本,如果输入为空则返回null + */ + public String encrypt(String plainText) { + if (StringUtils.isBlank(plainText)) { + return null; + } + try { + return EncryptionUtils.encrypt(plainText, secretKey, salt); + } catch (Exception e) { + log.error("加密失败", e); + throw new RuntimeException("数据加密失败", e); + } + } + + /** + * 解密密文 + * + * @param encryptedText 密文 + * @return 解密后的明文,如果输入为空则返回null + */ + public String decrypt(String encryptedText) { + if (StringUtils.isBlank(encryptedText)) { + return null; + } + try { + return EncryptionUtils.decrypt(encryptedText, secretKey, salt); + } catch (Exception e) { + log.error("解密失败,可能是明文或密钥错误", e); + // 如果解密失败,可能是旧数据(明文),直接返回 + return encryptedText; + } + } + + /** + * 判断字符串是否为掩码 + * + * @param value 要判断的字符串 + * @return true表示是掩码 + */ + public static boolean isMasked(String value) { + return EncryptionUtils.isMasked(value); + } + + /** + * 如果有值则返回掩码,否则返回null + * + * @param value 原始值 + * @return 掩码或null + */ + public static String maskIfPresent(String value) { + return StringUtils.isNotBlank(value) ? PASSWORD_MASK : null; + } +} diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index fb7d4230..714b65de 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -100,3 +100,14 @@ jwt: jackson: time-zone: Asia/Shanghai + +# 部署平台配置 +deploy: + # 敏感数据加密配置 + encryption: + # 加密密钥(强烈建议使用环境变量 ENCRYPTION_SECRET_KEY) + # 生产环境务必修改为强随机字符串,密钥长度至少32位 + secret-key: ${ENCRYPTION_SECRET_KEY:CHANGE-THIS-IN-PRODUCTION-ENV-32CHARS} + # 加密盐值(强烈建议使用环境变量 ENCRYPTION_SALT) + # 盐值必须是16位十六进制字符串(只能包含0-9和a-f) + salt: ${ENCRYPTION_SALT:0123456789abcdef} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 8149dd65..468ca7b8 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -100,3 +100,14 @@ jwt: jackson: time-zone: Asia/Shanghai + +# 部署平台配置 +deploy: + # 敏感数据加密配置 + encryption: + # 加密密钥(生产环境建议使用环境变量 ENCRYPTION_SECRET_KEY) + # 密钥长度至少32位,建议使用强随机字符串 + secret-key: ${ENCRYPTION_SECRET_KEY:deploy-ease-platform-secret-key-2025} + # 加密盐值(生产环境建议使用环境变量 ENCRYPTION_SALT) + # 盐值必须是16位十六进制字符串(只能包含0-9和a-f) + salt: ${ENCRYPTION_SALT:a1b2c3d4e5f6a7b8} diff --git a/backend/src/main/resources/db/changelog/changes/v1.0.0-data.sql b/backend/src/main/resources/db/changelog/changes/v1.0.0-data.sql index 2ff451be..a7e83044 100644 --- a/backend/src/main/resources/db/changelog/changes/v1.0.0-data.sql +++ b/backend/src/main/resources/db/changelog/changes/v1.0.0-data.sql @@ -542,8 +542,13 @@ VALUES (2, 304); -- 基础权限模板关联三方系统菜单 -- -------------------------------------------------------------------------------------- -- 初始化外部系统数据 -- -------------------------------------------------------------------------------------- +-- 注意:password和token字段支持加密存储(AES-256) +-- 1. 初始化数据可以使用明文,首次编辑保存时会自动加密 +-- 2. 表结构已扩展为VARCHAR(500)以容纳加密后的数据 +-- 3. 生产环境建议通过管理界面添加外部系统,避免在SQL中暴露明文密码 +-- -------------------------------------------------------------------------------------- --- 初始化外部系统 +-- 初始化外部系统(示例数据,生产环境请删除或修改) INSERT INTO sys_external_system ( id, create_by, create_time, deleted, update_by, update_time, version, name, type, url, remark, sort, enabled, auth_type, username, password, token, @@ -551,12 +556,12 @@ INSERT INTO sys_external_system ( ) VALUES ( 1, 'admin', '2023-12-01 00:00:00', 0, 'admin', '2023-12-01 00:00:00', 0, '链宇JENKINS', 'JENKINS', 'https://ly-jenkins.iscmtech.com', '链宇JENKINS', 1, 1, - 'BASIC', 'admin', 'Lianyu!@#~123456', NULL, + 'BASIC', 'admin', 'Lianyu!@#~123456', NULL, -- 密码为明文,首次编辑会自动加密 'SUCCESS', '2023-12-01 00:00:00', '2023-12-01 00:00:00', '{}' ), ( 2, 'admin', '2024-12-03 10:35:58.932966', 0, 'admin', '2024-12-03 10:35:58.932966', 0, '链宇GIT', 'GIT', 'https://ly-gitlab.iscmtech.com/', NULL, 1, 1, - 'TOKEN', NULL, NULL, 'cNSud7D1GmYQKEMco7s5', + 'TOKEN', NULL, NULL, 'cNSud7D1GmYQKEMco7s5', -- Token为明文,首次编辑会自动加密 NULL, NULL, NULL, '{}' ); diff --git a/backend/src/main/resources/db/changelog/changes/v1.0.0-schema.sql b/backend/src/main/resources/db/changelog/changes/v1.0.0-schema.sql index 29cd9289..698fa03d 100644 --- a/backend/src/main/resources/db/changelog/changes/v1.0.0-schema.sql +++ b/backend/src/main/resources/db/changelog/changes/v1.0.0-schema.sql @@ -278,8 +278,8 @@ CREATE TABLE sys_external_system enabled BIT NOT NULL DEFAULT 1 COMMENT '是否启用(0:禁用,1:启用)', auth_type VARCHAR(50) NOT NULL COMMENT '认证方式(BASIC/TOKEN/OAUTH等)', username VARCHAR(100) NULL COMMENT '用户名', - password VARCHAR(255) NULL COMMENT '密码', - token VARCHAR(255) NULL COMMENT '访问令牌', + password VARCHAR(500) NULL COMMENT '密码(加密存储,AES-256)', + token VARCHAR(500) NULL COMMENT '访问令牌(加密存储,AES-256)', sync_status VARCHAR(50) NULL COMMENT '同步状态(SUCCESS/FAILED/RUNNING)', last_sync_time DATETIME(6) NULL COMMENT '最后同步时间', last_connect_time DATETIME(6) NULL COMMENT '最近连接成功时间',