Explorar o código

refactor: isolate database connector operations

马小龙 hai 2 días
pai
achega
bca758e461

+ 137 - 0
frontend/src/components/connectors/ConnectorOperations.vue

@@ -0,0 +1,137 @@
+<template>
+  <div>
+    <div class="d-flex justify-end mb-3">
+      <v-btn outlined color="primary" :loading="loading" @click="loadAll">刷新</v-btn>
+    </div>
+    <v-chip v-if="canManage" small color="primary" outlined class="mb-3">具备连接器管理权限</v-chip>
+    <v-row>
+      <v-col v-for="item in manifests" :key="`${item.connector_id}:${item.version}`" cols="12" md="4">
+        <v-card outlined height="100%">
+          <v-card-title>{{ item.display_name }}</v-card-title>
+          <v-card-subtitle>{{ item.connector_id }} · {{ item.version }} · SDK {{ item.sdk_version }}</v-card-subtitle>
+          <v-card-text>
+            <v-chip v-for="capability in item.capabilities" :key="capability" small outlined class="mr-1 mb-1">{{ capability }}</v-chip>
+            <div class="mt-3">兼容状态:<strong>{{ compatibility[`${item.connector_id}:${item.version}`] || '待检查' }}</strong></div>
+          </v-card-text>
+          <v-card-actions>
+            <v-btn text color="primary" @click="checkCompatibility(item)">兼容检查</v-btn>
+            <v-btn v-if="canOperate" text color="primary" @click="openDryRun(item)">Dry-run</v-btn>
+          </v-card-actions>
+        </v-card>
+      </v-col>
+    </v-row>
+    <v-card outlined class="mt-5">
+      <v-card-title>运行与检查点</v-card-title>
+      <v-data-table :headers="runHeaders" :items="runs" :loading="loading">
+        <template v-slot:[`item.error_category`]="{ item }">{{ item.error_category || '-' }}</template>
+        <template v-slot:[`item.run_type`]="{ item }">{{ isHumanDryRun(item) ? '人工 Dry-run' : '机器运行' }}</template>
+        <template v-slot:[`item.checkpoint_summary`]="{ item }"><code>{{ compact(item.checkpoint_summary) }}</code></template>
+        <template v-slot:[`item.cursor_summary`]="{ item }"><code>{{ compact(item.cursor_summary) }}</code></template>
+        <template v-slot:[`item.actions`]="{ item }">
+          <v-btn v-if="canOperate && isHumanDryRun(item) && item.status === 'running'" text small color="warning" @click="cancel(item)">取消</v-btn>
+          <v-btn v-if="canOperate && isHumanDryRun(item) && ['failed','cancelled'].includes(item.status)" text small color="primary" @click="resume(item)">恢复</v-btn>
+          <span v-if="!isHumanDryRun(item)" class="text--secondary">仅机器凭证可操作</span>
+        </template>
+      </v-data-table>
+    </v-card>
+    <v-card outlined class="mt-5">
+      <v-card-title>数据关系摘要</v-card-title>
+      <v-card-text>节点 {{ graph.node_count || 0 }} · 关系 {{ graph.edge_count || 0 }}</v-card-text>
+    </v-card>
+    <v-dialog v-model="dialog" max-width="620">
+      <v-card>
+        <v-card-title>连接器 Dry-run</v-card-title>
+        <v-card-text>
+          <v-text-field v-model.trim="form.source_uid" label="数据源 UID *" />
+          <v-text-field v-model.trim="form.credential_ref" label="秘密引用 *" hint="例如 env:DATAOPS_CONNECTOR_CREDENTIAL" persistent-hint />
+          <v-text-field v-if="mode === 'rest-catalog'" v-model.trim="form.base_url" label="HTTPS Catalog URL *" />
+          <v-text-field v-if="mode === 'rest-catalog'" v-model.trim="form.allowed_host" label="允许主机 *" />
+        </v-card-text>
+        <v-card-actions><v-spacer /><v-btn text @click="dialog=false">取消</v-btn><v-btn color="primary" @click="dryRun">执行</v-btn></v-card-actions>
+      </v-card>
+    </v-dialog>
+  </div>
+</template>
+
+<script>
+import { cancelConnectorRun, executeConnectorRun, getConnectorCompatibility, getConnectorManifests, getConnectorRuns, getDatasourceGraph, resumeConnectorRun } from '@/api/dataOrigin'
+
+const CONNECTOR_ID_PATTERN = /^[a-z][a-z0-9_-]{2,63}$/
+
+export default {
+  name: 'ConnectorOperations',
+  props: {
+    connectorIds: {
+      type: Array,
+      required: true,
+      validator: value => Array.isArray(value) && value.length > 0 && value.every(id => typeof id === 'string' && CONNECTOR_ID_PATTERN.test(id))
+    },
+    mode: {
+      type: String,
+      required: true,
+      validator: value => ['database', 'rest-catalog'].includes(value)
+    }
+  },
+  data: () => ({
+    loading: false,
+    manifests: [],
+    runs: [],
+    compatibility: {},
+    graph: {},
+    dialog: false,
+    selected: null,
+    form: { source_uid: '', credential_ref: 'env:DATAOPS_CONNECTOR_CREDENTIAL', base_url: '', allowed_host: '' },
+    runHeaders: [
+      { text: '连接器', value: 'connector_id' }, { text: '运行类型', value: 'run_type' }, { text: '操作', value: 'operation' }, { text: '状态', value: 'status' },
+      { text: '尝试', value: 'attempt_count' }, { text: '检查点摘要', value: 'checkpoint_summary' }, { text: '游标摘要', value: 'cursor_summary' }, { text: '错误类别', value: 'error_category' }, { text: '操作', value: 'actions', sortable: false }
+    ]
+  }),
+  computed: {
+    connectorPermissions () { return (this.$store.state.user.userInfo && this.$store.state.user.userInfo.permissions) || [] },
+    canRead () { return this.connectorPermissions.includes('connectors:read') },
+    canOperate () { return this.connectorPermissions.includes('connectors:operate') || this.canManage },
+    canManage () { return this.connectorPermissions.includes('connectors:manage') },
+    normalizedConnectorIds () {
+      if (!Array.isArray(this.connectorIds) || this.connectorIds.some(id => typeof id !== 'string' || !CONNECTOR_ID_PATTERN.test(id))) return []
+      return [...new Set(this.connectorIds)]
+    }
+  },
+  created () { this.loadAll() },
+  methods: {
+    compact (value) { const text = JSON.stringify(value || {}); return text.length > 100 ? `${text.slice(0, 100)}…` : text },
+    isHumanDryRun (item) { return item.dry_run === true && !item.principal_uid },
+    clearConnectorState () { this.manifests = []; this.runs = []; this.graph = {} },
+    async loadAll () {
+      this.clearConnectorState()
+      if (!this.normalizedConnectorIds.length) return
+      this.loading = true
+      try {
+        const allowedConnectorIds = new Set(this.normalizedConnectorIds)
+        const [manifests, runs, graph] = await Promise.all([getConnectorManifests(), getConnectorRuns(), getDatasourceGraph()])
+        this.manifests = (manifests.data.manifests || []).filter(item => allowedConnectorIds.has(item.connector_id))
+        this.runs = (runs.data.runs || []).filter(item => allowedConnectorIds.has(item.connector_id))
+        this.graph = graph.data.summary || {}
+      } catch (error) {
+        this.clearConnectorState()
+        this.$snackbar.error(error)
+      } finally {
+        this.loading = false
+      }
+    },
+    async checkCompatibility (item) { try { const { data } = await getConnectorCompatibility(item.connector_id, item.version); this.$set(this.compatibility, `${item.connector_id}:${item.version}`, data.compatible ? '兼容' : '不兼容') } catch (error) { this.$snackbar.error(error) } },
+    openDryRun (item) { this.selected = item; this.dialog = true },
+    async dryRun () {
+      if (!this.selected) return
+      const config = { credential_ref: this.form.credential_ref }
+      if (this.mode === 'rest-catalog') Object.assign(config, { base_url: this.form.base_url, allowed_host: this.form.allowed_host })
+      try {
+        await executeConnectorRun({ connector_id: this.selected.connector_id, version: this.selected.version, source_uid: this.form.source_uid, operation: 'discover', config, scope: {}, dry_run: true })
+        this.dialog = false
+        await this.loadAll()
+      } catch (error) { this.$snackbar.error(error) }
+    },
+    async cancel (item) { try { await cancelConnectorRun(item.idempotency_key); await this.loadAll() } catch (error) { this.$snackbar.error(error) } },
+    async resume (item) { try { await resumeConnectorRun(item.idempotency_key); await this.loadAll() } catch (error) { this.$snackbar.error(error) } }
+  }
+}
+</script>

+ 5 - 82
frontend/src/views/dataGovernance/development/enterpriseConnectors.vue

@@ -3,99 +3,22 @@
     <div class="d-flex align-start mb-5">
       <div>
         <h1 class="text-h4 mb-1">企业连接器</h1>
-        <p class="text--secondary mb-0">统一查看扩展契约、兼容性、运行检查点与关系摘要。</p>
+        <p class="text--secondary mb-0">统一查看数据库连接器的扩展契约、兼容性、运行检查点与关系摘要。</p>
       </div>
-      <v-spacer />
-      <v-btn outlined color="primary" :loading="loading" @click="loadAll">刷新</v-btn>
     </div>
     <v-alert type="warning" outlined>
       Oracle 与 SQL Server 已完成工程兼容验证,真实企业账号、版本、网络与 UAT 尚未提供。界面不会显示秘密或短期机器凭证。
     </v-alert>
-    <v-chip v-if="canManage" small color="primary" outlined class="mb-3">具备连接器管理权限</v-chip>
-    <v-row>
-      <v-col v-for="item in manifests" :key="`${item.connector_id}:${item.version}`" cols="12" md="4">
-        <v-card outlined height="100%">
-          <v-card-title>{{ item.display_name }}</v-card-title>
-          <v-card-subtitle>{{ item.connector_id }} · {{ item.version }} · SDK {{ item.sdk_version }}</v-card-subtitle>
-          <v-card-text>
-            <v-chip v-for="capability in item.capabilities" :key="capability" small outlined class="mr-1 mb-1">{{ capability }}</v-chip>
-            <div class="mt-3">兼容状态:<strong>{{ compatibility[`${item.connector_id}:${item.version}`] || '待检查' }}</strong></div>
-          </v-card-text>
-          <v-card-actions>
-            <v-btn text color="primary" @click="checkCompatibility(item)">兼容检查</v-btn>
-            <v-btn v-if="canOperate" text color="primary" @click="openDryRun(item)">Dry-run</v-btn>
-          </v-card-actions>
-        </v-card>
-      </v-col>
-    </v-row>
-    <v-card outlined class="mt-5">
-      <v-card-title>运行与检查点</v-card-title>
-      <v-data-table :headers="runHeaders" :items="runs" :loading="loading">
-        <template v-slot:[`item.error_category`]="{ item }">{{ item.error_category || '-' }}</template>
-        <template v-slot:[`item.run_type`]="{ item }">{{ isHumanDryRun(item) ? '人工 Dry-run' : '机器运行' }}</template>
-        <template v-slot:[`item.checkpoint_summary`]="{ item }"><code>{{ compact(item.checkpoint_summary) }}</code></template>
-        <template v-slot:[`item.cursor_summary`]="{ item }"><code>{{ compact(item.cursor_summary) }}</code></template>
-        <template v-slot:[`item.actions`]="{ item }">
-          <v-btn v-if="canOperate && isHumanDryRun(item) && item.status === 'running'" text small color="warning" @click="cancel(item)">取消</v-btn>
-          <v-btn v-if="canOperate && isHumanDryRun(item) && ['failed','cancelled'].includes(item.status)" text small color="primary" @click="resume(item)">恢复</v-btn>
-          <span v-if="!isHumanDryRun(item)" class="text--secondary">仅机器凭证可操作</span>
-        </template>
-      </v-data-table>
-    </v-card>
-    <v-card outlined class="mt-5">
-      <v-card-title>数据关系摘要</v-card-title>
-      <v-card-text>节点 {{ graph.node_count || 0 }} · 关系 {{ graph.edge_count || 0 }}</v-card-text>
-    </v-card>
-    <v-dialog v-model="dialog" max-width="620">
-      <v-card>
-        <v-card-title>连接器 Dry-run</v-card-title>
-        <v-card-text>
-          <v-text-field v-model.trim="form.source_uid" label="数据源 UID *" />
-          <v-text-field v-model.trim="form.credential_ref" label="秘密引用 *" hint="例如 env:DATAOPS_CONNECTOR_CREDENTIAL" persistent-hint />
-          <v-text-field v-if="selected && selected.connector_id === 'rest-catalog'" v-model.trim="form.base_url" label="HTTPS Catalog URL *" />
-          <v-text-field v-if="selected && selected.connector_id === 'rest-catalog'" v-model.trim="form.allowed_host" label="允许主机 *" />
-        </v-card-text>
-        <v-card-actions><v-spacer /><v-btn text @click="dialog=false">取消</v-btn><v-btn color="primary" @click="dryRun">执行</v-btn></v-card-actions>
-      </v-card>
-    </v-dialog>
+    <v-alert type="info" outlined>当前开放范围仅限数据库访问。文件目录、对象存储、API 与消息系统等来源将在后续版本扩展开发。</v-alert>
+    <connector-operations :connector-ids="['oracle', 'postgresql', 'sqlserver']" mode="database" />
   </div>
 </template>
 
 <script>
-import { cancelConnectorRun, executeConnectorRun, getConnectorCompatibility, getConnectorManifests, getConnectorRuns, getDatasourceGraph, resumeConnectorRun } from '@/api/dataOrigin'
+import ConnectorOperations from '@/components/connectors/ConnectorOperations'
 
 export default {
   name: 'EnterpriseConnectors',
-  data: () => ({
-    loading: false,
-    manifests: [],
-    runs: [],
-    compatibility: {},
-    graph: {},
-    dialog: false,
-    selected: null,
-    form: { source_uid: '', credential_ref: 'env:DATAOPS_CONNECTOR_CREDENTIAL', base_url: '', allowed_host: '' },
-    runHeaders: [
-      { text: '连接器', value: 'connector_id' }, { text: '运行类型', value: 'run_type' }, { text: '操作', value: 'operation' }, { text: '状态', value: 'status' },
-      { text: '尝试', value: 'attempt_count' }, { text: '检查点摘要', value: 'checkpoint_summary' }, { text: '游标摘要', value: 'cursor_summary' }, { text: '错误类别', value: 'error_category' }, { text: '操作', value: 'actions', sortable: false }
-    ]
-  }),
-  computed: {
-    connectorPermissions () { return (this.$store.state.user.userInfo && this.$store.state.user.userInfo.permissions) || [] },
-    canRead () { return this.connectorPermissions.includes('connectors:read') },
-    canOperate () { return this.connectorPermissions.includes('connectors:operate') || this.canManage },
-    canManage () { return this.connectorPermissions.includes('connectors:manage') }
-  },
-  created () { this.loadAll() },
-  methods: {
-    compact (value) { const text = JSON.stringify(value || {}); return text.length > 100 ? `${text.slice(0, 100)}…` : text },
-    isHumanDryRun (item) { return item.dry_run === true && !item.principal_uid },
-    async loadAll () { this.loading = true; try { const [manifests, runs, graph] = await Promise.all([getConnectorManifests(), getConnectorRuns(), getDatasourceGraph()]); this.manifests = manifests.data.manifests; this.runs = runs.data.runs; this.graph = graph.data.summary || {} } catch (error) { this.$snackbar.error(error) } finally { this.loading = false } },
-    async checkCompatibility (item) { try { const { data } = await getConnectorCompatibility(item.connector_id, item.version); this.$set(this.compatibility, `${item.connector_id}:${item.version}`, data.compatible ? '兼容' : '不兼容') } catch (error) { this.$snackbar.error(error) } },
-    openDryRun (item) { this.selected = item; this.dialog = true },
-    async dryRun () { const config = { credential_ref: this.form.credential_ref }; if (this.selected.connector_id === 'rest-catalog') Object.assign(config, { base_url: this.form.base_url, allowed_host: this.form.allowed_host }); try { await executeConnectorRun({ connector_id: this.selected.connector_id, version: this.selected.version, source_uid: this.form.source_uid, operation: 'discover', config, scope: {}, dry_run: true }); this.dialog = false; this.loadAll() } catch (error) { this.$snackbar.error(error) } },
-    async cancel (item) { try { await cancelConnectorRun(item.idempotency_key); this.loadAll() } catch (error) { this.$snackbar.error(error) } },
-    async resume (item) { try { await resumeConnectorRun(item.idempotency_key); this.loadAll() } catch (error) { this.$snackbar.error(error) } }
-  }
+  components: { ConnectorOperations }
 }
 </script>

+ 61 - 0
tests/test_enterprise_connector_metadata_frontend_contract.py

@@ -0,0 +1,61 @@
+from pathlib import Path
+import re
+
+
+ROOT = Path(__file__).resolve().parents[1]
+COMPONENT = ROOT / "frontend/src/components/connectors/ConnectorOperations.vue"
+DATABASE_PAGE = ROOT / "frontend/src/views/dataGovernance/development/enterpriseConnectors.vue"
+
+
+def _method_block(source: str, name: str) -> str:
+    match = re.search(rf"(?s)\b{name}\s*\([^)]*\)\s*\{{(.*?)(?=\n\s{{4}}\w+\s*\(|\n\s{{2}}\}}\n\}})", source)
+    assert match, f"{name} method was not found"
+    return match.group(0)
+
+
+def test_connector_operations_has_fail_closed_allowlist_for_manifests_and_runs():
+    assert COMPONENT.is_file(), "ConnectorOperations shared surface is missing"
+    source = COMPONENT.read_text(encoding="utf-8")
+
+    assert re.search(
+        r"connectorIds:\s*\{\s*type:\s*Array,\s*required:\s*true,.*?validator:",
+        source,
+        re.S,
+    )
+    assert "/^[a-z][a-z0-9_-]{2,63}$/" in source
+    assert re.search(
+        r"mode:\s*\{\s*type:\s*String,\s*required:\s*true,.*?database.*?rest-catalog",
+        source,
+        re.S,
+    )
+
+    normalized = _method_block(source, "normalizedConnectorIds")
+    assert "Array.isArray(this.connectorIds)" in normalized
+    assert "new Set" in normalized
+    assert "return []" in normalized
+
+    load_all = _method_block(source, "loadAll")
+    empty_guard = load_all.index("if (!this.normalizedConnectorIds.length)")
+    first_api_call = load_all.index("getConnectorManifests()")
+    assert empty_guard < first_api_call
+    assert "this.clearConnectorState()" in load_all[:first_api_call]
+    clear_state = _method_block(source, "clearConnectorState")
+    assert "this.manifests = []" in clear_state
+    assert "this.runs = []" in clear_state
+    assert "this.graph = {}" in clear_state
+    assert "return" in load_all[empty_guard:first_api_call]
+
+    assert "const allowedConnectorIds = new Set(this.normalizedConnectorIds)" in load_all
+    assert "this.manifests = (manifests.data.manifests || []).filter(item => allowedConnectorIds.has(item.connector_id))" in load_all
+    assert "this.runs = (runs.data.runs || []).filter(item => allowedConnectorIds.has(item.connector_id))" in load_all
+
+
+def test_database_wrapper_is_database_only_and_keeps_scope_warning():
+    source = DATABASE_PAGE.read_text(encoding="utf-8")
+
+    assert "rest-catalog" not in source
+    assert "当前开放范围仅限数据库访问。文件目录、对象存储、API 与消息系统等来源将在后续版本扩展开发。" in source
+    assert re.search(
+        r"<connector-operations\s+:connector-ids=\"\['oracle', 'postgresql', 'sqlserver'\]\"\s+mode=\"database\"\s*/>",
+        source,
+    )

+ 9 - 6
tests/test_phase3_wp03_enterprise_connectors.py

@@ -1413,15 +1413,18 @@ def test_migration_permissions_frontend_docs_and_release_parity_contract():
     page = (
         ROOT / "frontend/src/views/dataGovernance/development/enterpriseConnectors.vue"
     ).read_text()
+    connector_operations = (
+        ROOT / "frontend/src/components/connectors/ConnectorOperations.vue"
+    ).read_text()
     assert "真实企业账号、版本、网络与 UAT 尚未提供" in page
     assert "{{ item.credential" not in page and "{{ credential" not in page
-    assert "$store.state.user.userInfo.permissions" in page
-    assert "$store.getters.roles" not in page
+    assert "$store.state.user.userInfo.permissions" in connector_operations
+    assert "$store.getters.roles" not in connector_operations
     for permission in ("connectors:read", "connectors:operate", "connectors:manage"):
-        assert permission in page
-    assert "isHumanDryRun(item)" in page and 'v-if="canManage"' in page
-    assert "checkpoint_summary" in page and "cursor_summary" in page
-    assert "仅机器凭证可操作" in page
+        assert permission in connector_operations
+    assert "isHumanDryRun(item)" in connector_operations and 'v-if="canManage"' in connector_operations
+    assert "checkpoint_summary" in connector_operations and "cursor_summary" in connector_operations
+    assert "仅机器凭证可操作" in connector_operations
     assert "human connector runs must use dry_run=true" in api
     connector_files = [
         path.relative_to(ROOT).as_posix()