From aa9c803193fd6bec0869e3235ee87b85f412a884 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Thu, 30 Jul 2026 11:13:15 +0800 Subject: [PATCH 1/2] feat: decouple datasource connectivity from privilege checks Split IsConnectable and privilege reply semantics; add unit tests for plugin exemption, allowlist unsupported path, and privilege reply field mapping. --- .gitignore | 1 + api/dms/service/v1/project.go | 30 ++- internal/dms/biz/db_service.go | 184 ++++++++++++++++-- .../db_service_connectivity_privilege_test.go | 124 ++++++++++++ internal/dms/service/project.go | 51 +++-- .../service/project_privilege_reply_test.go | 88 +++++++++ pkg/dms-common/api/dms/v1/router.go | 4 + 7 files changed, 451 insertions(+), 31 deletions(-) create mode 100644 internal/dms/biz/db_service_connectivity_privilege_test.go create mode 100644 internal/dms/service/project_privilege_reply_test.go diff --git a/.gitignore b/.gitignore index c1dc3845b..86d461f7f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ static bin process_record.json *.db +*.pid *.rpm *.rpm.md5 builddir/ diff --git a/api/dms/service/v1/project.go b/api/dms/service/v1/project.go index 3c8ffa660..94c6f6eab 100644 --- a/api/dms/service/v1/project.go +++ b/api/dms/service/v1/project.go @@ -307,9 +307,33 @@ type CheckDBServicesPrivilegesReq struct { // swagger:model CheckDBServicesPrivilegesReply type CheckDBServicesPrivilegesReply struct { base.GenericResp - Data []CheckDBServicesPrivilegesItem `json:"data"` + Data []CheckDBServicePrivilegeResult `json:"data"` } -type CheckDBServicesPrivilegesItem struct { - CheckDBServicesPrivileges []CheckDBServiceIsConnectableReplyItem +// CheckDBServicePrivilegeResult is the per-datasource privilege probe result (S2). +type CheckDBServicePrivilegeResult struct { + DBType string `json:"db_type"` + CheckSupport string `json:"check_support"` // supported | unsupported_auto_check + ConnectivityPrecheck ConnectivityPrecheckResult `json:"connectivity_precheck"` + Modules []DBServicePrivilegeModuleItem `json:"modules"` + SummaryMessage string `json:"summary_message,omitempty"` +} + +type ConnectivityPrecheckResult struct { + OK bool `json:"ok"` + ErrorMessage string `json:"error_message,omitempty"` +} + +type DBServicePrivilegeModuleItem struct { + Module string `json:"module"` + ModuleName string `json:"module_name"` + Status string `json:"status"` // available | partially_available | unavailable | unsupported_auto_check + MissingPrivileges []DBServiceMissingPrivilegeItem `json:"missing_privileges"` + Message string `json:"message,omitempty"` +} + +type DBServiceMissingPrivilegeItem struct { + Privilege string `json:"privilege"` + ObjectScope string `json:"object_scope,omitempty"` + Note string `json:"note,omitempty"` } diff --git a/internal/dms/biz/db_service.go b/internal/dms/biz/db_service.go index f626d324f..a935c9673 100644 --- a/internal/dms/biz/db_service.go +++ b/internal/dms/biz/db_service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/url" + "strings" "sync" "time" @@ -792,6 +793,15 @@ type IsConnectableReply struct { ConnectErrorMessage string `json:"connect_error_message"` } +// isConnectivityPluginUnavailable reports whether a component failed only because +// its DB driver plugin is missing/unloaded. That must not be mapped to datasource +// connectivity failure (S1: connect = dial+auth+session; AC-007 SQL Server without ms-plugin). +func isConnectivityPluginUnavailable(msg string) bool { + m := strings.ToLower(msg) + return strings.Contains(m, "plugin not found") || + strings.Contains(m, "open plugin:") +} + func (d *DBServiceUsecase) IsConnectable(ctx context.Context, params dmsCommonV1.CheckDbConnectable) ([]*IsConnectableReply, error) { dmsProxyTargets, err := d.dmsProxyTargetRepo.ListProxyTargetsByScenarios(ctx, []ProxyScenario{ProxyScenarioInternalService}) if err != nil { @@ -817,9 +827,18 @@ func (d *DBServiceUsecase) IsConnectable(ctx context.Context, params dmsCommonV1 var reply = &v1Base.GenericResp{} err = pkgHttp.POST(ctx, fmt.Sprintf("%s%s", target.URL.String(), uri), header, params, reply) if err != nil { - isConnectableReply.ConnectErrorMessage = err.Error() + if isConnectivityPluginUnavailable(err.Error()) { + // Plugin absent ≠ instance unreachable; do not fail aggregate / leak plugin text. + isConnectableReply.IsConnectable = true + } else { + isConnectableReply.ConnectErrorMessage = err.Error() + } } else if reply.Code != 0 { - isConnectableReply.ConnectErrorMessage = reply.Message + if isConnectivityPluginUnavailable(reply.Message) { + isConnectableReply.IsConnectable = true + } else { + isConnectableReply.ConnectErrorMessage = reply.Message + } } else { isConnectableReply.IsConnectable = true } @@ -880,7 +899,77 @@ func (d *DBServiceUsecase) GetBusiness(ctx context.Context, projectUid string) ( } type CheckDBServicePrivileges struct { - ComponentPrivilegesResult []*IsConnectableReply + DBType string + CheckSupport string + ConnectivityPrecheck ConnectivityPrecheck + Modules []PrivilegeModuleResult + SummaryMessage string +} + +type ConnectivityPrecheck struct { + OK bool + ErrorMessage string +} + +type PrivilegeModuleResult struct { + Module string + ModuleName string + Status string + MissingPrivileges []MissingPrivilegeItem + Message string +} + +type MissingPrivilegeItem struct { + Privilege string + ObjectScope string + Note string +} + +const provisionProxyName = "provision" + +func unsupportedPrivilegeModules() []PrivilegeModuleResult { + defs := []struct{ module, name string }{ + {"sql_workbench_query", "SQL 工作台查询"}, + {"sql_audit_analysis", "SQL 审核 / SQL 分析"}, + {"data_export", "数据导出"}, + {"sql_deploy", "SQL 上线"}, + {"smart_scan", "智能扫描"}, + {"account_privilege_mgmt", "账号与权限管理"}, + } + out := make([]PrivilegeModuleResult, 0, len(defs)) + for _, d := range defs { + out = append(out, PrivilegeModuleResult{ + Module: d.module, + ModuleName: d.name, + Status: "unsupported_auto_check", + MissingPrivileges: []MissingPrivilegeItem{}, + }) + } + return out +} + +type provisionModulePrivilegesReply struct { + v1Base.GenericResp + Data *struct { + DBType string `json:"db_type"` + CheckSupport string `json:"check_support"` + ConnectivityPrecheck struct { + OK bool `json:"ok"` + ErrorMessage string `json:"error_message"` + } `json:"connectivity_precheck"` + Modules []struct { + Module string `json:"module"` + ModuleName string `json:"module_name"` + Status string `json:"status"` + MissingPrivileges []struct { + Privilege string `json:"privilege"` + ObjectScope string `json:"object_scope"` + Note string `json:"note"` + } `json:"missing_privileges"` + Message string `json:"message"` + } `json:"modules"` + SummaryMessage string `json:"summary_message"` + } `json:"data"` } func (d *DBServiceUsecase) CheckDBServiceHasEnoughPrivileges(ctx context.Context, params []dmsCommonV1.CheckDbConnectable) ([]*CheckDBServicePrivileges, error) { @@ -902,21 +991,15 @@ func (d *DBServiceUsecase) CheckDBServiceHasEnoughPrivileges(ctx context.Context go func(i int, param dmsCommonV1.CheckDbConnectable) { defer wg.Done() - semaphore <- struct{}{} // acquire slot - defer func() { <-semaphore }() // release slot + semaphore <- struct{}{} + defer func() { <-semaphore }() - r, err := d.IsConnectable(ctx, param) + r, err := d.checkOneDBServicePrivileges(ctx, param) if err != nil { resultCh <- resultItem{index: i, err: fmt.Errorf("check db service privileges failed: %v", err)} return } - - resultCh <- resultItem{ - index: i, - result: &CheckDBServicePrivileges{ - ComponentPrivilegesResult: r, - }, - } + resultCh <- resultItem{index: i, result: r} }(i, v) } @@ -932,3 +1015,78 @@ func (d *DBServiceUsecase) CheckDBServiceHasEnoughPrivileges(ctx context.Context return ret, nil } + +func (d *DBServiceUsecase) checkOneDBServicePrivileges(ctx context.Context, param dmsCommonV1.CheckDbConnectable) (*CheckDBServicePrivileges, error) { + // Phase-3 allowlist:MySQL / OceanBase For MySQL / SQL Server 走模块自动探测;其余短路径 unsupported。 + if !isModulePrivilegeAutoCheckSupported(param.DBType) { + return &CheckDBServicePrivileges{ + DBType: param.DBType, + CheckSupport: "unsupported_auto_check", + ConnectivityPrecheck: ConnectivityPrecheck{ + OK: true, + }, + Modules: unsupportedPrivilegeModules(), + SummaryMessage: "暂不支持自动检查", + }, nil + } + + target, err := d.dmsProxyTargetRepo.GetProxyTargetByName(ctx, provisionProxyName) + if err != nil { + return nil, fmt.Errorf("get provision proxy: %w", err) + } + + header := map[string]string{ + "Authorization": pkgHttp.DefaultDMSToken, + } + uri := dmsCommonV1.GetDBServicePrivilegesRouter() + reply := &provisionModulePrivilegesReply{} + err = pkgHttp.POST(ctx, fmt.Sprintf("%s%s", target.URL.String(), uri), header, param, reply) + if err != nil { + return nil, err + } + if reply.Code != 0 { + return nil, fmt.Errorf("provision privileges check failed: %s", reply.Message) + } + if reply.Data == nil { + return nil, fmt.Errorf("provision privileges check returned empty data") + } + + modules := make([]PrivilegeModuleResult, 0, len(reply.Data.Modules)) + for _, m := range reply.Data.Modules { + missing := make([]MissingPrivilegeItem, 0, len(m.MissingPrivileges)) + for _, mp := range m.MissingPrivileges { + missing = append(missing, MissingPrivilegeItem{ + Privilege: mp.Privilege, + ObjectScope: mp.ObjectScope, + Note: mp.Note, + }) + } + modules = append(modules, PrivilegeModuleResult{ + Module: m.Module, + ModuleName: m.ModuleName, + Status: m.Status, + MissingPrivileges: missing, + Message: m.Message, + }) + } + + return &CheckDBServicePrivileges{ + DBType: reply.Data.DBType, + CheckSupport: reply.Data.CheckSupport, + ConnectivityPrecheck: ConnectivityPrecheck{ + OK: reply.Data.ConnectivityPrecheck.OK, + ErrorMessage: reply.Data.ConnectivityPrecheck.ErrorMessage, + }, + Modules: modules, + SummaryMessage: reply.Data.SummaryMessage, + }, nil +} + +func isModulePrivilegeAutoCheckSupported(dbType string) bool { + switch strings.TrimSpace(dbType) { + case "MySQL", "OceanBase For MySQL", "SQL Server": + return true + default: + return false + } +} diff --git a/internal/dms/biz/db_service_connectivity_privilege_test.go b/internal/dms/biz/db_service_connectivity_privilege_test.go new file mode 100644 index 000000000..9ba0edd43 --- /dev/null +++ b/internal/dms/biz/db_service_connectivity_privilege_test.go @@ -0,0 +1,124 @@ +package biz + +import ( + "context" + "encoding/json" + "testing" + + dmsCommonV1 "github.com/actiontech/dms/pkg/dms-common/api/dms/v1" + "github.com/stretchr/testify/assert" +) + +func TestIsConnectivityPluginUnavailable(t *testing.T) { + // AC-007: plugin missing must be recognized so aggregate does not treat it as dial/auth failure. + cases := []struct { + name string + msg string + want bool + }{ + {name: "plugin_not_found_lower", msg: "open plugin: plugin not found", want: true}, + {name: "plugin_not_found_mixed_case", msg: "Open Plugin: Plugin Not Found", want: true}, + {name: "open_plugin_prefix_only", msg: "open plugin: /path/to/ms.so: no such file", want: true}, + {name: "access_denied_not_plugin", msg: "Error 1045: Access denied for user", want: false}, + {name: "login_failed_mssql", msg: "Login failed for user 'env_mssql_ro'", want: false}, + {name: "empty", msg: "", want: false}, + {name: "unrelated_error", msg: "connection refused", want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, isConnectivityPluginUnavailable(tc.msg)) + }) + } +} + +func TestIsModulePrivilegeAutoCheckSupported(t *testing.T) { + cases := []struct { + dbType string + want bool + }{ + {"MySQL", true}, + {"OceanBase For MySQL", true}, + {"SQL Server", true}, + {"PostgreSQL", false}, + {"Oracle", false}, + {"DB2", false}, + {"", false}, + {" MySQL ", true}, + } + for _, tc := range cases { + t.Run(tc.dbType, func(t *testing.T) { + assert.Equal(t, tc.want, isModulePrivilegeAutoCheckSupported(tc.dbType)) + }) + } +} + +func TestUnsupportedPrivilegeModules_Shape(t *testing.T) { + mods := unsupportedPrivilegeModules() + if assert.Len(t, mods, 6) { + wantOrder := []string{ + "sql_workbench_query", + "sql_audit_analysis", + "data_export", + "sql_deploy", + "smart_scan", + "account_privilege_mgmt", + } + for i, id := range wantOrder { + assert.Equal(t, id, mods[i].Module) + assert.Equal(t, "unsupported_auto_check", mods[i].Status) + assert.NotEmpty(t, mods[i].ModuleName) + assert.Empty(t, mods[i].MissingPrivileges) + } + } +} + +func TestCheckOneDBServicePrivileges_AllowlistUnsupportedShortPath(t *testing.T) { + // AC-001 boundary: types outside the auto-check allowlist must not become connectivity failures. + d := &DBServiceUsecase{} + cases := []string{"PostgreSQL", "Oracle", "DB2", "TiDB", "UnknownType"} + for _, dbType := range cases { + t.Run(dbType, func(t *testing.T) { + got, err := d.checkOneDBServicePrivileges(context.Background(), dmsCommonV1.CheckDbConnectable{ + DBType: dbType, + Host: "127.0.0.1", + Port: "5432", + User: "ro", + }) + assert.NoError(t, err) + if !assert.NotNil(t, got) { + return + } + assert.Equal(t, dbType, got.DBType) + assert.Equal(t, "unsupported_auto_check", got.CheckSupport) + assert.True(t, got.ConnectivityPrecheck.OK, "缺权/暂不支持不得写成连通失败") + assert.Empty(t, got.ConnectivityPrecheck.ErrorMessage) + assert.Equal(t, "暂不支持自动检查", got.SummaryMessage) + assert.Len(t, got.Modules, 6) + for _, m := range got.Modules { + assert.Equal(t, "unsupported_auto_check", m.Status) + } + raw, err := json.Marshal(got) + assert.NoError(t, err) + assert.NotContains(t, string(raw), `"is_connectable"`) + assert.NotContains(t, string(raw), "CREATE USER") + }) + } +} + +func TestCheckDBServiceHasEnoughPrivileges_UnsupportedBatch(t *testing.T) { + d := &DBServiceUsecase{} + got, err := d.CheckDBServiceHasEnoughPrivileges(context.Background(), []dmsCommonV1.CheckDbConnectable{ + {DBType: "PostgreSQL", Host: "h", Port: "1", User: "u"}, + {DBType: "Oracle", Host: "h", Port: "1", User: "u"}, + }) + assert.NoError(t, err) + if !assert.Len(t, got, 2) { + return + } + assert.Equal(t, "PostgreSQL", got[0].DBType) + assert.Equal(t, "Oracle", got[1].DBType) + for _, item := range got { + assert.Equal(t, "unsupported_auto_check", item.CheckSupport) + assert.True(t, item.ConnectivityPrecheck.OK) + } +} diff --git a/internal/dms/service/project.go b/internal/dms/service/project.go index 5b5f24b5a..656ce0079 100644 --- a/internal/dms/service/project.go +++ b/internal/dms/service/project.go @@ -271,23 +271,44 @@ func (d *DMSService) CheckDBServiceHasEnoughPrivileges(ctx context.Context, req if err != nil { return nil, err } - checkDBServicesPrivileges := make([]dmsV1.CheckDBServicesPrivilegesItem, len(req.DBServices)) - for i, checkDBServicePrivilegesResult := range checkdbServicePrivilegesResults { - items := make([]dmsV1.CheckDBServiceIsConnectableReplyItem, 0) - for _, item := range checkDBServicePrivilegesResult.ComponentPrivilegesResult { - items = append(items, dmsV1.CheckDBServiceIsConnectableReplyItem{ - IsConnectable: item.IsConnectable, - Component: item.Component, - ConnectErrorMessage: item.ConnectErrorMessage, + return &dmsV1.CheckDBServicesPrivilegesReply{ + Data: mapPrivilegeCheckResults(checkdbServicePrivilegesResults), + }, nil +} + +// mapPrivilegeCheckResults maps biz privilege probe results to the S2 HTTP Reply shape. +// Missing privileges must surface as modules[] statuses, never as connectivity failure fields. +func mapPrivilegeCheckResults(results []*biz.CheckDBServicePrivileges) []dmsV1.CheckDBServicePrivilegeResult { + out := make([]dmsV1.CheckDBServicePrivilegeResult, len(results)) + for i, checkDBServicePrivilegesResult := range results { + modules := make([]dmsV1.DBServicePrivilegeModuleItem, 0, len(checkDBServicePrivilegesResult.Modules)) + for _, m := range checkDBServicePrivilegesResult.Modules { + missing := make([]dmsV1.DBServiceMissingPrivilegeItem, 0, len(m.MissingPrivileges)) + for _, mp := range m.MissingPrivileges { + missing = append(missing, dmsV1.DBServiceMissingPrivilegeItem{ + Privilege: mp.Privilege, + ObjectScope: mp.ObjectScope, + Note: mp.Note, + }) + } + modules = append(modules, dmsV1.DBServicePrivilegeModuleItem{ + Module: m.Module, + ModuleName: m.ModuleName, + Status: m.Status, + MissingPrivileges: missing, + Message: m.Message, }) } - checkDBServicesPrivileges[i] = dmsV1.CheckDBServicesPrivilegesItem{ - CheckDBServicesPrivileges: items, + out[i] = dmsV1.CheckDBServicePrivilegeResult{ + DBType: checkDBServicePrivilegesResult.DBType, + CheckSupport: checkDBServicePrivilegesResult.CheckSupport, + ConnectivityPrecheck: dmsV1.ConnectivityPrecheckResult{ + OK: checkDBServicePrivilegesResult.ConnectivityPrecheck.OK, + ErrorMessage: checkDBServicePrivilegesResult.ConnectivityPrecheck.ErrorMessage, + }, + Modules: modules, + SummaryMessage: checkDBServicePrivilegesResult.SummaryMessage, } - } - - return &dmsV1.CheckDBServicesPrivilegesReply{ - Data: checkDBServicesPrivileges, - }, nil + return out } diff --git a/internal/dms/service/project_privilege_reply_test.go b/internal/dms/service/project_privilege_reply_test.go new file mode 100644 index 000000000..9865b6563 --- /dev/null +++ b/internal/dms/service/project_privilege_reply_test.go @@ -0,0 +1,88 @@ +package service + +import ( + "encoding/json" + "testing" + + dmsV1 "github.com/actiontech/dms/api/dms/service/v1" + "github.com/actiontech/dms/internal/dms/biz" + "github.com/stretchr/testify/assert" +) + +func TestMapPrivilegeCheckResults_FieldMapping(t *testing.T) { + // AC-001: privilege Reply carries module statuses + precheck; missing priv ≠ connect failure shape. + in := []*biz.CheckDBServicePrivileges{ + { + DBType: "MySQL", + CheckSupport: "supported", + ConnectivityPrecheck: biz.ConnectivityPrecheck{ + OK: true, + ErrorMessage: "", + }, + Modules: []biz.PrivilegeModuleResult{ + { + Module: "sql_workbench_query", + ModuleName: "SQL 工作台查询", + Status: "available", + }, + { + Module: "account_privilege_mgmt", + ModuleName: "账号与权限管理", + Status: "unavailable", + MissingPrivileges: []biz.MissingPrivilegeItem{ + {Privilege: "CREATE USER", ObjectScope: "*.*", Note: "缺少管理权限"}, + }, + Message: "账号管理不可用", + }, + }, + SummaryMessage: "部分模块可用,不影响创建/保存", + }, + { + DBType: "PostgreSQL", + CheckSupport: "unsupported_auto_check", + ConnectivityPrecheck: biz.ConnectivityPrecheck{ + OK: true, + }, + Modules: []biz.PrivilegeModuleResult{ + {Module: "sql_workbench_query", ModuleName: "SQL 工作台查询", Status: "unsupported_auto_check"}, + }, + SummaryMessage: "暂不支持自动检查", + }, + } + + out := mapPrivilegeCheckResults(in) + if !assert.Len(t, out, 2) { + return + } + + assert.Equal(t, "MySQL", out[0].DBType) + assert.Equal(t, "supported", out[0].CheckSupport) + assert.True(t, out[0].ConnectivityPrecheck.OK) + assert.Empty(t, out[0].ConnectivityPrecheck.ErrorMessage) + if assert.Len(t, out[0].Modules, 2) { + assert.Equal(t, "available", out[0].Modules[0].Status) + assert.Equal(t, "unavailable", out[0].Modules[1].Status) + if assert.Len(t, out[0].Modules[1].MissingPrivileges, 1) { + assert.Equal(t, "CREATE USER", out[0].Modules[1].MissingPrivileges[0].Privilege) + assert.Equal(t, "*.*", out[0].Modules[1].MissingPrivileges[0].ObjectScope) + assert.Equal(t, "缺少管理权限", out[0].Modules[1].MissingPrivileges[0].Note) + } + assert.Equal(t, "账号管理不可用", out[0].Modules[1].Message) + } + assert.Equal(t, "部分模块可用,不影响创建/保存", out[0].SummaryMessage) + + assert.Equal(t, "PostgreSQL", out[1].DBType) + assert.Equal(t, "unsupported_auto_check", out[1].CheckSupport) + assert.True(t, out[1].ConnectivityPrecheck.OK) + + reply := dmsV1.CheckDBServicesPrivilegesReply{Data: out} + raw, err := json.Marshal(reply) + assert.NoError(t, err) + body := string(raw) + assert.Contains(t, body, `"check_support"`) + assert.Contains(t, body, `"connectivity_precheck"`) + assert.Contains(t, body, `"modules"`) + assert.Contains(t, body, `"missing_privileges"`) + assert.NotContains(t, body, `"is_connectable"`) + assert.NotContains(t, body, `"CheckDBServicesPrivileges"`) +} diff --git a/pkg/dms-common/api/dms/v1/router.go b/pkg/dms-common/api/dms/v1/router.go index 8d9989d00..d2e261b0e 100644 --- a/pkg/dms-common/api/dms/v1/router.go +++ b/pkg/dms-common/api/dms/v1/router.go @@ -93,6 +93,10 @@ func GetDBConnectionAbleRouter() string { return fmt.Sprintf("%s%s/connection", CurrentGroupVersion, InternalDBServiceRouterGroup) } +func GetDBServicePrivilegesRouter() string { + return fmt.Sprintf("%s%s/privileges", CurrentGroupVersion, InternalDBServiceRouterGroup) +} + func GetGlobalDataExportWorkflowsRouter() string { return fmt.Sprintf("%s%s", CurrentGroupVersion, "/dms/dashboard/data_export_workflows") } From 7935c9919044835cf096b0ef19e4fdc010f72404 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Thu, 30 Jul 2026 11:19:52 +0800 Subject: [PATCH 2/2] chore: add code_review_manifest for feat-950 --- docs/dev/code_review_manifest.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 docs/dev/code_review_manifest.json diff --git a/docs/dev/code_review_manifest.json b/docs/dev/code_review_manifest.json new file mode 100644 index 000000000..1953c391e --- /dev/null +++ b/docs/dev/code_review_manifest.json @@ -0,0 +1,6 @@ +{ + "repo": "dms", + "pr_branch": "dms/feat-950", + "target_branch": "main", + "local_head": "aa9c803193fd6bec0869e3235ee87b85f412a884" +}