增加密码加密
This commit is contained in:
parent
f75a4b5f7e
commit
10e75b9769
989
backend/docs/frontend/部署流程图弹窗开发指南.md
Normal file
989
backend/docs/frontend/部署流程图弹窗开发指南.md
Normal file
@ -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<string, any>; // 节点配置
|
||||
inputMapping: Record<string, any>; // 输入映射
|
||||
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<Props> = ({
|
||||
visible,
|
||||
deployRecordId,
|
||||
onClose
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<DeployRecordFlowGraphDTO | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<NodeInstanceDTO | null>(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 (
|
||||
<Modal
|
||||
title="部署流程详情"
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width={1200}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{data && (
|
||||
<div className="deploy-flow-graph">
|
||||
{/* 头部信息 */}
|
||||
<FlowGraphHeader data={data} />
|
||||
|
||||
{/* 流程图画布 */}
|
||||
<FlowGraphCanvas
|
||||
graph={data.graph}
|
||||
nodeInstances={data.nodeInstances}
|
||||
selectedNode={selectedNode}
|
||||
onNodeClick={setSelectedNode}
|
||||
/>
|
||||
|
||||
{/* 节点详情面板 */}
|
||||
{selectedNode && (
|
||||
<NodeDetailPanel
|
||||
node={selectedNode}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
#### 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<Props> = ({ data }) => {
|
||||
return (
|
||||
<div className="flow-graph-header">
|
||||
{/* 基本信息 */}
|
||||
<Descriptions column={3} size="small" bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="应用">
|
||||
{data.applicationName} ({data.applicationCode})
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="环境">
|
||||
{data.environmentName}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="团队">
|
||||
{data.teamName}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="部署人">
|
||||
{data.deployBy}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开始时间">
|
||||
{data.deployStartTime}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{getStatusTag(data.deployStatus)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="总时长" span={3}>
|
||||
{formatDuration(data.deployDuration)}
|
||||
</Descriptions.Item>
|
||||
{data.deployRemark && (
|
||||
<Descriptions.Item label="备注" span={3}>
|
||||
{data.deployRemark}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={16}>
|
||||
<Col span={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="总节点"
|
||||
value={data.totalNodeCount}
|
||||
suffix="个"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="已执行"
|
||||
value={data.executedNodeCount}
|
||||
suffix={`/ ${data.totalNodeCount}`}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#8c8c8c' }}>
|
||||
{((data.executedNodeCount / data.totalNodeCount) * 100).toFixed(0)}%
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="成功"
|
||||
value={data.successNodeCount}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="失败"
|
||||
value={data.failedNodeCount}
|
||||
valueStyle={{ color: data.failedNodeCount > 0 ? '#ff4d4f' : undefined }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="运行中"
|
||||
value={data.runningNodeCount}
|
||||
valueStyle={{ color: data.runningNodeCount > 0 ? '#1890ff' : undefined }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
#### 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<Props> = ({
|
||||
graph,
|
||||
nodeInstances,
|
||||
selectedNode,
|
||||
onNodeClick
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const graphRef = useRef<Graph | null>(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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flow-graph-canvas"
|
||||
style={{ border: '1px solid #d9d9d9', marginTop: 16 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
#### 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<Props> = ({ node, onClose }) => {
|
||||
return (
|
||||
<Card
|
||||
title="📌 节点详情"
|
||||
extra={<a onClick={onClose}>关闭</a>}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="节点名称" span={2}>
|
||||
{node.nodeName}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="节点类型">
|
||||
{node.nodeType}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="执行状态">
|
||||
{getStatusTag(node.status)}
|
||||
</Descriptions.Item>
|
||||
|
||||
{node.startTime && (
|
||||
<>
|
||||
<Descriptions.Item label="开始时间">
|
||||
{node.startTime}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结束时间">
|
||||
{node.endTime || '执行中...'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="执行时长" span={2}>
|
||||
{node.duration ? formatDuration(node.duration) : '计算中...'}
|
||||
</Descriptions.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{node.status === 'NOT_STARTED' && (
|
||||
<Descriptions.Item label="说明" span={2}>
|
||||
<Tag color="default">该节点尚未执行</Tag>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{node.errorMessage && (
|
||||
<Alert
|
||||
message="执行错误"
|
||||
description={node.errorMessage}
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 执行结果(outputs)*/}
|
||||
{node.outputs && Object.keys(node.outputs).length > 0 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<h4>📊 执行结果</h4>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
{Object.entries(node.outputs).map(([key, value]) => (
|
||||
<Descriptions.Item key={key} label={key}>
|
||||
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 <Tag color={config.color}>{config.text}</Tag>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点颜色
|
||||
*/
|
||||
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
|
||||
<Progress
|
||||
percent={(data.executedNodeCount / data.totalNodeCount) * 100}
|
||||
status={data.failedNodeCount > 0 ? 'exception' : 'active'}
|
||||
format={(percent) => `${data.executedNodeCount} / ${data.totalNodeCount}`}
|
||||
/>
|
||||
```
|
||||
|
||||
### 6.4 节点搜索/过滤
|
||||
|
||||
添加节点名称搜索功能:
|
||||
|
||||
```tsx
|
||||
<Input
|
||||
placeholder="搜索节点"
|
||||
prefix={<SearchOutlined />}
|
||||
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文档。
|
||||
|
||||
@ -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<String> entity = new HttpEntity<>(headers);
|
||||
|
||||
ResponseEntity<String> 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());
|
||||
}
|
||||
|
||||
@ -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<ExternalSystem, E
|
||||
@Resource
|
||||
private IRepositorySyncHistoryRepository repositorySyncHistoryRepository;
|
||||
|
||||
@Resource
|
||||
private SensitiveDataEncryptor encryptor;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
integrationMap = systemIntegrations.stream()
|
||||
@ -113,10 +117,51 @@ public class ExternalSystemServiceImpl extends BaseServiceImpl<ExternalSystem, E
|
||||
validateGitAuth(dto);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExternalSystemDTO create(ExternalSystemDTO dto) {
|
||||
// 加密密码和Token
|
||||
encryptSensitiveData(dto);
|
||||
return super.create(dto);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExternalSystemDTO update(Long id, ExternalSystemDTO dto) {
|
||||
// 先验证Git认证
|
||||
validateGitAuth(dto);
|
||||
|
||||
// 获取现有系统
|
||||
ExternalSystem existingSystem = findEntityById(id);
|
||||
|
||||
// 密码处理
|
||||
if (SensitiveDataEncryptor.isMasked(dto.getPassword())) {
|
||||
// 如果是掩码,保留原密码
|
||||
dto.setPassword(existingSystem.getPassword());
|
||||
log.debug("保留原密码(掩码):系统ID={}", id);
|
||||
} else if (StringUtils.isNotBlank(dto.getPassword())) {
|
||||
// 如果是新密码,加密后保存
|
||||
dto.setPassword(encryptor.encrypt(dto.getPassword()));
|
||||
log.debug("更新并加密新密码:系统ID={}", id);
|
||||
} else {
|
||||
// 如果为空,保留原密码
|
||||
dto.setPassword(existingSystem.getPassword());
|
||||
log.debug("保留原密码(空值):系统ID={}", id);
|
||||
}
|
||||
|
||||
// Token处理
|
||||
if (SensitiveDataEncryptor.isMasked(dto.getToken())) {
|
||||
// 如果是掩码,保留原Token
|
||||
dto.setToken(existingSystem.getToken());
|
||||
log.debug("保留原Token(掩码):系统ID={}", id);
|
||||
} else if (StringUtils.isNotBlank(dto.getToken())) {
|
||||
// 如果是新Token,加密后保存
|
||||
dto.setToken(encryptor.encrypt(dto.getToken()));
|
||||
log.debug("更新并加密新Token:系统ID={}", id);
|
||||
} else {
|
||||
// 如果为空,保留原Token
|
||||
dto.setToken(existingSystem.getToken());
|
||||
log.debug("保留原Token(空值):系统ID={}", id);
|
||||
}
|
||||
|
||||
return super.update(id, dto);
|
||||
}
|
||||
|
||||
@ -136,6 +181,22 @@ public class ExternalSystemServiceImpl extends BaseServiceImpl<ExternalSystem, E
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExternalSystemDTO findById(Long id) {
|
||||
ExternalSystemDTO dto = super.findById(id);
|
||||
// 查询后处理:用掩码替换敏感数据
|
||||
maskSensitiveData(dto);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ExternalSystemDTO> findAll() {
|
||||
List<ExternalSystemDTO> 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<ExternalSystem, E
|
||||
throw new BusinessException(ResponseCode.EXTERNAL_SYSTEM_DISABLED);
|
||||
}
|
||||
|
||||
// 解密密码和Token用于连接测试
|
||||
ExternalSystem decryptedSystem = decryptSystemForUse(system);
|
||||
|
||||
IExternalSystemIntegration integration = integrationMap.get(system.getType());
|
||||
if (integration == null) {
|
||||
throw new BusinessException(ResponseCode.EXTERNAL_SYSTEM_TYPE_NOT_SUPPORTED);
|
||||
}
|
||||
|
||||
boolean success = integration.testConnection(system);
|
||||
boolean success = integration.testConnection(decryptedSystem);
|
||||
if (success) {
|
||||
system.setLastConnectTime(LocalDateTime.now());
|
||||
externalSystemRepository.save(system);
|
||||
@ -250,4 +314,60 @@ public class ExternalSystemServiceImpl extends BaseServiceImpl<ExternalSystem, E
|
||||
return instanceDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密敏感数据(密码和Token)
|
||||
* 在保存前调用
|
||||
*/
|
||||
private void encryptSensitiveData(ExternalSystemDTO dto) {
|
||||
if (StringUtils.isNotBlank(dto.getPassword())) {
|
||||
dto.setPassword(encryptor.encrypt(dto.getPassword()));
|
||||
log.debug("密码已加密");
|
||||
}
|
||||
if (StringUtils.isNotBlank(dto.getToken())) {
|
||||
dto.setToken(encryptor.encrypt(dto.getToken()));
|
||||
log.debug("Token已加密");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用掩码替换敏感数据
|
||||
* 在查询后调用
|
||||
*/
|
||||
private void maskSensitiveData(ExternalSystemDTO dto) {
|
||||
if (dto == null) {
|
||||
return;
|
||||
}
|
||||
// 如果有密码,用掩码替换
|
||||
dto.setPassword(SensitiveDataEncryptor.maskIfPresent(dto.getPassword()));
|
||||
// 如果有Token,用掩码替换
|
||||
dto.setToken(SensitiveDataEncryptor.maskIfPresent(dto.getToken()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密系统信息用于实际调用外部系统
|
||||
* 创建一个临时对象,不影响原对象
|
||||
*/
|
||||
private ExternalSystem decryptSystemForUse(ExternalSystem system) {
|
||||
ExternalSystem decrypted = new ExternalSystem();
|
||||
// 复制所有属性
|
||||
decrypted.setId(system.getId());
|
||||
decrypted.setName(system.getName());
|
||||
decrypted.setType(system.getType());
|
||||
decrypted.setUrl(system.getUrl());
|
||||
decrypted.setAuthType(system.getAuthType());
|
||||
decrypted.setUsername(system.getUsername());
|
||||
decrypted.setEnabled(system.getEnabled());
|
||||
decrypted.setConfig(system.getConfig());
|
||||
|
||||
// 解密敏感数据
|
||||
if (StringUtils.isNotBlank(system.getPassword())) {
|
||||
decrypted.setPassword(encryptor.decrypt(system.getPassword()));
|
||||
}
|
||||
if (StringUtils.isNotBlank(system.getToken())) {
|
||||
decrypted.setToken(encryptor.decrypt(system.getToken()));
|
||||
}
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,221 @@
|
||||
package com.qqchen.deploy.backend.framework.util;
|
||||
|
||||
import org.springframework.security.crypto.encrypt.Encryptors;
|
||||
import org.springframework.security.crypto.encrypt.TextEncryptor;
|
||||
|
||||
/**
|
||||
* 加密解密工具类
|
||||
* 可独立使用,无需启动Spring容器
|
||||
*
|
||||
* 使用场景:
|
||||
* 1. 命令行工具:加密/解密敏感数据
|
||||
* 2. 数据迁移:批量加密/解密数据库数据
|
||||
* 3. 调试:验证加密结果
|
||||
* 4. 紧急情况:在服务器上直接解密数据
|
||||
*
|
||||
* @author qqchen
|
||||
* @since 2025-11-11
|
||||
*/
|
||||
public class EncryptionUtils {
|
||||
|
||||
/**
|
||||
* 密码掩码
|
||||
*/
|
||||
public static final String PASSWORD_MASK = "********";
|
||||
|
||||
/**
|
||||
* 默认加密密钥(与配置文件一致)
|
||||
*/
|
||||
private static final String DEFAULT_SECRET_KEY = "deploy-ease-platform-secret-key-2025";
|
||||
|
||||
/**
|
||||
* 默认加密盐值(与配置文件一致)
|
||||
*/
|
||||
private static final String DEFAULT_SALT = "a1b2c3d4e5f6a7b8";
|
||||
|
||||
/**
|
||||
* 使用默认配置加密
|
||||
*
|
||||
* @param plainText 明文
|
||||
* @return 密文
|
||||
*/
|
||||
public static String encrypt(String plainText) {
|
||||
return encrypt(plainText, DEFAULT_SECRET_KEY, DEFAULT_SALT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定配置加密
|
||||
*
|
||||
* @param plainText 明文
|
||||
* @param secretKey 密钥
|
||||
* @param salt 盐值(16位十六进制字符串)
|
||||
* @return 密文
|
||||
*/
|
||||
public static String encrypt(String plainText, String secretKey, String salt) {
|
||||
if (plainText == null || plainText.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
TextEncryptor encryptor = Encryptors.text(secretKey, salt);
|
||||
return encryptor.encrypt(plainText);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("加密失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用默认配置解密
|
||||
*
|
||||
* @param encryptedText 密文
|
||||
* @return 明文
|
||||
*/
|
||||
public static String decrypt(String encryptedText) {
|
||||
return decrypt(encryptedText, DEFAULT_SECRET_KEY, DEFAULT_SALT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定配置解密
|
||||
*
|
||||
* @param encryptedText 密文
|
||||
* @param secretKey 密钥
|
||||
* @param salt 盐值(16位十六进制字符串)
|
||||
* @return 明文
|
||||
*/
|
||||
public static String decrypt(String encryptedText, String secretKey, String salt) {
|
||||
if (encryptedText == null || encryptedText.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
TextEncryptor encryptor = Encryptors.text(secretKey, salt);
|
||||
return encryptor.decrypt(encryptedText);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("解密失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为掩码
|
||||
*
|
||||
* @param value 要判断的值
|
||||
* @return true表示是掩码
|
||||
*/
|
||||
public static boolean isMasked(String value) {
|
||||
return PASSWORD_MASK.equals(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令行工具入口
|
||||
* 用法:
|
||||
* 1. 加密(使用默认配置):
|
||||
* java EncryptionUtils encrypt "mypassword"
|
||||
*
|
||||
* 2. 解密(使用默认配置):
|
||||
* java EncryptionUtils decrypt "encrypted_text_here"
|
||||
*
|
||||
* 3. 加密(指定配置):
|
||||
* java EncryptionUtils encrypt "mypassword" "your-secret-key" "0123456789abcdef"
|
||||
*
|
||||
* 4. 解密(指定配置):
|
||||
* java EncryptionUtils decrypt "encrypted_text" "your-secret-key" "0123456789abcdef"
|
||||
*
|
||||
* @param args 命令行参数
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
if (args.length < 2) {
|
||||
printUsage();
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
String operation = args[0].toLowerCase();
|
||||
String input = args[1];
|
||||
|
||||
try {
|
||||
String result;
|
||||
if (args.length >= 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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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, '{}'
|
||||
);
|
||||
|
||||
|
||||
@ -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 '最近连接成功时间',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user