From 49b90ebd29dad118e3fbaebdd6767b380de14427 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Mon, 24 Aug 2026 14:35:10 +0800 Subject: [PATCH] Synthesize default server file workspace --- platform/api/resource_handlers_test.go | 23 +++++++++++ platform/service/server_files.go | 55 +++++++++++++++++++------- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index 4826430..d10df59 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -286,6 +286,29 @@ func TestCoreAPIServerFileWorkspaceRoutesAreScoped(t *testing.T) { assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/workspace", "", otherSession), http.StatusForbidden, errorCodeForbidden) } +func TestCoreAPIServerFileWorkspaceSynthesizesDefaultDirectoryForLegacyPlugin(t *testing.T) { + router := newTestRouter() + adminSession := createAdminSession(t, router) + pluginRequest := validGamePluginRequest() + pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityFilesList, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite) + pluginRequest.DeclaredPermissions = []string{"server.files.read", "server.files.write"} + pluginRequest.Permissions.Files = true + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest) + endpointRequest := validRunEndpointRequest() + endpointRequest.Capabilities = append(endpointRequest.Capabilities, domain.JobCapabilityFilesList, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite) + postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpointRequest) + instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-file-legacy-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Legacy File API Server", State: domain.ServerInstanceStateRunning}, adminSession) + + workspace := getJSONWithAuth[dto.ServerFileWorkspaceResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/workspace", adminSession) + if workspace.DefaultDirectoryKey != "server-files" || workspace.DeclaredOnly || len(workspace.Directories) == 0 || workspace.Directories[0].Label != "服务器文件" || workspace.Directories == nil || workspace.Files == nil || workspace.ConfigFields == nil { + t.Fatalf("expected synthesized non-null workspace, got %+v", workspace) + } + list := getJSONWithAuth[dto.ServerFileListResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/list", adminSession) + if list.DirectoryKey != "server-files" || list.Entries == nil || !strings.Contains(list.Reason, "通用服务器文件目录") { + t.Fatalf("expected default file list, got %+v", list) + } +} + func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { releaseBuilds := make(chan struct{}) t.Cleanup(func() { close(releaseBuilds) }) diff --git a/platform/service/server_files.go b/platform/service/server_files.go index 82731d9..30fa3bc 100644 --- a/platform/service/server_files.go +++ b/platform/service/server_files.go @@ -16,8 +16,10 @@ import ( ) const ( - serverFileTransferChannel = "run-file-transfer" - serverFileMaxInlineEditBytes = 64 * 1024 + serverFileTransferChannel = "run-file-transfer" + serverFileMaxInlineEditBytes = 64 * 1024 + serverFileDefaultDirectoryKey = "server-files" + serverFileDefaultLogDirectoryKey = "server-logs" ) type serverFileContext struct { @@ -25,6 +27,7 @@ type serverFileContext struct { Instance domain.ServerInstance Plugin domain.GamePlugin Directory domain.PluginLogicalDirectory + Workspace domain.PluginFileWorkspace Scope string } @@ -54,10 +57,7 @@ func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serve if err != nil { return domain.ServerFileWorkspaceView{}, err } - workspace := domain.CopyPluginFileWorkspace(ctx.Plugin.FileWorkspace) - if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) > 0 { - workspace.DefaultDirectoryKey = workspace.Directories[0].Key - } + workspace := domain.CopyPluginFileWorkspace(ctx.Workspace) view := domain.ServerFileWorkspaceView{ ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, @@ -65,7 +65,7 @@ func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serve Directories: workspace.Directories, Files: workspace.Files, ConfigFields: workspace.ConfigFields, - DeclaredOnly: true, + DeclaredOnly: serverFileWorkspaceIsPluginDeclared(ctx.Plugin.FileWorkspace), RuntimeWorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), Transfer: domain.ServerFileTransferPolicy{ Channel: serverFileTransferChannel, @@ -107,7 +107,14 @@ func (svc *CoreService) ListServerFilesForSession(sessionID string, request doma state = "pending" reason = "Run 正在刷新目录。" } - entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Plugin.FileWorkspace, request.DirectoryKey), request.Query) + entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query) + if state == "declared" { + reasonPrefix := "展示插件声明的逻辑文件" + if !serverFileWorkspaceIsPluginDeclared(ctx.Plugin.FileWorkspace) { + reasonPrefix = "展示通用服务器文件目录" + } + reason = reasonPrefix + ";点击刷新可请求 Run 返回实时目录。" + } return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: state, Entries: entries, Job: latest, RefreshedAt: latest.TerminalAt, Reason: reason}), nil } @@ -142,7 +149,7 @@ func (svc *CoreService) RefreshServerFileListForSession(sessionID string, reques if err != nil { return domain.ServerFileListResult{}, err } - entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Plugin.FileWorkspace, request.DirectoryKey), request.Query) + entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query) return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "pending", Entries: entries, Job: job, Reason: "目录刷新任务已派发到 Run。"}), nil } @@ -213,7 +220,7 @@ func (svc *CoreService) PrepareServerFileDownloadForSession(sessionID string, re if err != nil { return domain.ServerFileDownloadResult{}, err } - filename := serverFileDisplayName(ctx.Plugin.FileWorkspace, request.Key) + filename := serverFileDisplayName(ctx.Workspace, request.Key) job, hasJob, err := svc.latestFileReadJob(ctx.Instance.ID, request.Key) if err != nil { return domain.ServerFileDownloadResult{}, err @@ -323,21 +330,41 @@ func (svc *CoreService) serverFileContextForSession(sessionID string, serverInst } else if !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") { return serverFileContext{}, ErrForbidden } + workspace := effectiveServerFileWorkspace(plugin) directory := domain.PluginLogicalDirectory{} if directoryKey != "" { var found bool - for _, candidate := range plugin.FileWorkspace.Directories { + for _, candidate := range workspace.Directories { if candidate.Key == directoryKey { directory = candidate found = true break } } - if !found && len(plugin.FileWorkspace.Directories) > 0 { - return serverFileContext{}, validationError("directoryKey must reference a plugin-declared directory") + if !found && len(workspace.Directories) > 0 { + return serverFileContext{}, validationError("directoryKey must reference an available logical directory") } } - return serverFileContext{User: user, Instance: instance, Plugin: plugin, Directory: directory, Scope: svc.runtimeProfileScope(instance.ID)}, nil + return serverFileContext{User: user, Instance: instance, Plugin: plugin, Directory: directory, Workspace: workspace, Scope: svc.runtimeProfileScope(instance.ID)}, nil +} + +func effectiveServerFileWorkspace(plugin domain.GamePlugin) domain.PluginFileWorkspace { + workspace := domain.CopyPluginFileWorkspace(plugin.FileWorkspace) + if len(workspace.Directories) == 0 && workspace.DefaultDirectoryKey == "" { + workspace.DefaultDirectoryKey = serverFileDefaultDirectoryKey + workspace.Directories = []domain.PluginLogicalDirectory{{Key: serverFileDefaultDirectoryKey, Label: "服务器文件", Scope: "config"}} + if plugin.Permissions.Logs || containsString(plugin.DeclaredPermissions, "server.logs.read") || len(plugin.RuntimeProfiles.LogSources) > 0 { + workspace.Directories = append(workspace.Directories, domain.PluginLogicalDirectory{Key: serverFileDefaultLogDirectoryKey, Label: "日志文件", Scope: "logs"}) + } + } + if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) > 0 { + workspace.DefaultDirectoryKey = workspace.Directories[0].Key + } + return workspace +} + +func serverFileWorkspaceIsPluginDeclared(workspace domain.PluginFileWorkspace) bool { + return workspace.DefaultDirectoryKey != "" || len(workspace.Directories) > 0 || len(workspace.Files) > 0 || len(workspace.ConfigFields) > 0 } func normalizeServerFileListRequest(request domain.ServerFileListRequest) domain.ServerFileListRequest {