diff --git a/docs/需求追踪/需求进度追踪-生产连接只读保护-20260623.md b/docs/需求追踪/需求进度追踪-生产连接只读保护-20260623.md new file mode 100644 index 0000000..60fab97 --- /dev/null +++ b/docs/需求追踪/需求进度追踪-生产连接只读保护-20260623.md @@ -0,0 +1,74 @@ +# 需求进度追踪 - 生产连接只读保护 + +## 1. 需求摘要 +- 需求名称:生产连接只读保护 +- 提出日期:2026-06-23 +- 负责人:Codex +- 目标:为 SQL 类数据库与 MongoDB 连接增加连接级只读保护,启用后仅允许查询,阻止写入、DDL、导入和同步目标操作 +- 非目标:不为所有侧栏写操作都新增前端隐藏逻辑;不引入新的环境分级体系;不改造 JVM 只读能力 + +## 2. 范围与验收 +- 范围: +- 连接配置模型、保存/回填与 RPC 序列化链路 +- 连接弹窗只读开关、查询编辑器本地拦截、DataGrid 导入入口收口 +- 后端 SQL/Mongo 查询判定与写操作统一守卫 +- 验收标准: +- 支持的数据源出现“生产连接/只允许查询”开关 +- 启用后普通查询仍可执行,非查询 SQL / Mongo 写命令被前后端阻止 +- 导入、结构变更、清表、同步目标等关键写入口被后端拒绝 +- 依赖与约束: +- 保持现有数据源能力判定与 QueryEditor 执行链路 +- MongoDB 前端判定以保守拦截为主,最终正确性由后端守卫兜底 + +## 3. 里程碑与进度 +- [x] 阶段 1(需求澄清):确认采用连接级 `readOnly` 布尔字段,不新建环境系统 +- [x] 阶段 2(影响分析):梳理前端能力面板、查询执行、导入与后端写入口 +- [x] 阶段 3(方案设计):确定“前端预拦截 + 后端最终守卫”双层保护 +- [x] 阶段 4(实施计划):接入配置链路、能力判定、查询判定与写入口守卫 +- [x] 阶段 5(实现与自检):补文案、测试与定向验证 +- [x] 阶段 6(评审与交付):确认范围、风险、回滚点和验证命令 +- [ ] 阶段 7(发布与观察):待体验验证 + +## 4. 变更清单 +- 已完成: +- 新增连接级 `readOnly` 配置字段及前后端序列化支持 +- 连接弹窗为 SQL 类数据库与 MongoDB 增加生产连接保护开关 +- QueryEditor 增加本地非查询拦截,DataGrid 导入入口在只读连接下禁用 +- 后端为查询、DDL、导入、清表、同步等写入口增加统一只读守卫 +- MongoDB 查询判定改为命令级白名单,不再把所有 JSON 命令都视为只读 +- 补充前端/后端定向测试与需求追踪文档 +- 进行中: +- 等待体验包验证连接弹窗、查询拦截和写操作拒绝文案 +- 待处理: +- 如需进一步优化体验,再补侧栏对象级写菜单的前端隐藏/禁用 + +## 5. 风险与阻塞 +- 风险: +- 前端 SQL/Mongo 只读判定是保守策略,边界命令可能仍需后端兜底 +- 现有部分侧栏写菜单仍可能显示,但执行时会被后端拒绝 +- 阻塞: +- 暂无 +- 缓解措施: +- 关键写入口统一走后端守卫;前端只负责提前反馈与减少误操作 + +## 6. 决策记录 +- 决策 1:只对 SQL 类数据库和 MongoDB 支持连接级生产保护,其他数据源忽略 `readOnly` +- 决策 2:采用顶层 `readOnly` 布尔字段,避免新增环境枚举和迁移成本 +- 决策 3:MongoDB 只读判定按命令白名单处理,防止把写命令误放行 + +## 7. 验证记录 +- 验证项: +- 前端数据源能力判定、RPC 配置、连接配置测试 +- 后端只读连接守卫与 SQL/Mongo 查询判定测试 +- 结果: +- 通过 +- 证据(日志/截图/链接): +- `go test ./internal/app -run 'Test(EnsureReadOnlyConnectionAllows|SupportsConnectionReadOnlyMode|IsReadOnlySQLQuery)'` +- `npm --prefix frontend test -- src/utils/dataSourceCapabilities.test.ts src/utils/connectionRpcConfig.test.ts src/components/connectionModal/connectionModalConfig.keepalive.test.ts` + +## 8. 下一步 +- 下一步行动: +- 用体验包回归验证生产连接下的查询、导入、建库删库、同步目标和 Mongo 命令拦截 +- 如需更完整 UX,再补侧栏写菜单的只读态收口 +- 负责人: +- Codex diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 4f0297c..d95a455 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -1387,6 +1387,7 @@ const ConnectionModal: React.FC<{ user: config.user, password: config.password, database: config.database, + readOnly: config.readOnly === true, uri: config.uri || "", connectionParams: config.connectionParams || diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 3d057f7..4f881d4 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -539,7 +539,7 @@ const DataGrid: React.FC = ({ const supportsCopyInsert = dataSourceCaps.supportsCopyInsert; const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport; const isQueryResultExport = exportScope === 'queryResult'; - const canImport = exportScope === 'table' && !!tableName; + const canImport = exportScope === 'table' && !!tableName && !readOnly; const canExport = !!connectionId && (isQueryResultExport || !!tableName); const canViewDdl = exportScope === 'table' && !!connectionId && !!tableName; const canOpenObjectDesigner = exportScope === 'table' && objectType === 'table' && !!connectionId && !!tableName; diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx index 9d77890..2faa8da 100644 --- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -10,6 +10,7 @@ import type { SavedQuery, TabData } from '../types'; import { formatSqlExecutionError } from '../utils/sqlErrorSemantics'; import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator'; import { clearQueryTabDraft, clearSQLFileTabDraft, getQueryTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts'; +import { normalizeQueryResultMessages } from './queryEditor/QueryEditorHelpers'; import QueryEditor, { collectQueryEditorObjectDecorationCandidates, resolveQueryEditorNavigationDecorations, @@ -793,6 +794,18 @@ describe('QueryEditor external SQL save', () => { expect(dataGridState.latestProps?.columnNames).not.toEqual([]); }); + it('normalizes sqlserver mssql-prefixed message lines line-by-line', () => { + expect(normalizeQueryResultMessages([ + "mssql: select c.queryno,'' ,left(dbo.f_vendor_class(''' + b.groupid + ''',' + colname + '),", + "mssql: 'char','',''),'自动生成',0,isdefault,defaultoperator,defaultvalue,defaultvalue2,ishaving", + " where funcno = @funcno and tabname = '$vendorclass'", + ])).toEqual([ + "select c.queryno,'' ,left(dbo.f_vendor_class(''' + b.groupid + ''',' + colname + '),", + "'char','',''),'自动生成',0,isdefault,defaultoperator,defaultvalue,defaultvalue2,ishaving", + "where funcno = @funcno and tabname = '$vendorclass'", + ]); + }); + it('keeps multiple result sets from a single sqlserver statement', async () => { storeState.connections[0].config.type = 'sqlserver'; storeState.connections[0].config.database = 'master'; @@ -938,6 +951,46 @@ describe('QueryEditor external SQL save', () => { expect(dataGridState.latestProps).toBeNull(); }); + it('strips mssql prefixes before rendering sqlserver message-only results', async () => { + storeState.connections[0].config.type = 'sqlserver'; + storeState.connections[0].config.database = 'hydee'; + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [ + { + statementIndex: 1, + columns: [], + rows: [], + messages: [ + "mssql: select c.queryno,'' ,left(dbo.f_vendor_class(''' + b.groupid + ''',' + colname + '),", + "mssql: 'char','',''),'自动生成',0,isdefault,defaultoperator,defaultvalue,defaultvalue2,ishaving", + " where funcno = @funcno and tabname = '$vendorclass'", + ], + }, + ], + }); + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + await findButton(renderer!, '运行').props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const rendered = textContent(renderer!.toJSON()); + expect(rendered).toContain('消息 1'); + expect(rendered).toContain("select c.queryno,'' ,left(dbo.f_vendor_class"); + expect(rendered).toContain("'char','',''),'自动生成'"); + expect(rendered).toContain("where funcno = @funcno and tabname = '$vendorclass'"); + expect(rendered).not.toContain('mssql:'); + }); + it('renders top-level sqlserver print messages when result sets contain only status rows', async () => { storeState.connections[0].config.type = 'sqlserver'; storeState.connections[0].config.database = 'hydee'; @@ -2363,6 +2416,24 @@ describe('QueryEditor external SQL save', () => { expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tab-text {'); }); + it('keeps query message blocks explicitly left, top aligned, copyable, and textarea-based', () => { + const source = readFileSync(new URL('./QueryEditorResultsPanel.tsx', import.meta.url), 'utf8'); + + expect(source).toContain("textAlign: 'left'"); + expect(source).toContain("justifyContent: 'flex-start'"); + expect(source).toContain("data-query-result-message-textarea"); + expect(source).toContain("query_editor.results_panel.message.action.copy"); + expect(source).toContain("typeof navigator?.clipboard?.writeText !== 'function'"); + expect(source).toContain('await navigator.clipboard.writeText(safeText);'); + expect(source).toContain('event.currentTarget.select();'); + }); + + it('keeps editor select-all scoped away from non-editor editable targets', () => { + const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8'); + + expect(source).toContain("if (isEditableElement(event.target) && !inEditorPane) {"); + }); + it('embeds the sql execution log as a result tab instead of a standalone workspace panel in v2', () => { const panelSource = readFileSync(new URL('./QueryEditorResultsPanel.tsx', import.meta.url), 'utf8'); const editorSource = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8'); diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 9d8a4c9..23c58a5 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -8,6 +8,7 @@ import { TabData, ColumnDefinition } from '../types'; import { useStore } from '../store'; import { DBQuery, DBQueryWithCancel, DBQueryMulti, DBQueryMultiTransactional, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID, WriteSQLFile, ExportSQLFile } from '../../wailsjs/go/app/App'; import { GONAVI_ROW_KEY } from './DataGrid'; +import { findConnectionMutatingStatements } from '../utils/connectionReadOnly'; import { getDataSourceCapabilities, shouldShowOceanBaseRowNumberColumn } from '../utils/dataSourceCapabilities'; import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb"; import { getShortcutDisplayLabel, getShortcutPlatform, getShortcutPrimaryModifierDisplayLabel, isEditableElement, isImeComposingKeyEvent, isShortcutMatch, comboToMonacoKeyBinding, resolveShortcutBinding } from "../utils/shortcuts"; @@ -95,6 +96,7 @@ import { isDocumentLevelShortcutTarget, isQueryEditorPrimaryMouseButton, normalizeCommentText, + normalizeQueryResultMessages, normalizeCompletionQualifiedName, normalizeEditorPosition, normalizeExecutedSqlKey, @@ -2863,6 +2865,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const cols = (rsData.columns && rsData.columns.length > 0) ? rsData.columns : (rows.length > 0 ? Object.keys(rows[0]) : []); + const refreshedMessages = normalizeQueryResultMessages(rsData?.messages); rows.forEach((row: any, i: number) => { if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i; }); @@ -2874,8 +2877,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc ...rs, rows, columns: cols, - messages: Array.isArray(rsData.messages) ? rsData.messages : [], - resultType: ((!Array.isArray(rsData.rows) || rsData.rows.length === 0) && (!Array.isArray(rsData.columns) || rsData.columns.length === 0) && Array.isArray(rsData.messages) && rsData.messages.length > 0) + messages: refreshedMessages, + resultType: ((!Array.isArray(rsData.rows) || rsData.rows.length === 0) && (!Array.isArray(rsData.columns) || rsData.columns.length === 0) && refreshedMessages.length > 0) ? 'message' : 'grid', truncated, @@ -2957,6 +2960,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const cols = (rsData.columns && rsData.columns.length > 0) ? rsData.columns : (rows.length > 0 ? Object.keys(rows[0]) : target.columns); + const pageMessages = normalizeQueryResultMessages(rsData?.messages); const totalState = resolveQueryResultPaginationTotal({ current: safePage, pageSize: safePageSize, @@ -2969,7 +2973,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc ...rs, rows, columns: cols, - messages: Array.isArray(rsData.messages) ? rsData.messages : [], + messages: pageMessages, resultType: 'grid', truncated: false, page: { @@ -3031,13 +3035,18 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return; } const connCaps = getDataSourceCapabilities(conn.config); - if (!connCaps.supportsQueryEditor) { - message.error(translate('query_editor.message.unsupported_source')); - if (runSeqRef.current === runSeq) setLoading(false); - return; - } + if (!connCaps.supportsQueryEditor) { + message.error(translate('query_editor.message.unsupported_source')); + if (runSeqRef.current === runSeq) setLoading(false); + return; + } + if (findConnectionMutatingStatements(conn.config, executableSQL).length > 0) { + message.warning(translate('query_editor.message.connection_readonly_blocked')); + if (runSeqRef.current === runSeq) setLoading(false); + return; + } - const config = { + const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", @@ -3122,6 +3131,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc setQueryId(queryId); const res = await DBQueryWithCancel(buildRpcConnectionConfig(config) as any, currentDb, executedSql, queryId); + const legacyResultMessages = normalizeQueryResultMessages(res?.messages); const duration = Date.now() - startTime; addSqlLog({ id: `log-${Date.now()}-query-${idx + 1}`, @@ -3165,12 +3175,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc statementResultIndex: 1, rows, columns: cols, - messages: Array.isArray(res.messages) ? res.messages : [], + messages: legacyResultMessages, pkColumns: [], readOnly: true, truncated }); - } else if (Array.isArray(res.messages) && res.messages.length > 0) { + } else if (legacyResultMessages.length > 0) { nextResultSets.push({ key: `result-${idx + 1}`, sql: rawStatement, @@ -3179,7 +3189,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc statementResultIndex: 1, rows: [], columns: [], - messages: res.messages, + messages: legacyResultMessages, resultType: 'message', pkColumns: [], readOnly: true, @@ -3197,7 +3207,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc statementResultIndex: 1, rows: [row], columns: ['affectedRows'], - messages: Array.isArray(res.messages) ? res.messages : [], + messages: legacyResultMessages, pkColumns: [], readOnly: true }); @@ -3413,9 +3423,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc // res.data 是 ResultSetData[] 数组 const resultSetDataArray = Array.isArray(res.data) ? (res.data as any[]) : []; - const topLevelMessages = Array.isArray(res.messages) - ? (res.messages as any[]).map((item) => String(item ?? '').trim()).filter(Boolean) - : []; + const topLevelMessages = normalizeQueryResultMessages(res.messages); const nextResultSets: ResultSet[] = []; const maxRows = Number(queryOptions?.maxRows) || 0; let anyTruncated = false; @@ -3429,7 +3437,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const plan = executablePlans[Math.max(0, sourceStatementIndex - 1)]; const originalSql = plan?.originalSql || ''; const executedSql = plan?.executedSql || originalSql; - const resultMessages = Array.isArray(rsData?.messages) ? rsData.messages : []; + const resultMessages = normalizeQueryResultMessages(rsData?.messages); // 检查是否为 affectedRows 类结果集 const isAffectedResult = Array.isArray(rsData.rows) && rsData.rows.length === 1 @@ -3622,10 +3630,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const editorHasFocus = !!editor.hasTextFocus?.(); const inEditorPane = !!(targetNode && editorPaneRef.current?.contains(targetNode)); const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); - if (!editorHasFocus && !inEditorPane) { + if (isEditableElement(event.target) && !inEditorPane) { return; } - if (!editorHasFocus && isEditableElement(event.target) && !inEditorPane) { + if (!editorHasFocus && !inEditorPane) { return; } if (!editorHasFocus && !inQueryEditor) { diff --git a/frontend/src/components/QueryEditorResultsPanel.i18n.test.ts b/frontend/src/components/QueryEditorResultsPanel.i18n.test.ts index d1a0eca..ce1f633 100644 --- a/frontend/src/components/QueryEditorResultsPanel.i18n.test.ts +++ b/frontend/src/components/QueryEditorResultsPanel.i18n.test.ts @@ -35,6 +35,9 @@ const requiredKeys = [ 'query_editor.results_panel.tab.message', 'query_editor.results_panel.tab.result', 'query_editor.results_panel.message.title', + 'query_editor.results_panel.message.action.copy', + 'query_editor.results_panel.message.copy_unsupported', + 'query_editor.results_panel.message.copy_failed', 'query_editor.results_panel.panel.title', 'query_editor.empty_state.title', 'query_editor.empty_state.description', diff --git a/frontend/src/components/QueryEditorResultsPanel.tsx b/frontend/src/components/QueryEditorResultsPanel.tsx index 7ecc8ce..f607526 100644 --- a/frontend/src/components/QueryEditorResultsPanel.tsx +++ b/frontend/src/components/QueryEditorResultsPanel.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Button, Dropdown, Tabs, Tooltip, type MenuProps } from 'antd'; -import { BugOutlined, CloseOutlined, EyeInvisibleOutlined, RobotOutlined } from '@ant-design/icons'; +import { Button, Dropdown, Tabs, Tooltip, message, type MenuProps } from 'antd'; +import { BugOutlined, CloseOutlined, CopyOutlined, EyeInvisibleOutlined, RobotOutlined } from '@ant-design/icons'; import type { EditRowLocator } from '../utils/rowLocator'; import type { QueryResultPaginationState } from '../utils/queryResultPagination'; @@ -89,6 +89,111 @@ const QueryEditorResultsPanel: React.FC = ({ const hideTooltipTitle = toggleShortcutLabel ? t('query_editor.results_panel.tooltip.hide_with_shortcut', { shortcut: toggleShortcutLabel }) : t('query_editor.results_panel.tooltip.hide'); + const handleMessageTextareaKeyDown = (event: React.KeyboardEvent) => { + if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'a') { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.focus(); + event.currentTarget.select(); + }; + const handleCopyMessageText = async (text: string) => { + const safeText = String(text || ''); + if (!safeText.trim()) { + return; + } + try { + if (typeof navigator?.clipboard?.writeText !== 'function') { + throw new Error(t('query_editor.results_panel.message.copy_unsupported')); + } + await navigator.clipboard.writeText(safeText); + message.success(t('data_grid.message.copied_to_clipboard')); + } catch (error: any) { + message.error(t('query_editor.results_panel.message.copy_failed', { + detail: error?.message || t('common.unknown'), + })); + } + }; + const renderMessageBlock = ({ + text, + title, + fontSize, + fillHeight = false, + compact = false, + maxWidth, + color, + marginTop, + }: { + text: string; + title?: string; + fontSize: string; + fillHeight?: boolean; + compact?: boolean; + maxWidth?: number; + color: string; + marginTop?: number; + }) => ( +
+
+ {title ? {title} : } + +
+