feat(jvm): 落地 JVM 连接契约与配置归一化

- 新增 JVM 连接配置与共享 DTO,补齐 JMX 和 Endpoint 契约
- 实现后端归一化规则,支持默认只读、模式回退和 JMX 端口兜底
- 新增前端 JVM 默认值与配置构建工具,统一模式环境和端口收敛
- 补充 Go 与 Vitest 用例并更新需求追踪,记录 Task 1 验证证据
This commit is contained in:
Syngnat
2026-04-22 17:20:00 +08:00
parent f584270209
commit 15b1ad24d1
8 changed files with 549 additions and 5 deletions

View File

@@ -26,7 +26,7 @@
- [x] 阶段 2影响分析完成
- [x] 阶段 3方案设计完成已形成正式设计文档
- [x] 阶段 4实施计划完成已形成正式实施计划
- [ ] 阶段 5实现与自检
- [ ] 阶段 5实现与自检进行中Task 1 已完成并通过回归)
- [ ] 阶段 6评审与交付
- [ ] 阶段 7发布与观察
@@ -40,10 +40,11 @@
- 用户明确目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach
- 已形成 JVM 缓存可视化编辑正式设计文档
- 已形成 JVM Connector MVP 正式实施计划文档
- - 已完成 Task 1JVM 共享契约与配置归一化
- 进行中:
- 等待用户选择执行方式并进入实现
- Task 2建立后端 Provider 注册与连接探测 API
- 待处理:
- 进入 MVP 分期实施与验证
- Task 3+Guard/Audit/App/UI/AI 结构化计划等后续任务
## 5. 风险与阻塞
- 风险:
@@ -71,10 +72,13 @@
- GoNavi 现有 Redis/编辑器/UI 复用能力核查
- JVM Connector 正式设计文档自检
- JVM Connector 实施计划文档自检
- Task 1JVM 共享契约与配置归一化
- 结果:
- 已确认存在可复用的连接桥接与编辑器基础设施
- 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突
- 已完成正式实施计划落盘与自检,已补齐共享 DTO、provider factory 和审计落盘等关键实现细节
- 已完成 JVM 连接共享契约、默认只读/默认 JMX 归一化、前端配置收敛与补测
- Task 1 已完成规格审查与代码质量审查,结论均通过
- 证据(日志/截图/链接):
- `cmd/optional-driver-agent/main.go`
- `internal/db/database.go`
@@ -83,7 +87,19 @@
- `frontend/src/components/QueryEditor.tsx`
- `docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md`
- `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md`
- `internal/connection/types.go`
- `internal/jvm/types.go`
- `internal/jvm/config.go`
- `internal/jvm/config_test.go`
- `frontend/src/types.ts`
- `frontend/src/utils/jvmConnectionConfig.ts`
- `frontend/src/utils/jvmConnectionConfig.test.ts`
- `go test ./internal/jvm -count=1`
- `go test ./...`
- `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts`
- `cd frontend && npm test -- --run`
- `cd frontend && npm run build`
## 8. 下一步
- 下一步行动:请用户选择实施执行方式;推荐按 task 粒度执行并在每个 task 后做回归和提交
- 下一步行动:进入 Task 2建立 JVM Provider 注册、连接测试与能力探测 API并在完成后生成/校验 Wails 绑定代码
- 负责人Codex

View File

@@ -21,6 +21,72 @@ export interface HTTPTunnelConfig {
password?: string;
}
export interface JVMJMXConfig {
enabled?: boolean;
host?: string;
port?: number;
username?: string;
password?: string;
domainAllowlist?: string[];
}
export interface JVMEndpointConfig {
enabled?: boolean;
baseUrl?: string;
apiKey?: string;
timeoutSeconds?: number;
}
export interface JVMConfig {
environment?: 'dev' | 'uat' | 'prod';
readOnly?: boolean;
allowedModes?: Array<'jmx' | 'endpoint' | 'agent'>;
preferredMode?: 'jmx' | 'endpoint' | 'agent';
jmx?: JVMJMXConfig;
endpoint?: JVMEndpointConfig;
}
export interface JVMCapability {
mode: 'jmx' | 'endpoint' | 'agent';
canBrowse: boolean;
canWrite: boolean;
canPreview: boolean;
reason?: string;
displayLabel: string;
}
export interface JVMResourceSummary {
id: string;
parentId?: string;
kind: string;
name: string;
path: string;
providerMode: 'jmx' | 'endpoint' | 'agent';
canRead: boolean;
canWrite: boolean;
hasChildren: boolean;
sensitive?: boolean;
}
export interface JVMValueSnapshot {
resourceId: string;
kind: string;
format: string;
version?: string;
value: any;
metadata?: Record<string, any>;
}
export interface JVMChangePreview {
allowed: boolean;
requiresConfirmation?: boolean;
summary: string;
riskLevel: 'low' | 'medium' | 'high';
blockingReason?: string;
before: JVMValueSnapshot;
after: JVMValueSnapshot;
}
export interface ConnectionConfig {
id?: string;
type: string;
@@ -56,6 +122,7 @@ export interface ConnectionConfig {
mongoAuthMechanism?: string;
mongoReplicaUser?: string;
mongoReplicaPassword?: string;
jvm?: JVMConfig;
}
export interface MongoMemberInfo {
@@ -344,4 +411,3 @@ export interface SecurityUpdateStatus {
lastError?: string;
}

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { buildDefaultJVMConnectionValues, buildJVMConnectionConfig } from './jvmConnectionConfig';
describe('jvmConnectionConfig', () => {
it('defaults to readonly jmx mode', () => {
const values = buildDefaultJVMConnectionValues();
expect(values.type).toBe('jvm');
expect(values.jvmReadOnly).toBe(true);
expect(values.jvmAllowedModes).toEqual(['jmx']);
expect(values.jvmPreferredMode).toBe('jmx');
});
it('builds nested jvm config payload', () => {
const config = buildJVMConnectionConfig({
name: 'Orders JVM',
type: 'jvm',
host: 'orders.internal',
port: 9010,
jvmReadOnly: true,
jvmAllowedModes: ['jmx', 'endpoint'],
jvmPreferredMode: 'endpoint',
jvmEnvironment: 'prod',
jvmEndpointEnabled: true,
jvmEndpointBaseUrl: 'https://orders.internal/manage/jvm',
jvmEndpointApiKey: 'token-1',
});
expect(config.jvm?.preferredMode).toBe('endpoint');
expect(config.jvm?.endpoint?.baseUrl).toBe('https://orders.internal/manage/jvm');
});
it('normalizes allowed modes and falls back preferred mode to first allowed mode', () => {
const config = buildJVMConnectionConfig({
host: 'cache.internal',
port: 9010,
jvmAllowedModes: [' Endpoint ', 'invalid', 'JMX', 'endpoint'],
jvmPreferredMode: 'AGENT',
});
expect(config.jvm?.allowedModes).toEqual(['endpoint', 'jmx']);
expect(config.jvm?.preferredMode).toBe('endpoint');
expect(config.jvm?.jmx?.enabled).toBe(true);
});
it('normalizes environment and port defaults when input is invalid', () => {
const config = buildJVMConnectionConfig({
host: 'orders.internal',
port: 0,
jvmJmxPort: '',
jvmEnvironment: ' PROD ',
jvmReadOnly: false,
jvmAllowedModes: ['JMX'],
jvmPreferredMode: 'jmx',
});
expect(config.port).toBe(9010);
expect(config.jvm?.jmx?.port).toBe(9010);
expect(config.jvm?.environment).toBe('prod');
expect(config.jvm?.readOnly).toBe(false);
});
});

View File

@@ -0,0 +1,123 @@
import type { ConnectionConfig } from '../types';
const DEFAULT_JMX_PORT = 9010;
const DEFAULT_TIMEOUT_SECONDS = 30;
const DEFAULT_ENVIRONMENT = 'dev';
const JVM_MODES = ['jmx', 'endpoint', 'agent'] as const;
type JVMMode = typeof JVM_MODES[number];
type JVMEnvironment = 'dev' | 'uat' | 'prod';
type JVMConnectionFormValues = Record<string, unknown>;
const isJVMMode = (value: string): value is JVMMode => JVM_MODES.includes(value as JVMMode);
const toStringValue = (value: unknown): string => {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
return '';
};
const toInteger = (value: unknown, fallback: number): number => {
if (value === undefined || value === null || value === '') {
return fallback;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
const intValue = Math.trunc(parsed);
return intValue > 0 ? intValue : fallback;
};
const normalizeModes = (value: unknown): JVMMode[] => {
if (!Array.isArray(value)) {
return ['jmx'];
}
const result: JVMMode[] = [];
const seen = new Set<JVMMode>();
for (const item of value) {
const mode = toStringValue(item).toLowerCase();
if (!isJVMMode(mode) || seen.has(mode)) {
continue;
}
seen.add(mode);
result.push(mode);
}
return result.length > 0 ? result : ['jmx'];
};
const normalizePreferredMode = (value: unknown, allowedModes: JVMMode[]): JVMMode => {
const preferred = toStringValue(value).toLowerCase();
if (isJVMMode(preferred) && allowedModes.includes(preferred)) {
return preferred;
}
return allowedModes[0];
};
const normalizeEnvironment = (value: unknown): JVMEnvironment => {
const env = toStringValue(value).toLowerCase();
if (env === 'uat' || env === 'prod') {
return env;
}
return DEFAULT_ENVIRONMENT;
};
const normalizeReadOnly = (value: unknown): boolean => {
if (typeof value === 'boolean') {
return value;
}
return true;
};
export const buildDefaultJVMConnectionValues = () => ({
type: 'jvm',
host: 'localhost',
port: DEFAULT_JMX_PORT,
jvmReadOnly: true,
jvmAllowedModes: ['jmx'],
jvmPreferredMode: 'jmx',
jvmEnvironment: DEFAULT_ENVIRONMENT,
jvmEndpointEnabled: false,
jvmEndpointBaseUrl: '',
jvmEndpointApiKey: '',
});
export const buildJVMConnectionConfig = (values: JVMConnectionFormValues): ConnectionConfig => {
const allowedModes = normalizeModes(values.jvmAllowedModes);
const preferredMode = normalizePreferredMode(values.jvmPreferredMode, allowedModes);
const port = toInteger(values.port, DEFAULT_JMX_PORT);
const timeout = toInteger(values.timeout, DEFAULT_TIMEOUT_SECONDS);
return {
type: 'jvm',
host: toStringValue(values.host),
port,
user: '',
password: '',
timeout,
jvm: {
environment: normalizeEnvironment(values.jvmEnvironment),
readOnly: normalizeReadOnly(values.jvmReadOnly),
allowedModes,
preferredMode,
jmx: {
enabled: allowedModes.includes('jmx'),
host: toStringValue(values.jvmJmxHost) || toStringValue(values.host),
port: toInteger(values.jvmJmxPort, port),
username: toStringValue(values.jvmJmxUsername),
password: toStringValue(values.jvmJmxPassword),
},
endpoint: {
enabled: values.jvmEndpointEnabled === true,
baseUrl: toStringValue(values.jvmEndpointBaseUrl),
apiKey: toStringValue(values.jvmEndpointApiKey),
timeoutSeconds: toInteger(values.jvmEndpointTimeoutSeconds, timeout),
},
},
};
};

View File

@@ -26,6 +26,34 @@ type HTTPTunnelConfig struct {
Password string `json:"password,omitempty"`
}
// JVMJMXConfig 存储 JVM JMX 连接配置。
type JVMJMXConfig struct {
Enabled bool `json:"enabled,omitempty"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
DomainAllowlist []string `json:"domainAllowlist,omitempty"`
}
// JVMEndpointConfig 存储 JVM Management Endpoint 连接配置。
type JVMEndpointConfig struct {
Enabled bool `json:"enabled,omitempty"`
BaseURL string `json:"baseUrl,omitempty"`
APIKey string `json:"apiKey,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
}
// JVMConfig 存储 JVM 连接的协议与能力偏好配置。
type JVMConfig struct {
Environment string `json:"environment,omitempty"`
ReadOnly *bool `json:"readOnly,omitempty"`
AllowedModes []string `json:"allowedModes,omitempty"`
PreferredMode string `json:"preferredMode,omitempty"`
JMX JVMJMXConfig `json:"jmx,omitempty"`
Endpoint JVMEndpointConfig `json:"endpoint,omitempty"`
}
// ConnectionConfig 存储数据库连接的完整配置,包括 SSH、代理、SSL 等网络层设置。
type ConnectionConfig struct {
ID string `json:"id,omitempty"`
@@ -62,6 +90,7 @@ type ConnectionConfig struct {
MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism
MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password
JVM JVMConfig `json:"jvm,omitempty"` // JVM connector config
}
// ResultSetData 表示一个查询结果集(行 + 列名),用于多结果集场景。

82
internal/jvm/config.go Normal file
View File

@@ -0,0 +1,82 @@
package jvm
import (
"fmt"
"strings"
"GoNavi-Wails/internal/connection"
)
const defaultJMXPort = 9010
func NormalizeConnectionConfig(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
cfg := raw
if strings.ToLower(strings.TrimSpace(cfg.Type)) != "jvm" {
return connection.ConnectionConfig{}, fmt.Errorf("unexpected connection type: %s", cfg.Type)
}
cfg.Type = "jvm"
cfg.JVM.Environment = strings.ToLower(strings.TrimSpace(cfg.JVM.Environment))
if cfg.JVM.ReadOnly == nil {
cfg.JVM.ReadOnly = boolPtr(true)
}
if cfg.JVM.JMX.Port <= 0 {
if cfg.Port > 0 {
cfg.JVM.JMX.Port = cfg.Port
} else {
cfg.JVM.JMX.Port = defaultJMXPort
}
}
cfg.JVM.AllowedModes = normalizeModes(cfg.JVM.AllowedModes)
preferredMode := strings.ToLower(strings.TrimSpace(cfg.JVM.PreferredMode))
if preferredMode == "" || !containsMode(cfg.JVM.AllowedModes, preferredMode) {
cfg.JVM.PreferredMode = cfg.JVM.AllowedModes[0]
} else {
cfg.JVM.PreferredMode = preferredMode
}
return cfg, nil
}
func normalizeModes(input []string) []string {
if len(input) == 0 {
return []string{ModeJMX}
}
result := make([]string, 0, len(input))
seen := make(map[string]struct{}, len(input))
for _, item := range input {
mode := strings.ToLower(strings.TrimSpace(item))
switch mode {
case ModeJMX, ModeEndpoint, ModeAgent:
default:
continue
}
if _, exists := seen[mode]; exists {
continue
}
seen[mode] = struct{}{}
result = append(result, mode)
}
if len(result) == 0 {
return []string{ModeJMX}
}
return result
}
func containsMode(items []string, target string) bool {
normalizedTarget := strings.ToLower(strings.TrimSpace(target))
for _, item := range items {
if strings.ToLower(strings.TrimSpace(item)) == normalizedTarget {
return true
}
}
return false
}
func boolPtr(value bool) *bool {
return &value
}

View File

@@ -0,0 +1,93 @@
package jvm
import (
"testing"
"GoNavi-Wails/internal/connection"
)
func TestNormalizeConnectionConfigDefaultsToReadOnlyJMX(t *testing.T) {
raw := connection.ConnectionConfig{
Type: "jvm",
Host: "orders-prod.internal",
Port: 9010,
}
got, err := NormalizeConnectionConfig(raw)
if err != nil {
t.Fatalf("NormalizeConnectionConfig returned error: %v", err)
}
if got.JVM.ReadOnly == nil || !*got.JVM.ReadOnly {
t.Fatalf("expected JVM connection to default to readOnly")
}
if got.JVM.PreferredMode != ModeJMX {
t.Fatalf("expected preferred mode %q, got %q", ModeJMX, got.JVM.PreferredMode)
}
if len(got.JVM.AllowedModes) != 1 || got.JVM.AllowedModes[0] != ModeJMX {
t.Fatalf("expected allowed modes [jmx], got %#v", got.JVM.AllowedModes)
}
if got.JVM.JMX.Port != 9010 {
t.Fatalf("expected JMX port to inherit root port 9010, got %d", got.JVM.JMX.Port)
}
}
func TestNormalizeConnectionConfigFallsBackToFirstAllowedMode(t *testing.T) {
raw := connection.ConnectionConfig{
Type: "jvm",
Host: "cache-svc.internal",
JVM: connection.JVMConfig{
AllowedModes: []string{ModeEndpoint, ModeJMX},
PreferredMode: ModeAgent,
Endpoint: connection.JVMEndpointConfig{
Enabled: true,
BaseURL: "https://cache-svc.internal/manage/jvm",
},
},
}
got, err := NormalizeConnectionConfig(raw)
if err != nil {
t.Fatalf("NormalizeConnectionConfig returned error: %v", err)
}
if got.JVM.PreferredMode != ModeEndpoint {
t.Fatalf("expected preferred mode %q, got %q", ModeEndpoint, got.JVM.PreferredMode)
}
}
func TestNormalizeConnectionConfigKeepsExplicitReadOnlyFalse(t *testing.T) {
readOnly := false
raw := connection.ConnectionConfig{
Type: "jvm",
Port: 9010,
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
},
}
got, err := NormalizeConnectionConfig(raw)
if err != nil {
t.Fatalf("NormalizeConnectionConfig returned error: %v", err)
}
if got.JVM.ReadOnly == nil {
t.Fatalf("expected readOnly to remain explicitly configured")
}
if *got.JVM.ReadOnly {
t.Fatalf("expected explicit readOnly=false to be preserved")
}
}
func TestNormalizeConnectionConfigDefaultsJMXPortTo9010WhenPortsMissing(t *testing.T) {
raw := connection.ConnectionConfig{
Type: "jvm",
Host: "orders-prod.internal",
Port: 0,
}
got, err := NormalizeConnectionConfig(raw)
if err != nil {
t.Fatalf("NormalizeConnectionConfig returned error: %v", err)
}
if got.JVM.JMX.Port != 9010 {
t.Fatalf("expected JMX port default 9010, got %d", got.JVM.JMX.Port)
}
}

74
internal/jvm/types.go Normal file
View File

@@ -0,0 +1,74 @@
package jvm
const (
ModeJMX = "jmx"
ModeEndpoint = "endpoint"
ModeAgent = "agent"
EnvPROD = "prod"
)
type Capability struct {
Mode string `json:"mode"`
CanBrowse bool `json:"canBrowse"`
CanWrite bool `json:"canWrite"`
CanPreview bool `json:"canPreview"`
Reason string `json:"reason,omitempty"`
DisplayLabel string `json:"displayLabel"`
}
type ResourceSummary struct {
ID string `json:"id"`
ParentID string `json:"parentId,omitempty"`
Kind string `json:"kind"`
Name string `json:"name"`
Path string `json:"path"`
ProviderMode string `json:"providerMode"`
CanRead bool `json:"canRead"`
CanWrite bool `json:"canWrite"`
HasChildren bool `json:"hasChildren"`
Sensitive bool `json:"sensitive,omitempty"`
}
type ValueSnapshot struct {
ResourceID string `json:"resourceId"`
Kind string `json:"kind"`
Format string `json:"format"`
Version string `json:"version,omitempty"`
Value interface{} `json:"value"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type ChangeRequest struct {
ProviderMode string `json:"providerMode"`
ResourceID string `json:"resourceId"`
Action string `json:"action"`
Reason string `json:"reason"`
ExpectedVersion string `json:"expectedVersion,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
}
type ChangePreview struct {
Allowed bool `json:"allowed"`
RequiresConfirmation bool `json:"requiresConfirmation,omitempty"`
Summary string `json:"summary"`
RiskLevel string `json:"riskLevel"`
BlockingReason string `json:"blockingReason,omitempty"`
Before ValueSnapshot `json:"before"`
After ValueSnapshot `json:"after"`
}
type ApplyResult struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
UpdatedValue ValueSnapshot `json:"updatedValue"`
}
type AuditRecord struct {
Timestamp int64 `json:"timestamp"`
ConnectionID string `json:"connectionId"`
ProviderMode string `json:"providerMode"`
ResourceID string `json:"resourceId"`
Action string `json:"action"`
Reason string `json:"reason"`
Result string `json:"result"`
}