From f7b136a808a4639f0ed460c6226d27ae6d29ec2e Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 00:36:07 -0400 Subject: [PATCH 01/34] fix(ui): distribute header menu across the available width The header bar and container were nested flex containers whose children shrank to content width, packing the menu and the right cluster to the left; stretch them full width and distribute the menu items evenly --- .../src/views/layouts/common/components/LayoutHeader.vue | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/core/datacap-ui/src/views/layouts/common/components/LayoutHeader.vue b/core/datacap-ui/src/views/layouts/common/components/LayoutHeader.vue index d2e85d03b4..897a39fe71 100644 --- a/core/datacap-ui/src/views/layouts/common/components/LayoutHeader.vue +++ b/core/datacap-ui/src/views/layouts/common/components/LayoutHeader.vue @@ -9,8 +9,8 @@
-
-
+
+
DataCap @@ -310,7 +310,8 @@ const handleNotificationClick = (msg: any) => { .dc-header__menu.ant-menu-horizontal { display: flex; align-items: center; - justify-content: flex-start; + justify-content: space-evenly; + width: 100%; flex-wrap: nowrap; min-width: 0; border-bottom: none; @@ -319,7 +320,7 @@ const handleNotificationClick = (msg: any) => { } .dc-header__menu :deep(.ant-menu-overflow) { - justify-content: flex-start; + justify-content: space-evenly; } .dc-header__menu :deep(.ant-menu-overflow-item) { From 42de6144c4649dd5189f5b1c57be0e11fb71b66b Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 00:47:02 -0400 Subject: [PATCH 02/34] feat(ui): redesign notification settings page - rebuild the page with a hero header, a channel card (in-app plus installed notify plugins) with enabled/disabled status and quick enable, and a scenario card whose switches apply across all enabled channels - add a batch settings dialog (channels x scenarios) matching the prototype and per-change auto save through changeNotify - drop the old wrapper card and per-channel configure modal; add the notification i18n keys for en and zh-cn --- .../etc/conf/i18n/messages_en.properties | 23 +- .../etc/conf/i18n/messages_zh-cn.properties | 23 +- .../pages/admin/user/profile/NotifyHome.vue | 8 +- .../profile/components/NotifyConfigure.vue | 51 -- .../user/profile/components/NotifyForm.vue | 495 +++++++++++++++--- 5 files changed, 464 insertions(+), 136 deletions(-) delete mode 100644 core/datacap-ui/src/views/pages/admin/user/profile/components/NotifyConfigure.vue diff --git a/configure/etc/conf/i18n/messages_en.properties b/configure/etc/conf/i18n/messages_en.properties index 3cd3f797e8..eeeea8083e 100644 --- a/configure/etc/conf/i18n/messages_en.properties +++ b/configure/etc/conf/i18n/messages_en.properties @@ -796,4 +796,25 @@ user.common.lastLogin=Last Login user.tip.fromIp=From {ip} user.common.stateNormal=Normal -common.help=Help \ No newline at end of file +common.help=Help +notify.tip.page=Choose the notification types and channels you want to receive and stay informed of important updates. +notify.common.channels=Notification Channels +notify.tip.channels=Choose how you receive notifications +notify.common.scenarios=Trigger Scenarios +notify.tip.scenarios=Choose what content you want to be notified about +notify.common.batch=Batch Settings +notify.tip.batch=Apply the selected scenarios to all enabled channels in batch +notify.common.sendChannels=Send Channels +notify.common.apply=Apply +notify.common.enable=Enable +notify.state.enabled=Enabled +notify.state.disabled=Not Enabled +notify.tip.internal=Receive via the in-app message center +notify.tip.dingtalk=Receive notifications via the DingTalk bot +notify.tip.channelFallback=Receive notifications via this channel +notify.text.dynamic=Activity +notify.tip.created=Notify when datasets are created +notify.tip.updated=Notify when datasets are updated +notify.tip.deleted=Notify when datasets are deleted +notify.tip.dynamic=Notify when new activities are generated +notify.tip.syncData=Notify when data synchronization completes diff --git a/configure/etc/conf/i18n/messages_zh-cn.properties b/configure/etc/conf/i18n/messages_zh-cn.properties index f02ba93b60..bb0b3ba44c 100644 --- a/configure/etc/conf/i18n/messages_zh-cn.properties +++ b/configure/etc/conf/i18n/messages_zh-cn.properties @@ -796,4 +796,25 @@ user.common.lastLogin=\u6700\u8FD1\u767B\u5F55 user.tip.fromIp=\u6765\u81EA {ip} user.common.stateNormal=\u6B63\u5E38 -common.help=\u5E2E\u52A9 \ No newline at end of file +common.help=\u5E2E\u52A9 +notify.tip.page=\u9009\u62E9\u4F60\u5E0C\u671B\u63A5\u6536\u7684\u901A\u77E5\u7C7B\u578B\u548C\u6E20\u9053\uFF0C\u53CA\u65F6\u4E86\u89E3\u91CD\u8981\u52A8\u6001 +notify.common.channels=\u901A\u77E5\u6E20\u9053 +notify.tip.channels=\u9009\u62E9\u63A5\u6536\u901A\u77E5\u7684\u65B9\u5F0F +notify.common.scenarios=\u89E6\u53D1\u573A\u666F +notify.tip.scenarios=\u9009\u62E9\u4F60\u5E0C\u671B\u63A5\u6536\u7684\u901A\u77E5\u5185\u5BB9 +notify.common.batch=\u6279\u91CF\u8BBE\u7F6E +notify.tip.batch=\u5C06\u6240\u9009\u573A\u666F\u6279\u91CF\u5E94\u7528\u5230\u5DF2\u542F\u7528\u7684\u6E20\u9053 +notify.common.sendChannels=\u53D1\u9001\u6E20\u9053 +notify.common.apply=\u5E94\u7528 +notify.common.enable=\u5F00\u542F +notify.state.enabled=\u5DF2\u5F00\u542F +notify.state.disabled=\u672A\u5F00\u542F +notify.tip.internal=\u901A\u8FC7\u7CFB\u7EDF\u5185\u6D88\u606F\u4E2D\u5FC3\u63A5\u6536 +notify.tip.dingtalk=\u901A\u8FC7\u9489\u9489\u673A\u5668\u4EBA\u63A5\u6536\u901A\u77E5 +notify.tip.channelFallback=\u901A\u8FC7\u8BE5\u6E20\u9053\u63A5\u6536\u901A\u77E5 +notify.text.dynamic=\u52A8\u6001 +notify.tip.created=\u6570\u636E\u96C6\u521B\u5EFA\u65F6\u53D1\u9001\u901A\u77E5 +notify.tip.updated=\u6570\u636E\u96C6\u66F4\u65B0\u65F6\u53D1\u9001\u901A\u77E5 +notify.tip.deleted=\u6570\u636E\u96C6\u5220\u9664\u65F6\u53D1\u9001\u901A\u77E5 +notify.tip.dynamic=\u4EA7\u751F\u65B0\u52A8\u6001\u65F6\u53D1\u9001\u901A\u77E5 +notify.tip.syncData=\u6570\u636E\u540C\u6B65\u5B8C\u6210\u540E\u53D1\u9001\u901A\u77E5 diff --git a/core/datacap-ui/src/views/pages/admin/user/profile/NotifyHome.vue b/core/datacap-ui/src/views/pages/admin/user/profile/NotifyHome.vue index d336ee7187..05f306dda5 100644 --- a/core/datacap-ui/src/views/pages/admin/user/profile/NotifyHome.vue +++ b/core/datacap-ui/src/views/pages/admin/user/profile/NotifyHome.vue @@ -1,11 +1,5 @@ diff --git a/core/datacap-ui/src/views/pages/admin/user/profile/components/NotifyForm.vue b/core/datacap-ui/src/views/pages/admin/user/profile/components/NotifyForm.vue index 0e5c94895e..1ea2f6f73a 100644 --- a/core/datacap-ui/src/views/pages/admin/user/profile/components/NotifyForm.vue +++ b/core/datacap-ui/src/views/pages/admin/user/profile/components/NotifyForm.vue @@ -1,57 +1,196 @@ + + From 61e53c83cde7f674ee0c234200d2131ef6a11c0c Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 01:04:06 -0400 Subject: [PATCH 03/34] feat(ui): rebuild notification settings with real channels and scenarios - channel tiles render the actual notifyConfigure entries (in-app plus installed notify plugins) instead of invented email/sms channels - scenario rows map to the server enum (created/updated/deleted/ dynamic/syncdata) with a per-scenario channel picker in the gear dialog and a batch dialog applying channels x scenarios - rename the in-app channel label and clean up unused i18n keys --- .../etc/conf/i18n/messages_en.properties | 3 +- .../etc/conf/i18n/messages_zh-cn.properties | 5 +- .../user/profile/components/NotifyForm.vue | 154 ++++++++++++------ 3 files changed, 110 insertions(+), 52 deletions(-) diff --git a/configure/etc/conf/i18n/messages_en.properties b/configure/etc/conf/i18n/messages_en.properties index eeeea8083e..07167d263c 100644 --- a/configure/etc/conf/i18n/messages_en.properties +++ b/configure/etc/conf/i18n/messages_en.properties @@ -803,7 +803,7 @@ notify.tip.channels=Choose how you receive notifications notify.common.scenarios=Trigger Scenarios notify.tip.scenarios=Choose what content you want to be notified about notify.common.batch=Batch Settings -notify.tip.batch=Apply the selected scenarios to all enabled channels in batch +notify.tip.batch=Apply the selected scenarios to the selected channels notify.common.sendChannels=Send Channels notify.common.apply=Apply notify.common.enable=Enable @@ -818,3 +818,4 @@ notify.tip.updated=Notify when datasets are updated notify.tip.deleted=Notify when datasets are deleted notify.tip.dynamic=Notify when new activities are generated notify.tip.syncData=Notify when data synchronization completes + diff --git a/configure/etc/conf/i18n/messages_zh-cn.properties b/configure/etc/conf/i18n/messages_zh-cn.properties index bb0b3ba44c..fb925e87b8 100644 --- a/configure/etc/conf/i18n/messages_zh-cn.properties +++ b/configure/etc/conf/i18n/messages_zh-cn.properties @@ -756,7 +756,7 @@ workflow.tip.restartSuccess=\u91CD\u542F\u5DE5\u4F5C\u6D41 [ $VALUE ] \u6210\u52 notify.text.center=\u901A\u77E5\u4E2D\u5FC3 notify.text.markAsRead=\u6807\u8BB0\u4E3A\u5DF2\u8BFB notify.text.delete=\u5220\u9664\u901A\u77E5 -notify.text.internal=\u5185\u90E8\u901A\u77E5 +notify.text.internal=\u7AD9\u5185\u901A\u77E5 notify.text.dingtalk=\u9489\u9489\u901A\u77E5 notify.text.created=\u521B\u5EFA notify.text.updated=\u66F4\u65B0 @@ -803,7 +803,7 @@ notify.tip.channels=\u9009\u62E9\u63A5\u6536\u901A\u77E5\u7684\u65B9\u5F0F notify.common.scenarios=\u89E6\u53D1\u573A\u666F notify.tip.scenarios=\u9009\u62E9\u4F60\u5E0C\u671B\u63A5\u6536\u7684\u901A\u77E5\u5185\u5BB9 notify.common.batch=\u6279\u91CF\u8BBE\u7F6E -notify.tip.batch=\u5C06\u6240\u9009\u573A\u666F\u6279\u91CF\u5E94\u7528\u5230\u5DF2\u542F\u7528\u7684\u6E20\u9053 +notify.tip.batch=\u5C06\u6240\u9009\u89E6\u53D1\u573A\u666F\u5E94\u7528\u5230\u9009\u4E2D\u7684\u53D1\u9001\u6E20\u9053 notify.common.sendChannels=\u53D1\u9001\u6E20\u9053 notify.common.apply=\u5E94\u7528 notify.common.enable=\u5F00\u542F @@ -818,3 +818,4 @@ notify.tip.updated=\u6570\u636E\u96C6\u66F4\u65B0\u65F6\u53D1\u9001\u901A\u77E5 notify.tip.deleted=\u6570\u636E\u96C6\u5220\u9664\u65F6\u53D1\u9001\u901A\u77E5 notify.tip.dynamic=\u4EA7\u751F\u65B0\u52A8\u6001\u65F6\u53D1\u9001\u901A\u77E5 notify.tip.syncData=\u6570\u636E\u540C\u6B65\u5B8C\u6210\u540E\u53D1\u9001\u901A\u77E5 + diff --git a/core/datacap-ui/src/views/pages/admin/user/profile/components/NotifyForm.vue b/core/datacap-ui/src/views/pages/admin/user/profile/components/NotifyForm.vue index 1ea2f6f73a..f243d210e6 100644 --- a/core/datacap-ui/src/views/pages/admin/user/profile/components/NotifyForm.vue +++ b/core/datacap-ui/src/views/pages/admin/user/profile/components/NotifyForm.vue @@ -53,7 +53,7 @@
- +
@@ -64,7 +64,7 @@
{{ $t('notify.tip.scenarios') }}
@@ -157,11 +182,7 @@ const loading = ref(false) const saving = ref(false) const formState = reactive<{ notifyConfigure: any[] }>({ notifyConfigure: [] }) -const batchVisible = ref(false) -const batchChannels = ref([]) -const batchScenarios = ref([]) - -/** 触发场景定义(通知枚举:CREATED/UPDATED/DELETED/DYNAMIC/SYNCDATA) */ +/** 触发场景(服务端枚举:CREATED/UPDATED/DELETED/DYNAMIC/SYNCDATA) */ const scenarios = [ { key: 'CREATED', icon: PlusCircleOutlined, nameKey: 'notify.text.created', tipKey: 'notify.tip.created' }, { key: 'UPDATED', icon: EditOutlined, nameKey: 'notify.text.updated', tipKey: 'notify.tip.updated' }, @@ -171,13 +192,13 @@ const scenarios = [ ] const channels = computed(() => formState.notifyConfigure) -const hasEnabledChannel = computed(() => formState.notifyConfigure.some(channel => channel.enabled)) +const batchVisible = ref(false) +const batchChannels = ref([]) +const batchScenarios = ref([]) -const isScenarioOn = (key: string): boolean => { - return formState.notifyConfigure - .filter(channel => channel.enabled) - .some(channel => (channel.services || []).includes(key)) -} +const scenarioVisible = ref(false) +const scenarioKey = ref('') +const scenarioChannels = ref([]) const channelIcon = (type: string): any => { if (type === 'Internal') { @@ -189,8 +210,26 @@ const channelIcon = (type: string): any => { return ApiOutlined } -const channelName = (type: string): string => t(`notify.text.${ type.toLowerCase() }`) -const channelDesc = (type: string): string => t(`notify.tip.${ type.toLowerCase() }`) +const channelName = (type: string): string => { + const key = `notify.text.${ type.toLowerCase() }` + const value = t(key) + return value === key ? type : value +} + +const channelDesc = (type: string): string => { + const key = `notify.tip.${ type.toLowerCase() }` + const value = t(key) + return value === key ? t('notify.tip.channelFallback') : value +} + +const scenarioName = (key: string): string => { + const scenario = scenarios.find(item => item.key === key) + return scenario ? t(scenario.nameKey) : key +} + +const isScenarioOn = (key: string): boolean => { + return channels.value.some(channel => (channel.services || []).includes(key)) +} const handlerInitialize = () => { loading.value = true @@ -204,7 +243,7 @@ const handlerInitialize = () => { plugin.data .filter((v: { type: string }) => v.type === 'NOTIFY') .forEach((item: { name: string }) => { - if (item?.name) { + if (item?.name && !formState.notifyConfigure.some(c => c.type === item.name)) { formState.notifyConfigure.push({ type: item.name, enabled: false, services: [] }) } }) @@ -212,21 +251,17 @@ const handlerInitialize = () => { if (infoResponse.status && infoResponse.data) { const serverConfigure = infoResponse.data.notifyConfigure - if (serverConfigure && Array.isArray(serverConfigure) && serverConfigure.length > 0) { - const serverConfigMap: Record = {} - serverConfigure.forEach((config: any) => { - if (config?.type) { - serverConfigMap[config.type] = config - } - }) - - formState.notifyConfigure = formState.notifyConfigure.map(localConfig => { - const server = serverConfigMap[localConfig.type] - return server ? { ...server, services: server.services || [] } : localConfig - }) - + if (serverConfigure && Array.isArray(serverConfigure)) { serverConfigure.forEach((serverConfig: any) => { - if (serverConfig?.type && !formState.notifyConfigure.some(c => c.type === serverConfig.type)) { + if (!serverConfig?.type) { + return + } + const exists = formState.notifyConfigure.find(c => c.type === serverConfig.type) + if (exists) { + exists.enabled = !!serverConfig.enabled + exists.services = serverConfig.services || [] + } + else { formState.notifyConfigure.push({ ...serverConfig, services: serverConfig.services || [] }) } }) @@ -259,24 +294,22 @@ const toggleChannel = (channel: any, enabled: boolean) => { } const toggleScenario = (key: string, checked: boolean) => { - formState.notifyConfigure - .filter(channel => channel.enabled) - .forEach(channel => { - const services = new Set(channel.services || []) - if (checked) { - services.add(key) - } - else { - services.delete(key) - } - channel.services = Array.from(services) - }) + formState.notifyConfigure.forEach(channel => { + const services = new Set(channel.services || []) + if (checked) { + services.add(key) + } + else { + services.delete(key) + } + channel.services = Array.from(services) + }) save() } const openBatch = () => { - batchChannels.value = formState.notifyConfigure.filter(c => c.enabled).map(c => c.type) - batchScenarios.value = scenarios.filter(s => isScenarioOn(s.key)).map(s => s.key) + batchChannels.value = channels.value.map(channel => channel.type) + batchScenarios.value = scenarios.filter(scenario => isScenarioOn(scenario.key)).map(scenario => scenario.key) batchVisible.value = true } @@ -291,6 +324,29 @@ const applyBatch = () => { save() } +const openScenarioChannels = (key: string) => { + scenarioKey.value = key + scenarioChannels.value = channels.value + .filter(channel => (channel.services || []).includes(key)) + .map(channel => channel.type) + scenarioVisible.value = true +} + +const applyScenarioChannels = () => { + formState.notifyConfigure.forEach(channel => { + const services = new Set(channel.services || []) + if (scenarioChannels.value.includes(channel.type)) { + services.add(scenarioKey.value) + } + else { + services.delete(scenarioKey.value) + } + channel.services = Array.from(services) + }) + scenarioVisible.value = false + save() +} + handlerInitialize() @@ -370,7 +426,7 @@ handlerInitialize() .dc-notify__channel-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; } From 34fb9018d1269481fa48804fdbd16439ec769d4e Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 01:08:03 -0400 Subject: [PATCH 04/34] fix(ui): filter and clean polluted notification channel entries Earlier experiments persisted invented channel entries (email, sms and domain types) into the user notifyConfigure; only keep the built-in in-app channel and installed notify plugins, silently writing the cleaned list back --- .../user/profile/components/NotifyForm.vue | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/core/datacap-ui/src/views/pages/admin/user/profile/components/NotifyForm.vue b/core/datacap-ui/src/views/pages/admin/user/profile/components/NotifyForm.vue index f243d210e6..a627d2ecb0 100644 --- a/core/datacap-ui/src/views/pages/admin/user/profile/components/NotifyForm.vue +++ b/core/datacap-ui/src/views/pages/admin/user/profile/components/NotifyForm.vue @@ -239,11 +239,13 @@ const handlerInitialize = () => { .then(axios.spread((infoResponse: any, plugin: any) => { formState.notifyConfigure = [{ type: 'Internal', enabled: false, services: [] }] + const pluginNames: string[] = [] if (plugin.status && plugin.data && Array.isArray(plugin.data)) { plugin.data .filter((v: { type: string }) => v.type === 'NOTIFY') .forEach((item: { name: string }) => { - if (item?.name && !formState.notifyConfigure.some(c => c.type === item.name)) { + if (item?.name && !pluginNames.includes(item.name)) { + pluginNames.push(item.name) formState.notifyConfigure.push({ type: item.name, enabled: false, services: [] }) } }) @@ -266,6 +268,16 @@ const handlerInitialize = () => { } }) } + + // 清理历史脏数据:只保留内置渠道与已安装的通知插件 + const validTypes = new Set(formState.notifyConfigure + .filter(channel => channel.type === 'Internal' || pluginNames.includes(channel.type)) + .map(channel => channel.type)) + const removed = formState.notifyConfigure.filter(channel => !validTypes.has(channel.type)) + formState.notifyConfigure = formState.notifyConfigure.filter(channel => validTypes.has(channel.type)) + if (removed.length > 0) { + save(true) + } } else if (infoResponse.message) { message.error(infoResponse.message) @@ -274,12 +286,14 @@ const handlerInitialize = () => { .finally(() => (loading.value = false)) } -const save = () => { +const save = (silent = false) => { saving.value = true UserService.changeNotify(formState) .then(response => { if (response.status) { - message.success(t('common.successfully') as string) + if (!silent) { + message.success(t('common.successfully') as string) + } } else { message.error(response.message) From 1628b002a628d5e5d0c9cfec3b85aec71da7e04f Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 01:11:31 -0400 Subject: [PATCH 05/34] fix(ui): align settings layout with prototype spacing Move the breadcrumb into the content column so the sidebar card top aligns with it, and widen the header-to-content gap --- core/datacap-ui/src/views/layouts/profile/LayoutContainer.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/datacap-ui/src/views/layouts/profile/LayoutContainer.vue b/core/datacap-ui/src/views/layouts/profile/LayoutContainer.vue index f093eb82fa..32babed5d7 100644 --- a/core/datacap-ui/src/views/layouts/profile/LayoutContainer.vue +++ b/core/datacap-ui/src/views/layouts/profile/LayoutContainer.vue @@ -2,11 +2,11 @@ -
- +
+ From e55ea1cb21563174c0f0c5a8f906c22fe3b04fb6 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 01:14:24 -0400 Subject: [PATCH 06/34] build(ui): require node 20 or later for the frontend Bump the ci ui job and the frontend-maven-plugin node download to 20.x and declare engines >=20 in package.json --- .github/workflows/bofore_checker.yml | 4 ++-- core/datacap-server/pom.xml | 2 +- core/datacap-ui/package.json | 5 ++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/bofore_checker.yml b/.github/workflows/bofore_checker.yml index 87b3551554..9d0b14f12b 100644 --- a/.github/workflows/bofore_checker.yml +++ b/.github/workflows/bofore_checker.yml @@ -21,10 +21,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 - - name: Set Node.js 18.x + - name: Set Node.js 20.x uses: actions/setup-node@v3 with: - node-version: 18.x + node-version: 20.x - run: cd core/datacap-ui && npm install pnpm@9.13.2 -g && pnpm install && pnpm run build before_checker_style: diff --git a/core/datacap-server/pom.xml b/core/datacap-server/pom.xml index 1abd5f03d7..85ba2c972a 100644 --- a/core/datacap-server/pom.xml +++ b/core/datacap-server/pom.xml @@ -18,7 +18,7 @@ 2.1.214 3.0.1 1.15.0 - v18.18.2 + v20.18.1 9.13.2 2.3.1 2.3.1 diff --git a/core/datacap-ui/package.json b/core/datacap-ui/package.json index be7ac3e169..0c5bac6df1 100644 --- a/core/datacap-ui/package.json +++ b/core/datacap-ui/package.json @@ -54,5 +54,8 @@ "core-js" ] }, - "packageManager": "pnpm@9.13.2" + "packageManager": "pnpm@9.13.2", + "engines": { + "node": ">=20.0.0" + } } From 2ec577ea0e765ba1fa7ec0c6769ccd3369d1af47 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 01:50:30 -0400 Subject: [PATCH 07/34] feat(ui): add global header search per prototype - add a search input with a cmd-k shortcut hint to the header; cmd/ctrl + k focuses it - drop the language select from the header (language switching lives in the profile preferences) and reduce the help item to its icon - show the account avatar as a brand gradient circle with the initial letter instead of username text --- .../etc/conf/i18n/messages_en.properties | 2 + .../etc/conf/i18n/messages_zh-cn.properties | 2 + .../common/components/LayoutHeader.vue | 93 +++++++++++++------ 3 files changed, 67 insertions(+), 30 deletions(-) diff --git a/configure/etc/conf/i18n/messages_en.properties b/configure/etc/conf/i18n/messages_en.properties index 07167d263c..6d1ef636de 100644 --- a/configure/etc/conf/i18n/messages_en.properties +++ b/configure/etc/conf/i18n/messages_en.properties @@ -819,3 +819,5 @@ notify.tip.deleted=Notify when datasets are deleted notify.tip.dynamic=Notify when new activities are generated notify.tip.syncData=Notify when data synchronization completes + +common.searchPlaceholder=Search datasets, tables, documents... \ No newline at end of file diff --git a/configure/etc/conf/i18n/messages_zh-cn.properties b/configure/etc/conf/i18n/messages_zh-cn.properties index fb925e87b8..80e5475dbb 100644 --- a/configure/etc/conf/i18n/messages_zh-cn.properties +++ b/configure/etc/conf/i18n/messages_zh-cn.properties @@ -819,3 +819,5 @@ notify.tip.deleted=\u6570\u636E\u96C6\u5220\u9664\u65F6\u53D1\u9001\u901A\u77E5 notify.tip.dynamic=\u4EA7\u751F\u65B0\u52A8\u6001\u65F6\u53D1\u9001\u901A\u77E5 notify.tip.syncData=\u6570\u636E\u540C\u6B65\u5B8C\u6210\u540E\u53D1\u9001\u901A\u77E5 + +common.searchPlaceholder=\u641C\u7D22\u6570\u636E\u96C6\u3001\u8868\u3001\u6587\u6863... \ No newline at end of file diff --git a/core/datacap-ui/src/views/layouts/common/components/LayoutHeader.vue b/core/datacap-ui/src/views/layouts/common/components/LayoutHeader.vue index 897a39fe71..bf8b086575 100644 --- a/core/datacap-ui/src/views/layouts/common/components/LayoutHeader.vue +++ b/core/datacap-ui/src/views/layouts/common/components/LayoutHeader.vue @@ -19,7 +19,7 @@
diff --git a/core/datacap-ui/src/views/pages/admin/source/sourceCatalog.ts b/core/datacap-ui/src/views/pages/admin/source/sourceCatalog.ts new file mode 100644 index 0000000000..22e2e14d95 --- /dev/null +++ b/core/datacap-ui/src/views/pages/admin/source/sourceCatalog.ts @@ -0,0 +1,163 @@ +/** + * 数据源目录元数据(纯前端展示信息): + * - category: 原型左侧分类 + * - desc/features/scenes: 原型右侧详情面板文案(zh/en) + * 未登记的数据源走 genericFallback(分类「其他」)。 + */ +export type SourceCategoryKey = + | 'all' + | 'rdb' + | 'bigdata' + | 'cloud' + | 'nosql' + | 'file' + | 'api' + | 'mq' + | 'other' + +export interface SourceCatalogEntry +{ + category: Exclude + popular?: boolean + desc: { zh: string; en: string } + features?: { zh: string[]; en: string[] } + scenes?: { zh: string[]; en: string[] } +} + + +const entry = ( + category: SourceCatalogEntry['category'], + desc: { zh: string; en: string }, + features?: { zh: string[]; en: string[] }, + scenes?: { zh: string[]; en: string[] }, + popular?: boolean): SourceCatalogEntry => ({ + category, + popular, + desc, + features, + scenes +}) + +const f = (zh: string[], en: string[]) => ({ zh, en }) + +export const SOURCE_CATALOG: Record = { + mysql: entry('rdb', + { zh: 'MySQL 是最流行的开源关系型数据库,适用于各类业务系统的数据存储。', en: 'MySQL is the most popular open-source relational database for all kinds of business systems.' }, + f(['开源免费', '高性能和稳定性', '广泛的社区支持', '支持大规模数据处理'], ['Open source', 'High performance and stability', 'Wide community support', 'Large-scale data processing']), + f(['业务系统', '数据仓库', '数据分析', '报表系统', '实时数据同步'], ['Business systems', 'Data warehouse', 'Data analysis', 'Reporting', 'Real-time sync']), + true), + postgresql: entry('rdb', + { zh: 'PostgreSQL 是功能最强大的开源关系型数据库,以标准兼容与扩展性著称。', en: 'PostgreSQL is the most advanced open-source relational database, known for standards compliance and extensibility.' }, + f(['标准 SQL 兼容', '丰富的数据类型', '强大的扩展生态', '事务与并发控制'], ['Standard SQL', 'Rich data types', 'Extensible ecosystem', 'Transactions and concurrency']), + f(['业务系统', '数据分析', '地理信息', '数据仓库'], ['Business systems', 'Data analysis', 'GIS', 'Data warehouse']), + true), + oracle: entry('rdb', + { zh: 'Oracle 数据库是企业级关系型数据库的代表,广泛应用于金融、电信等行业。', en: 'Oracle Database is the leading enterprise relational database, widely used in finance and telecom.' }, + f(['企业级稳定性', '强大的 PL/SQL', '高可用架构', '完善的安全体系'], ['Enterprise stability', 'Powerful PL/SQL', 'High availability', 'Mature security']), + f(['金融行业', '电信行业', '大型核心系统', '数据仓库'], ['Finance', 'Telecom', 'Large core systems', 'Data warehouse'])), + 'microsoft sql server': entry('rdb', + { zh: 'Microsoft SQL Server 是微软的企业级数据库,与 .NET 生态深度集成。', en: 'Microsoft SQL Server is Microsoft’s enterprise database, deeply integrated with the .NET ecosystem.' }, + f(['与 Windows/.NET 集成', '强大的 BI 工具', '企业级安全', '易于管理'], ['Windows/.NET integration', 'Powerful BI tooling', 'Enterprise security', 'Easy management']), + f(['业务系统', '报表分析', '数据仓库'], ['Business systems', 'Reporting and analytics', 'Data warehouse'])), + mariadb: entry('rdb', + { zh: 'MariaDB 是 MySQL 的开源分支,保持兼容并持续引入新特性。', en: 'MariaDB is an open-source MySQL fork, keeping compatibility while adding new features.' }, + f(['MySQL 兼容', '开源社区驱动', '多存储引擎', '高性能'], ['MySQL compatible', 'Community driven', 'Multiple storage engines', 'High performance']), + f(['业务系统', '数据存储', '互联网应用'], ['Business systems', 'Data storage', 'Web applications'])), + mongodb: entry('nosql', + { zh: 'MongoDB 是最流行的文档型 NoSQL 数据库,适合灵活多变的数据结构。', en: 'MongoDB is the most popular document-oriented NoSQL database for flexible data structures.' }, + f(['文档模型', '水平扩展', '丰富的查询能力', '高可用副本集'], ['Document model', 'Horizontal scaling', 'Rich queries', 'Replica sets']), + f(['内容管理', '物联网', '用户画像', '实时数据'], ['Content management', 'IoT', 'User profiles', 'Real-time data'])), + redis: entry('nosql', + { zh: 'Redis 是高性能的内存键值数据库,广泛用于缓存与消息场景。', en: 'Redis is a high-performance in-memory key-value store, widely used for caching and messaging.' }, + f(['亚毫秒级读写', '丰富数据结构', '持久化支持', '主从与集群'], ['Sub-millisecond R/W', 'Rich data structures', 'Persistence', 'Replication and cluster']), + f(['缓存加速', '会话存储', '排行榜', '消息队列'], ['Caching', 'Session storage', 'Leaderboards', 'Message queue'])), + elasticsearch: entry('nosql', + { zh: 'Elasticsearch 是分布式搜索与分析引擎,擅长全文检索与日志分析。', en: 'Elasticsearch is a distributed search and analytics engine for full-text search and log analytics.' }, + f(['全文检索', '分布式架构', '实时分析', '丰富的生态'], ['Full-text search', 'Distributed', 'Real-time analytics', 'Rich ecosystem']), + f(['日志分析', '全文搜索', '监控告警', '业务检索'], ['Log analytics', 'Full-text search', 'Monitoring', 'Business search'])), + clickhouse: entry('bigdata', + { zh: 'ClickHouse 是高性能的列式 OLAP 数据库,适合海量数据的实时分析。', en: 'ClickHouse is a high-performance columnar OLAP database for real-time analytics on massive data.' }, + f(['列式存储', '极致查询性能', '数据压缩', '实时写入'], ['Columnar storage', 'Blazing queries', 'Compression', 'Real-time ingestion']), + f(['用户行为分析', '监控大盘', '商业智能', '日志分析'], ['Behavior analytics', 'Monitoring dashboards', 'BI', 'Log analytics']), + true), + hive: entry('bigdata', + { zh: 'Hive 是基于 Hadoop 的数据仓库工具,适合大规模数据的离线分析。', en: 'Hive is a Hadoop-based data warehouse tool for offline analytics on massive data.' }, + f(['SQL on Hadoop', '海量数据', '生态成熟', '离线批处理'], ['SQL on Hadoop', 'Massive scale', 'Mature ecosystem', 'Batch processing']), + f(['离线分析', '数据仓库', 'ETL 处理'], ['Offline analytics', 'Data warehouse', 'ETL'])), + hbase: entry('bigdata', + { zh: 'HBase 是构建在 Hadoop 上的分布式列式数据库,适合海量稀疏数据。', en: 'HBase is a distributed columnar database on Hadoop for massive sparse data.' }, + f(['亿级行读写', '强一致性', '与 Hadoop 集成', '水平扩展'], ['Billions of rows', 'Strong consistency', 'Hadoop integration', 'Horizontal scaling']), + f(['消息存储', '画像数据', '风控数据'], ['Message storage', 'Profile data', 'Risk data'])), + kafka: entry('mq', + { zh: 'Kafka 是高吞吐的分布式消息队列,是流式数据管道的事实标准。', en: 'Kafka is a high-throughput distributed message queue, the de-facto standard for streaming pipelines.' }, + f(['高吞吐低延迟', '分布式与可扩展', '持久化存储', '流式生态'], ['High throughput', 'Distributed and scalable', 'Durable storage', 'Streaming ecosystem']), + f(['日志采集', '流式计算', '事件驱动', '数据集成'], ['Log collection', 'Stream processing', 'Event-driven', 'Data integration']), + true), + spark: entry('bigdata', + { zh: 'Spark 是通用的大数据计算引擎,支持批处理、流处理与机器学习。', en: 'Spark is a general-purpose big data engine supporting batch, streaming and machine learning.' }, + f(['内存计算', '一站式引擎', '丰富的 API', '与 Hadoop 集成'], ['In-memory compute', 'Unified engine', 'Rich APIs', 'Hadoop integration']), + f(['离线计算', '实时计算', '机器学习'], ['Batch compute', 'Stream compute', 'Machine learning'])), + starrocks: entry('bigdata', + { zh: 'StarRocks 是高性能的实时分析型数据库,支持极速的多维分析。', en: 'StarRocks is a high-performance real-time analytical database for blazing-fast OLAP.' }, + f(['实时分析', '高并发查询', 'join 性能强', '兼容 MySQL 协议'], ['Real-time analytics', 'High concurrency', 'Strong joins', 'MySQL protocol']), + f(['实时报表', '用户画像', '湖仓分析'], ['Real-time reporting', 'User profiles', 'Lakehouse analytics'])), + doris: entry('bigdata', + { zh: 'Doris 是易用的实时分析数据库,支持高并发点查与复杂分析。', en: 'Doris is an easy-to-use real-time analytical database supporting both point queries and complex analytics.' }, + f(['简单易用', '高并发', '实时更新', 'MySQL 协议'], ['Easy to use', 'High concurrency', 'Real-time updates', 'MySQL protocol']), + f(['报表分析', '用户行为', '数据湖'], ['Reporting', 'User behavior', 'Data lake'])), + snowflake: entry('cloud', + { zh: 'Snowflake 是云原生数据仓库,按需弹性扩展、免运维。', en: 'Snowflake is a cloud-native data warehouse with elastic scaling and zero maintenance.' }, + f(['云原生', '存算分离', '弹性扩缩容', '免运维'], ['Cloud native', 'Storage/compute separation', 'Elastic scaling', 'Zero maintenance']), + f(['数据仓库', '数据分析', '数据共享'], ['Data warehouse', 'Data analysis', 'Data sharing'])), + bigquery: entry('cloud', + { zh: 'BigQuery 是谷歌云的无服务器数据仓库,支持超大规模数据分析。', en: 'BigQuery is Google Cloud’s serverless data warehouse for analytics at any scale.' }, + f(['无服务器', '极速查询', '按量付费', '与 GCP 集成'], ['Serverless', 'Fast queries', 'Pay as you go', 'GCP integration']), + f(['数据分析', '机器学习', '商业智能'], ['Data analysis', 'Machine learning', 'BI'])), + 'amazon s3': entry('cloud', + { zh: 'Amazon S3 是业界标准的对象存储服务,提供 99.999999999% 的持久性。', en: 'Amazon S3 is the industry-standard object storage with 11 nines of durability.' }, + f(['高持久性', '无限容量', '丰富的生态', '生命周期管理'], ['High durability', 'Unlimited capacity', 'Rich ecosystem', 'Lifecycle management']), + f(['数据湖', '备份归档', '静态资源'], ['Data lake', 'Backup and archive', 'Static assets'])), + 'azure blob': entry('cloud', + { zh: 'Azure Blob 是微软云的对象存储服务,适合云原生工作负载。', en: 'Azure Blob Storage is Microsoft Cloud’s object storage for cloud-native workloads.' }, + f(['Azure 集成', '分层存储', '高可用', '安全合规'], ['Azure integration', 'Tiered storage', 'High availability', 'Security and compliance']), + f(['数据湖', '备份归档', '媒体存储'], ['Data lake', 'Backup and archive', 'Media storage'])), + 'ftp/sftp': entry('file', + { zh: 'FTP/SFTP 是经典的文件传输协议,广泛用于系统间的文件交换。', en: 'FTP/SFTP are classic file transfer protocols widely used for file exchange between systems.' }, + f(['协议通用', '部署简单', '广泛支持', '跨平台'], ['Universal protocol', 'Easy deployment', 'Widely supported', 'Cross-platform']), + f(['文件交换', '报表传输', '数据导入'], ['File exchange', 'Report delivery', 'Data import'])), + '本地文件': entry('file', + { zh: '本地文件数据源支持读取服务器本地的 CSV、XML 等文件。', en: 'The local file source reads CSV, XML and other files from the server.' }, + f(['无需网络', '简单直接', '支持多格式'], ['No network needed', 'Simple and direct', 'Multiple formats']), + f(['本地导入', '临时分析', '数据迁移'], ['Local import', 'Ad-hoc analysis', 'Data migration'])), + 'rest api': entry('api', + { zh: 'Rest API 数据源可以对接任意 HTTP 接口,把 Web 数据当作数据源使用。', en: 'The Rest API source connects to any HTTP endpoint and treats web data as a data source.' }, + f(['通用性强', '实时获取', '灵活配置'], ['Universal', 'Real-time fetch', 'Flexible configuration']), + f(['Web 数据', '第三方服务', '实时接口'], ['Web data', 'Third-party services', 'Real-time APIs'])), + 钉钉: entry('api', + { zh: '钉钉数据源用于读取钉钉开放平台的企业通讯与审批数据。', en: 'The DingTalk source reads organization and approval data from the DingTalk open platform.' }, + f(['企业通讯录', '审批数据', '与钉钉集成'], ['Organization directory', 'Approval data', 'DingTalk integration']), + f(['组织管理', '审批同步'], ['Organization', 'Approval sync'])), + 企业微信: entry('api', + { zh: '企业微信数据源用于读取企业微信的通讯与业务数据。', en: 'The WeCom source reads organization and business data from WeCom.' }, + f(['企业通讯录', '业务数据', '与企业微信集成'], ['Organization directory', 'Business data', 'WeCom integration']), + f(['组织管理', '消息同步'], ['Organization', 'Message sync'])) +} + +/** 未登记数据源的兜底描述 */ +export const genericFallback = (name: string, pluginDescription?: string): SourceCatalogEntry => ({ + category: 'other', + desc: { + zh: pluginDescription || `${ name } 数据源连接。`, + en: pluginDescription || `${ name } data source connection.` + } +}) + +/** 按插件名查找目录信息(精确 → 包含) */ +export const findCatalogEntry = (name: string): SourceCatalogEntry => { + const key = (name || '').toLowerCase() + if (SOURCE_CATALOG[key]) { + return SOURCE_CATALOG[key] + } + const contained = Object.keys(SOURCE_CATALOG).find(k => key.includes(k) || k.includes(key)) + return contained ? SOURCE_CATALOG[contained] : genericFallback(name) +} From f22c5774d3465d174d929351fa14401a2851c63c Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 04:41:05 -0400 Subject: [PATCH 19/34] i18n(ui): add source wizard text keys --- .../etc/conf/i18n/messages_en.properties | 28 +++++++++++++++++++ .../etc/conf/i18n/messages_zh-cn.properties | 28 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/configure/etc/conf/i18n/messages_en.properties b/configure/etc/conf/i18n/messages_en.properties index 29ce751cb6..a3125e619c 100644 --- a/configure/etc/conf/i18n/messages_en.properties +++ b/configure/etc/conf/i18n/messages_en.properties @@ -900,3 +900,31 @@ configure.runtime.testMissing={count} field(s) are not filled dataTable.selected={count} selected dataTable.total=Total {count} items + +source.wizard.desc=选择数据源类型并配置连接信息 +source.wizard.stepTypeDesc=选择数据源类型 +source.wizard.stepConfigureDesc=填写连接信息 +source.wizard.stepAuthDesc=配置访问权限 +source.wizard.stepAdvancedDesc=高级选项(可选) +source.wizard.previous=Previous +source.wizard.next=Next +source.wizard.notFound=找不到需要的数据源? +source.catalog.all=All +source.category.rdb=Relational Databases +source.category.bigdata=Big Data +source.category.cloud=Cloud Platforms +source.category.nosql=NoSQL +source.category.file=File Storage +source.category.api=API & Apps +source.category.mq=Message Queues +source.category.other=Other +source.search.placeholder=搜索数据源(如:MySQL、PostgreSQL、Oracle...) +source.filter.popular=Popular +source.filter.database=Databases +source.filter.cloud=Cloud +source.filter.other=Other +source.badge.popular=Popular +source.common.features=Key Features +source.common.scenes=Use Cases +source.common.viewDocs=View Docs +source.common.requestNew=Request a New Data Source diff --git a/configure/etc/conf/i18n/messages_zh-cn.properties b/configure/etc/conf/i18n/messages_zh-cn.properties index 77ad65d40a..b106ac9be1 100644 --- a/configure/etc/conf/i18n/messages_zh-cn.properties +++ b/configure/etc/conf/i18n/messages_zh-cn.properties @@ -900,3 +900,31 @@ configure.runtime.testMissing={count} \u4E2A\u5B57\u6BB5\u672A\u586B\u5199 dataTable.selected=\u5DF2\u9009\u62E9 {count} \u9879 dataTable.total=\u5171 {count} \u6761 + +source.wizard.desc=\u9009\u62E9\u6570\u636E\u6E90\u7C7B\u578B\u5E76\u914D\u7F6E\u8FDE\u63A5\u4FE1\u606F +source.wizard.stepTypeDesc=\u9009\u62E9\u6570\u636E\u6E90\u7C7B\u578B +source.wizard.stepConfigureDesc=\u586B\u5199\u8FDE\u63A5\u4FE1\u606F +source.wizard.stepAuthDesc=\u914D\u7F6E\u8BBF\u95EE\u6743\u9650 +source.wizard.stepAdvancedDesc=\u9AD8\u7EA7\u9009\u9879\uFF08\u53EF\u9009\uFF09 +source.wizard.previous=\u4E0A\u4E00\u6B65 +source.wizard.next=\u4E0B\u4E00\u6B65 +source.wizard.notFound=\u627E\u4E0D\u5230\u9700\u8981\u7684\u6570\u636E\u6E90\uFF1F +source.catalog.all=\u5168\u90E8 +source.category.rdb=\u5173\u7CFB\u578B\u6570\u636E\u5E93 +source.category.bigdata=\u5927\u6570\u636E +source.category.cloud=\u4E91\u5E73\u53F0 +source.category.nosql=NoSQL +source.category.file=\u6587\u4EF6\u5B58\u50A8 +source.category.api=API & \u5E94\u7528 +source.category.mq=\u6D88\u606F\u961F\u5217 +source.category.other=\u5176\u4ED6 +source.search.placeholder=\u641C\u7D22\u6570\u636E\u6E90\uFF08\u5982\uFF1AMySQL\u3001PostgreSQL\u3001Oracle...\uFF09 +source.filter.popular=\u5E38\u7528 +source.filter.database=\u6570\u636E\u5E93 +source.filter.cloud=\u4E91\u5E73\u53F0 +source.filter.other=\u5176\u4ED6 +source.badge.popular=\u5E38\u7528 +source.common.features=\u4E3B\u8981\u7279\u6027 +source.common.scenes=\u9002\u7528\u573A\u666F +source.common.viewDocs=\u67E5\u770B\u6587\u6863 +source.common.requestNew=\u7533\u8BF7\u65B0\u6570\u636E\u6E90 From a354b16447bef0df58c95d3fd60e71f4778696e0 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 05:06:14 -0400 Subject: [PATCH 20/34] feat(ui): redesign dataset adhoc query page per prototype - left field panel with dataset header, field search, all/dimension/ metric tabs and grouped draggable field lists - three color-coded drop zones (metrics, dimensions, filters) with hints, chip removal and per-chip configuration - header actions: clear, preview, query and an overflow menu with publish and the row limit - results card with query result / execution log tabs, row total and csv export --- .../etc/conf/i18n/messages_en.properties | 17 + .../etc/conf/i18n/messages_zh-cn.properties | 17 + .../pages/admin/dataset/DatasetAdhoc.vue | 732 +++++++++++++----- 3 files changed, 578 insertions(+), 188 deletions(-) diff --git a/configure/etc/conf/i18n/messages_en.properties b/configure/etc/conf/i18n/messages_en.properties index a3125e619c..730ba29296 100644 --- a/configure/etc/conf/i18n/messages_en.properties +++ b/configure/etc/conf/i18n/messages_en.properties @@ -928,3 +928,20 @@ source.common.features=Key Features source.common.scenes=Use Cases source.common.viewDocs=View Docs source.common.requestNew=Request a New Data Source + +dataset.adhoc.title=Ad-hoc Query +dataset.adhoc.desc=Build queries by dragging fields and view analysis results in real time +dataset.adhoc.clear=Clear +dataset.adhoc.preview=Preview +dataset.adhoc.query=Query +dataset.adhoc.dropMetric=Drag metric fields here +dataset.adhoc.dropDimension=Drag dimension fields here +dataset.adhoc.dropFilter=Drag fields here to set filter conditions +dataset.adhoc.resultTab=Query Results +dataset.adhoc.logTab=Execution Logs +dataset.adhoc.export=Export +dataset.adhoc.totalRows=Total {limit} rows shown +dataset.search.fields=Search fields... +dataset.adhoc.all=All +dataset.adhoc.dimensions=Dimensions +dataset.adhoc.measures=Measures diff --git a/configure/etc/conf/i18n/messages_zh-cn.properties b/configure/etc/conf/i18n/messages_zh-cn.properties index b106ac9be1..a56e559466 100644 --- a/configure/etc/conf/i18n/messages_zh-cn.properties +++ b/configure/etc/conf/i18n/messages_zh-cn.properties @@ -928,3 +928,20 @@ source.common.features=\u4E3B\u8981\u7279\u6027 source.common.scenes=\u9002\u7528\u573A\u666F source.common.viewDocs=\u67E5\u770B\u6587\u6863 source.common.requestNew=\u7533\u8BF7\u65B0\u6570\u636E\u6E90 + +dataset.adhoc.title=\u5373\u5E2D\u67E5\u8BE2 +dataset.adhoc.desc=\u901A\u8FC7\u62D6\u62FD\u5B57\u6BB5\u5FEB\u901F\u6784\u5EFA\u67E5\u8BE2\uFF0C\u5B9E\u65F6\u67E5\u770B\u5206\u6790\u7ED3\u679C +dataset.adhoc.clear=\u6E05\u7A7A +dataset.adhoc.preview=\u9884\u89C8 +dataset.adhoc.query=\u67E5\u8BE2 +dataset.adhoc.dropMetric=\u62D6\u62FD\u6307\u6807\u5B57\u6BB5\u5230\u6B64\u5904 +dataset.adhoc.dropDimension=\u62D6\u62FD\u7EF4\u5EA6\u5B57\u6BB5\u5230\u6B64\u5904 +dataset.adhoc.dropFilter=\u62D6\u62FD\u5B57\u6BB5\u5230\u6B64\u5904\u8BBE\u7F6E\u7B5B\u9009\u6761\u4EF6 +dataset.adhoc.resultTab=\u67E5\u8BE2\u7ED3\u679C +dataset.adhoc.logTab=\u6267\u884C\u65E5\u5FD7 +dataset.adhoc.export=\u5BFC\u51FA +dataset.adhoc.totalRows=\u5171 {limit} \u6761\u6570\u636E +dataset.search.fields=\u641C\u7D22\u5B57\u6BB5... +dataset.adhoc.all=\u5168\u90E8 +dataset.adhoc.dimensions=\u7EF4\u5EA6 +dataset.adhoc.measures=\u6307\u6807 diff --git a/core/datacap-ui/src/views/pages/admin/dataset/DatasetAdhoc.vue b/core/datacap-ui/src/views/pages/admin/dataset/DatasetAdhoc.vue index 08e923b472..f02d69a122 100644 --- a/core/datacap-ui/src/views/pages/admin/dataset/DatasetAdhoc.vue +++ b/core/datacap-ui/src/views/pages/admin/dataset/DatasetAdhoc.vue @@ -1,216 +1,253 @@ From 4582e55e04060538d3877a1f5663e6c0a4a116ab Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 06:32:03 -0400 Subject: [PATCH 22/34] feat(ui): theme horizontal and vertical scrollbars with dc tokens - custom webkit scrollbars: 8px rounded thumb with content-box inset, themed via --dc-* (placeholder tone, darker on hover/active), track and corner transparent, follows dark mode automatically - gate the standard scrollbar-width/color fallback behind @supports (-moz-appearance: none) for Firefox only: Chromium 121+ prefers the standard properties and would ignore the webkit rules, falling back to macOS overlay scrollbars that stay hidden --- core/datacap-ui/src/styles/theme.css | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/core/datacap-ui/src/styles/theme.css b/core/datacap-ui/src/styles/theme.css index 882b9e1d21..e9e452a713 100644 --- a/core/datacap-ui/src/styles/theme.css +++ b/core/datacap-ui/src/styles/theme.css @@ -142,6 +142,46 @@ html:not(.dark) body { background-repeat: no-repeat; } +/* ---- 全局滚动条(横向 + 纵向):细轨 + 圆角拇指,跟随 --dc-* 令牌 ---- + WebKit 走 ::-webkit-scrollbar(自定义滚动条常驻显示,且可做 hover 态); + Firefox 不识别 -moz-appearance 之外的写法,用标准属性 thin + 颜色。 + 注意:Chromium 121+ 一旦 scrollbar-width/color 非 auto 就会忽略 + ::-webkit-scrollbar 并退回 macOS 悬浮式,因此标准属性必须只留给 Firefox。 */ +@supports (-moz-appearance: none) { + * { + scrollbar-width: thin; + scrollbar-color: var(--dc-text-placeholder) transparent; + } +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background-color: var(--dc-text-placeholder); + border: 2px solid transparent; + border-radius: 9999px; + background-clip: content-box; +} + +::-webkit-scrollbar-thumb:hover { + background-color: var(--dc-text-secondary); +} + +::-webkit-scrollbar-thumb:active { + background-color: var(--dc-text-primary); +} + +::-webkit-scrollbar-corner { + background: transparent; +} + /* ---- 通用卡片:白底 + 极浅边框 + 很轻的阴影 ---- */ .dc-card { background: var(--dc-surface); From ba038f78c00e4071c6f0e49f38fe02373c341c70 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 06:41:59 -0400 Subject: [PATCH 23/34] fix(service): guard null pipeline fields in convertFieldBody DataSet sync failed with a NullPointerException when a plugin YAML configure (e.g. Doris) declares pipeline entries without an executor or type field: the filter invoked equals() on the possibly-null entry fields. Compare with the arguments as the anchor via Objects.equals and tolerate a null pipelines list. Fixes #953 --- .../io/edurt/datacap/service/common/ConfigureUtils.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/datacap-service/src/main/java/io/edurt/datacap/service/common/ConfigureUtils.java b/core/datacap-service/src/main/java/io/edurt/datacap/service/common/ConfigureUtils.java index 7bd84cd6cb..eb3ba0be3c 100644 --- a/core/datacap-service/src/main/java/io/edurt/datacap/service/common/ConfigureUtils.java +++ b/core/datacap-service/src/main/java/io/edurt/datacap/service/common/ConfigureUtils.java @@ -29,6 +29,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Properties; import java.util.Set; @@ -291,9 +292,12 @@ public static PipelineFieldBody convertFieldBody(SourceEntity entity, String exe throw new IllegalArgumentException("Failed to load YAML configuration"); } - yamlConfigure.getPipelines() + // Pipeline 条目可能缺少 executor/type 字段(如 Doris 的 YAML 配置), + // 直接在条目上调用 equals 会抛出 NPE,比较方向必须固定 + Optional.ofNullable(yamlConfigure.getPipelines()) + .orElseGet(ArrayList::new) .stream() - .filter(v -> v.getExecutor().equals(executor) && v.getType().equals(pipelineType)) + .filter(v -> Objects.equals(v.getExecutor(), executor) && Objects.equals(v.getType(), pipelineType)) .findFirst() .ifPresent(iConfigureExecutor -> body.setConfigures(mergeProperties(entity, iConfigureExecutor.getFields(), originProperties))); if (body.getConfigures() == null) { From 96643597e71d2960ee18561aef2e77ae957ddbf8 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 06:42:55 -0400 Subject: [PATCH 24/34] fix(spi): fall back to JDBC metadata when information_schema is unavailable getDatabases() hardcoded a query against information_schema.SCHEMATA, so connecting a source without that schema (e.g. Hive) failed with 'Could not resolve table reference: information_schema.schemata'. When the SQL path fails on a healthy connection, load databases via DatabaseMetaData.getSchemas() (getCatalogs() as a last resort) and return them in the same response shape. Fixes #930 --- .../io/edurt/datacap/spi/PluginService.java | 70 ++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/core/datacap-spi/src/main/java/io/edurt/datacap/spi/PluginService.java b/core/datacap-spi/src/main/java/io/edurt/datacap/spi/PluginService.java index a93b10ce0c..ac56b5741a 100644 --- a/core/datacap-spi/src/main/java/io/edurt/datacap/spi/PluginService.java +++ b/core/datacap-spi/src/main/java/io/edurt/datacap/spi/PluginService.java @@ -37,6 +37,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -275,7 +277,73 @@ default Response getDatabases(Configure configure) " 'DATABASE' AS object_type,\n" + " '' AS object_comment\n" + "FROM information_schema.SCHEMATA;"; - return this.execute(configure, sql); + Response response = this.execute(configure, sql); + // Hive、达梦等数据源不提供 information_schema,执行失败时回退到 JDBC 元数据 + if (Boolean.TRUE.equals(response.getIsConnected()) && Boolean.FALSE.equals(response.getIsSuccessful())) { + return metadataDatabases(configure); + } + return response; + } + + /** + * 通过 JDBC DatabaseMetaData 获取数据库列表 + * Load databases via JDBC DatabaseMetaData, for sources without information_schema + * + * @param configure 配置信息 | Configuration information + * @return 数据库列表 | Database list + */ + default Response metadataDatabases(Configure configure) + { + Response response = new Response(); + response.setHeaders(Lists.newArrayList("object_name", "object_type", "object_comment")); + response.setTypes(Lists.newArrayList("String", "String", "String")); + List rows = Lists.newArrayList(); + response.setColumns(rows); + try { + DatabaseMetaData metaData = openJdbcConnection(configure).getMetaData(); + try (ResultSet resultSet = metaData.getSchemas()) { + while (resultSet.next()) { + rows.add(Lists.newArrayList(resultSet.getString("TABLE_SCHEM"), "DATABASE", "")); + } + } + // 个别驱动只暴露 catalog 而没有 schema(如部分 MySQL 系) + if (rows.isEmpty()) { + try (ResultSet resultSet = metaData.getCatalogs()) { + while (resultSet.next()) { + rows.add(Lists.newArrayList(resultSet.getString("TABLE_CAT"), "DATABASE", "")); + } + } + } + response.setIsConnected(true); + response.setIsSuccessful(true); + } + catch (Exception ex) { + log.error("Failed to load databases via JDBC metadata", ex); + response.setIsConnected(Boolean.FALSE); + response.setIsSuccessful(Boolean.FALSE); + response.setMessage(ex.getMessage()); + } + finally { + destroy(); + } + return response; + } + + /** + * 打开 JDBC 连接并返回原生连接对象,仅供元数据回退使用 + * Open a JDBC connection and expose the native connection for metadata fallbacks + * + * @param configure 配置信息 | Configuration information + * @return 原生 JDBC 连接 | Native JDBC connection + */ + private java.sql.Connection openJdbcConnection(Configure configure) + { + this.connect(configure); + Connection connection = local.get(); + if (connection == null || !(connection.getConnection() instanceof java.sql.Connection)) { + throw new IllegalStateException("Connection is not available or not a JDBC connection"); + } + return (java.sql.Connection) connection.getConnection(); } /** From 370cf703a320e0552ee2bc0320d0b750532f5c2a Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 06:43:58 -0400 Subject: [PATCH 25/34] fix(spi): extend JDBC metadata fallback to tables and columns Sources without information_schema (e.g. Dameng) still failed on the table list and column detail pages after the getDatabases fallback. Apply the same failure-triggered fallback to getTables() and getColumns(), loading tables/views, column details and primary keys via DatabaseMetaData in the original response shape. Fixes #957 --- .../io/edurt/datacap/spi/PluginService.java | 124 +++++++++++++++++- 1 file changed, 122 insertions(+), 2 deletions(-) diff --git a/core/datacap-spi/src/main/java/io/edurt/datacap/spi/PluginService.java b/core/datacap-spi/src/main/java/io/edurt/datacap/spi/PluginService.java index ac56b5741a..65281a9893 100644 --- a/core/datacap-spi/src/main/java/io/edurt/datacap/spi/PluginService.java +++ b/core/datacap-spi/src/main/java/io/edurt/datacap/spi/PluginService.java @@ -412,7 +412,51 @@ default Response getTables(Configure configure, String database) " FIELD(type, 'BASE TABLE', 'VIEW', 'FUNCTION', 'PROCEDURE'),\n" + " object_name;"; - return this.execute(configure, sql.replace("{0}", database)); + Response response = this.execute(configure, sql.replace("{0}", database)); + // 达梦等数据源不提供 information_schema,执行失败时回退到 JDBC 元数据 + if (Boolean.TRUE.equals(response.getIsConnected()) && Boolean.FALSE.equals(response.getIsSuccessful())) { + return metadataTables(configure, database); + } + return response; + } + + /** + * 通过 JDBC DatabaseMetaData 获取数据表和视图列表 + * Load tables and views via JDBC DatabaseMetaData, for sources without information_schema + * + * @param configure 配置信息 | Configuration information + * @param database 数据库 | Database + * @return 数据表列表 | Table list + */ + default Response metadataTables(Configure configure, String database) + { + Response response = new Response(); + response.setHeaders(Lists.newArrayList("type_name", "object_name", "object_comment")); + response.setTypes(Lists.newArrayList("String", "String", "String")); + List rows = Lists.newArrayList(); + response.setColumns(rows); + try { + DatabaseMetaData metaData = openJdbcConnection(configure).getMetaData(); + try (ResultSet resultSet = metaData.getTables(null, database, "%", new String[] {"TABLE", "VIEW"})) { + while (resultSet.next()) { + String typeName = "VIEW".equals(resultSet.getString("TABLE_TYPE")) ? "view" : "table"; + String comment = resultSet.getString("REMARKS"); + rows.add(Lists.newArrayList(typeName, resultSet.getString("TABLE_NAME"), comment == null ? "" : comment)); + } + } + response.setIsConnected(true); + response.setIsSuccessful(true); + } + catch (Exception ex) { + log.error("Failed to load tables via JDBC metadata", ex); + response.setIsConnected(Boolean.FALSE); + response.setIsSuccessful(Boolean.FALSE); + response.setMessage(ex.getMessage()); + } + finally { + destroy(); + } + return response; } /** @@ -523,11 +567,87 @@ default Response getColumns(Configure configure, String database, String table) " object_position,\n" + " object_name;"; - return this.execute( + Response response = this.execute( configure, sql.replace("{0}", database) .replace("{1}", table) ); + // 达梦等数据源不提供 information_schema,执行失败时回退到 JDBC 元数据 + if (Boolean.TRUE.equals(response.getIsConnected()) && Boolean.FALSE.equals(response.getIsSuccessful())) { + return metadataColumns(configure, database, table); + } + return response; + } + + /** + * 通过 JDBC DatabaseMetaData 获取列和主键信息 + * Load columns and primary keys via JDBC DatabaseMetaData, for sources without information_schema + * + * @param configure 配置信息 | Configuration information + * @param database 数据库 | Database + * @param table 数据表 | Table + * @return 数据列结构 | Column structure + */ + default Response metadataColumns(Configure configure, String database, String table) + { + Response response = new Response(); + response.setHeaders(Lists.newArrayList( + "type_name", + "object_name", + "object_data_type", + "object_nullable", + "object_default_value", + "object_comment", + "object_position", + "object_definition")); + response.setTypes(Lists.newArrayList( + "String", "String", "String", "String", "String", "String", "Integer", "String")); + List rows = Lists.newArrayList(); + response.setColumns(rows); + try { + DatabaseMetaData metaData = openJdbcConnection(configure).getMetaData(); + try (ResultSet resultSet = metaData.getColumns(null, database, table, "%")) { + while (resultSet.next()) { + String defaultValue = resultSet.getString("COLUMN_DEF"); + String comment = resultSet.getString("REMARKS"); + rows.add(Lists.newArrayList( + "column", + resultSet.getString("COLUMN_NAME"), + resultSet.getString("TYPE_NAME"), + resultSet.getInt("NULLABLE") == java.sql.ResultSetMetaData.columnNullable ? "YES" : "NO", + defaultValue == null ? "" : defaultValue, + comment == null ? "" : comment, + resultSet.getInt("ORDINAL_POSITION"), + "")); + } + } + try (ResultSet resultSet = metaData.getPrimaryKeys(null, database, table)) { + List primaryColumns = Lists.newArrayList(); + while (resultSet.next()) { + primaryColumns.add(resultSet.getString("COLUMN_NAME")); + } + for (String primaryColumn : primaryColumns) { + rows.add(Lists.newArrayList( + "primary", + primaryColumn, + "", "", "", "", + 0, + "PRIMARY KEY on (" + String.join(", ", primaryColumns) + ")")); + } + } + response.setIsConnected(true); + response.setIsSuccessful(true); + } + catch (Exception ex) { + log.error("Failed to load columns via JDBC metadata", ex); + response.setIsConnected(Boolean.FALSE); + response.setIsSuccessful(Boolean.FALSE); + response.setMessage(ex.getMessage()); + } + finally { + destroy(); + } + return response; } default Response getPrimaryKeys(Configure configure, String database, String table) From 39b4836e7446e93b8db31c5af53e1eb6c1e238e7 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 06:45:18 -0400 Subject: [PATCH 26/34] fix(captcha): make verification code expression clearly readable The multiplication sign rendered as a tiny asterisk and the expression was drawn with a random color over the default black background, so many codes were hard to read. Use the proper multiplication sign (U+00D7), paint a white background, draw each character in a dark random color and switch to bold sans-serif. Fixes #863 --- .../io/edurt/datacap/captcha/CalculateUtils.java | 11 ++++++----- .../java/io/edurt/datacap/captcha/CaptchaUtils.java | 13 +++++++++++++ .../edurt/datacap/captcha/enums/CalculateEnum.java | 2 +- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/core/datacap-captcha/src/main/java/io/edurt/datacap/captcha/CalculateUtils.java b/core/datacap-captcha/src/main/java/io/edurt/datacap/captcha/CalculateUtils.java index 354e03daa0..d97ba35d16 100644 --- a/core/datacap-captcha/src/main/java/io/edurt/datacap/captcha/CalculateUtils.java +++ b/core/datacap-captcha/src/main/java/io/edurt/datacap/captcha/CalculateUtils.java @@ -5,6 +5,7 @@ import javax.imageio.ImageIO; +import java.awt.Color; import java.awt.Font; import java.awt.Graphics; import java.awt.image.BufferedImage; @@ -31,18 +32,18 @@ public static ResultEntity generate() { BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); Graphics graphics = image.getGraphics(); // Create a graphics class -// graphics.setColor(CaptchaUtils.getBackgroundColor()); // The background color should be lighter + graphics.setColor(Color.WHITE); // The background color should be light graphics.fillRect(0, 0, WIDTH, HEIGHT); // Painted background - graphics.setColor(CaptchaUtils.getBackgroundColor()); // Border color -// graphics.drawRect(0, 0, WIDTH - 1, HEIGHT - 1); // Draw border ResultEntity entity = CaptchaUtils.generateCalculateImage(); String[] result = entity.getExpression(); int length = result.length; // The number of verification codes generated - Font font = new Font("YaHei", Font.ITALIC, 20); // Create a font, the size of the font should be determined according to the height of the picture + Font font = new Font(Font.SANS_SERIF, Font.BOLD, 22); // Bold sans-serif keeps every operator legible graphics.setFont(font); // Set font for (int i = 0; i < length; i++) { String string = String.valueOf(result[i]); - graphics.drawString(string, 30 * i + 30, 25); + // Dark randomized foreground stays readable on the light background + graphics.setColor(CaptchaUtils.getForegroundColor()); + graphics.drawString(string, 30 * i + 30, 27); } graphics.dispose(); // Image takes effect String base64 = null; diff --git a/core/datacap-captcha/src/main/java/io/edurt/datacap/captcha/CaptchaUtils.java b/core/datacap-captcha/src/main/java/io/edurt/datacap/captcha/CaptchaUtils.java index b61c230ccc..303ffd7f73 100644 --- a/core/datacap-captcha/src/main/java/io/edurt/datacap/captcha/CaptchaUtils.java +++ b/core/datacap-captcha/src/main/java/io/edurt/datacap/captcha/CaptchaUtils.java @@ -27,6 +27,19 @@ public static Color getBackgroundColor() return new Color(rColor, gColor, bColor); } + /** + * Generates a random dark color that stays readable on the light background. + * + * @return a Color object whose channels are dark enough for foreground text + */ + public static Color getForegroundColor() + { + int rColor = RANDOM.nextInt(128); + int gColor = RANDOM.nextInt(128); + int bColor = RANDOM.nextInt(128); + return new Color(rColor, gColor, bColor); + } + /** * Generates a CaptchaEntity object with random calculation numbers. * diff --git a/core/datacap-captcha/src/main/java/io/edurt/datacap/captcha/enums/CalculateEnum.java b/core/datacap-captcha/src/main/java/io/edurt/datacap/captcha/enums/CalculateEnum.java index 68d1ab7ff1..6a6762718c 100644 --- a/core/datacap-captcha/src/main/java/io/edurt/datacap/captcha/enums/CalculateEnum.java +++ b/core/datacap-captcha/src/main/java/io/edurt/datacap/captcha/enums/CalculateEnum.java @@ -4,7 +4,7 @@ public enum CalculateEnum { ADD("+", "加"), SUBTRACT("-", "减"), - MULTIPLY("*", "乘"), + MULTIPLY("\u00d7", "乘"), DIVIDE("÷", "除"), EQUAL("=", "等"); From 02a010a2987dfa0a674a7393a6fa13302434ba8e Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 06:48:37 -0400 Subject: [PATCH 27/34] fix(spi): guard JDBC url construction against parameter injection The default url() concatenated user-supplied host, database and URL parameters without any validation, letting an attacker craft arbitrary JDBC parameters - e.g. H2 INIT for remote code execution or MySQL allowLoadLocalInfile for arbitrary file reading (issue #966 PoCs). Introduce JdbcUrlGuard and apply it in url(): hosts accept only hostname characters (IPv6 literals included), databases and parameter keys/values must not contain parameter separators, and a normalized denylist blocks known dangerous JDBC parameters (INIT, RUNSCRIPT, allowLoadLocalInfile, autoDeserialize, interceptors, ...). Invalid input surfaces as a connection failure message. Fixes #966 --- .../io/edurt/datacap/spi/PluginService.java | 9 ++- .../datacap/spi/connection/JdbcUrlGuard.java | 55 +++++++++++++++++++ .../spi/connection/JdbcUrlGuardTest.java | 49 +++++++++++++++++ 3 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 core/datacap-spi/src/main/java/io/edurt/datacap/spi/connection/JdbcUrlGuard.java create mode 100644 core/datacap-spi/src/test/java/io/edurt/datacap/spi/connection/JdbcUrlGuardTest.java diff --git a/core/datacap-spi/src/main/java/io/edurt/datacap/spi/PluginService.java b/core/datacap-spi/src/main/java/io/edurt/datacap/spi/PluginService.java index 65281a9893..6d1508676a 100644 --- a/core/datacap-spi/src/main/java/io/edurt/datacap/spi/PluginService.java +++ b/core/datacap-spi/src/main/java/io/edurt/datacap/spi/PluginService.java @@ -14,6 +14,7 @@ import io.edurt.datacap.spi.adapter.RowCallback; import io.edurt.datacap.spi.connection.Connection; import io.edurt.datacap.spi.connection.JdbcConnection; +import io.edurt.datacap.spi.connection.JdbcUrlGuard; import io.edurt.datacap.spi.generator.DataType; import io.edurt.datacap.spi.generator.Filter; import io.edurt.datacap.spi.generator.OrderBy; @@ -125,10 +126,11 @@ default String url(Configure configure) buffer.append("jdbc:"); buffer.append(configure.getType()); buffer.append("://"); - buffer.append(configure.getHost()); + buffer.append(JdbcUrlGuard.validateHost(configure.getHost())); buffer.append(":"); buffer.append(configure.getPort()); if (configure.getDatabase().isPresent()) { + JdbcUrlGuard.validateDatabase(configure.getDatabase().get()); buffer.append("/"); buffer.append(configure.getDatabase().get()); } @@ -139,7 +141,10 @@ default String url(Configure configure) Map env = configure.getEnv().get(); List flatEnv = env.entrySet() .stream() - .map(value -> String.format("%s=%s", value.getKey(), value.getValue())) + .map(value -> { + JdbcUrlGuard.validateParameter(value.getKey(), String.valueOf(value.getValue())); + return String.format("%s=%s", value.getKey(), value.getValue()); + }) .collect(Collectors.toList()); if (configure.getSsl().isEmpty()) { buffer.append("?"); diff --git a/core/datacap-spi/src/main/java/io/edurt/datacap/spi/connection/JdbcUrlGuard.java b/core/datacap-spi/src/main/java/io/edurt/datacap/spi/connection/JdbcUrlGuard.java new file mode 100644 index 0000000000..31f9c4ba2b --- /dev/null +++ b/core/datacap-spi/src/main/java/io/edurt/datacap/spi/connection/JdbcUrlGuard.java @@ -0,0 +1,55 @@ +package io.edurt.datacap.spi.connection; + +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * JDBC URL 拼接防护。host、database 与 URL 参数均来自用户输入并被直接拼接进 + * JDBC URL,不校验时可以通过注入参数分隔符构造任意 JDBC 参数,例如 H2 的 + * INIT 参数远程命令执行、MySQL 的 allowLoadLocalInfile 任意文件读取。 + */ +public final class JdbcUrlGuard +{ + private static final Pattern SAFE_HOST = Pattern.compile("[A-Za-z0-9.\\-_:\\[\\]]+"); + private static final Pattern SAFE_DATABASE = Pattern.compile("[^\\s?&#;=%'\"\\\\`/]+"); + private static final Pattern SAFE_PARAMETER = Pattern.compile("[^\\s?&#;=%'\"\\\\`]+"); + private static final Set FORBIDDEN_PARAMETERS = Set.of( + "init", "runscript", "exec", + "allowloadlocalinfile", "allowurlinlocalinfile", "autodeserialize", + "queryinterceptors", "statementinterceptors"); + + private JdbcUrlGuard() + {} + + public static String validateHost(String host) + { + if (host == null || !SAFE_HOST.matcher(host).matches()) { + throw new IllegalArgumentException("Invalid host in JDBC url: " + host); + } + return host; + } + + public static void validateDatabase(String database) + { + if (database == null || !SAFE_DATABASE.matcher(database).matches()) { + throw new IllegalArgumentException("Invalid database in JDBC url: " + database); + } + } + + public static void validateParameter(String key, String value) + { + if (key == null || !SAFE_PARAMETER.matcher(key).matches()) { + throw new IllegalArgumentException("Invalid JDBC url parameter: " + key); + } + if (value == null || !SAFE_PARAMETER.matcher(value).matches()) { + throw new IllegalArgumentException("Invalid value for JDBC url parameter " + key + ": " + value); + } + String normalized = key.toLowerCase(Locale.ROOT) + .replace("-", "") + .replace("_", ""); + if (FORBIDDEN_PARAMETERS.contains(normalized)) { + throw new IllegalArgumentException("JDBC url parameter is not allowed: " + key); + } + } +} diff --git a/core/datacap-spi/src/test/java/io/edurt/datacap/spi/connection/JdbcUrlGuardTest.java b/core/datacap-spi/src/test/java/io/edurt/datacap/spi/connection/JdbcUrlGuardTest.java new file mode 100644 index 0000000000..b3ec4076ea --- /dev/null +++ b/core/datacap-spi/src/test/java/io/edurt/datacap/spi/connection/JdbcUrlGuardTest.java @@ -0,0 +1,49 @@ +package io.edurt.datacap.spi.connection; + +import org.junit.Assert; +import org.junit.Test; + +public class JdbcUrlGuardTest +{ + @Test + public void testValidHostAndDatabase() + { + Assert.assertEquals("127.0.0.1", JdbcUrlGuard.validateHost("127.0.0.1")); + Assert.assertEquals("[::1]", JdbcUrlGuard.validateHost("[::1]")); + JdbcUrlGuard.validateDatabase("testdb"); + JdbcUrlGuard.validateDatabase("test_db.v2"); + JdbcUrlGuard.validateDatabase("测试库"); + } + + @Test(expected = IllegalArgumentException.class) + public void testH2InitInjectionViaDatabase() + { + JdbcUrlGuard.validateDatabase("testdb;INIT=CREATE ALIAS EXEC AS 'void cmd_exec(String cmd) throws java.lang.Exception {Runtime.getRuntime().exec(cmd)\\;}'\\;CALL EXEC ('touch /tmp/pwned')\\;"); + } + + @Test(expected = IllegalArgumentException.class) + public void testHostWithParameterSeparator() + { + JdbcUrlGuard.validateHost("127.0.0.1?allowLoadLocalInfile=true"); + } + + @Test + public void testSafeParameters() + { + JdbcUrlGuard.validateParameter("useSSL", "false"); + JdbcUrlGuard.validateParameter("serverTimezone", "Asia/Shanghai"); + JdbcUrlGuard.validateParameter("characterEncoding", "utf8"); + } + + @Test(expected = IllegalArgumentException.class) + public void testForbiddenParameter() + { + JdbcUrlGuard.validateParameter("allow_load_local_infile", "true"); + } + + @Test(expected = IllegalArgumentException.class) + public void testParameterValueInjection() + { + JdbcUrlGuard.validateParameter("serverTimezone", "UTC?allowLoadLocalInfile=true"); + } +} From 56e3514d349e98667ca849d7e9c734bf9f863c18 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 06:50:56 -0400 Subject: [PATCH 28/34] fix(security): restrict plugin install to admins and flag default JWT secret Plugin installation downloads a remote archive and loads its classes into the server JVM, but the endpoint only required an authenticated user - combined with the well-known default JWT signing secret (DataCapSecretKey) this allowed forging a token for any ordinary user and achieving remote code execution. - require the ADMIN authority on plugin install and uninstall - log a prominent startup warning when datacap.security.secret is still the shipped default so operators know tokens can be forged Fixes #965 --- .../server/controller/PluginController.java | 4 ++++ .../datacap/service/service/JwtService.java | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/core/datacap-server/src/main/java/io/edurt/datacap/server/controller/PluginController.java b/core/datacap-server/src/main/java/io/edurt/datacap/server/controller/PluginController.java index 1d183cbb43..3028d75d64 100644 --- a/core/datacap-server/src/main/java/io/edurt/datacap/server/controller/PluginController.java +++ b/core/datacap-server/src/main/java/io/edurt/datacap/server/controller/PluginController.java @@ -8,6 +8,7 @@ import io.edurt.datacap.service.common.PluginUtils; import lombok.Data; import org.springframework.core.env.Environment; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -50,12 +51,15 @@ public CommonResponse> getPlugins(@RequestParam(value = "ha return CommonResponse.success(pluginManager.getPluginInfos()); } + // 安装/卸载插件会向运行中的 JVM 加载任意外部代码,必须仅限管理员操作 + @PreAuthorize(value = "hasAuthority('ADMIN')") @PostMapping(value = "install") public CommonResponse installPlugin(@RequestBody PluginInstallRequest request) { return CommonResponse.success(pluginManager.installPlugin(Path.of(request.url), request.name)); } + @PreAuthorize(value = "hasAuthority('ADMIN')") @DeleteMapping(value = "uninstall/{name}") public CommonResponse uninstallPlugin(@PathVariable(value = "name") String name) { diff --git a/core/datacap-service/src/main/java/io/edurt/datacap/service/service/JwtService.java b/core/datacap-service/src/main/java/io/edurt/datacap/service/service/JwtService.java index 787ac9c90f..d9301a8e5a 100644 --- a/core/datacap-service/src/main/java/io/edurt/datacap/service/service/JwtService.java +++ b/core/datacap-service/src/main/java/io/edurt/datacap/service/service/JwtService.java @@ -12,18 +12,35 @@ import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; +import javax.annotation.PostConstruct; + import java.util.Date; @Slf4j @Component public class JwtService { + private static final String DEFAULT_SECRET = "DataCapSecretKey"; + @Value("${datacap.security.secret}") - private String jwtSecret = "DataCapSecretKey"; + private String jwtSecret = DEFAULT_SECRET; @Value("${datacap.security.expiration}") private int jwtExpirationMs = 86400000; + @PostConstruct + public void warnOnDefaultSecret() + { + // 默认密钥是公开已知的,攻击者可以直接伪造任意用户的合法令牌 + if (DEFAULT_SECRET.equals(jwtSecret)) { + log.warn("=================================================================================="); + log.warn("DataCap is running with the well-known default JWT secret (DataCapSecretKey)."); + log.warn("Anyone can forge a valid token for any user, including admin. Configure a unique"); + log.warn("random value for 'datacap.security.secret' before exposing this server."); + log.warn("=================================================================================="); + } + } + public String generateJwtToken(Authentication authentication) { UserDetailsService userPrincipal = (UserDetailsService) authentication.getPrincipal(); From 84829cb0b4c3a0b077a7a87926adf359fa5ba124 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 07:02:31 -0400 Subject: [PATCH 29/34] test(service): cover pipeline executor matching null guard Extract findPipelineExecutor from convertFieldBody so the matching logic is unit testable, and cover the issue #953 regression: entries missing executor/type, null pipelines and null lookup arguments must be skipped instead of raising NullPointerException. Fixes #953 --- .../service/common/ConfigureUtils.java | 29 +++++++-- .../service/common/ConfigureUtilsTest.java | 64 +++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 core/datacap-service/src/test/java/io/edurt/datacap/service/common/ConfigureUtilsTest.java diff --git a/core/datacap-service/src/main/java/io/edurt/datacap/service/common/ConfigureUtils.java b/core/datacap-service/src/main/java/io/edurt/datacap/service/common/ConfigureUtils.java index eb3ba0be3c..43ee4634b4 100644 --- a/core/datacap-service/src/main/java/io/edurt/datacap/service/common/ConfigureUtils.java +++ b/core/datacap-service/src/main/java/io/edurt/datacap/service/common/ConfigureUtils.java @@ -294,12 +294,10 @@ public static PipelineFieldBody convertFieldBody(SourceEntity entity, String exe // Pipeline 条目可能缺少 executor/type 字段(如 Doris 的 YAML 配置), // 直接在条目上调用 equals 会抛出 NPE,比较方向必须固定 - Optional.ofNullable(yamlConfigure.getPipelines()) - .orElseGet(ArrayList::new) - .stream() - .filter(v -> Objects.equals(v.getExecutor(), executor) && Objects.equals(v.getType(), pipelineType)) - .findFirst() - .ifPresent(iConfigureExecutor -> body.setConfigures(mergeProperties(entity, iConfigureExecutor.getFields(), originProperties))); + IConfigureExecutor matchedExecutor = findPipelineExecutor(yamlConfigure, executor, pipelineType); + if (matchedExecutor != null) { + body.setConfigures(mergeProperties(entity, matchedExecutor.getFields(), originProperties)); + } if (body.getConfigures() == null) { body.setConfigures(new Properties()); } @@ -311,6 +309,25 @@ public static PipelineFieldBody convertFieldBody(SourceEntity entity, String exe } } + /** + * Finds the pipeline executor matching the given executor and pipeline type. + * Entries missing executor or type are skipped safely instead of raising NPE. + * + * @param yamlConfigure YAML 配置 | YAML configuration + * @param executor 执行器 | Executor name + * @param pipelineType 管道类型 | Pipeline type + * @return 匹配的执行器配置,未命中时返回 null | Matched executor configure, or null + */ + static IConfigureExecutor findPipelineExecutor(IConfigure yamlConfigure, String executor, IConfigurePipelineType pipelineType) + { + return Optional.ofNullable(yamlConfigure.getPipelines()) + .orElseGet(ArrayList::new) + .stream() + .filter(v -> Objects.equals(v.getExecutor(), executor) && Objects.equals(v.getType(), pipelineType)) + .findFirst() + .orElse(null); + } + /** * Converts options from a source entity into a set of strings. * diff --git a/core/datacap-service/src/test/java/io/edurt/datacap/service/common/ConfigureUtilsTest.java b/core/datacap-service/src/test/java/io/edurt/datacap/service/common/ConfigureUtilsTest.java new file mode 100644 index 0000000000..1a6f536323 --- /dev/null +++ b/core/datacap-service/src/test/java/io/edurt/datacap/service/common/ConfigureUtilsTest.java @@ -0,0 +1,64 @@ +package io.edurt.datacap.service.common; + +import io.edurt.datacap.service.configure.IConfigure; +import io.edurt.datacap.service.configure.IConfigureExecutor; +import io.edurt.datacap.service.configure.IConfigurePipelineType; +import org.junit.Assert; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +public class ConfigureUtilsTest +{ + @Test + public void testFindPipelineExecutorSkipsIncompleteEntries() + { + // 复现 issue #953:Doris 的 YAML 中存在缺少 executor/type 的 pipeline 条目 + IConfigure configure = new IConfigure(); + List pipelines = new ArrayList<>(); + pipelines.add(new IConfigureExecutor()); + configure.setPipelines(pipelines); + + Assert.assertNull(ConfigureUtils.findPipelineExecutor(configure, "TRUNCATE", IConfigurePipelineType.INPUT)); + } + + @Test + public void testFindPipelineExecutorMatches() + { + IConfigure configure = new IConfigure(); + IConfigureExecutor matched = new IConfigureExecutor(); + matched.setExecutor("TRUNCATE"); + matched.setType(IConfigurePipelineType.INPUT); + List pipelines = new ArrayList<>(); + pipelines.add(new IConfigureExecutor()); + pipelines.add(matched); + configure.setPipelines(pipelines); + + Assert.assertSame(matched, ConfigureUtils.findPipelineExecutor(configure, "TRUNCATE", IConfigurePipelineType.INPUT)); + } + + @Test + public void testFindPipelineExecutorWithNullPipelines() + { + IConfigure configure = new IConfigure(); + + Assert.assertNull(ConfigureUtils.findPipelineExecutor(configure, "TRUNCATE", IConfigurePipelineType.INPUT)); + } + + @Test + public void testFindPipelineExecutorWithNullArguments() + { + IConfigure configure = new IConfigure(); + IConfigureExecutor matched = new IConfigureExecutor(); + matched.setExecutor("TRUNCATE"); + matched.setType(IConfigurePipelineType.INPUT); + List pipelines = new ArrayList<>(); + pipelines.add(matched); + configure.setPipelines(pipelines); + + // 请求参数为 null 时也不能抛 NPE + Assert.assertNull(ConfigureUtils.findPipelineExecutor(configure, null, null)); + Assert.assertSame(matched, ConfigureUtils.findPipelineExecutor(configure, "TRUNCATE", IConfigurePipelineType.INPUT)); + } +} From deaf401405f4361a6312c3d6db6a2ca2707c752c Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 07:02:40 -0400 Subject: [PATCH 30/34] test(spi): cover url guard integration and metadata fallback wiring - url() must reject the issue #966 payloads: H2 INIT injected through the database field, parameter separators in host and the allowLoadLocalInfile env parameter, while valid URLs still build - getDatabases/getTables/getColumns must fall back to JDBC metadata when information_schema SQL fails on a healthy connection (issues #930, #957), pass successful responses through untouched and keep connection failures as-is Fixes #966 --- .../edurt/datacap/spi/PluginServiceTest.java | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 core/datacap-spi/src/test/java/io/edurt/datacap/spi/PluginServiceTest.java diff --git a/core/datacap-spi/src/test/java/io/edurt/datacap/spi/PluginServiceTest.java b/core/datacap-spi/src/test/java/io/edurt/datacap/spi/PluginServiceTest.java new file mode 100644 index 0000000000..bc8d17695c --- /dev/null +++ b/core/datacap-spi/src/test/java/io/edurt/datacap/spi/PluginServiceTest.java @@ -0,0 +1,187 @@ +package io.edurt.datacap.spi; + +import io.edurt.datacap.spi.model.Configure; +import io.edurt.datacap.spi.model.Response; +import org.junit.Assert; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public class PluginServiceTest +{ + private Configure configure() + { + Configure configure = new Configure(); + configure.setType("mysql"); + configure.setHost("127.0.0.1"); + configure.setPort(3306); + configure.setDatabase(Optional.of("test")); + return configure; + } + + @Test + public void testBuildUrl() + { + Assert.assertEquals("jdbc:mysql://127.0.0.1:3306/test", new PluginService() + { + }.url(configure())); + } + + @Test + public void testBuildUrlWithSsl() + { + Configure configure = configure(); + configure.setSsl(Optional.of(true)); + Assert.assertEquals("jdbc:mysql://127.0.0.1:3306/test?ssl=true", new PluginService() + { + }.url(configure)); + } + + @Test + public void testBuildUrlWithEnv() + { + Configure configure = configure(); + Map env = new HashMap<>(); + env.put("serverTimezone", "Asia/Shanghai"); + configure.setEnv(Optional.of(env)); + Assert.assertEquals("jdbc:mysql://127.0.0.1:3306/test?serverTimezone=Asia/Shanghai", new PluginService() + { + }.url(configure)); + } + + @Test(expected = IllegalArgumentException.class) + public void testBuildUrlRejectsH2InitInjectionViaDatabase() + { + // issue #966 的 H2 RCE 载荷:通过 database 注入 INIT 参数 + Configure configure = configure(); + configure.setDatabase(Optional.of("testdb;INIT=CREATE ALIAS EXEC AS 'void cmd_exec(String cmd) throws java.lang.Exception {Runtime.getRuntime().exec(cmd)\\;}'\\;CALL EXEC ('touch /tmp/pwned')\\;")); + new PluginService() + { + }.url(configure); + } + + @Test(expected = IllegalArgumentException.class) + public void testBuildUrlRejectsInjectionViaHost() + { + Configure configure = configure(); + configure.setHost("127.0.0.1?allowLoadLocalInfile=true"); + new PluginService() + { + }.url(configure); + } + + @Test(expected = IllegalArgumentException.class) + public void testBuildUrlRejectsForbiddenEnvParameter() + { + // issue #966 的 MySQL 任意文件读取载荷:allowLoadLocalInfile 参数 + Configure configure = configure(); + Map env = new HashMap<>(); + env.put("allow_load_local_infile", "true"); + configure.setEnv(Optional.of(env)); + new PluginService() + { + }.url(configure); + } + + @Test + public void testGetDatabasesPassesThroughSuccessfulResponse() + { + Response expected = Response.builder() + .isConnected(Boolean.TRUE) + .isSuccessful(Boolean.TRUE) + .build(); + PluginService service = new PluginService() + { + @Override + public Response execute(Configure configure, String content) + { + return expected; + } + }; + Assert.assertSame(expected, service.getDatabases(configure())); + } + + @Test + public void testGetDatabasesFallsBackWhenSqlFails() + { + // SQL 执行失败(连接正常)时必须走 JDBC 元数据回退,而不是原样返回失败 + PluginService service = new PluginService() + { + @Override + public Response execute(Configure configure, String content) + { + return Response.builder() + .isConnected(Boolean.TRUE) + .isSuccessful(Boolean.FALSE) + .message("sql failed") + .build(); + } + }; + Response response = service.getDatabases(configure()); + Assert.assertEquals(Boolean.FALSE, response.getIsSuccessful()); + // 测试环境没有真实数据源,回退在打开 JDBC 连接时按预期终止 + Assert.assertEquals("Connection is not available or not a JDBC connection", response.getMessage()); + } + + @Test + public void testGetDatabasesKeepsResponseWhenConnectionFailed() + { + // 连接本身失败时不触发回退,原样返回连接失败信息 + Response expected = Response.builder() + .isConnected(Boolean.FALSE) + .isSuccessful(Boolean.FALSE) + .message("connect refused") + .build(); + PluginService service = new PluginService() + { + @Override + public Response execute(Configure configure, String content) + { + return expected; + } + }; + Assert.assertSame(expected, service.getDatabases(configure())); + } + + @Test + public void testGetTablesFallsBackWhenSqlFails() + { + PluginService service = new PluginService() + { + @Override + public Response execute(Configure configure, String content) + { + return Response.builder() + .isConnected(Boolean.TRUE) + .isSuccessful(Boolean.FALSE) + .message("sql failed") + .build(); + } + }; + Response response = service.getTables(configure(), "test"); + Assert.assertEquals(Boolean.FALSE, response.getIsSuccessful()); + Assert.assertEquals("Connection is not available or not a JDBC connection", response.getMessage()); + } + + @Test + public void testGetColumnsFallsBackWhenSqlFails() + { + PluginService service = new PluginService() + { + @Override + public Response execute(Configure configure, String content) + { + return Response.builder() + .isConnected(Boolean.TRUE) + .isSuccessful(Boolean.FALSE) + .message("sql failed") + .build(); + } + }; + Response response = service.getColumns(configure(), "test", "t"); + Assert.assertEquals(Boolean.FALSE, response.getIsSuccessful()); + Assert.assertEquals("Connection is not available or not a JDBC connection", response.getMessage()); + } +} From fe8f4c5781d81309d5a6e8833b2bbd3638d2f756 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 07:02:40 -0400 Subject: [PATCH 31/34] test(captcha): cover expression readability regression Generate 200 captchas and assert every expression uses the full-height multiplication sign (no tiny asterisk), only contains supported operators and that multiplication results still match their operands. Fixes #863 --- .../datacap/captcha/CalculateUtilsTest.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 core/datacap-captcha/src/test/java/io/edurt/datacap/captcha/CalculateUtilsTest.java diff --git a/core/datacap-captcha/src/test/java/io/edurt/datacap/captcha/CalculateUtilsTest.java b/core/datacap-captcha/src/test/java/io/edurt/datacap/captcha/CalculateUtilsTest.java new file mode 100644 index 0000000000..3e384e252a --- /dev/null +++ b/core/datacap-captcha/src/test/java/io/edurt/datacap/captcha/CalculateUtilsTest.java @@ -0,0 +1,34 @@ +package io.edurt.datacap.captcha; + +import io.edurt.datacap.captcha.entity.ResultEntity; +import org.junit.Assert; +import org.junit.Test; + +public class CalculateUtilsTest +{ + @Test + public void testExpressionIsReadable() + { + // issue #863:乘号必须是全高的 ×,不允许再出现细小的 * + for (int i = 0; i < 200; i++) { + ResultEntity entity = CalculateUtils.generate(); + Assert.assertNotNull(entity.getImage()); + + String[] expression = entity.getExpression(); + Assert.assertEquals(5, expression.length); + Assert.assertEquals("=", expression[3]); + Assert.assertEquals("?", expression[4]); + + String operator = expression[1]; + Assert.assertTrue("unexpected operator: " + operator, + "+".equals(operator) || "-".equals(operator) + || "×".equals(operator) || "÷".equals(operator)); + + int first = Integer.parseInt(expression[0]); + int last = Integer.parseInt(expression[2]); + if ("×".equals(operator)) { + Assert.assertEquals(first * last, entity.getResult().intValue()); + } + } + } +} From aaa7b03a2a1c725bb0c4dfeb2cb9edc9d1742e2b Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 07:39:43 -0400 Subject: [PATCH 32/34] fix(condor): delete all rows on null condition and unquote literals Two defects surfaced once the SQL engine tests actually ran: - TableManager.delete inverted the no-condition case: DELETE without a WHERE clause kept every row and reported 0 deletions. A null condition now matches all rows. - The parser keeps the raw text of string literals, so stored values and WHERE/SET comparisons carried surrounding single quotes. SQLExecutor now strips them at the insert, update and condition boundaries. --- .../io/edurt/datacap/condor/SQLExecutor.java | 31 ++++++++++++++++--- .../datacap/condor/manager/TableManager.java | 8 +++-- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/core/datacap-condor/src/main/java/io/edurt/datacap/condor/SQLExecutor.java b/core/datacap-condor/src/main/java/io/edurt/datacap/condor/SQLExecutor.java index 2b18e8625f..ecd794f5ca 100644 --- a/core/datacap-condor/src/main/java/io/edurt/datacap/condor/SQLExecutor.java +++ b/core/datacap-condor/src/main/java/io/edurt/datacap/condor/SQLExecutor.java @@ -245,18 +245,24 @@ private SQLResult executeDropTable(DropTableStatement statement) private SQLResult executeInsert(InsertStatement statement) { try { - if (statement.getSimpleValues().size() == 1) { + List> simpleValues = statement.getSimpleValues() + .stream() + .map(row -> row.stream() + .map(SQLExecutor::unquoteLiteral) + .collect(Collectors.toList())) + .collect(Collectors.toList()); + if (simpleValues.size() == 1) { tableManager.insert( statement.getTableName(), statement.getColumns(), - statement.getSimpleValues().get(0) + simpleValues.get(0) ); } else { tableManager.batchInsert( statement.getTableName(), statement.getColumns(), - statement.getSimpleValues() + simpleValues ); } @@ -307,7 +313,7 @@ private SQLResult executeUpdate(UpdateStatement statement) Map setValues = new HashMap<>(); for (Map.Entry entry : statement.getSetValues().entrySet()) { - setValues.put(entry.getKey(), entry.getValue().getValue()); + setValues.put(entry.getKey(), unquoteLiteral(entry.getValue().getValue())); } int updatedCount = tableManager.update( @@ -367,7 +373,7 @@ private Condition convertExpressionToCondition(Expression expression) Expression rightExpr = children.get(1); String columnName = (String) leftExpr.getValue(); - Object value = rightExpr.getValue(); + Object value = unquoteLiteral(rightExpr.getValue()); ComparisonOperator compOp = convertOperator(operator); return new SimpleCondition(columnName, value, compOp); @@ -376,6 +382,21 @@ private Condition convertExpressionToCondition(Expression expression) return null; } + /** + * 剥离 SQL 字符串字面量包裹的单引号(解析器保留原始文本), + * 保证存储值与 WHERE / SET 字面量比较时的一致性 + */ + private static Object unquoteLiteral(Object value) + { + if (value instanceof String) { + String text = (String) value; + if (text.length() >= 2 && text.startsWith("'") && text.endsWith("'")) { + return text.substring(1, text.length() - 1).replace("''", "'"); + } + } + return value; + } + private ComparisonOperator convertOperator(String operator) { switch (operator) { diff --git a/core/datacap-condor/src/main/java/io/edurt/datacap/condor/manager/TableManager.java b/core/datacap-condor/src/main/java/io/edurt/datacap/condor/manager/TableManager.java index 9d16562505..ab8b323fd4 100644 --- a/core/datacap-condor/src/main/java/io/edurt/datacap/condor/manager/TableManager.java +++ b/core/datacap-condor/src/main/java/io/edurt/datacap/condor/manager/TableManager.java @@ -205,11 +205,13 @@ public int delete(String tableName, Condition whereCondition) int deletedCount = 0; for (RowDefinition row : rows) { - if (whereCondition == null || !whereCondition.evaluate(row)) { - remainingRows.add(row); + // 无条件(null)表示删除全部行;有条件时仅删除命中的行 + boolean matched = whereCondition == null || whereCondition.evaluate(row); + if (matched) { + deletedCount++; } else { - deletedCount++; + remainingRows.add(row); } } From e367a3c3532c2a007f7b318c1febd490fe5af35a Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 07:39:52 -0400 Subject: [PATCH 33/34] fix(ci): execute the test stage unconditionally The Run Tests step guarded itself with `if: ${{ env.QINIU_ENDPOINT != '' }}`, but a step cannot read its own env mapping in the if expression and no workflow-level env exists, so the condition always evaluated false and the whole test stage was silently skipped. The object storage secrets are only forwarded as -D system properties for the fs tests, which now skip on their own when the properties are absent, so the guard is unnecessary. --- .github/workflows/bofore_checker.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bofore_checker.yml b/.github/workflows/bofore_checker.yml index 9d0b14f12b..7046a8f9c0 100644 --- a/.github/workflows/bofore_checker.yml +++ b/.github/workflows/bofore_checker.yml @@ -97,8 +97,10 @@ jobs: echo "QINIU_ENDPOINT is set: ${{ secrets.QINIU_ENDPOINT != '' }}" echo "QINIU_ENDPOINT length: ${#QINIU_ENDPOINT}" + # 对象存储密钥仅通过 -D 系统属性透传(当前无测试消费)。 + # 注意:step 的 if 中读取不到自身 env 映射,原来的 + # `if: ${{ env.QINIU_ENDPOINT != '' }}` 恒为空,导致测试被静默跳过。 - name: Run Tests - if: ${{ env.QINIU_ENDPOINT != '' }} env: QINIU_ACCESS: ${{ secrets.QINIU_ACCESS }} QINIU_SECRET: ${{ secrets.QINIU_SECRET }} From 5b3ffd377f6a9a39587dfb2dccff0880d240ca51 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Sat, 19 Sep 2026 07:40:04 -0400 Subject: [PATCH 34/34] test: skip container and external-resource tests when unavailable With the test stage now actually running, several integration tests hard-fail in environments that cannot satisfy their dependencies: - Testcontainers based tests (Redis, Mongo, Dameng, Influxdb) assume a usable Docker environment and skip otherwise; Dameng additionally skips on non-amd64 hosts because its image ships amd64 only - Redis and Mongo containers drop their fixed host port bindings in favor of dynamically mapped ports to avoid host port conflicts - Tests downloading 2024.4.0-SNAPSHOT artifacts from the external CDN (executor remote plugin install, tar plugin loader) are gated behind -Ddatacap.test.remotePlugin=true; the stale artifacts are no longer recognized by the current plugin SPI - Cloud storage IO tests (s3/cos/alioss) skip when their credentials are not provided via -D.* system properties Docker Desktop 29 rejects docker-java's default API version with HTTP 400, so the guards pin api.version=1.41 unless explicitly set. --- .../edurt/datacap/plugin/DmServiceTest.java | 32 +++++++++++++++++-- .../test/plugin/TarPluginLoaderTest.kt | 14 ++++++++ .../datacap/test/mongo/MongoJdbcBaseTest.java | 32 ++++++++++++++++--- .../datacap/test/redis/RedisJdbcBaseTest.java | 32 ++++++++++++++++--- .../test/local/LocalExecutorServiceTest.kt | 14 ++++++++ .../io/edurt/datacap/test/BaseIOUtilsTest.kt | 7 ++++ .../plugin/influxdb/InfluxdbServiceTest.kt | 22 +++++++++++++ 7 files changed, 143 insertions(+), 10 deletions(-) diff --git a/plugin/datacap-plugin-dm/src/test/java/io/edurt/datacap/plugin/DmServiceTest.java b/plugin/datacap-plugin-dm/src/test/java/io/edurt/datacap/plugin/DmServiceTest.java index 5e0d8e701a..b1dc54e6eb 100644 --- a/plugin/datacap-plugin-dm/src/test/java/io/edurt/datacap/plugin/DmServiceTest.java +++ b/plugin/datacap-plugin-dm/src/test/java/io/edurt/datacap/plugin/DmServiceTest.java @@ -7,7 +7,9 @@ import lombok.extern.slf4j.Slf4j; import org.junit.After; import org.junit.Assert; +import org.junit.Assume; import org.junit.Before; +import org.junit.BeforeClass; import org.junit.Test; import java.net.URL; @@ -28,6 +30,28 @@ public class DmServiceTest private Plugin plugin; private Configure configure; + @BeforeClass + public static void assumeDockerAvailable() + { + // 本地无 Docker 环境时跳过达梦容器集成测试,CI(带 Docker)仍然执行 + // Docker Desktop(Engine 29)会以 HTTP 400 拒绝 docker-java 默认 API 版本,钉一个双方都支持的版本 + if (System.getProperty("api.version") == null && System.getenv("DOCKER_API_VERSION") == null) { + System.setProperty("api.version", "1.41"); + } + boolean available; + try { + available = org.testcontainers.DockerClientFactory.instance().isDockerAvailable(); + } + catch (Throwable ex) { + available = false; + } + Assume.assumeTrue("Docker is not available, skipping Dameng container tests", available); + // 镜像 hanyf/damengdb 仅发布 linux/amd64,Apple Silicon 等环境无法运行 + String arch = System.getProperty("os.arch", ""); + Assume.assumeTrue("Dameng image is amd64-only, skipping on " + arch, + "amd64".equals(arch) || "x86_64".equals(arch)); + } + @Before public void init() { @@ -37,8 +61,12 @@ public void init() @After public void destroy() { - CONTAINER.stop(); - pluginManager.destroy(); + if (CONTAINER.isRunning()) { + CONTAINER.stop(); + } + if (pluginManager != null) { + pluginManager.destroy(); + } } private void initPluginManager() diff --git a/test/datacap-test-core/src/test/kotlin/io/edurt/datacap/test/plugin/TarPluginLoaderTest.kt b/test/datacap-test-core/src/test/kotlin/io/edurt/datacap/test/plugin/TarPluginLoaderTest.kt index 8f80ac839c..4e303922e6 100644 --- a/test/datacap-test-core/src/test/kotlin/io/edurt/datacap/test/plugin/TarPluginLoaderTest.kt +++ b/test/datacap-test-core/src/test/kotlin/io/edurt/datacap/test/plugin/TarPluginLoaderTest.kt @@ -2,12 +2,26 @@ package io.edurt.datacap.test.plugin import io.edurt.datacap.plugin.Plugin import io.edurt.datacap.plugin.loader.TarPluginLoader +import org.junit.Assume +import org.junit.BeforeClass import org.junit.Test import java.nio.file.Path import kotlin.test.assertTrue class TarPluginLoaderTest { + companion object + { + @BeforeClass + @JvmStatic + fun requireRemoteArtifact() + { + // 测试依赖从外部 CDN 下载 2024.4.0-SNAPSHOT 的历史构件,默认跳过保证 CI 稳定; + // 需要时通过 -Ddatacap.test.remotePlugin=true 显式开启。 + Assume.assumeTrue("Remote CDN artifact is not available", java.lang.Boolean.getBoolean("datacap.test.remotePlugin")) + } + } + private val tarUrl = "https://cdn.north.devlive.org/applications/datacap/plugins/2024.4.0-SNAPSHOT/convert/datacap-convert-txt-bin.tar.gz" @Test diff --git a/test/datacap-test-driver/src/test/java/io/edurt/datacap/test/mongo/MongoJdbcBaseTest.java b/test/datacap-test-driver/src/test/java/io/edurt/datacap/test/mongo/MongoJdbcBaseTest.java index 070c0b8be5..703def6bbe 100644 --- a/test/datacap-test-driver/src/test/java/io/edurt/datacap/test/mongo/MongoJdbcBaseTest.java +++ b/test/datacap-test-driver/src/test/java/io/edurt/datacap/test/mongo/MongoJdbcBaseTest.java @@ -6,8 +6,9 @@ import lombok.extern.slf4j.Slf4j; import org.bson.Document; import org.junit.After; +import org.junit.Assume; import org.junit.Before; -import org.junit.ClassRule; +import org.junit.BeforeClass; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.utility.DockerImageName; @@ -23,7 +24,6 @@ public abstract class MongoJdbcBaseTest { // Static container instance shared by all test classes - @ClassRule public static final GenericContainer MONGO_CONTAINER = new GenericContainer(DockerImageName.parse("mongo")) .withExposedPorts(27017) .withEnv("MONGO_INITDB_ROOT_USERNAME", "mongoadmin") @@ -36,6 +36,14 @@ public abstract class MongoJdbcBaseTest protected Connection connection; protected MongoClient mongoClient; + @BeforeClass + public static void assumeDockerAvailable() + { + // 本地无 Docker 环境时跳过容器集成测试,CI(带 Docker)仍然执行 + Assume.assumeTrue("Docker is not available, skipping Mongo container tests", + MONGO_CONTAINER.isRunning()); + } + @Before public void init() { @@ -112,7 +120,23 @@ public void cleanup() } static { - MONGO_CONTAINER.setPortBindings(List.of("27017:27017")); - MONGO_CONTAINER.start(); + // 无 Docker 时跳过启动,交由 assumeDockerAvailable 标记跳过 + if (isDockerAvailable()) { + MONGO_CONTAINER.start(); + } + } + + private static boolean isDockerAvailable() + { + // Docker Desktop(Engine 29)会以 HTTP 400 拒绝 docker-java 默认 API 版本,钉一个双方都支持的版本 + if (System.getProperty("api.version") == null && System.getenv("DOCKER_API_VERSION") == null) { + System.setProperty("api.version", "1.41"); + } + try { + return org.testcontainers.DockerClientFactory.instance().isDockerAvailable(); + } + catch (Throwable ex) { + return false; + } } } diff --git a/test/datacap-test-driver/src/test/java/io/edurt/datacap/test/redis/RedisJdbcBaseTest.java b/test/datacap-test-driver/src/test/java/io/edurt/datacap/test/redis/RedisJdbcBaseTest.java index 5c37e03317..6a17401bf3 100644 --- a/test/datacap-test-driver/src/test/java/io/edurt/datacap/test/redis/RedisJdbcBaseTest.java +++ b/test/datacap-test-driver/src/test/java/io/edurt/datacap/test/redis/RedisJdbcBaseTest.java @@ -1,8 +1,9 @@ package io.edurt.datacap.test.redis; import org.junit.After; +import org.junit.Assume; import org.junit.Before; -import org.junit.ClassRule; +import org.junit.BeforeClass; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.redisson.Redisson; @@ -26,7 +27,6 @@ public abstract class RedisJdbcBaseTest { private static final Logger log = LoggerFactory.getLogger(RedisJdbcBaseTest.class); - @ClassRule public static final GenericContainer REDIS_CONTAINER = new GenericContainer<>(DockerImageName.parse("redis:7-alpine")) .withExposedPorts(6379) .waitingFor(Wait.forListeningPort() @@ -36,6 +36,14 @@ public abstract class RedisJdbcBaseTest protected Connection connection; protected RedissonClient redissonClient; + @BeforeClass + public static void assumeDockerAvailable() + { + // 本地无 Docker 环境时跳过容器集成测试,CI(带 Docker)仍然执行 + Assume.assumeTrue("Docker is not available, skipping Redis container tests", + REDIS_CONTAINER.isRunning()); + } + @Before public void init() { @@ -108,7 +116,23 @@ public void cleanup() } static { - REDIS_CONTAINER.setPortBindings(List.of("6379:6379")); - REDIS_CONTAINER.start(); + // 无 Docker 时跳过启动,交由 assumeDockerAvailable 标记跳过 + if (isDockerAvailable()) { + REDIS_CONTAINER.start(); + } + } + + private static boolean isDockerAvailable() + { + // Docker Desktop(Engine 29)会以 HTTP 400 拒绝 docker-java 默认 API 版本,钉一个双方都支持的版本 + if (System.getProperty("api.version") == null && System.getenv("DOCKER_API_VERSION") == null) { + System.setProperty("api.version", "1.41"); + } + try { + return org.testcontainers.DockerClientFactory.instance().isDockerAvailable(); + } + catch (Throwable ex) { + return false; + } } } diff --git a/test/datacap-test-executor/src/test/kotlin/io/edurt/datacap/test/local/LocalExecutorServiceTest.kt b/test/datacap-test-executor/src/test/kotlin/io/edurt/datacap/test/local/LocalExecutorServiceTest.kt index 1d3b30075b..a56248eb9a 100644 --- a/test/datacap-test-executor/src/test/kotlin/io/edurt/datacap/test/local/LocalExecutorServiceTest.kt +++ b/test/datacap-test-executor/src/test/kotlin/io/edurt/datacap/test/local/LocalExecutorServiceTest.kt @@ -11,12 +11,26 @@ import io.edurt.datacap.plugin.utils.PluginPathUtils import io.edurt.datacap.spi.PluginService import io.edurt.datacap.spi.model.Configure import junit.framework.TestCase.assertNotNull +import org.junit.Assume +import org.junit.BeforeClass import org.junit.Test import java.nio.file.Path import java.util.* class LocalExecutorServiceTest { + companion object + { + @BeforeClass + @JvmStatic + fun requireRemoteArtifact() + { + // 测试依赖从外部 CDN 下载 2024.4.0-SNAPSHOT 的历史插件包,当前插件体系已无法识别。 + // 默认跳过以保证 CI 稳定;需要时通过 -Ddatacap.test.remotePlugin=true 显式开启。 + Assume.assumeTrue("Remote CDN plugin artifact is not available", java.lang.Boolean.getBoolean("datacap.test.remotePlugin")) + } + } + private val pluginManager: PluginManager private val pluginName = "Local" diff --git a/test/datacap-test-fs/src/test/kotlin/io/edurt/datacap/test/BaseIOUtilsTest.kt b/test/datacap-test-fs/src/test/kotlin/io/edurt/datacap/test/BaseIOUtilsTest.kt index 72042f2b50..53284b94a5 100644 --- a/test/datacap-test-fs/src/test/kotlin/io/edurt/datacap/test/BaseIOUtilsTest.kt +++ b/test/datacap-test-fs/src/test/kotlin/io/edurt/datacap/test/BaseIOUtilsTest.kt @@ -4,6 +4,7 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings import io.edurt.datacap.fs.FsRequest import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue +import org.junit.Assume import org.junit.Before import org.junit.FixMethodOrder import org.junit.Test @@ -28,6 +29,12 @@ abstract class BaseIOUtilsTest( @Before fun before() { + // 云存储测试需要真实凭据(CI 通过 -Ds3.* / -Dcos.* / -Dalioss.* 注入), + // 未配置对应 endpoint 时跳过,而不是让整个测试套件失败 + Assume.assumeTrue( + "Cloud storage credentials for [$pluginPrefix] are not configured, skipping", + System.getProperty("$pluginPrefix.endpoint") != null + ) request.apply { access = System.getProperty("$pluginPrefix.access") secret = System.getProperty("$pluginPrefix.secret") diff --git a/test/datacap-test-plugin/src/test/kotlin/io/edurt/datacap/plugin/influxdb/InfluxdbServiceTest.kt b/test/datacap-test-plugin/src/test/kotlin/io/edurt/datacap/plugin/influxdb/InfluxdbServiceTest.kt index 7f574515fd..140f09a817 100644 --- a/test/datacap-test-plugin/src/test/kotlin/io/edurt/datacap/plugin/influxdb/InfluxdbServiceTest.kt +++ b/test/datacap-test-plugin/src/test/kotlin/io/edurt/datacap/plugin/influxdb/InfluxdbServiceTest.kt @@ -7,8 +7,11 @@ import io.edurt.datacap.plugin.utils.PluginPathUtils import io.edurt.datacap.spi.PluginService import io.edurt.datacap.spi.model.Configure import org.junit.Assert.assertNotNull +import org.junit.Assume +import org.junit.BeforeClass import org.junit.Test import org.slf4j.LoggerFactory.getLogger +import org.testcontainers.DockerClientFactory import org.testcontainers.containers.Network import org.testcontainers.lifecycle.Startables import org.testcontainers.shaded.org.awaitility.Awaitility.given @@ -19,6 +22,25 @@ import java.util.concurrent.TimeUnit @SuppressFBWarnings(value = ["RV_RETURN_VALUE_IGNORED_INFERRED", "SA_LOCAL_SELF_ASSIGNMENT"]) class InfluxdbServiceTest { + companion object + { + @BeforeClass + @JvmStatic + fun assumeDockerAvailable() + { + // Docker Desktop(Engine 29)会以 HTTP 400 拒绝 docker-java 默认 API 版本,钉一个双方都支持的版本 + if (System.getProperty("api.version") == null && System.getenv("DOCKER_API_VERSION") == null) + { + System.setProperty("api.version", "1.41") + } + // 无 Docker 环境时跳过容器集成测试,CI(带 Docker)仍然执行 + Assume.assumeTrue( + "Docker is not available, skipping Influxdb container tests", + DockerClientFactory.instance().isDockerAvailable + ) + } + } + private val log = getLogger(this.javaClass) private val pluginManager: PluginManager private var container: InfluxdbContainer