增加密码加密

This commit is contained in:
dengqichen 2025-11-11 14:06:52 +08:00
parent f75a4b5f7e
commit 10e75b9769
9 changed files with 1478 additions and 8 deletions

View 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; // 流程实例IDFlowable
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文档。

View File

@ -62,7 +62,20 @@ public class JenkinsServiceIntegrationImpl implements IJenkinsServiceIntegration
public boolean testConnection(ExternalSystem system) { public boolean testConnection(ExternalSystem system) {
try { try {
String url = system.getUrl() + "/api/json"; 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); HttpHeaders headers = createHeaders(system);
// 打印实际发送的请求头
log.info("Authorization头: {}", headers.getFirst("Authorization"));
log.info("===================================");
HttpEntity<String> entity = new HttpEntity<>(headers); HttpEntity<String> entity = new HttpEntity<>(headers);
ResponseEntity<String> response = restTemplate.exchange( ResponseEntity<String> response = restTemplate.exchange(
@ -106,8 +119,11 @@ public class JenkinsServiceIntegrationImpl implements IJenkinsServiceIntegration
headers.set("Authorization", authHeader); headers.set("Authorization", authHeader);
} }
case TOKEN -> { case TOKEN -> {
// Token认证 // Jenkins API Token认证也使用Basic Auth格式username:apiToken
headers.set("Authorization", "Bearer " + jenkins.getToken()); 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()); default -> throw new RuntimeException("Unsupported authentication type: " + jenkins.getAuthType());
} }

View File

@ -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.BusinessException;
import com.qqchen.deploy.backend.framework.exception.UniqueConstraintException; import com.qqchen.deploy.backend.framework.exception.UniqueConstraintException;
import com.qqchen.deploy.backend.framework.service.impl.BaseServiceImpl; 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.deploy.integration.IExternalSystemIntegration;
import com.qqchen.deploy.backend.system.model.ExternalSystemDTO; import com.qqchen.deploy.backend.system.model.ExternalSystemDTO;
import com.qqchen.deploy.backend.deploy.repository.IExternalSystemRepository; import com.qqchen.deploy.backend.deploy.repository.IExternalSystemRepository;
@ -88,6 +89,9 @@ public class ExternalSystemServiceImpl extends BaseServiceImpl<ExternalSystem, E
@Resource @Resource
private IRepositorySyncHistoryRepository repositorySyncHistoryRepository; private IRepositorySyncHistoryRepository repositorySyncHistoryRepository;
@Resource
private SensitiveDataEncryptor encryptor;
@PostConstruct @PostConstruct
public void init() { public void init() {
integrationMap = systemIntegrations.stream() integrationMap = systemIntegrations.stream()
@ -113,10 +117,51 @@ public class ExternalSystemServiceImpl extends BaseServiceImpl<ExternalSystem, E
validateGitAuth(dto); validateGitAuth(dto);
} }
@Override
public ExternalSystemDTO create(ExternalSystemDTO dto) {
// 加密密码和Token
encryptSensitiveData(dto);
return super.create(dto);
}
@Override @Override
public ExternalSystemDTO update(Long id, ExternalSystemDTO dto) { public ExternalSystemDTO update(Long id, ExternalSystemDTO dto) {
// 先验证Git认证 // 先验证Git认证
validateGitAuth(dto); 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); 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 @Override
@Transactional @Transactional
public boolean testConnection(Long id) { public boolean testConnection(Long id) {
@ -144,12 +205,15 @@ public class ExternalSystemServiceImpl extends BaseServiceImpl<ExternalSystem, E
throw new BusinessException(ResponseCode.EXTERNAL_SYSTEM_DISABLED); throw new BusinessException(ResponseCode.EXTERNAL_SYSTEM_DISABLED);
} }
// 解密密码和Token用于连接测试
ExternalSystem decryptedSystem = decryptSystemForUse(system);
IExternalSystemIntegration integration = integrationMap.get(system.getType()); IExternalSystemIntegration integration = integrationMap.get(system.getType());
if (integration == null) { if (integration == null) {
throw new BusinessException(ResponseCode.EXTERNAL_SYSTEM_TYPE_NOT_SUPPORTED); throw new BusinessException(ResponseCode.EXTERNAL_SYSTEM_TYPE_NOT_SUPPORTED);
} }
boolean success = integration.testConnection(system); boolean success = integration.testConnection(decryptedSystem);
if (success) { if (success) {
system.setLastConnectTime(LocalDateTime.now()); system.setLastConnectTime(LocalDateTime.now());
externalSystemRepository.save(system); externalSystemRepository.save(system);
@ -250,4 +314,60 @@ public class ExternalSystemServiceImpl extends BaseServiceImpl<ExternalSystem, E
return instanceDTO; 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;
}
} }

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -100,3 +100,14 @@ jwt:
jackson: jackson:
time-zone: Asia/Shanghai 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}

View File

@ -100,3 +100,14 @@ jwt:
jackson: jackson:
time-zone: Asia/Shanghai 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}

View File

@ -542,8 +542,13 @@ VALUES (2, 304); -- 基础权限模板关联三方系统菜单
-- -------------------------------------------------------------------------------------- -- --------------------------------------------------------------------------------------
-- 初始化外部系统数据 -- 初始化外部系统数据
-- -------------------------------------------------------------------------------------- -- --------------------------------------------------------------------------------------
-- 注意password和token字段支持加密存储AES-256
-- 1. 初始化数据可以使用明文,首次编辑保存时会自动加密
-- 2. 表结构已扩展为VARCHAR(500)以容纳加密后的数据
-- 3. 生产环境建议通过管理界面添加外部系统避免在SQL中暴露明文密码
-- --------------------------------------------------------------------------------------
-- 初始化外部系统 -- 初始化外部系统(示例数据,生产环境请删除或修改)
INSERT INTO sys_external_system ( INSERT INTO sys_external_system (
id, create_by, create_time, deleted, update_by, update_time, version, id, create_by, create_time, deleted, update_by, update_time, version,
name, type, url, remark, sort, enabled, auth_type, username, password, token, name, type, url, remark, sort, enabled, auth_type, username, password, token,
@ -551,12 +556,12 @@ INSERT INTO sys_external_system (
) VALUES ( ) VALUES (
1, 'admin', '2023-12-01 00:00:00', 0, 'admin', '2023-12-01 00:00:00', 0, 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, '链宇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', '{}' '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, 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, '链宇GIT', 'GIT', 'https://ly-gitlab.iscmtech.com/', NULL, 1, 1,
'TOKEN', NULL, NULL, 'cNSud7D1GmYQKEMco7s5', 'TOKEN', NULL, NULL, 'cNSud7D1GmYQKEMco7s5', -- Token为明文首次编辑会自动加密
NULL, NULL, NULL, '{}' NULL, NULL, NULL, '{}'
); );

View File

@ -278,8 +278,8 @@ CREATE TABLE sys_external_system
enabled BIT NOT NULL DEFAULT 1 COMMENT '是否启用0禁用1启用', enabled BIT NOT NULL DEFAULT 1 COMMENT '是否启用0禁用1启用',
auth_type VARCHAR(50) NOT NULL COMMENT '认证方式BASIC/TOKEN/OAUTH等', auth_type VARCHAR(50) NOT NULL COMMENT '认证方式BASIC/TOKEN/OAUTH等',
username VARCHAR(100) NULL COMMENT '用户名', username VARCHAR(100) NULL COMMENT '用户名',
password VARCHAR(255) NULL COMMENT '密码', password VARCHAR(500) NULL COMMENT '密码加密存储AES-256',
token VARCHAR(255) NULL COMMENT '访问令牌', token VARCHAR(500) NULL COMMENT '访问令牌加密存储AES-256',
sync_status VARCHAR(50) NULL COMMENT '同步状态SUCCESS/FAILED/RUNNING', sync_status VARCHAR(50) NULL COMMENT '同步状态SUCCESS/FAILED/RUNNING',
last_sync_time DATETIME(6) NULL COMMENT '最后同步时间', last_sync_time DATETIME(6) NULL COMMENT '最后同步时间',
last_connect_time DATETIME(6) NULL COMMENT '最近连接成功时间', last_connect_time DATETIME(6) NULL COMMENT '最近连接成功时间',