mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-20 21:43:56 +08:00
✨ feat(data-sync/oceanbase): 拆分比对入口并修复 OceanBase Oracle 连接
- 数据同步:新增表结构比对、数据比对两个独立工具入口 - 比对模式:为 DataSyncModal 增加只读入口展示与模式化文案 - OceanBase:Oracle 租户改用 OB Oracle 专用 MySQL-wire 连接路径 - 连接表单:允许 OceanBase Oracle Service Name 留空,仅 TNS 场景需要填写 - 驱动提示:revision 不匹配提示收敛到驱动管理,不再在普通数据源入口弹出 - 测试覆盖:补充数据比对入口、OceanBase Oracle、driver-agent 提示边界测试
This commit is contained in:
@@ -9,6 +9,7 @@ import ConnectionModal from './components/ConnectionModal';
|
||||
import SnippetSettingsModal from './components/SnippetSettingsModal';
|
||||
import ConnectionPackagePasswordModal from './components/ConnectionPackagePasswordModal';
|
||||
import DataSyncModal from './components/DataSyncModal';
|
||||
import { type DataSyncEntryMode } from './components/dataSyncEntryMode';
|
||||
import DriverManagerModal from './components/DriverManagerModal';
|
||||
import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';
|
||||
import LogPanel from './components/LogPanel';
|
||||
@@ -208,6 +209,7 @@ function App() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isConnectionModalMounted, setIsConnectionModalMounted] = useState(false);
|
||||
const [isSyncModalOpen, setIsSyncModalOpen] = useState(false);
|
||||
const [syncModalEntryMode, setSyncModalEntryMode] = useState<DataSyncEntryMode>('sync');
|
||||
const [isDriverModalOpen, setIsDriverModalOpen] = useState(false);
|
||||
const [editingConnection, setEditingConnection] = useState<SavedConnection | null>(null);
|
||||
const connectionModalWarmupDoneRef = useRef(false);
|
||||
@@ -3759,13 +3761,36 @@ function App() {
|
||||
void handleExportConnections();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'schema-compare',
|
||||
icon: <AppstoreOutlined />,
|
||||
title: '表结构比对',
|
||||
description: '对比源表与目标表结构差异,只预览不执行。',
|
||||
onClick: () => {
|
||||
setIsToolsModalOpen(false);
|
||||
setSyncModalEntryMode('schemaCompare');
|
||||
setIsSyncModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'data-compare',
|
||||
icon: <SwitcherOutlined />,
|
||||
title: '数据比对',
|
||||
description: '按主键分析新增、更新、删除和相同行。',
|
||||
onClick: () => {
|
||||
setIsToolsModalOpen(false);
|
||||
setSyncModalEntryMode('dataCompare');
|
||||
setIsSyncModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sync',
|
||||
icon: <UploadOutlined rotate={90} />,
|
||||
title: '数据同步',
|
||||
description: '进入跨源同步工作流。',
|
||||
description: '进入可执行写入的跨源同步工作流。',
|
||||
onClick: () => {
|
||||
setIsToolsModalOpen(false);
|
||||
setSyncModalEntryMode('sync');
|
||||
setIsSyncModalOpen(true);
|
||||
},
|
||||
},
|
||||
@@ -3976,6 +4001,7 @@ function App() {
|
||||
<DataSyncModal
|
||||
open={isSyncModalOpen}
|
||||
onClose={() => setIsSyncModalOpen(false)}
|
||||
entryMode={syncModalEntryMode}
|
||||
/>
|
||||
)}
|
||||
{isDriverModalOpen && (
|
||||
|
||||
@@ -163,6 +163,16 @@ describe('ConnectionModal data source registry', () => {
|
||||
expect(source).toContain('type === "goldendb" ? "goldendb" : "mysql"');
|
||||
expect(source).toContain('? "goldendb"');
|
||||
});
|
||||
|
||||
it('keeps OceanBase Oracle service name optional for OBClient/MySQL-wire connections', () => {
|
||||
expect(source).toContain('OceanBase Oracle 服务名 (Service Name,可选)');
|
||||
expect(source).toContain('isOceanBaseOracle\n ? []');
|
||||
expect(source).toContain('连接 OBClient/OBServer MySQL-wire 入口时可留空');
|
||||
expect(source).toContain('只有连接 OBProxy Oracle listener/TNS 入口时才需要填写 SERVICE_NAME');
|
||||
expect(source).toContain('createUriAwareRequiredRule("请输入 Oracle 服务名(例如 ORCLPDB1)")');
|
||||
expect(source).not.toContain('请输入 OceanBase Oracle 服务名');
|
||||
expect(source).not.toContain('Oracle 租户必须填写监听器注册的 SERVICE_NAME');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConnectionModal Redis Sentinel configuration', () => {
|
||||
|
||||
@@ -2075,7 +2075,7 @@ const ConnectionModal: React.FC<{
|
||||
const scheme =
|
||||
dbType === "diros" ? "doris" : dbType === "starrocks" ? "starrocks" : dbType === "oceanbase" ? "oceanbase" : dbType === "goldendb" ? "goldendb" : "mysql";
|
||||
if (dbType === "oceanbase") {
|
||||
return `${scheme}://sys%40oracle001:pass@127.0.0.1:${defaultPort}/SERVICE_NAME?protocol=oracle`;
|
||||
return `${scheme}://sys%40oracle001:pass@127.0.0.1:${defaultPort}?protocol=oracle`;
|
||||
}
|
||||
return `${scheme}://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`;
|
||||
}
|
||||
@@ -4227,14 +4227,6 @@ const ConnectionModal: React.FC<{
|
||||
? currentDriverSnapshot.message ||
|
||||
`${currentDriverSnapshot.name || dbType} 驱动未安装启用`
|
||||
: "";
|
||||
const currentDriverUpdateReason =
|
||||
hasCurrentDriverType &&
|
||||
currentDriverSnapshot?.connectable &&
|
||||
currentDriverSnapshot.needsUpdate
|
||||
? currentDriverSnapshot.message ||
|
||||
currentDriverSnapshot.updateReason ||
|
||||
`${currentDriverSnapshot.name || dbType} 驱动代理需要重装后才能应用当前版本的驱动侧更新`
|
||||
: "";
|
||||
const driverStatusChecking =
|
||||
hasCurrentDriverType && !driverStatusLoaded && step === 2;
|
||||
|
||||
@@ -5246,9 +5238,9 @@ const ConnectionModal: React.FC<{
|
||||
label="OceanBase 协议"
|
||||
help={
|
||||
<span>
|
||||
MySQL 租户选择 MySQL;Oracle 租户选择 Oracle。GoNavi 会根据端口自动选择:OB MySQL wire 端口走 OBClient capability 注入(与 Navicat 相同路径),OBProxy Oracle listener 端口走标准 TNS。
|
||||
MySQL 租户选择 MySQL;Oracle 租户选择 Oracle。Oracle 协议会优先使用 OBClient/OBServer MySQL-wire 入口;只有连接 OBProxy Oracle listener/TNS 入口时才需要 Service Name。
|
||||
<br />
|
||||
如果 Oracle 租户连接报「Error 1235」或 OBClient 握手失败,可在「连接参数」字段通过 <code>connectionAttributes=key1:value1,key2:value2</code> 覆盖 GoNavi 默认注入的 OBClient capability。
|
||||
如果 MySQL 协议连接 Oracle 租户报「Error 1235」,请切换为 Oracle 协议;通常不需要手工配置 connectionAttributes。
|
||||
</span>
|
||||
}
|
||||
style={{ marginBottom: 0 }}
|
||||
@@ -5356,24 +5348,22 @@ const ConnectionModal: React.FC<{
|
||||
children: (
|
||||
<Form.Item
|
||||
name="database"
|
||||
label={isOceanBaseOracle ? "OceanBase Oracle 服务名 (Service Name)" : "服务名 (Service Name)"}
|
||||
rules={[
|
||||
createUriAwareRequiredRule(
|
||||
isOceanBaseOracle
|
||||
? "请输入 OceanBase Oracle 服务名"
|
||||
: "请输入 Oracle 服务名(例如 ORCLPDB1)",
|
||||
),
|
||||
]}
|
||||
label={isOceanBaseOracle ? "OceanBase Oracle 服务名 (Service Name,可选)" : "服务名 (Service Name)"}
|
||||
rules={
|
||||
isOceanBaseOracle
|
||||
? []
|
||||
: [createUriAwareRequiredRule("请输入 Oracle 服务名(例如 ORCLPDB1)")]
|
||||
}
|
||||
help={
|
||||
isOceanBaseOracle
|
||||
? "Oracle 租户必须填写监听器注册的 SERVICE_NAME;用户名仍按 OceanBase 租户格式填写。"
|
||||
? "连接 OBClient/OBServer MySQL-wire 入口时可留空;只有连接 OBProxy Oracle listener/TNS 入口时才需要填写 SERVICE_NAME。"
|
||||
: "请填写监听器注册的 SERVICE_NAME(不是用户名)。例如:ORCLPDB1"
|
||||
}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="例如:ORCLPDB1"
|
||||
placeholder={isOceanBaseOracle ? "TNS 入口例如 ORCLPDB1;OBClient/MySQL-wire 可留空" : "例如:ORCLPDB1"}
|
||||
/>
|
||||
</Form.Item>
|
||||
),
|
||||
@@ -6778,26 +6768,6 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{currentDriverUpdateReason && (
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
style={{ marginBottom: 12 }}
|
||||
message="当前数据源驱动代理建议重装"
|
||||
description={
|
||||
<Space size={8}>
|
||||
<span>{currentDriverUpdateReason}</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => onOpenDriverManager?.()}
|
||||
>
|
||||
去驱动管理重装
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(() => {
|
||||
const sectionItems: Array<{
|
||||
key: "basic" | "network" | "appearance";
|
||||
|
||||
32
frontend/src/components/DataSyncModal.entry-mode.test.ts
Normal file
32
frontend/src/components/DataSyncModal.entry-mode.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveDataSyncEntryModePresentation } from './dataSyncEntryMode';
|
||||
|
||||
describe('resolveDataSyncEntryModePresentation', () => {
|
||||
it('marks schema compare as a read-only independent entry', () => {
|
||||
const presentation = resolveDataSyncEntryModePresentation('schemaCompare');
|
||||
|
||||
expect(presentation.title).toBe('表结构比对');
|
||||
expect(presentation.analyzeButtonText).toBe('开始比对');
|
||||
expect(presentation.badgeText).toBe('结构比对');
|
||||
expect(presentation.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('marks data compare as a read-only independent entry', () => {
|
||||
const presentation = resolveDataSyncEntryModePresentation('dataCompare');
|
||||
|
||||
expect(presentation.title).toBe('数据比对');
|
||||
expect(presentation.tableSelectLabel).toContain('比对数据');
|
||||
expect(presentation.badgeText).toBe('数据比对');
|
||||
expect(presentation.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the original sync entry writable', () => {
|
||||
const presentation = resolveDataSyncEntryModePresentation('sync');
|
||||
|
||||
expect(presentation.title).toBe('数据同步工作台');
|
||||
expect(presentation.analyzeButtonText).toBe('对比差异');
|
||||
expect(presentation.badgeText).toBe('同步模式');
|
||||
expect(presentation.readOnly).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
|
||||
import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert';
|
||||
import { buildDataSyncRequest, type SourceDatasetMode, validateDataSyncSelection } from './dataSyncRequest';
|
||||
import { resolveDataSyncEntryModePresentation, type DataSyncEntryMode } from './dataSyncEntryMode';
|
||||
const { Title, Text } = Typography;
|
||||
const { Step } = Steps;
|
||||
const { Option } = Select;
|
||||
@@ -188,7 +189,11 @@ const buildSqlPreview = (
|
||||
};
|
||||
};
|
||||
|
||||
const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||
const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; entryMode?: DataSyncEntryMode }> = ({ open, onClose, entryMode = 'sync' }) => {
|
||||
const entryPresentation = resolveDataSyncEntryModePresentation(entryMode);
|
||||
const isSchemaCompareEntry = entryMode === 'schemaCompare';
|
||||
const isDataCompareEntry = entryMode === 'dataCompare';
|
||||
const isCompareEntry = entryPresentation.readOnly;
|
||||
const connections = useStore((state) => state.connections);
|
||||
const themeMode = useStore((state) => state.theme);
|
||||
const appearance = useStore((state) => state.appearance);
|
||||
@@ -217,7 +222,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
|
||||
// Options
|
||||
const [workflowType, setWorkflowType] = useState<WorkflowType>('sync');
|
||||
const [syncContent, setSyncContent] = useState<'data' | 'schema' | 'both'>('data');
|
||||
const [syncContent, setSyncContent] = useState<'data' | 'schema' | 'both'>(isSchemaCompareEntry ? 'schema' : 'data');
|
||||
const [syncMode, setSyncMode] = useState<string>('insert_update');
|
||||
const [autoAddColumns, setAutoAddColumns] = useState<boolean>(true);
|
||||
const [targetTableStrategy, setTargetTableStrategy] = useState<'existing_only' | 'auto_create_if_missing' | 'smart'>('existing_only');
|
||||
@@ -276,8 +281,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
});
|
||||
|
||||
return () => {
|
||||
offLog();
|
||||
offProgress();
|
||||
if (typeof offLog === 'function') offLog();
|
||||
if (typeof offProgress === 'function') offProgress();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
@@ -299,7 +304,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
setSourceDatasetMode('table');
|
||||
setSourceQuery('');
|
||||
setWorkflowType('sync');
|
||||
setSyncContent('data');
|
||||
setSyncContent(isSchemaCompareEntry ? 'schema' : 'data');
|
||||
setSyncMode('insert_update');
|
||||
setAutoAddColumns(true);
|
||||
setTargetTableStrategy('existing_only');
|
||||
@@ -319,9 +324,48 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
jobIdRef.current = '';
|
||||
autoScrollRef.current = true;
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, isSchemaCompareEntry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSchemaCompareEntry) {
|
||||
if (workflowType !== 'sync') {
|
||||
setWorkflowType('sync');
|
||||
}
|
||||
if (sourceDatasetMode !== 'table') {
|
||||
setSourceDatasetMode('table');
|
||||
}
|
||||
if (syncContent !== 'schema') {
|
||||
setSyncContent('schema');
|
||||
}
|
||||
if (syncMode !== 'insert_update') {
|
||||
setSyncMode('insert_update');
|
||||
}
|
||||
if (targetTableStrategy !== 'existing_only') {
|
||||
setTargetTableStrategy('existing_only');
|
||||
}
|
||||
if (createIndexes) {
|
||||
setCreateIndexes(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isDataCompareEntry) {
|
||||
if (workflowType !== 'sync') {
|
||||
setWorkflowType('sync');
|
||||
}
|
||||
if (syncContent !== 'data') {
|
||||
setSyncContent('data');
|
||||
}
|
||||
if (syncMode !== 'insert_update') {
|
||||
setSyncMode('insert_update');
|
||||
}
|
||||
if (targetTableStrategy !== 'existing_only') {
|
||||
setTargetTableStrategy('existing_only');
|
||||
}
|
||||
if (createIndexes) {
|
||||
setCreateIndexes(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (workflowType === 'migration') {
|
||||
if (syncMode === 'insert_update') {
|
||||
setSyncMode('insert_only');
|
||||
@@ -343,7 +387,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
setCreateIndexes(false);
|
||||
}
|
||||
}
|
||||
}, [workflowType]);
|
||||
}, [isSchemaCompareEntry, isDataCompareEntry, workflowType, sourceDatasetMode, syncContent, syncMode, targetTableStrategy, createIndexes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceDatasetMode !== 'query') return;
|
||||
@@ -371,38 +415,38 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
setSourceConnId(connId);
|
||||
setSourceDb('');
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
if (conn) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
||||
if (res.success) {
|
||||
const dbRows = Array.isArray(res.data) ? res.data : [];
|
||||
setSourceDbs(dbRows
|
||||
.map((r: any) => r?.Database || r?.database || r?.username)
|
||||
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
|
||||
}
|
||||
} catch(e) { message.error("Failed to fetch source databases"); }
|
||||
setLoading(false);
|
||||
}
|
||||
if (conn) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
||||
if (res.success) {
|
||||
const dbRows = Array.isArray(res.data) ? res.data : [];
|
||||
setSourceDbs(dbRows
|
||||
.map((r: any) => r?.Database || r?.database || r?.username)
|
||||
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
|
||||
}
|
||||
} catch(e) { message.error("Failed to fetch source databases"); }
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTargetConnChange = async (connId: string) => {
|
||||
setTargetConnId(connId);
|
||||
setTargetDb('');
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
if (conn) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
||||
if (res.success) {
|
||||
const dbRows = Array.isArray(res.data) ? res.data : [];
|
||||
setTargetDbs(dbRows
|
||||
.map((r: any) => r?.Database || r?.database || r?.username)
|
||||
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
|
||||
}
|
||||
} catch(e) { message.error("Failed to fetch target databases"); }
|
||||
setLoading(false);
|
||||
}
|
||||
if (conn) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
||||
if (res.success) {
|
||||
const dbRows = Array.isArray(res.data) ? res.data : [];
|
||||
setTargetDbs(dbRows
|
||||
.map((r: any) => r?.Database || r?.database || r?.username)
|
||||
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
|
||||
}
|
||||
} catch(e) { message.error("Failed to fetch target databases"); }
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const nextToTables = async () => {
|
||||
@@ -416,15 +460,15 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const dbName = isSourceQueryMode ? targetDb : sourceDb;
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
if (conn) {
|
||||
const config = normalizeConnConfig(conn, dbName);
|
||||
const res = await DBGetTables(config as any, dbName);
|
||||
if (res.success) {
|
||||
// DBGetTables returns [{Table: "name"}, ...]
|
||||
const tableRows = Array.isArray(res.data) ? res.data : [];
|
||||
const tables = tableRows
|
||||
.map((row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0])
|
||||
.filter((name: any) => typeof name === 'string' && name.trim() !== '');
|
||||
setAllTables(tables as string[]);
|
||||
const config = normalizeConnConfig(conn, dbName);
|
||||
const res = await DBGetTables(config as any, dbName);
|
||||
if (res.success) {
|
||||
// DBGetTables returns [{Table: "name"}, ...]
|
||||
const tableRows = Array.isArray(res.data) ? res.data : [];
|
||||
const tables = tableRows
|
||||
.map((row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0])
|
||||
.filter((name: any) => typeof name === 'string' && name.trim() !== '');
|
||||
setAllTables(tables as string[]);
|
||||
setSelectedTables(prev => {
|
||||
const existing = prev.filter((name) => tables.includes(name));
|
||||
if (isSourceQueryMode) {
|
||||
@@ -432,8 +476,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
}
|
||||
return existing;
|
||||
});
|
||||
setCurrentStep(1);
|
||||
} else {
|
||||
setCurrentStep(1);
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
}
|
||||
@@ -681,7 +725,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
}, [diffTables]);
|
||||
|
||||
const isSourceQueryMode = sourceDatasetMode === 'query';
|
||||
const isMigrationWorkflow = workflowType === 'migration';
|
||||
const isMigrationWorkflow = !isCompareEntry && workflowType === 'migration';
|
||||
const sourceConn = useMemo(() => connections.find(c => c.id === sourceConnId), [connections, sourceConnId]);
|
||||
const targetConn = useMemo(() => connections.find(c => c.id === targetConnId), [connections, targetConnId]);
|
||||
const sourceType = String(sourceConn?.config?.type || '').toLowerCase();
|
||||
@@ -797,7 +841,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={renderModalTitle(isMigrationWorkflow ? '跨库迁移工作台' : '数据同步工作台', isMigrationWorkflow ? '按源库 → 目标库完成建表、导入与风险预检。' : '按已有目标表完成差异对比、同步执行与结果确认。')}
|
||||
title={renderModalTitle(isMigrationWorkflow ? '跨库迁移工作台' : entryPresentation.title, isMigrationWorkflow ? '按源库 → 目标库完成建表、导入与风险预检。' : entryPresentation.description)}
|
||||
open={open}
|
||||
onCancel={() => {
|
||||
if (syncing) {
|
||||
@@ -830,15 +874,15 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
<div style={heroPanelStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: darkMode ? '#f8fafc' : '#0f172a' }}>{isMigrationWorkflow ? '跨数据源迁移' : '数据同步'}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: darkMode ? '#f8fafc' : '#0f172a' }}>{isMigrationWorkflow ? '跨数据源迁移' : entryPresentation.heroTitle}</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, lineHeight: 1.7, color: darkMode ? 'rgba(255,255,255,0.62)' : 'rgba(15,23,42,0.62)' }}>
|
||||
{isMigrationWorkflow
|
||||
? '适合把源表迁移到另一套数据库,可按策略自动建表、导入数据并补建可兼容索引。'
|
||||
: '适合目标表已存在的场景,先做差异分析,再按勾选执行插入、更新或删除。'}
|
||||
: entryPresentation.heroDescription}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<span style={badgeStyle}>{isMigrationWorkflow ? <RocketOutlined /> : <SwapOutlined />} {isMigrationWorkflow ? '迁移模式' : '同步模式'}</span>
|
||||
<span style={badgeStyle}>{isMigrationWorkflow ? <RocketOutlined /> : <SwapOutlined />} {isMigrationWorkflow ? '迁移模式' : entryPresentation.badgeText}</span>
|
||||
<span style={badgeStyle}><DatabaseOutlined /> {sourceConnId ? '已选源连接' : '待选源连接'}</span>
|
||||
<span style={badgeStyle}><TableOutlined /> {selectedTables.length || 0} 张表</span>
|
||||
</div>
|
||||
@@ -847,7 +891,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
<Steps current={currentStep} style={{ marginBottom: 24 }}>
|
||||
<Step title="配置源与目标" />
|
||||
<Step title="选择表" />
|
||||
<Step title="执行结果" />
|
||||
<Step title={entryPresentation.resultTitle} />
|
||||
</Steps>
|
||||
</div>
|
||||
|
||||
@@ -900,35 +944,45 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title={isMigrationWorkflow ? '迁移选项' : '同步选项'}
|
||||
title={isMigrationWorkflow ? '迁移选项' : entryPresentation.optionTitle}
|
||||
style={{ ...shellCardStyle, marginTop: 18 }}
|
||||
styles={{ header: { borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)', fontWeight: 700 }, body: { padding: 18 } }}
|
||||
>
|
||||
<div style={{ ...quietPanelStyle, marginBottom: 14 }}>
|
||||
<Text style={{ color: darkMode ? 'rgba(255,255,255,0.72)' : 'rgba(15,23,42,0.68)', lineHeight: 1.7 }}>
|
||||
先明确当前要做的是“已有目标表同步”还是“跨库迁移”,页面会按功能类型自动给出更安全的默认策略。
|
||||
{isCompareEntry
|
||||
? '当前入口只做差异分析和预览,不会执行同步、建表、补字段或删除数据。'
|
||||
: '先明确当前要做的是“已有目标表同步”还是“跨库迁移”,页面会按功能类型自动给出更安全的默认策略。'}
|
||||
</Text>
|
||||
</div>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="功能类型">
|
||||
<Select value={workflowType} onChange={setWorkflowType}>
|
||||
<Option value="sync">数据同步(基于已有目标表做差异同步)</Option>
|
||||
<Option value="migration" disabled={isSourceQueryMode}>跨库迁移(可自动建表后导入)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="源数据方式">
|
||||
<Select value={sourceDatasetMode} onChange={setSourceDatasetMode}>
|
||||
<Option value="table">按表同步</Option>
|
||||
<Option value="query">按 SQL 结果集同步</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{!isCompareEntry && (
|
||||
<Form.Item label="功能类型">
|
||||
<Select value={workflowType} onChange={setWorkflowType}>
|
||||
<Option value="sync">数据同步(基于已有目标表做差异同步)</Option>
|
||||
<Option value="migration" disabled={isSourceQueryMode}>跨库迁移(可自动建表后导入)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!isSchemaCompareEntry && (
|
||||
<Form.Item label="源数据方式">
|
||||
<Select value={sourceDatasetMode} onChange={setSourceDatasetMode}>
|
||||
<Option value="table">{isCompareEntry ? '按表比对' : '按表同步'}</Option>
|
||||
<Option value="query">{isCompareEntry ? '按 SQL 结果集比对' : '按 SQL 结果集同步'}</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Alert
|
||||
type={isMigrationWorkflow ? 'info' : 'success'}
|
||||
type={isMigrationWorkflow || isCompareEntry ? 'info' : 'success'}
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message={isMigrationWorkflow
|
||||
? '当前为“跨库迁移”模式:适合将表迁移到另一数据源,可自动建表并导入数据。'
|
||||
: '当前为“数据同步”模式:适合目标表已存在时做增量同步或覆盖导入。'}
|
||||
: isSchemaCompareEntry
|
||||
? '当前为“表结构比对”入口:固定只分析结构差异和生成可审阅 SQL,不执行变更。'
|
||||
: isDataCompareEntry
|
||||
? '当前为“数据比对”入口:固定按主键分析行级差异,不执行写入。'
|
||||
: '当前为“数据同步”模式:适合目标表已存在时做增量同步或覆盖导入。'}
|
||||
/>
|
||||
{isSourceQueryMode && (
|
||||
<Alert
|
||||
@@ -938,27 +992,33 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
message="SQL 结果集同步当前只支持:源端自定义 SQL -> 单个已存在目标表;查询结果需包含目标表主键列。"
|
||||
/>
|
||||
)}
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移内容' : '同步内容'}>
|
||||
<Select value={syncContent} onChange={setSyncContent}>
|
||||
<Option value="data">仅同步数据</Option>
|
||||
<Option value="schema" disabled={isSourceQueryMode}>仅同步结构</Option>
|
||||
<Option value="both" disabled={isSourceQueryMode}>同步结构 + 数据</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移模式' : '同步模式'}>
|
||||
<Select value={syncMode} onChange={setSyncMode} disabled={syncContent === 'schema'}>
|
||||
<Option value="insert_update">增量同步(对比差异,按插入/更新/删除勾选执行)</Option>
|
||||
<Option value="insert_only">仅插入(不对比目标;无主键表将跳过)</Option>
|
||||
<Option value="full_overwrite">全量覆盖(清空目标表后插入)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={isMigrationWorkflow ? '目标表处理策略' : '目标表要求'}>
|
||||
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow || isSourceQueryMode}>
|
||||
<Option value="existing_only">仅使用已有目标表</Option>
|
||||
<Option value="auto_create_if_missing">目标表不存在时自动建表后导入</Option>
|
||||
<Option value="smart">智能模式(存在则直接导入,不存在则自动建表)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{!isCompareEntry && (
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移内容' : '同步内容'}>
|
||||
<Select value={syncContent} onChange={setSyncContent}>
|
||||
<Option value="data">仅同步数据</Option>
|
||||
<Option value="schema" disabled={isSourceQueryMode}>仅同步结构</Option>
|
||||
<Option value="both" disabled={isSourceQueryMode}>同步结构 + 数据</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!isCompareEntry && (
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移模式' : '同步模式'}>
|
||||
<Select value={syncMode} onChange={setSyncMode} disabled={syncContent === 'schema'}>
|
||||
<Option value="insert_update">增量同步(对比差异,按插入/更新/删除勾选执行)</Option>
|
||||
<Option value="insert_only">仅插入(不对比目标;无主键表将跳过)</Option>
|
||||
<Option value="full_overwrite">全量覆盖(清空目标表后插入)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!isCompareEntry && (
|
||||
<Form.Item label={isMigrationWorkflow ? '目标表处理策略' : '目标表要求'}>
|
||||
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow || isSourceQueryMode}>
|
||||
<Option value="existing_only">仅使用已有目标表</Option>
|
||||
<Option value="auto_create_if_missing">目标表不存在时自动建表后导入</Option>
|
||||
<Option value="smart">智能模式(存在则直接导入,不存在则自动建表)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
{isRedisMongoKeyspaceMigration && (
|
||||
<Form.Item
|
||||
label="Mongo 集合名(可选)"
|
||||
@@ -975,16 +1035,22 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item>
|
||||
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}>
|
||||
自动补齐目标表缺失字段(按源/目标数据源选择可兼容规划器;SQL 结果集模式暂不支持)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}>
|
||||
自动迁移可兼容的普通索引/唯一索引(仅自动建表模式生效)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
{(!isCompareEntry || isSchemaCompareEntry) && (
|
||||
<Form.Item>
|
||||
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}>
|
||||
{isSchemaCompareEntry
|
||||
? '生成目标表缺失字段的兼容变更 SQL(仅预览,不执行)'
|
||||
: '自动补齐目标表缺失字段(按源/目标数据源选择可兼容规划器;SQL 结果集模式暂不支持)'}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!isCompareEntry && (
|
||||
<Form.Item>
|
||||
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}>
|
||||
自动迁移可兼容的普通索引/唯一索引(仅自动建表模式生效)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
)}
|
||||
{isMigrationWorkflow && targetTableStrategy !== 'existing_only' && (
|
||||
<Alert
|
||||
type="info"
|
||||
@@ -993,7 +1059,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
{!isMigrationWorkflow && (
|
||||
{!isCompareEntry && !isMigrationWorkflow && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
@@ -1020,7 +1086,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
{!isSourceQueryMode && (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<Text type="secondary">请选择需要同步的表:</Text>
|
||||
<Text type="secondary">{entryPresentation.tableSelectLabel}</Text>
|
||||
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
|
||||
显示相同表
|
||||
</Checkbox>
|
||||
@@ -1204,48 +1270,50 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
{currentStep === 2 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={quietPanelStyle}>
|
||||
<Alert
|
||||
message={syncing ? "正在同步" : (syncResult?.success ? "同步完成" : "同步失败")}
|
||||
description={
|
||||
syncing
|
||||
? `当前阶段:${syncProgress.stage || '执行中'}${syncProgress.table ? `,表:${syncProgress.table}` : ''}`
|
||||
: (syncResult?.message || `成功同步 ${syncResult?.tablesSynced || 0} 张表. 插入: ${syncResult?.rowsInserted || 0}, 更新: ${syncResult?.rowsUpdated || 0}`)
|
||||
}
|
||||
type={syncing ? "info" : (syncResult?.success ? "success" : "error")}
|
||||
showIcon
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<Progress
|
||||
percent={syncProgress.percent}
|
||||
status={syncing ? "active" : (syncResult?.success ? "success" : "exception")}
|
||||
format={() => `${syncProgress.current}/${syncProgress.total}`}
|
||||
<Alert
|
||||
message={syncing ? (isCompareEntry ? '正在比对' : '正在同步') : (syncResult?.success ? (isCompareEntry ? '比对完成' : '同步完成') : (isCompareEntry ? '比对失败' : '同步失败'))}
|
||||
description={
|
||||
syncing
|
||||
? `当前阶段:${syncProgress.stage || '执行中'}${syncProgress.table ? `,表:${syncProgress.table}` : ''}`
|
||||
: (syncResult?.message || (isCompareEntry
|
||||
? `成功比对 ${diffTables.length || syncResult?.tablesSynced || 0} 张表。`
|
||||
: `成功同步 ${syncResult?.tablesSynced || 0} 张表. 插入: ${syncResult?.rowsInserted || 0}, 更新: ${syncResult?.rowsUpdated || 0}`))
|
||||
}
|
||||
type={syncing ? "info" : (syncResult?.success ? "success" : "error")}
|
||||
showIcon
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<Progress
|
||||
percent={syncProgress.percent}
|
||||
status={syncing ? "active" : (syncResult?.success ? "success" : "exception")}
|
||||
format={() => `${syncProgress.current}/${syncProgress.total}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div style={quietPanelStyle}>
|
||||
<Divider orientation="left" style={{ marginTop: 0 }}>执行日志</Divider>
|
||||
<div
|
||||
ref={logBoxRef}
|
||||
onScroll={() => {
|
||||
const el = logBoxRef.current;
|
||||
if (!el) return;
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
autoScrollRef.current = nearBottom;
|
||||
}}
|
||||
style={{
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(248,250,252,0.92)',
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)',
|
||||
borderRadius: 14,
|
||||
padding: 12,
|
||||
height: 300,
|
||||
overflowY: 'auto',
|
||||
fontFamily: 'var(--gn-font-mono)'
|
||||
}}
|
||||
>
|
||||
{syncLogs.map((item, i: number) => <div key={i}>{renderSyncLogItem(item)}</div>)}
|
||||
</div>
|
||||
<Divider orientation="left" style={{ marginTop: 0 }}>{isCompareEntry ? '分析日志' : '执行日志'}</Divider>
|
||||
<div
|
||||
ref={logBoxRef}
|
||||
onScroll={() => {
|
||||
const el = logBoxRef.current;
|
||||
if (!el) return;
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
autoScrollRef.current = nearBottom;
|
||||
}}
|
||||
style={{
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(248,250,252,0.92)',
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)',
|
||||
borderRadius: 14,
|
||||
padding: 12,
|
||||
height: 300,
|
||||
overflowY: 'auto',
|
||||
fontFamily: 'var(--gn-font-mono)'
|
||||
}}
|
||||
>
|
||||
{syncLogs.map((item, i: number) => <div key={i}>{renderSyncLogItem(item)}</div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1256,25 +1324,32 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
{currentStep === 0 && (
|
||||
<Button type="primary" onClick={nextToTables} loading={loading}>下一步</Button>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<>
|
||||
<Button onClick={() => setCurrentStep(0)} style={{ marginRight: 8 }}>上一步</Button>
|
||||
<Button onClick={analyzeDiff} loading={loading} disabled={syncContent === 'schema' || selectedTables.length === 0 || analyzing || (isSourceQueryMode && !sourceQuery.trim())} style={{ marginRight: 8 }}>
|
||||
对比差异
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={runSync}
|
||||
loading={loading}
|
||||
disabled={selectedTables.length === 0 || (isSourceQueryMode && !sourceQuery.trim()) || (syncContent !== 'schema' && diffTables.length === 0)}
|
||||
>
|
||||
开始同步
|
||||
</Button>
|
||||
{currentStep === 1 && (
|
||||
<>
|
||||
<Button onClick={() => setCurrentStep(0)} style={{ marginRight: 8 }}>上一步</Button>
|
||||
<Button onClick={analyzeDiff} loading={loading} disabled={selectedTables.length === 0 || analyzing || (isSourceQueryMode && !sourceQuery.trim())} style={{ marginRight: 8 }}>
|
||||
{entryPresentation.analyzeButtonText}
|
||||
</Button>
|
||||
{isCompareEntry && (
|
||||
<Button onClick={onClose}>
|
||||
{entryPresentation.closeButtonText}
|
||||
</Button>
|
||||
)}
|
||||
{!isCompareEntry && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={runSync}
|
||||
loading={loading}
|
||||
disabled={selectedTables.length === 0 || (isSourceQueryMode && !sourceQuery.trim()) || (syncContent !== 'schema' && diffTables.length === 0)}
|
||||
>
|
||||
开始同步
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<>
|
||||
<Button disabled={syncing} onClick={() => setCurrentStep(1)} style={{ marginRight: 8 }}>继续同步</Button>
|
||||
<Button disabled={syncing} onClick={() => setCurrentStep(1)} style={{ marginRight: 8 }}>{isCompareEntry ? '返回比对' : '继续同步'}</Button>
|
||||
<Button type="primary" disabled={syncing} onClick={onClose}>关闭</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -1351,7 +1426,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
label: `插入(${previewData.totalInserts || 0})`,
|
||||
children: (
|
||||
<div>
|
||||
<Text type="secondary">未勾选任何行表示“同步全部插入差异”;如不想执行插入请在对比结果中取消勾选“插入”。</Text>
|
||||
<Text type="secondary">{isCompareEntry ? '行选择只影响 SQL 预览范围,不会执行写入。' : '未勾选任何行表示“同步全部插入差异”;如不想执行插入请在对比结果中取消勾选“插入”。'}</Text>
|
||||
<Table
|
||||
size="small"
|
||||
style={{ marginTop: 8 }}
|
||||
@@ -1376,7 +1451,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
label: `更新(${previewData.totalUpdates || 0})`,
|
||||
children: (
|
||||
<div>
|
||||
<Text type="secondary">未勾选任何行表示“同步全部更新差异”;如不想执行更新请在对比结果中取消勾选“更新”。</Text>
|
||||
<Text type="secondary">{isCompareEntry ? '行选择只影响 SQL 预览范围,不会执行写入。' : '未勾选任何行表示“同步全部更新差异”;如不想执行更新请在对比结果中取消勾选“更新”。'}</Text>
|
||||
<Table
|
||||
size="small"
|
||||
style={{ marginTop: 8 }}
|
||||
@@ -1427,7 +1502,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
children: (
|
||||
<div>
|
||||
<Alert type="warning" showIcon message="删除默认不勾选。请确认业务允许后再开启删除操作。" />
|
||||
<Text type="secondary">未勾选任何行表示“同步全部删除差异”;如不想执行删除请在对比结果中取消勾选“删除”。</Text>
|
||||
<Text type="secondary">{isCompareEntry ? '行选择只影响 SQL 预览范围,不会执行写入。' : '未勾选任何行表示“同步全部删除差异”;如不想执行删除请在对比结果中取消勾选“删除”。'}</Text>
|
||||
<Table
|
||||
size="small"
|
||||
style={{ marginTop: 8 }}
|
||||
@@ -1457,8 +1532,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
showIcon
|
||||
message={
|
||||
previewHasDataDiff
|
||||
? "SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
|
||||
: "SQL 预览展示将执行的结构变更语句,用于审核确认。"
|
||||
? (isCompareEntry ? 'SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,仅用于审核差异。' : 'SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。')
|
||||
: (isCompareEntry ? 'SQL 预览展示结构差异建议语句,仅用于审核差异。' : 'SQL 预览展示将执行的结构变更语句,用于审核确认。')
|
||||
}
|
||||
/>
|
||||
<div style={{ marginTop: 8, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import React from 'react';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DriverManagerModal from './DriverManagerModal';
|
||||
|
||||
const connectionModalSource = readFileSync(new URL('./ConnectionModal.tsx', import.meta.url), 'utf8');
|
||||
const driverManagerModalSource = readFileSync(new URL('./DriverManagerModal.tsx', import.meta.url), 'utf8');
|
||||
const sidebarSource = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
theme: 'light',
|
||||
appearance: {
|
||||
@@ -57,6 +62,20 @@ vi.mock('@ant-design/icons', () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe('driver-agent update prompt placement', () => {
|
||||
it('keeps revision mismatch prompts inside driver manager only', () => {
|
||||
expect(driverManagerModalSource).toContain('需要重装');
|
||||
expect(driverManagerModalSource).toContain('row.needsUpdate');
|
||||
|
||||
expect(connectionModalSource).not.toContain('当前数据源驱动代理建议重装');
|
||||
expect(connectionModalSource).not.toContain('去驱动管理重装');
|
||||
|
||||
expect(sidebarSource).not.toContain('warnIfConnectionDriverAgentNeedsUpdate');
|
||||
expect(sidebarSource).not.toContain('driver-agent-update-');
|
||||
expect(sidebarSource).not.toContain('驱动代理需要重装:');
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock('antd', () => {
|
||||
const Button: any = ({ children, disabled, loading, onClick, ...rest }: any) => (
|
||||
<button type="button" disabled={disabled || loading} onClick={onClick} {...rest}>
|
||||
|
||||
@@ -55,7 +55,7 @@ import {
|
||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types';
|
||||
import { getDbIcon } from './DatabaseIcons';
|
||||
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App';
|
||||
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities } from '../../wailsjs/go/app/App';
|
||||
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
@@ -91,7 +91,6 @@ import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
|
||||
import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts';
|
||||
import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree';
|
||||
import { SIDEBAR_SQL_EDITOR_DRAG_MIME, encodeSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag';
|
||||
import type { DriverStatusSnapshot } from '../utils/connectionDriverType';
|
||||
import JVMModeBadge from './jvm/JVMModeBadge';
|
||||
import MessagePublishModal from './MessagePublishModal';
|
||||
import {
|
||||
@@ -104,7 +103,6 @@ import {
|
||||
isExternalSQLDirectoryModalMode,
|
||||
isPostgresSchemaDialect,
|
||||
isV2SidebarObjectNode,
|
||||
normalizeDriverType,
|
||||
normalizeMySQLViewDDLForEditing,
|
||||
resolveSavedConnectionDriverType,
|
||||
resolveSidebarContextMenuPosition,
|
||||
@@ -247,8 +245,6 @@ interface BatchObjectItem {
|
||||
dataRef: any;
|
||||
}
|
||||
|
||||
const DRIVER_STATUS_CACHE_TTL_MS = 30_000;
|
||||
|
||||
const SEARCH_SCOPE_ICON_MAP: Record<SearchScope, React.ReactNode> = {
|
||||
smart: <ThunderboltOutlined />,
|
||||
object: <TableOutlined />,
|
||||
@@ -525,8 +521,6 @@ const Sidebar: React.FC<{
|
||||
selectedNodes: [],
|
||||
activeContext: null,
|
||||
});
|
||||
const driverStatusCacheRef = useRef<{ fetchedAt: number; items: Record<string, DriverStatusSnapshot> } | null>(null);
|
||||
const driverUpdateWarningKeysRef = useRef<Set<string>>(new Set());
|
||||
const connectionReloadSignaturesRef = useRef<Record<string, string>>({});
|
||||
const [contextMenu, setContextMenu] = useState<SidebarContextMenuState | null>(null);
|
||||
const contextMenuPortalRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -1825,67 +1819,8 @@ const Sidebar: React.FC<{
|
||||
return { schemas, supported: hasSuccessfulQuery };
|
||||
};
|
||||
|
||||
const fetchDriverStatusMap = async (): Promise<Record<string, DriverStatusSnapshot>> => {
|
||||
const cached = driverStatusCacheRef.current;
|
||||
if (cached && Date.now() - cached.fetchedAt < DRIVER_STATUS_CACHE_TTL_MS) {
|
||||
return cached.items;
|
||||
}
|
||||
const result: Record<string, DriverStatusSnapshot> = {};
|
||||
const res = await GetDriverStatusList('', '');
|
||||
if (!res?.success) {
|
||||
return result;
|
||||
}
|
||||
const data = (res.data || {}) as any;
|
||||
const drivers = Array.isArray(data.drivers) ? data.drivers : [];
|
||||
drivers.forEach((item: any) => {
|
||||
const type = normalizeDriverType(String(item.type || '').trim());
|
||||
if (!type) return;
|
||||
result[type] = {
|
||||
type,
|
||||
name: String(item.name || item.type || type).trim(),
|
||||
connectable: !!item.connectable,
|
||||
expectedRevision: String(item.expectedRevision || '').trim() || undefined,
|
||||
needsUpdate: !!item.needsUpdate,
|
||||
updateReason: String(item.updateReason || '').trim() || undefined,
|
||||
message: String(item.message || '').trim() || undefined,
|
||||
};
|
||||
});
|
||||
driverStatusCacheRef.current = { fetchedAt: Date.now(), items: result };
|
||||
return result;
|
||||
};
|
||||
|
||||
const warnIfConnectionDriverAgentNeedsUpdate = async (conn: SavedConnection) => {
|
||||
try {
|
||||
const driverType = resolveSavedConnectionDriverType(conn);
|
||||
if (!driverType || driverType === 'custom') {
|
||||
return;
|
||||
}
|
||||
const statusMap = await fetchDriverStatusMap();
|
||||
const status = statusMap[driverType];
|
||||
if (!status?.connectable || !status.needsUpdate) {
|
||||
return;
|
||||
}
|
||||
const revisionKey = status.expectedRevision || status.updateReason || status.message || 'unknown';
|
||||
const warningKey = `${conn.id}:${driverType}:${revisionKey}`;
|
||||
if (driverUpdateWarningKeysRef.current.has(warningKey)) {
|
||||
return;
|
||||
}
|
||||
driverUpdateWarningKeysRef.current.add(warningKey);
|
||||
const driverName = status.name || driverType;
|
||||
const reason = status.message || status.updateReason || `${driverName} driver-agent 与当前 GoNavi 版本要求不一致`;
|
||||
message.warning({
|
||||
content: `${driverName} 驱动代理需要重装:${reason}`,
|
||||
key: `driver-agent-update-${conn.id}`,
|
||||
duration: 10,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('检查驱动代理更新状态失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadDatabases = async (node: any) => {
|
||||
const conn = node.dataRef as SavedConnection;
|
||||
void warnIfConnectionDriverAgentNeedsUpdate(conn);
|
||||
const loadKey = `dbs-${conn.id}`;
|
||||
if (loadingNodesRef.current.has(loadKey)) return;
|
||||
loadingNodesRef.current.add(loadKey);
|
||||
@@ -3161,7 +3096,6 @@ const Sidebar: React.FC<{
|
||||
};
|
||||
|
||||
const loadDatabasesForBatch = async (conn: SavedConnection) => {
|
||||
void warnIfConnectionDriverAgentNeedsUpdate(conn);
|
||||
const config = {
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
@@ -3483,7 +3417,6 @@ const Sidebar: React.FC<{
|
||||
|
||||
const loadDatabasesForDbBatch = async (conn: SavedConnection) => {
|
||||
setBatchConnContext(conn);
|
||||
void warnIfConnectionDriverAgentNeedsUpdate(conn);
|
||||
|
||||
const config = {
|
||||
...conn.config,
|
||||
|
||||
62
frontend/src/components/dataSyncEntryMode.ts
Normal file
62
frontend/src/components/dataSyncEntryMode.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export type DataSyncEntryMode = 'sync' | 'schemaCompare' | 'dataCompare';
|
||||
|
||||
export type DataSyncEntryModePresentation = {
|
||||
title: string;
|
||||
description: string;
|
||||
heroTitle: string;
|
||||
heroDescription: string;
|
||||
optionTitle: string;
|
||||
tableSelectLabel: string;
|
||||
analyzeButtonText: string;
|
||||
closeButtonText: string;
|
||||
badgeText: string;
|
||||
resultTitle: string;
|
||||
readOnly: boolean;
|
||||
};
|
||||
|
||||
export const resolveDataSyncEntryModePresentation = (entryMode: DataSyncEntryMode): DataSyncEntryModePresentation => {
|
||||
switch (entryMode) {
|
||||
case 'schemaCompare':
|
||||
return {
|
||||
title: '表结构比对',
|
||||
description: '按源表与目标表生成结构差异、兼容风险和可审阅 SQL。',
|
||||
heroTitle: '表结构比对',
|
||||
heroDescription: '适合发布前核对两端表结构差异,只做分析与预览,不执行结构变更。',
|
||||
optionTitle: '比对选项',
|
||||
tableSelectLabel: '请选择需要比对结构的表:',
|
||||
analyzeButtonText: '开始比对',
|
||||
closeButtonText: '关闭',
|
||||
badgeText: '结构比对',
|
||||
resultTitle: '比对结果',
|
||||
readOnly: true,
|
||||
};
|
||||
case 'dataCompare':
|
||||
return {
|
||||
title: '数据比对',
|
||||
description: '按主键对比源表与目标表的数据差异,查看新增、更新和删除明细。',
|
||||
heroTitle: '数据比对',
|
||||
heroDescription: '适合核对两端数据一致性,只做差异分析与行级预览,不执行写入。',
|
||||
optionTitle: '比对选项',
|
||||
tableSelectLabel: '请选择需要比对数据的表:',
|
||||
analyzeButtonText: '开始比对',
|
||||
closeButtonText: '关闭',
|
||||
badgeText: '数据比对',
|
||||
resultTitle: '比对结果',
|
||||
readOnly: true,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: '数据同步工作台',
|
||||
description: '按已有目标表完成差异对比、同步执行与结果确认。',
|
||||
heroTitle: '数据同步',
|
||||
heroDescription: '适合目标表已存在的场景,先做差异分析,再按勾选执行插入、更新或删除。',
|
||||
optionTitle: '同步选项',
|
||||
tableSelectLabel: '请选择需要同步的表:',
|
||||
analyzeButtonText: '对比差异',
|
||||
closeButtonText: '关闭',
|
||||
badgeText: '同步模式',
|
||||
resultTitle: '执行结果',
|
||||
readOnly: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
1
go.mod
1
go.mod
@@ -15,6 +15,7 @@ require (
|
||||
github.com/elastic/go-elasticsearch/v8 v8.19.6
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/helingjun/obconnector-go v0.4.2
|
||||
github.com/highgo/pq-sm3 v0.0.0
|
||||
github.com/lib/pq v1.11.1
|
||||
github.com/microsoft/go-mssqldb v1.9.6
|
||||
|
||||
2
go.sum
2
go.sum
@@ -176,6 +176,8 @@ github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bP
|
||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/helingjun/obconnector-go v0.4.2 h1:TmN1jjFYJu1ZZ+X+uWr7lGmKW7MsJjdfsEb8A/Ox3+8=
|
||||
github.com/helingjun/obconnector-go v0.4.2/go.mod h1:zJL1M1xZvTm7OGKEDl04TPRDqi5MRXj7/hMb4CqTCDI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
|
||||
@@ -114,7 +114,7 @@ func TestVerifyInstalledOptionalDriverAgentRevisionRejectsProbeFailure(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRuntimeOptionalDriverAgentRevisionAllowsStaleOceanBaseAgent(t *testing.T) {
|
||||
func TestVerifyRuntimeOptionalDriverAgentRevisionAllowsStaleOceanBaseMySQLAgent(t *testing.T) {
|
||||
originalProbe := optionalDriverAgentMetadataProbe
|
||||
t.Cleanup(func() {
|
||||
optionalDriverAgentMetadataProbe = originalProbe
|
||||
@@ -132,6 +132,45 @@ func TestVerifyRuntimeOptionalDriverAgentRevisionAllowsStaleOceanBaseAgent(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRuntimeOptionalDriverAgentRevisionAllowsStaleOceanBaseOracleAgent(t *testing.T) {
|
||||
originalProbe := optionalDriverAgentMetadataProbe
|
||||
t.Cleanup(func() {
|
||||
optionalDriverAgentMetadataProbe = originalProbe
|
||||
})
|
||||
optionalDriverAgentMetadataProbe = func(driverType string, executablePath string) (db.OptionalDriverAgentMetadata, error) {
|
||||
return db.OptionalDriverAgentMetadata{
|
||||
DriverType: driverType,
|
||||
AgentRevision: "src-stale-agent",
|
||||
}, nil
|
||||
}
|
||||
|
||||
err := verifyRuntimeOptionalDriverAgentRevision(connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
ConnectionParams: "protocol=oracle",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runtime revision mismatch should stay in driver manager only, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRuntimeOptionalDriverAgentRevisionAllowsUnknownOceanBaseOracleAgent(t *testing.T) {
|
||||
originalProbe := optionalDriverAgentMetadataProbe
|
||||
t.Cleanup(func() {
|
||||
optionalDriverAgentMetadataProbe = originalProbe
|
||||
})
|
||||
optionalDriverAgentMetadataProbe = func(driverType string, executablePath string) (db.OptionalDriverAgentMetadata, error) {
|
||||
return db.OptionalDriverAgentMetadata{}, errOptionalDriverAgentMetadataUnavailable
|
||||
}
|
||||
|
||||
err := verifyRuntimeOptionalDriverAgentRevision(connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
OceanBaseProtocol: "oracle",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runtime metadata probe failure should stay in driver manager only, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRuntimeOptionalDriverAgentRevisionAllowsMetadataProbeFailure(t *testing.T) {
|
||||
originalProbe := optionalDriverAgentMetadataProbe
|
||||
t.Cleanup(func() {
|
||||
|
||||
@@ -5,7 +5,7 @@ package db
|
||||
func init() {
|
||||
optionalDriverAgentRevisions = map[string]string{
|
||||
"mariadb": "src-0a4176f4b5743323",
|
||||
"oceanbase": "src-e996325fd6d52648",
|
||||
"oceanbase": "src-7cb0f2c4dc0510a5",
|
||||
"diros": "src-cc11b882e28fa5d4",
|
||||
"starrocks": "src-83a6d81c91c7f5c8",
|
||||
"sphinx": "src-a70c2cd4d223dac2",
|
||||
|
||||
@@ -7,36 +7,25 @@
|
||||
// 2. OBProxy Oracle listener —— 标准 Oracle TNS 网络协议
|
||||
//
|
||||
// Navicat 的"OceanBase"数据源经实测能在 OB MySQL wire 端口上直接连接 Oracle 租户,
|
||||
// 证明 OB 服务端识别 OBClient 客户端的关键是 CLIENT_CONNECT_ATTRS 中的特定 attribute
|
||||
// 组合,而不是 capability bit 0-31 的扩展(这些 bit 是 MySQL 协议标准定义的)。
|
||||
// go-sql-driver/mysql v1.9+ 通过 DSN 参数 connectionAttributes 透传 CLIENT_CONNECT_ATTRS,
|
||||
// 因此 **不需要 fork mysql driver** 即可复刻 Navicat 的连接路径。
|
||||
// 但本机企业版验证表明:仅通过 go-sql-driver/mysql 注入 CLIENT_CONNECT_ATTRS 不足以
|
||||
// 让 Oracle 租户放行,还需要 CLIENT_SUPPORT_ORACLE_MODE 等 OceanBase 私有 capability。
|
||||
// 因此 GoNavi 将 Oracle 租户的 MySQL-wire 路径隔离到 OB Oracle 专用 driver。
|
||||
//
|
||||
// GoNavi 当前路由(按 OceanBase 协议字段选择决定):
|
||||
// - 协议=MySQL:走 go-sql-driver/mysql,连 MySQL 租户。OB 服务端在 Oracle 租户上返回
|
||||
// "Error 1235 (0A000): Oracle tenant for current client driver is not supported"
|
||||
// 时,错误信息提示用户切换到 Oracle 协议。
|
||||
// - 协议=Oracle:先做 mysql wire 端口预探测(probeOceanBaseMySQLWireHandshake)。
|
||||
// 识别为 OB MySQL wire 时,走 mysql wire + OBClient capability 注入路径
|
||||
// (ensureOceanBaseOBClientAttributes + ensureOceanBaseOracleANSIQuotes);
|
||||
// 识别为 OB MySQL wire 时,走 obconnector-go 的 OB Oracle 专用握手路径;
|
||||
// 元数据查询通过 OracleDB wrapper 复用 Oracle 方言 SQL,ApplyChanges 用
|
||||
// applyOracleChangesMySQLWire("?" 占位符 + 双引号引用)。
|
||||
// 端口非 OB MySQL wire 时,走 sijms/go-ora 连接 OBProxy 的 Oracle listener。
|
||||
//
|
||||
// OBClient capability attribute 候选清单(基于 OceanBase 公开 connector-j 资料 +
|
||||
// 社区经验,**未在本仓库联调验证 Navicat 用的具体组合**):
|
||||
// - _client_name=OceanBase Connector/J ← OB connector-j 标准
|
||||
// - _client_version=2.4.5
|
||||
// - __ob_client_attribute_capability_flag=1
|
||||
// - ob_capability_flag=1
|
||||
//
|
||||
// 默认注入完整候选清单(mysql server 忽略未知 attribute 是安全行为)。用户/DBA 通过
|
||||
// ConnectionParams 设置 connectionAttributes 时,会与默认注入合并(用户值优先)。
|
||||
//
|
||||
// 历史教训:d2dad751 / 17331ddb / 5/14 两次反转都没在真实 OB Oracle 租户集群上联调,
|
||||
// 多次方向摇摆。本次反转有 Navicat 真实工作证据(用户报告:Navicat 用 OceanBase 数据源
|
||||
// 类型连同一端口 60014 成功)。后续若收到"OBClient 默认注入仍失败"反馈,需要 Wireshark
|
||||
// 抓 Navicat 握手包对照 attribute 组合,不要再盲改方向。
|
||||
// 类型连同一端口 60014 成功)以及本机企业版 Oracle 租户验证。go-sql-driver/mysql 即使
|
||||
// 注入 connectionAttributes 也无法发出 OceanBase Oracle 租户需要的私有 capability,
|
||||
// 因此 Oracle/MySQL-wire 路径必须和普通 OceanBase MySQL 路径隔离。
|
||||
package db
|
||||
|
||||
import (
|
||||
@@ -56,10 +45,12 @@ import (
|
||||
"GoNavi-Wails/internal/utils"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/helingjun/obconnector-go"
|
||||
)
|
||||
|
||||
const (
|
||||
oceanbaseDriverName = "oceanbase"
|
||||
oceanbaseDriverName = "gonavi_oceanbase_mysql"
|
||||
oceanbaseOracleOBClientDriver = "oboracle"
|
||||
defaultOceanBasePort = 2881
|
||||
oceanBaseProtocolMySQL = "mysql"
|
||||
oceanBaseProtocolOracle = "oracle"
|
||||
@@ -290,39 +281,6 @@ func withoutOceanBaseProtocolParams(config connection.ConnectionConfig) connecti
|
||||
return next
|
||||
}
|
||||
|
||||
// ensureOceanBaseOracleANSIQuotes 在 ConnectionParams 中注入 sql_mode='ANSI_QUOTES',
|
||||
// 让 OceanBase Oracle 租户通过 MySQL wire 连接时,把双引号识别为标识符引用(Oracle 语义),
|
||||
// 否则元数据查询的列别名 `AS "OWNER"` 和 ApplyChanges 的 `"schema"."table"` 会被当字符串字面量。
|
||||
// 用户已显式设置 sql_mode 时追加 ANSI_QUOTES,保留其它 mode。
|
||||
func ensureOceanBaseOracleANSIQuotes(raw string) string {
|
||||
values := connectionParamsFromText(raw)
|
||||
if values == nil {
|
||||
values = url.Values{}
|
||||
}
|
||||
existing := strings.TrimSpace(values.Get("sql_mode"))
|
||||
if existing == "" {
|
||||
values.Set("sql_mode", "'ANSI_QUOTES'")
|
||||
return values.Encode()
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(existing), "ANSI_QUOTES") {
|
||||
return values.Encode()
|
||||
}
|
||||
trimmed := strings.Trim(existing, "'")
|
||||
values.Set("sql_mode", "'"+trimmed+",ANSI_QUOTES'")
|
||||
return values.Encode()
|
||||
}
|
||||
|
||||
// defaultOceanBaseOBClientAttributes 是 GoNavi 在 OceanBase Oracle 租户连接路径上默认注入的
|
||||
// CLIENT_CONNECT_ATTRS 列表,用于声明 OBClient 客户端身份让 OB 服务端放行 Oracle 租户。
|
||||
// 这些 key/value 基于公开 OceanBase Connector/J 资料整理,未经本仓库真实环境验证。
|
||||
// 用户通过 ConnectionParams 中的 connectionAttributes 设置的 attribute 优先级更高。
|
||||
var defaultOceanBaseOBClientAttributes = []struct{ Key, Value string }{
|
||||
{Key: "_client_name", Value: "OceanBase Connector/J"},
|
||||
{Key: "_client_version", Value: "2.4.5"},
|
||||
{Key: "__ob_client_attribute_capability_flag", Value: "1"},
|
||||
{Key: "ob_capability_flag", Value: "1"},
|
||||
}
|
||||
|
||||
// parseMySQLConnectionAttributes 解析 "key1:value1,key2:value2" 格式的 attribute 串。
|
||||
// 兼容 mysql DSN 中 connectionAttributes 参数的格式。
|
||||
func parseMySQLConnectionAttributes(raw string) map[string]string {
|
||||
@@ -350,51 +308,6 @@ func parseMySQLConnectionAttributes(raw string) map[string]string {
|
||||
return result
|
||||
}
|
||||
|
||||
// serializeMySQLConnectionAttributes 把 map 序列化回 mysql DSN 期望的 "key1:value1,key2:value2"。
|
||||
// 输出按 key 字典序排序以保证可重现。
|
||||
func serializeMySQLConnectionAttributes(attrs map[string]string) string {
|
||||
if len(attrs) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(attrs))
|
||||
for k := range attrs {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
// 字典序排序:测试可重现 + 用户视角一致
|
||||
for i := 1; i < len(keys); i++ {
|
||||
for j := i; j > 0 && keys[j-1] > keys[j]; j-- {
|
||||
keys[j-1], keys[j] = keys[j], keys[j-1]
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteString(k)
|
||||
b.WriteByte(':')
|
||||
b.WriteString(attrs[k])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ensureOceanBaseOBClientAttributes 把 GoNavi 的默认 OBClient capability attribute 合并到
|
||||
// ConnectionParams 的 connectionAttributes 中。用户已设置的 attribute 优先(不覆盖)。
|
||||
func ensureOceanBaseOBClientAttributes(rawConnectionParams string) string {
|
||||
values := connectionParamsFromText(rawConnectionParams)
|
||||
if values == nil {
|
||||
values = url.Values{}
|
||||
}
|
||||
existing := parseMySQLConnectionAttributes(values.Get("connectionAttributes"))
|
||||
for _, attr := range defaultOceanBaseOBClientAttributes {
|
||||
if _, ok := existing[attr.Key]; !ok {
|
||||
existing[attr.Key] = attr.Value
|
||||
}
|
||||
}
|
||||
values.Set("connectionAttributes", serializeMySQLConnectionAttributes(existing))
|
||||
return values.Encode()
|
||||
}
|
||||
|
||||
// promoteOceanBaseOracleURIParams 把 oceanbase:// URI 中的 Oracle 业务参数提升到 ConnectionParams,
|
||||
// 让 OracleDB.Connect 在不解析 oceanbase URI 的情况下仍能拿到 PREFETCH_ROWS 等参数。
|
||||
func promoteOceanBaseOracleURIParams(config connection.ConnectionConfig) connection.ConnectionConfig {
|
||||
@@ -415,6 +328,77 @@ func promoteOceanBaseOracleURIParams(config connection.ConnectionConfig) connect
|
||||
return config
|
||||
}
|
||||
|
||||
func mergeOceanBaseOracleOBClientParams(params url.Values, values url.Values) {
|
||||
if len(values) == 0 {
|
||||
return
|
||||
}
|
||||
for key, vals := range values {
|
||||
name := strings.TrimSpace(key)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
lowerName := strings.ToLower(name)
|
||||
if lowerName == "connectionattributes" {
|
||||
for _, value := range vals {
|
||||
for attrKey, attrValue := range parseMySQLConnectionAttributes(value) {
|
||||
params.Set("attr."+attrKey, attrValue)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(lowerName, "attr.") {
|
||||
for _, value := range vals {
|
||||
params.Set(name, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch lowerName {
|
||||
case "timeout", "connecttimeout", "connect timeout":
|
||||
for _, value := range vals {
|
||||
params.Set("timeout", normalizeMySQLDurationParam(value, time.Millisecond))
|
||||
}
|
||||
case "trace", "preset", "cap.add", "cap.drop", "collation", "ob20", "protocol.v2",
|
||||
"ob20.magic", "ob20.disablechecksum", "compress", "usecompression", "use_compression",
|
||||
"tls", "tls.ca", "tls_ca", "tls.cert", "tls_cert", "tls.key", "tls_key":
|
||||
for _, value := range vals {
|
||||
params.Set(name, value)
|
||||
}
|
||||
case "init":
|
||||
for _, value := range vals {
|
||||
params.Add("init", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildOceanBaseOracleOBClientDSN(config connection.ConnectionConfig) (string, error) {
|
||||
if strings.TrimSpace(config.User) == "" {
|
||||
return "", fmt.Errorf("OceanBase Oracle (OBClient 路径) 缺少用户名")
|
||||
}
|
||||
address := normalizeMySQLAddress(config.Host, config.Port)
|
||||
dsnURL := url.URL{
|
||||
Scheme: "oboracle",
|
||||
Host: address,
|
||||
User: url.UserPassword(config.User, config.Password),
|
||||
}
|
||||
if strings.TrimSpace(config.Database) != "" {
|
||||
dsnURL.Path = "/" + strings.TrimSpace(config.Database)
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("preset", "oboracle")
|
||||
if timeout := getConnectTimeout(config); timeout > 0 {
|
||||
params.Set("timeout", timeout.String())
|
||||
}
|
||||
mergeOceanBaseOracleOBClientParams(params, connectionParamsFromURI(config.URI, "oceanbase", "mysql", "oboracle"))
|
||||
mergeOceanBaseOracleOBClientParams(params, connectionParamsFromText(config.ConnectionParams))
|
||||
if strings.TrimSpace(params.Get("preset")) == "" {
|
||||
params.Set("preset", "oboracle")
|
||||
}
|
||||
dsnURL.RawQuery = params.Encode()
|
||||
return dsnURL.String(), nil
|
||||
}
|
||||
|
||||
func prepareOceanBaseOracleConfig(config connection.ConnectionConfig) connection.ConnectionConfig {
|
||||
runConfig := withoutOceanBaseProtocolParams(applyOceanBaseURI(config))
|
||||
runConfig = promoteOceanBaseOracleURIParams(runConfig)
|
||||
@@ -436,7 +420,7 @@ func isOceanBaseOracleTenantMySQLDriverError(err error) bool {
|
||||
|
||||
func formatOceanBaseMySQLAttemptError(address string, err error) string {
|
||||
if isOceanBaseOracleTenantMySQLDriverError(err) {
|
||||
return fmt.Sprintf("%s 验证失败:当前选择的是 OceanBase MySQL 协议,但服务端返回 Oracle 租户不支持 MySQL 客户端驱动(OB Error 1235);请在连接配置中将 OceanBase 协议切换为 Oracle,并填写 OBProxy 暴露的 Oracle 协议端口与服务名(Service Name)", address)
|
||||
return fmt.Sprintf("%s 验证失败:当前选择的是 OceanBase MySQL 协议,但服务端返回 Oracle 租户不支持 MySQL 客户端驱动(OB Error 1235);请在连接配置中将 OceanBase 协议切换为 Oracle。若使用 OBClient/OBServer MySQL-wire 入口,主机和端口可保持不变且服务名可留空;只有连接 OBProxy Oracle listener/TNS 入口时才需要填写服务名(Service Name)", address)
|
||||
}
|
||||
return fmt.Sprintf("%s 验证失败:%v", address, err)
|
||||
}
|
||||
@@ -459,7 +443,7 @@ func annotateOceanBaseOracleConnectError(err error) error {
|
||||
strings.Contains(lower, "unexpected packet"),
|
||||
strings.Contains(lower, "got packets out of order"),
|
||||
strings.Contains(lower, "use of closed network connection"):
|
||||
return fmt.Errorf("%w(OceanBase Oracle TNS 路径握手失败:当前端口可能是 OBServer 的 MySQL wire 协议端口而非 OBProxy 的 Oracle listener。GoNavi 已实现 OBClient capability 注入路径,路由层会优先尝试该路径;如这里仍报此错说明 OBClient 路径也未成功,详见随后的 OBClient 错误诊断)", err)
|
||||
return fmt.Errorf("%w(OceanBase Oracle TNS 路径握手失败:当前端口可能是 OBServer 的 MySQL wire 协议端口而非 OBProxy 的 Oracle listener。GoNavi 会优先尝试 OB Oracle 专用 MySQL-wire 路径;如这里仍报此错说明该路径也未成功,详见随后的 OBClient 错误诊断)", err)
|
||||
case strings.Contains(lower, "ora-"):
|
||||
return fmt.Errorf("%w(OceanBase Oracle 租户认证或服务名失败:请确认服务名(Service Name)、用户名(如 SYS@oracle_tenant#cluster_name)与权限配置)", err)
|
||||
}
|
||||
@@ -591,8 +575,8 @@ func (o *OceanBaseDB) connectOracleViaTNS(config connection.ConnectionConfig) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// connectOracleViaOBClient 走 mysql wire + OBClient capability attribute 注入,连 OceanBase
|
||||
// MySQL wire 端口上的 Oracle 租户(复刻 Navicat OceanBase 数据源的连接路径)。
|
||||
// connectOracleViaOBClient 走 OB Oracle 专用 MySQL-wire 握手,连 OceanBase
|
||||
// MySQL wire 端口上的 Oracle 租户。
|
||||
// 用于端口预探测识别为 OB MySQL wire 的情况。
|
||||
func (o *OceanBaseDB) connectOracleViaOBClient(config connection.ConnectionConfig) error {
|
||||
addresses := collectOceanBaseAddresses(config)
|
||||
@@ -610,17 +594,29 @@ func (o *OceanBaseDB) connectOracleViaOBClient(config connection.ConnectionConfi
|
||||
candidateConfig.Host = host
|
||||
candidateConfig.Port = port
|
||||
candidateConfig.User, candidateConfig.Password = resolveMySQLCredential(config, index)
|
||||
// 注入 OBClient capability attribute,让 OB 服务端识别为 OBClient 客户端而放行 Oracle 租户。
|
||||
// 同时确保 sql_mode='ANSI_QUOTES',让后续 Oracle 元数据查询里的双引号被识别为标识符引用。
|
||||
candidateConfig.ConnectionParams = ensureOceanBaseOBClientAttributes(candidateConfig.ConnectionParams)
|
||||
candidateConfig.ConnectionParams = ensureOceanBaseOracleANSIQuotes(candidateConfig.ConnectionParams)
|
||||
|
||||
dsn, err := o.getDSN(candidateConfig)
|
||||
if candidateConfig.UseSSH {
|
||||
forwarder, err := ssh.GetOrCreateLocalForwarder(candidateConfig.SSH, host, port)
|
||||
if err != nil {
|
||||
errorDetails = append(errorDetails, fmt.Sprintf("%s 创建 SSH 本地转发失败:%v", address, err))
|
||||
continue
|
||||
}
|
||||
localHost, localPort, ok := parseHostPortWithDefault(forwarder.LocalAddr, defaultOceanBasePort)
|
||||
if !ok {
|
||||
errorDetails = append(errorDetails, fmt.Sprintf("%s 解析 SSH 本地转发地址失败:%s", address, forwarder.LocalAddr))
|
||||
continue
|
||||
}
|
||||
candidateConfig.Host = localHost
|
||||
candidateConfig.Port = localPort
|
||||
candidateConfig.UseSSH = false
|
||||
}
|
||||
|
||||
dsn, err := buildOceanBaseOracleOBClientDSN(candidateConfig)
|
||||
if err != nil {
|
||||
errorDetails = append(errorDetails, fmt.Sprintf("%s 生成连接串失败:%v", address, err))
|
||||
continue
|
||||
}
|
||||
db, err := sql.Open(oceanbaseDriverName, dsn)
|
||||
db, err := sql.Open(oceanbaseOracleOBClientDriver, dsn)
|
||||
if err != nil {
|
||||
errorDetails = append(errorDetails, fmt.Sprintf("%s 打开失败:%v", address, err))
|
||||
continue
|
||||
@@ -650,10 +646,8 @@ func (o *OceanBaseDB) connectOracleViaOBClient(config connection.ConnectionConfi
|
||||
func formatOceanBaseOBClientAttemptError(address string, err error) string {
|
||||
if isOceanBaseOracleTenantMySQLDriverError(err) {
|
||||
return fmt.Sprintf("%s 验证失败:OceanBase 服务端仍返回 Error 1235 拒绝当前 client driver。"+
|
||||
"GoNavi 已默认注入 OBClient capability attribute(_client_name=OceanBase Connector/J 等),"+
|
||||
"但该组合未能让服务端放行 Oracle 租户。请用 Wireshark 抓 Navicat 连接此 OB 集群的 mysql 握手包,"+
|
||||
"对照 Client Login Request → Connection Attributes 部分确认服务端期望的 key/value,"+
|
||||
"然后在 GoNavi 连接配置的 ConnectionParams 里通过 connectionAttributes=key1:value1,key2:value2 覆盖。"+
|
||||
"GoNavi 已使用 OB Oracle 专用握手路径;如仍失败,请确认该端口是 OceanBase Oracle 租户的 MySQL-wire 入口,"+
|
||||
"并在 ConnectionParams 中通过 preset/cap.add/cap.drop 或 connectionAttributes=key1:value1 覆盖驱动握手参数。"+
|
||||
"详细错误:%v", address, err)
|
||||
}
|
||||
return fmt.Sprintf("%s 验证失败:%v", address, err)
|
||||
@@ -700,13 +694,15 @@ func (o *OceanBaseDB) Connect(config connection.ConnectionConfig) error {
|
||||
probeResult := probeOceanBaseMySQLWireHandshakeDetailWithTimeouts(runConfig, probeDialTimeout, probeReadTimeout)
|
||||
switch {
|
||||
case probeResult.probeSucceeded && probeResult.isOBMySQLWire:
|
||||
// 明确识别为 OB MySQL wire 端口:直接走 OBClient capability 路径
|
||||
logger.Infof("OceanBase 协议=Oracle 预探测:%s:%d 是 OB MySQL wire 端口,走 OBClient capability 注入路径连接 Oracle 租户", runConfig.Host, runConfig.Port)
|
||||
// 明确识别为 OB MySQL wire 端口:直接走 OB Oracle 专用 MySQL-wire 路径
|
||||
logger.Infof("OceanBase 协议=Oracle 预探测:%s:%d 是 OB MySQL wire 端口,走 OB Oracle 专用 MySQL-wire 路径连接 Oracle 租户", runConfig.Host, runConfig.Port)
|
||||
return o.connectOracleViaOBClient(runConfig)
|
||||
case probeResult.probeSucceeded:
|
||||
// 探测成功但 server_version 不含 OceanBase 标识:可能是真正的 Oracle TNS 端口
|
||||
logger.Infof("OceanBase 协议=Oracle 预探测:%s:%d 不是 OB MySQL wire,走标准 Oracle TNS 协议(OBProxy Oracle listener)", runConfig.Host, runConfig.Port)
|
||||
return o.connectOracleViaTNS(runConfig)
|
||||
// 已收到 MySQL handshake,但 server_version 不一定包含 OceanBase 标识。
|
||||
// 部分 OceanBase Oracle 租户会返回通用 MySQL 版本串;此时仍应优先按
|
||||
// OBClient/MySQL-wire 路径连接,失败后再尝试 TNS。
|
||||
logger.Infof("OceanBase 协议=Oracle 预探测:%s:%d 返回 MySQL handshake 但未识别 OceanBase 标识,优先尝试 OB Oracle 专用 MySQL-wire 路径", runConfig.Host, runConfig.Port)
|
||||
return o.connectOracleViaOBClientThenTNS(runConfig)
|
||||
case !probeResult.tcpReachable && probeResult.err != nil:
|
||||
logger.Warnf("OceanBase 协议=Oracle 预探测建连失败:%s:%d,跳过 OBClient/TNS 重复尝试:%v", runConfig.Host, runConfig.Port, probeResult.err)
|
||||
return formatOceanBaseOracleNetworkProbeError(runConfig, probeResult.err)
|
||||
@@ -714,17 +710,8 @@ func (o *OceanBaseDB) Connect(config connection.ConnectionConfig) error {
|
||||
// 探测失败但 TCP 已建连:可能是异常截断的握手包,或某些 OB 版本不主动发完整 handshake。
|
||||
// 不能盲选 TNS——用户填 60014/2881 这类端口大概率仍是 OB MySQL wire。
|
||||
// 串行尝试两条真实路径:先 OBClient(命中概率更高),失败再 TNS,合并错误信息。
|
||||
logger.Warnf("OceanBase 协议=Oracle 预探测失败:%s:%d,串行尝试 OBClient capability 与 TNS 两条路径", runConfig.Host, runConfig.Port)
|
||||
obclientErr := o.connectOracleViaOBClient(runConfig)
|
||||
if obclientErr == nil {
|
||||
return nil
|
||||
}
|
||||
logger.Warnf("OceanBase Oracle OBClient 路径失败,继续尝试 TNS 路径:%v", obclientErr)
|
||||
tnsErr := o.connectOracleViaTNS(runConfig)
|
||||
if tnsErr == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("OceanBase Oracle 两条连接路径均失败;OBClient 路径错误:%v;TNS 路径错误:%w", obclientErr, tnsErr)
|
||||
logger.Warnf("OceanBase 协议=Oracle 预探测失败:%s:%d,串行尝试 OB Oracle 专用 MySQL-wire 与 TNS 两条路径", runConfig.Host, runConfig.Port)
|
||||
return o.connectOracleViaOBClientThenTNS(runConfig)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -777,6 +764,22 @@ func (o *OceanBaseDB) Connect(config connection.ConnectionConfig) error {
|
||||
return fmt.Errorf("连接建立后验证失败:%s", strings.Join(errorDetails, ";"))
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) connectOracleViaOBClientThenTNS(config connection.ConnectionConfig) error {
|
||||
obclientErr := o.connectOracleViaOBClient(config)
|
||||
if obclientErr == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(config.Database) == "" {
|
||||
return fmt.Errorf("OceanBase Oracle OBClient/MySQL-wire 路径连接失败:%v;当前未填写 Service Name,已跳过 TNS 路径。若连接的是 OBClient/OBServer MySQL-wire 入口,Service Name 可继续留空,请检查主机、端口、用户名、密码和 driver-agent 是否为当前版本;Service Name 只用于 OBProxy Oracle listener/TNS 入口", obclientErr)
|
||||
}
|
||||
logger.Warnf("OceanBase Oracle OBClient 路径失败,继续尝试 TNS 路径:%v", obclientErr)
|
||||
tnsErr := o.connectOracleViaTNS(config)
|
||||
if tnsErr == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("OceanBase Oracle 两条连接路径均失败;OBClient 路径错误:%v;TNS 路径错误:%w", obclientErr, tnsErr)
|
||||
}
|
||||
|
||||
func (o *OceanBaseDB) activeDatabase() Database {
|
||||
if o.oracle != nil {
|
||||
return o.oracle
|
||||
@@ -906,7 +909,6 @@ func (o *OceanBaseDB) ApplyChanges(tableName string, changes connection.ChangeSe
|
||||
|
||||
// applyOracleChangesMySQLWire 在 OceanBase Oracle 租户的 mysql wire 连接上执行
|
||||
// DELETE/UPDATE/INSERT,使用 Oracle 风格双引号引用标识符 + mysql wire 风格 "?" 占位符。
|
||||
// 需要事先确保 sql_mode='ANSI_QUOTES'(由 ensureOceanBaseOracleANSIQuotes 在 DSN 中注入)。
|
||||
func (o *OceanBaseDB) applyOracleChangesMySQLWire(tableName string, changes connection.ChangeSet) error {
|
||||
if o.oracle == nil || o.oracle.conn == nil {
|
||||
return fmt.Errorf("连接未打开")
|
||||
|
||||
@@ -300,20 +300,39 @@ func TestOceanBaseOracleDSNParsesTenantCredentials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// buildMySQLHandshakePacket 构造一个最小化的 MySQL initial handshake packet(protocol v10),
|
||||
// buildMySQLHandshakePacket 构造一个完整的 MySQL initial handshake packet(protocol v10),
|
||||
// 用于 mock OceanBase / 通用 MySQL / OBProxy 各种 server_version 场景。
|
||||
// 实际字段顺序按 MySQL 协议规范:
|
||||
//
|
||||
// 4 字节 header (3 字节 payload length + 1 字节 sequence id)
|
||||
// payload[0] protocol_version (10)
|
||||
// payload[1..N] server_version (null-terminated)
|
||||
// ... (后续字段对协议探测无关,可省略)
|
||||
// ... auth seed / capability / auth plugin
|
||||
func buildMySQLHandshakePacket(serverVersion string) []byte {
|
||||
payload := []byte{10}
|
||||
payload = append(payload, []byte(serverVersion)...)
|
||||
payload = append(payload, 0)
|
||||
// 追加几个占位字节,让 packet 看起来更像真实 handshake(探测代码并不解析这些字段)
|
||||
// connection id
|
||||
payload = append(payload, []byte{0x01, 0x00, 0x00, 0x00}...)
|
||||
// auth-plugin-data-part-1 + filler
|
||||
payload = append(payload, []byte("12345678")...)
|
||||
payload = append(payload, 0)
|
||||
// capability lower 2 bytes: CLIENT_LONG_PASSWORD | CLIENT_LONG_FLAG |
|
||||
// CLIENT_PROTOCOL_41 | CLIENT_SECURE_CONNECTION
|
||||
payload = append(payload, 0x05, 0x82)
|
||||
// character set + status flags
|
||||
payload = append(payload, 45, 0x00, 0x00)
|
||||
// capability upper 2 bytes: CLIENT_MULTI_RESULTS | CLIENT_PLUGIN_AUTH |
|
||||
// CLIENT_CONNECT_ATTRS | CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA | CLIENT_SESSION_TRACK
|
||||
payload = append(payload, 0x1a, 0x00)
|
||||
// auth-plugin-data length + reserved
|
||||
payload = append(payload, 21)
|
||||
payload = append(payload, make([]byte, 10)...)
|
||||
// auth-plugin-data-part-2 minimum 13 bytes, last byte NUL
|
||||
payload = append(payload, []byte("abcdefghijkl")...)
|
||||
payload = append(payload, 0)
|
||||
payload = append(payload, []byte("mysql_native_password")...)
|
||||
payload = append(payload, 0)
|
||||
payloadLen := len(payload)
|
||||
header := []byte{byte(payloadLen), byte(payloadLen >> 8), byte(payloadLen >> 16), 0}
|
||||
return append(header, payload...)
|
||||
@@ -407,6 +426,37 @@ func TestProbeOceanBaseMySQLWireHandshakeReturnsFalseOnUnreachable(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOceanBaseOracleConnectTriesOBClientWhenMySQLHandshakeIsGeneric(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
host, port, cleanup := startMockHandshakeServer(t, buildMySQLHandshakePacket("8.0.36"))
|
||||
defer cleanup()
|
||||
|
||||
ob := &OceanBaseDB{}
|
||||
err := ob.Connect(connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
Host: host,
|
||||
Port: port,
|
||||
User: testOceanBaseOracleUser,
|
||||
Password: testOceanBaseOraclePassword,
|
||||
OceanBaseProtocol: oceanBaseProtocolOracle,
|
||||
Timeout: 1,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected connect error from mock generic MySQL handshake server")
|
||||
}
|
||||
got := err.Error()
|
||||
if !strings.Contains(got, "OBClient/MySQL-wire 路径连接失败") {
|
||||
t.Fatalf("expected generic MySQL handshake to try OBClient first, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "已跳过 TNS 路径") {
|
||||
t.Fatalf("expected empty Service Name to skip TNS fallback, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "需要填写服务名") {
|
||||
t.Fatalf("expected empty Service Name not to surface TNS required error for MySQL-wire fallback, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOceanBaseOracleConnectStopsOnProbeDialFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -648,85 +698,54 @@ func TestProbeOceanBaseMySQLWireHandshakeAcceptsLargerPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// decodeConnectionAttributesFromConnectionParams 把 connectionAttributes 从 url-encoded 的
|
||||
// ConnectionParams 中取出来并解析成 map,便于测试用解码后的值断言。
|
||||
func decodeConnectionAttributesFromConnectionParams(t *testing.T, raw string) map[string]string {
|
||||
t.Helper()
|
||||
values, err := url.ParseQuery(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseQuery(%q) failed: %v", raw, err)
|
||||
}
|
||||
return parseMySQLConnectionAttributes(values.Get("connectionAttributes"))
|
||||
}
|
||||
|
||||
// ensureOceanBaseOBClientAttributes 必须默认注入 OBClient capability 候选 attribute,
|
||||
// 并且用户在 ConnectionParams 里已设置的 attribute 优先级更高(不被默认值覆盖)。
|
||||
func TestEnsureOceanBaseOBClientAttributesInjectsDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
attrs := decodeConnectionAttributesFromConnectionParams(t, ensureOceanBaseOBClientAttributes(""))
|
||||
want := map[string]string{
|
||||
"_client_name": "OceanBase Connector/J",
|
||||
"_client_version": "2.4.5",
|
||||
"__ob_client_attribute_capability_flag": "1",
|
||||
"ob_capability_flag": "1",
|
||||
}
|
||||
for k, v := range want {
|
||||
if attrs[k] != v {
|
||||
t.Fatalf("expected default attribute %s=%q, got %q (all=%v)", k, v, attrs[k], attrs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOceanBaseOBClientAttributesPreservesUserOverrides(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
attrs := decodeConnectionAttributesFromConnectionParams(t,
|
||||
ensureOceanBaseOBClientAttributes("connectionAttributes=_client_name:libobclient,_pid:9527"))
|
||||
|
||||
if attrs["_client_name"] != "libobclient" {
|
||||
t.Fatalf("expected user-supplied _client_name preserved, got %q", attrs["_client_name"])
|
||||
}
|
||||
if attrs["_pid"] != "9527" {
|
||||
t.Fatalf("expected user extra attribute _pid preserved, got %q", attrs["_pid"])
|
||||
}
|
||||
// 仍应补齐默认值中用户未提供的部分
|
||||
if attrs["ob_capability_flag"] != "1" {
|
||||
t.Fatalf("expected default ob_capability_flag still injected when user did not set it, got %q (all=%v)", attrs["ob_capability_flag"], attrs)
|
||||
}
|
||||
}
|
||||
|
||||
// 锁定 Oracle 协议路径下,OBClient capability attribute 会被注入到生成的 mysql DSN 中。
|
||||
func TestOceanBaseOracleOBClientDSNCarriesCapabilityAttributes(t *testing.T) {
|
||||
// 锁定 Oracle 协议 MySQL-wire 路径使用 oboracle 专用 DSN,并把用户提供的
|
||||
// connectionAttributes 映射为 obconnector-go 支持的 attr.* 参数。
|
||||
func TestOceanBaseOracleOBClientDSNUsesDedicatedDriverParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
Host: "127.0.0.1",
|
||||
Port: 2881,
|
||||
User: "SYS@oracle_tenant#cluster",
|
||||
Password: "x",
|
||||
Database: "ORCL",
|
||||
Type: "oceanbase",
|
||||
Host: "127.0.0.1",
|
||||
Port: 2881,
|
||||
User: "SYS@oracle_tenant#cluster",
|
||||
Password: "p@ss",
|
||||
Database: "ORCL",
|
||||
Timeout: 12,
|
||||
ConnectionParams: "connectionAttributes=_client_name:Custom OB,_pid:9527&cap.add=0x80&init=alter+session+set+nls_date_format%3D%27YYYY-MM-DD%27",
|
||||
}
|
||||
cfg.ConnectionParams = ensureOceanBaseOBClientAttributes(cfg.ConnectionParams)
|
||||
cfg.ConnectionParams = ensureOceanBaseOracleANSIQuotes(cfg.ConnectionParams)
|
||||
ob := &OceanBaseDB{}
|
||||
dsn, err := ob.getDSN(cfg)
|
||||
dsn, err := buildOceanBaseOracleOBClientDSN(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("getDSN error: %v", err)
|
||||
t.Fatalf("buildOceanBaseOracleOBClientDSN error: %v", err)
|
||||
}
|
||||
parsed, err := mysqlDriver.ParseDSN(dsn)
|
||||
parsed, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDSN error: %v", err)
|
||||
t.Fatalf("Parse DSN error: %v", err)
|
||||
}
|
||||
if !strings.Contains(parsed.ConnectionAttributes, "_client_name:OceanBase Connector/J") {
|
||||
t.Fatalf("expected default _client_name in DSN, got %q", parsed.ConnectionAttributes)
|
||||
if parsed.Scheme != "oboracle" {
|
||||
t.Fatalf("expected oboracle scheme, got %q (dsn=%s)", parsed.Scheme, dsn)
|
||||
}
|
||||
if !strings.Contains(parsed.ConnectionAttributes, "ob_capability_flag:1") {
|
||||
t.Fatalf("expected default ob_capability_flag in DSN, got %q", parsed.ConnectionAttributes)
|
||||
if parsed.User.Username() != "SYS@oracle_tenant#cluster" {
|
||||
t.Fatalf("unexpected user %q", parsed.User.Username())
|
||||
}
|
||||
if !strings.Contains(dsn, "sql_mode=%27ANSI_QUOTES%27") {
|
||||
t.Fatalf("expected ANSI_QUOTES sys var in DSN, got %q", dsn)
|
||||
password, _ := parsed.User.Password()
|
||||
if password != "p@ss" {
|
||||
t.Fatalf("unexpected password %q", password)
|
||||
}
|
||||
query := parsed.Query()
|
||||
if query.Get("preset") != "oboracle" {
|
||||
t.Fatalf("expected preset=oboracle, got query=%v", query)
|
||||
}
|
||||
if query.Get("timeout") != "12s" {
|
||||
t.Fatalf("expected timeout=12s, got query=%v", query)
|
||||
}
|
||||
if query.Get("attr._client_name") != "Custom OB" || query.Get("attr._pid") != "9527" {
|
||||
t.Fatalf("expected connectionAttributes mapped to attr.*, got query=%v", query)
|
||||
}
|
||||
if query.Get("cap.add") != "0x80" {
|
||||
t.Fatalf("expected cap.add passthrough, got query=%v", query)
|
||||
}
|
||||
if got := query["init"]; len(got) != 1 || got[0] != "alter session set nls_date_format='YYYY-MM-DD'" {
|
||||
t.Fatalf("expected init SQL passthrough, got query=%v", query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,7 +826,10 @@ func TestFormatOceanBaseMySQLAttemptErrorHintsOracleProtocol(t *testing.T) {
|
||||
if !strings.Contains(got, "切换为 Oracle") {
|
||||
t.Fatalf("expected Oracle protocol hint, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "OBProxy") {
|
||||
t.Fatalf("expected hint to mention OBProxy Oracle protocol port, got %q", got)
|
||||
if !strings.Contains(got, "主机和端口可保持不变") {
|
||||
t.Fatalf("expected hint to mention OBClient/MySQL-wire host and port can be kept, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "只有连接 OBProxy Oracle listener/TNS 入口时才需要填写服务名") {
|
||||
t.Fatalf("expected hint to limit Service Name requirement to TNS path, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
43
internal/db/oceanbase_oracle_live_test.go
Normal file
43
internal/db/oceanbase_oracle_live_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
//go:build oceanbase_live
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestOceanBaseOracleLive(t *testing.T) {
|
||||
port, err := strconv.Atoi(os.Getenv("GONAVI_OB_PORT"))
|
||||
if err != nil {
|
||||
t.Fatalf("invalid GONAVI_OB_PORT: %v", err)
|
||||
}
|
||||
|
||||
ob := &OceanBaseDB{}
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
Host: os.Getenv("GONAVI_OB_HOST"),
|
||||
Port: port,
|
||||
User: os.Getenv("GONAVI_OB_USER"),
|
||||
Password: os.Getenv("GONAVI_OB_PASSWORD"),
|
||||
Database: os.Getenv("GONAVI_OB_DATABASE"),
|
||||
ConnectionParams: os.Getenv("GONAVI_OB_PARAMS"),
|
||||
OceanBaseProtocol: "oracle",
|
||||
Timeout: 10,
|
||||
}
|
||||
if err := ob.Connect(cfg); err != nil {
|
||||
t.Fatalf("connect failed: %v", err)
|
||||
}
|
||||
defer ob.Close()
|
||||
|
||||
rows, fields, err := ob.Query("select 1 from dual")
|
||||
if err != nil {
|
||||
t.Fatalf("query failed: %v", err)
|
||||
}
|
||||
if len(fields) != 1 || len(rows) != 1 {
|
||||
t.Fatalf("unexpected result fields=%v rows=%v", fields, rows)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user