【feat】 元数据变更拉取

This commit is contained in:
Kris 2026-01-03 20:23:46 +08:00
parent ea83fd6f0d
commit ecd34428de
4 changed files with 439 additions and 30 deletions

View File

@ -0,0 +1,245 @@
# 全对象元数据变更拉取接口文档
## 1. 接口概述
### 1.1 接口名称
全对象元数据变更拉取
### 1.2 接口描述
从Salesforce拉取所有对象的元数据变更信息并记录到元数据变更表中。该接口会自动检测对象和字段的新增、修改、删除操作并将变更记录到数据库中。
### 1.3 接口地址
```
POST /integration/change/pullAll
```
### 1.4 请求方式
POST
### 1.5 权限要求
需要 `integration:change:pullAll` 权限
### 1.6 日志记录
操作类型OTHER其他
日志标题:全对象元数据变更拉取
## 2. 请求参数
### 2.1 请求头
```
Content-Type: application/json
Authorization: Bearer {token}
```
### 2.2 请求体
该接口无需请求体参数
### 2.3 请求示例
```bash
curl -X POST "http://localhost:8080/integration/change/pullAll" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {your_token}"
```
## 3. 响应格式
### 3.1 响应结构
```json
{
"code": 200,
"msg": "操作成功",
"data": {
"success": true,
"objectChangeCount": 5,
"fieldChangeCount": 23,
"message": "全对象元数据变更拉取完成,对象变更: 5 个,字段变更: 23 个"
}
}
```
### 3.2 响应字段说明
| 字段名 | 类型 | 说明 |
|--------|------|------|
| code | Integer | 响应状态码200表示成功 |
| msg | String | 响应消息 |
| data | Object | 响应数据对象 |
| data.success | Boolean | 操作是否成功 |
| data.objectChangeCount | Integer | 对象变更数量 |
| data.fieldChangeCount | Integer | 字段变更数量 |
| data.message | String | 详细消息说明 |
### 3.3 成功响应示例
```json
{
"code": 200,
"msg": "操作成功",
"data": {
"success": true,
"objectChangeCount": 3,
"fieldChangeCount": 15,
"message": "全对象元数据变更拉取完成,对象变更: 3 个,字段变更: 15 个"
}
}
```
### 3.4 失败响应示例
```json
{
"code": 500,
"msg": "全对象元数据变更拉取失败: 无法连接到Salesforce",
"data": null
}
```
## 4. 业务逻辑说明
### 4.1 处理流程
1. **建立连接**使用重试机制最多3次每次间隔1秒建立与Salesforce的SOAP连接
2. **获取对象列表**从Salesforce获取所有对象的全局描述信息
3. **对象过滤**:只同步满足以下任一条件的对象:
- isQueryable可查询
- isCreateable可创建
- isUpdateable可更新
- isDeletable可删除
4. **对象变更检测**
- 新增对象:插入数据库并记录变更
- 修改对象:比较差异并记录变更
- 删除对象:检测并记录已删除的对象
5. **字段变更检测**
- 新增字段:记录字段新增变更
- 修改字段:比较字段属性差异并记录变更
- 删除字段:检测并记录已删除的字段
6. **增量更新处理**:如果检测到字段变更,自动禁用该对象的增量更新状态
7. **统计结果**:返回对象变更数量和字段变更数量
### 4.2 对象同步条件
只有满足以下任一条件的对象才会被同步:
- 可查询isQueryable = true
- 可创建isCreateable = true
- 可更新isUpdateable = true
- 可删除isDeletable = true
### 4.3 字段同步条件
所有字段都会被同步,无特殊限制
### 4.4 变更记录规则
- **对象变更**:记录到元数据变更表,变更类型为 "OBJECT"
- **字段变更**:记录到元数据变更表,变更类型为 "FIELD"
- **操作类型**INSERT新增、UPDATE修改、DELETE删除
- **同步状态**:默认为 false未同步
## 5. 错误处理
### 5.1 常见错误码
| 错误码 | 说明 | 处理建议 |
|--------|------|----------|
| 401 | 未授权 | 检查token是否有效 |
| 403 | 权限不足 | 确认用户是否具有 `integration:change:pullAll` 权限 |
| 500 | 服务器内部错误 | 查看服务器日志检查Salesforce连接配置 |
### 5.2 常见错误场景
#### 5.2.1 Salesforce连接失败
```
错误信息:全对象元数据变更拉取失败: 无法连接到Salesforce
原因SOAP连接配置错误或网络问题
解决方案检查SOAPConnectionFactory配置和网络连接
```
#### 5.2.2 权限不足
```
错误信息:您没有权限执行此操作
原因:用户缺少 `integration:change:pullAll` 权限
解决方案:联系管理员分配相应权限
```
#### 5.2.3 未获取到对象
```
错误信息未获取到任何Salesforce对象
原因Salesforce中无可访问对象或API权限不足
解决方案检查Salesforce API权限配置
```
## 6. 使用注意事项
### 6.1 性能考虑
- 该接口会遍历Salesforce中的所有对象和字段可能需要较长时间
- 建议在业务低峰期调用
- 避免频繁调用,建议按需触发
### 6.2 数据一致性
- 接口执行过程中会记录详细的变更日志
- 如果执行失败,已记录的变更不会回滚
- 建议在执行前备份重要数据
### 6.3 并发控制
- 不建议同时调用多个拉取接口
- 确保前一次拉取完成后再执行下一次拉取
### 6.4 监控建议
- 监控接口执行时间
- 监控变更数量,异常增长可能需要人工介入
- 定期检查同步状态,及时处理未同步的变更
## 7. 相关接口
### 7.1 查询元数据变更列表
```
GET /integration/change/list
```
### 7.2 查询未同步的元数据变更
```
GET /integration/change/unsynced
```
### 7.3 同步元数据变更到本地数据库
```
POST /integration/change/{id}/sync
```
### 7.4 批量同步元数据变更
```
POST /integration/change/syncBatch
```
### 7.5 获取变更统计信息
```
GET /integration/change/statistics
```
## 8. 附录
### 8.1 元数据变更表结构
| 字段名 | 类型 | 说明 |
|--------|------|------|
| id | Long | 主键ID |
| changeType | String | 变更类型OBJECT/FIELD |
| operationType | String | 操作类型INSERT/UPDATE/DELETE |
| objectApi | String | 对象API名称 |
| objectLabel | String | 对象标签名称 |
| fieldApi | String | 字段API名称字段变更时 |
| fieldLabel | String | 字段标签名称(字段变更时) |
| changeReason | String | 变更原因 |
| changeTime | LocalDateTime | 变更时间 |
| syncStatus | Boolean | 同步状态false-未同步true-已同步) |
| isCustom | Boolean | 是否为自定义对象 |
| changeUser | String | 变更用户 |
### 8.2 版本历史
| 版本 | 日期 | 说明 |
|------|------|------|
| 1.0 | 2025-12-27 | 初始版本 |
### 8.3 联系方式
如有问题,请联系技术支持团队。
---
**文档生成时间**2025-12-27
**最后更新时间**2025-12-27
**文档版本**1.0

View File

@ -51,3 +51,11 @@ export function delChange(id) {
method: 'delete'
})
}
// 全对象元数据变更拉取
export function pullAllChange() {
return request({
url: '/integration/change/pullAll',
method: 'post'
})
}

View File

@ -163,38 +163,101 @@
</el-descriptions-item>
</el-descriptions>
<div class="grid-layout">
<el-descriptions :column="2" border direction="vertical" class="mt-4">
<el-descriptions-item label="Instance URL">
<el-link :href="loginResult.instanceUrl" target="_blank" type="primary" :underline="false">
{{ loginResult.instanceUrl }} <el-icon><TopRight /></el-icon>
<el-divider content-position="left">
<el-icon><User /></el-icon>
</el-divider>
<el-descriptions :column="2" border class="mt-4">
<el-descriptions-item label="用户姓名">
<div class="user-info">
<el-icon class="user-icon"><User /></el-icon>
{{ loginResult.userFullName }}
</div>
</el-descriptions-item>
<el-descriptions-item label="用户邮箱">
<el-link :href="`mailto:${loginResult.userEmail}`" type="primary" :underline="false">
{{ loginResult.userEmail }}
</el-link>
</el-descriptions-item>
<el-descriptions-item label="用户 ID">
<span class="mono-text">{{ loginResult.userId }}</span>
</el-descriptions-item>
<el-descriptions-item label="组织名称">
<el-tag type="info" size="small" effect="light">
{{ loginResult.organizationName }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">
<el-icon><Monitor /></el-icon>
</el-divider>
<el-descriptions :column="2" border class="mt-4">
<el-descriptions-item label="Instance URL">
<el-link :href="loginResult.instanceUrl" target="_blank" type="primary" :underline="false">
{{ loginResult.instanceUrl }} <el-icon><TopRight /></el-icon>
</el-link>
</el-descriptions-item>
<el-descriptions-item label="Environment">
<el-tag :type="loginResult.sandbox ? 'warning' : 'success'" size="small" effect="light">
<el-icon><Monitor /></el-icon>
{{ loginResult.sandbox ? 'Sandbox' : 'Production' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Token Type">
<el-tag size="small" type="primary">{{ loginResult.tokenType }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="会话状态">
<el-tag :type="loginResult.sessionExpired ? 'danger' : 'success'" size="small" effect="light">
<el-icon><Switch /></el-icon>
{{ loginResult.sessionExpired ? '已过期' : '有效' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="有效时间">
<div class="expires-info">
<el-icon><Clock /></el-icon>
{{ loginResult.expiresIn ? formatExpiresIn(loginResult.expiresIn) : 'Unknown' }}
</div>
</el-descriptions-item>
<el-descriptions-item label="语言/时区">
<div class="locale-info">
<el-tag size="small" effect="plain">{{ loginResult.language }}</el-tag>
<span class="timezone-text">{{ loginResult.timeZone }}</span>
</div>
</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">
<el-icon><Link /></el-icon>
</el-divider>
<div class="metadata-link" v-if="loginResult.metadataServerUrl">
<el-descriptions :column="1" border>
<el-descriptions-item label="Metadata Server URL">
<el-link :href="loginResult.metadataServerUrl" target="_blank" type="info" :underline="false">
{{ loginResult.metadataServerUrl }} <el-icon><TopRight /></el-icon>
</el-link>
</el-descriptions-item>
<el-descriptions-item label="User ID">
<span class="mono-text">{{ loginResult.userId }}</span>
</el-descriptions-item>
<el-descriptions-item label="Org ID">
<span class="mono-text">{{ loginResult.organizationId }}</span>
</el-descriptions-item>
<el-descriptions-item label="Token Type">
<el-tag size="small">{{ loginResult.tokenType }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Environment">
<el-tag :type="loginResult.sandbox ? 'warning' : 'success'" size="small" effect="light">
{{ loginResult.sandbox ? 'Sandbox' : 'Production' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Expires In">
{{ loginResult.expiresIn ? loginResult.expiresIn + ' s' : 'Unknown' }}
</el-descriptions-item>
</el-descriptions>
</div>
<el-divider content-position="left">Metadata Server</el-divider>
<div class="metadata-link" v-if="loginResult.metadataServerUrl">
<el-link :href="loginResult.metadataServerUrl" target="_blank" type="info">
{{ loginResult.metadataServerUrl }}
</el-link>
<div v-if="loginResult.refreshToken" class="refresh-token-section">
<el-alert
title="Refresh Token Available"
type="info"
:closable="false"
show-icon
>
<template #default>
<div class="code-block">
{{ loginResult.refreshToken }}
<el-button link type="primary" @click="copyText(loginResult.refreshToken)">
<el-icon><DocumentCopy /></el-icon>
</el-button>
</div>
</template>
</el-alert>
</div>
</div>
</div>
@ -222,7 +285,7 @@
import { ElMessage, ElMessageBox } from 'element-plus';
import {
RefreshRight, User, SwitchButton, DocumentCopy, Switch,
Connection, Key, Lock, Ticket, Hide, Monitor, TopRight, ArrowDown, Link
Connection, Key, Lock, Ticket, Hide, Monitor, TopRight, ArrowDown, Link, Clock
} from '@element-plus/icons-vue';
const { proxy } = getCurrentInstance();
@ -478,6 +541,22 @@ const handleLoginTypeChange = () => {
}
};
const formatExpiresIn = (seconds) => {
if (!seconds) return 'Unknown';
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}`;
}
};
const copyText = (text) => {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制 Session ID'));
@ -633,6 +712,43 @@ const handleLoginTypeChange = () => {
line-height: 1.4;
}
/* 用户信息样式 */
.user-info {
display: flex;
align-items: center;
gap: 8px;
}
.user-icon {
color: #409eff;
font-size: 16px;
}
/* 过期信息样式 */
.expires-info {
display: flex;
align-items: center;
gap: 6px;
color: #606266;
}
/* 语言/时区样式 */
.locale-info {
display: flex;
align-items: center;
gap: 8px;
}
.timezone-text {
color: #606266;
font-size: 13px;
}
/* Refresh Token 区域样式 */
.refresh-token-section {
margin-top: 16px;
}
/* 响应式调整 */
@media screen and (max-width: 768px) {
.main-row {

View File

@ -117,6 +117,16 @@
v-hasPermi="['integration:change:export']"
>导出</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="Refresh"
:loading="pullLoading"
@click="handlePullAll"
v-hasPermi="['integration:change:pullAll']"
>变更拉取</el-button>
</el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
@ -498,12 +508,14 @@
</template>
<script setup name="Change">
import { listChange, getChange, delChange, addChange, updateChange } from "@/api/integration/change";
import { listChange, getChange, delChange, addChange, updateChange, pullAllChange } from "@/api/integration/change";
import { ElNotification } from 'element-plus';
const { proxy } = getCurrentInstance();
const changeList = ref([]);
const open = ref(false);
const loading = ref(true);
const pullLoading = ref(false);
const showSearch = ref(true);
const ids = ref([]);
const single = ref(true);
@ -743,5 +755,33 @@
}, `change_${new Date().getTime()}.xlsx`)
}
/** 全对象元数据变更拉取按钮操作 */
function handlePullAll() {
proxy.$modal.confirm('确认要执行全对象元数据变更拉取吗?此操作可能需要较长时间。').then(() => {
pullLoading.value = true;
const notification = ElNotification({
title: '正在拉取变更数据',
message: '正在从 Salesforce 拉取全对象元数据变更,请稍候...',
type: 'info',
duration: 0,
showClose: false
});
return pullAllChange().then(response => {
notification.close();
proxy.$modal.msgSuccess(response.data.message || '变更拉取成功');
getList();
}).catch(error => {
notification.close();
throw error;
});
}).catch(() => {
pullLoading.value = false;
}).finally(() => {
pullLoading.value = false;
});
}
getList();
</script>