package service import ( "context" "encoding/json" "errors" "fmt" "path" "sort" "strconv" "strings" "time" "browser.local/platform/domain" "browser.local/platform/repo" "browser.local/platform/validator" ) const ( serverFileTransferChannel = "run-file-transfer" serverFileMaxInlineEditBytes = 64 * 1024 serverFileDefaultDirectoryKey = "server-root" serverFileBrowseWait = 4 * time.Second ) type serverFileContext struct { User domain.User Instance domain.ServerInstance Plugin domain.GamePlugin Directory domain.PluginLogicalDirectory Workspace domain.PluginFileWorkspace Scope string } type runFileListEnvelope struct { DirectoryKey string `json:"directoryKey"` Path string `json:"path"` Entries []runFileListEntry `json:"entries"` } type runFileListEntry struct { Name string `json:"name"` Kind string `json:"kind"` DirectoryKey string `json:"directoryKey"` RelativePath string `json:"relativePath"` LogicalKey string `json:"logicalKey"` Scope string `json:"scope"` SizeBytes int64 `json:"sizeBytes"` ModifiedAt string `json:"modifiedAt"` Checksum string `json:"checksum"` Editable bool `json:"editable"` Downloadable bool `json:"downloadable"` Remark string `json:"remark"` } func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serverInstanceID string) (domain.ServerFileWorkspaceView, error) { ctx, err := svc.serverFileContextForSession(sessionID, serverInstanceID, "", false) if err != nil { return domain.ServerFileWorkspaceView{}, err } workspace := domain.CopyPluginFileWorkspace(ctx.Workspace) view := domain.ServerFileWorkspaceView{ ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DefaultDirectoryKey: workspace.DefaultDirectoryKey, Directories: workspace.Directories, Files: workspace.Files, ConfigFields: workspace.ConfigFields, DeclaredOnly: false, RuntimeWorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), Transfer: domain.ServerFileTransferPolicy{ Channel: serverFileTransferChannel, UploadChunkSizeBytes: validator.MaxArtifactChunkBytes, DownloadChunkSizeBytes: validator.MaxArtifactDownloadBytes, MaxInlineEditBytes: serverFileMaxInlineEditBytes, MaxBrowserUploadBytes: validator.MaxArtifactBytes, Notes: []string{ "文件字节通过独立文件传输端点传递,不占用 control、jobs 或 logs 通道。", "Web 只发送逻辑目录和相对路径;Run 在本机工作区内解析真实路径。", }, }, } return domain.CopyServerFileWorkspaceView(view), nil } func (svc *CoreService) ListServerFilesForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) { request = normalizeServerFileListRequest(request) if err := validator.ValidateServerFileListRequest(request); err != nil { return domain.ServerFileListResult{}, err } ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false) if err != nil { return domain.ServerFileListResult{}, err } latest, hasLatest, err := svc.latestFileListJob(ctx.Instance.ID, request.DirectoryKey, request.Path) if err != nil { return domain.ServerFileListResult{}, err } if hasLatest && latest.State == domain.JobStateSucceeded && latest.ExecutionResult.Kind == "file.list" { entries, parseErr := serverFileEntriesFromRunList(latest.ExecutionResult.Content, request.DirectoryKey, request.Path) if parseErr == nil { return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "ready", Entries: filterServerFileEntries(entries, request.Query), Job: latest, RefreshedAt: latest.TerminalAt}), nil } 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: "failed", Entries: entries, Job: latest, RefreshedAt: latest.TerminalAt, Reason: "Run 返回的文件列表无法解析。"}), nil } state := "declared" reason := "展示服务器文件缓存;打开文件标签时会自动读取 Run 实时目录。" if hasLatest { if !isTerminalJobState(latest.State) { state = "pending" reason = "Run 正在刷新目录。" } else if latest.State == domain.JobStateFailed || latest.State == domain.JobStateCancelled { state = "failed" reason = serverFileListJobFailureReason(latest) } } entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query) if state == "declared" { reason = "展示服务器文件缓存;打开文件标签时会自动读取 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 } func serverFileListJobFailureReason(job domain.Job) string { prefix := "Run 文件目录刷新失败" if job.State == domain.JobStateCancelled { prefix = "Run 文件目录刷新已取消" } detail := strings.TrimSpace(job.CancelReason) if detail == "" { detail = strings.TrimSpace(job.Progress.Message) } if detail == "" { detail = strings.TrimSpace(job.ExecutionResult.Summary) } if detail == "" { return prefix + "。" } return prefix + ":" + detail } func (svc *CoreService) RefreshServerFileListForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) { request = normalizeServerFileListRequest(request) if request.IdempotencyKey == "" { request.IdempotencyKey = fmt.Sprintf("file-list:%s:%s:%s", request.ServerInstanceID, request.DirectoryKey, request.Path) } if err := validator.ValidateServerFileListRequest(request); err != nil { return domain.ServerFileListResult{}, err } ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false) if err != nil { return domain.ServerFileListResult{}, err } endpoint, err := svc.GetRunEndpoint(ctx.Instance.RunEndpointID) if err != nil { return domain.ServerFileListResult{}, err } entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query) if !containsString(endpoint.Capabilities, domain.JobCapabilityFilesList) { return domain.CopyServerFileListResult(domain.ServerFileListResult{ ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "ready", Entries: entries, Reason: "当前 Run 未声明 files.list,已展示插件声明的逻辑文件。", }), nil } job, err := svc.CreateJob(domain.Job{ ID: jobIDFromParts("job-file-list", request.ServerInstanceID, request.IdempotencyKey), ServerInstanceID: ctx.Instance.ID, RunEndpointID: ctx.Instance.RunEndpointID, Capability: domain.JobCapabilityFilesList, TargetKey: request.DirectoryKey, InputRef: "", ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), Deployment: deploymentPlanForDispatch(ctx.Instance.Deployment), Inputs: map[string]string{ "directoryKey": request.DirectoryKey, "path": request.Path, "recursive": strconv.FormatBool(request.Recursive), "query": request.Query, }, MaxReadBytes: serverFileMaxInlineEditBytes}, IdempotencyKey: request.IdempotencyKey, Progress: domain.JobProgress{Percent: 0, Phase: "queued", Message: "file list queued"}, }) if err != nil { return domain.ServerFileListResult{}, err } 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 } func (svc *CoreService) BrowseServerFilesForSession(ctx context.Context, sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) { request = normalizeServerFileListRequest(request) if request.IdempotencyKey == "" { idempotencyKey, err := svc.serverFileBrowseIdempotencyKey(request) if err != nil { return domain.ServerFileListResult{}, err } request.IdempotencyKey = idempotencyKey } result, err := svc.RefreshServerFileListForSession(sessionID, request) if err != nil || result.State != "pending" || result.Job.ID == "" { return result, err } timer := time.NewTimer(serverFileBrowseWait) defer timer.Stop() for { current, ready, err := svc.serverFileListResultFromJob(request, result, result.Job.ID) if err != nil || ready { return current, err } waiter := svc.registerRunJobWaiter(result.Job.RunEndpointID) current, ready, err = svc.serverFileListResultFromJob(request, result, result.Job.ID) if err != nil || ready { svc.unregisterRunJobWaiter(result.Job.RunEndpointID, waiter) return current, err } select { case <-ctx.Done(): svc.unregisterRunJobWaiter(result.Job.RunEndpointID, waiter) return domain.ServerFileListResult{}, ctx.Err() case <-waiter: case <-timer.C: svc.unregisterRunJobWaiter(result.Job.RunEndpointID, waiter) current.Reason = "正在读取目录。" return current, nil } } } func (svc *CoreService) serverFileListResultFromJob(request domain.ServerFileListRequest, fallback domain.ServerFileListResult, jobID string) (domain.ServerFileListResult, bool, error) { job, err := svc.store.Jobs().Get(jobID) if err != nil { return domain.ServerFileListResult{}, true, err } result := domain.CopyServerFileListResult(fallback) result.Job = job if !isTerminalJobState(job.State) { return result, false, nil } result.RefreshedAt = job.TerminalAt if job.State != domain.JobStateSucceeded || job.ExecutionResult.Kind != "file.list" { result.State = "failed" result.Reason = serverFileListJobFailureReason(job) return result, true, nil } entries, err := serverFileEntriesFromRunList(job.ExecutionResult.Content, request.DirectoryKey, request.Path) if err != nil { result.State = "failed" result.Reason = "Run 返回的文件列表无法解析。" return result, true, nil } result.State = "ready" result.Entries = filterServerFileEntries(entries, request.Query) result.Reason = "目录读取完成。" return result, true, nil } func (svc *CoreService) serverFileBrowseIdempotencyKey(request domain.ServerFileListRequest) (string, error) { token, err := randomToken() if err != nil { return "", err } raw := strings.Join([]string{request.ServerInstanceID, request.DirectoryKey, request.Path, request.Query, strconv.FormatBool(request.Recursive), token}, ":") return fmt.Sprintf("file-browse:%d", stableStringNumber(raw)), nil } func (svc *CoreService) ReadServerFileForSession(sessionID string, request domain.ServerFileReadRequest) (domain.FileOperationDispatchResult, error) { if err := validator.ValidateServerFileReadRequest(request); err != nil { return domain.FileOperationDispatchResult{}, err } return svc.DispatchFileOperationForSession(sessionID, domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationRead, Key: request.Key, IdempotencyKey: request.IdempotencyKey}) } func (svc *CoreService) WriteServerFileForSession(sessionID string, request domain.ServerFileWriteRequest) (domain.FileOperationDispatchResult, error) { if request.InputRef == "" { request.InputRef = fileManagerInlineInputRef(request.ServerInstanceID, request.Key, request.IdempotencyKey) } if err := validator.ValidateServerFileWriteRequest(request); err != nil { return domain.FileOperationDispatchResult{}, err } return svc.DispatchFileOperationForSession(sessionID, domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationWrite, Key: request.Key, InputRef: request.InputRef, Content: request.Content, ExpectedConfigVersion: request.ExpectedVersion, ExpectedChecksum: request.ExpectedChecksum, IdempotencyKey: request.IdempotencyKey}) } func (svc *CoreService) UploadServerFileForSession(sessionID string, request domain.ServerFileUploadRequest) (domain.ServerFileUploadDispatch, error) { request = domain.CopyServerFileUploadRequest(request) if err := validator.ValidateServerFileUploadRequest(request); err != nil { return domain.ServerFileUploadDispatch{}, err } ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, true) if err != nil { return domain.ServerFileUploadDispatch{}, err } relativePath := cleanServerFileRelativePath(path.Join(request.RelativePath, request.Filename)) artifactID := serverFileUploadArtifactID(request.ServerInstanceID, request.IdempotencyKey, relativePath) artifact := domain.Artifact{ID: artifactID, OwnerKind: domain.ArtifactOwnerKindServerInstance, OwnerID: ctx.Instance.ID, SizeBytes: int64(len(request.Payload)), Checksum: request.Checksum, State: domain.ArtifactStateAvailable, CreatedAt: svc.now(), UpdatedAt: svc.now()} if err := svc.putBrowserFileArtifact(artifact, request.Payload); err != nil { return domain.ServerFileUploadDispatch{}, err } inputRef := "artifact://" + artifact.ID targetKey := cleanServerFileRelativePath(path.Join(request.DirectoryKey, relativePath)) job, err := svc.CreateJob(domain.Job{ ID: jobIDFromParts("job-file-upload", request.ServerInstanceID, request.IdempotencyKey), ServerInstanceID: ctx.Instance.ID, RunEndpointID: ctx.Instance.RunEndpointID, Capability: domain.JobCapabilityFilesWrite, TargetKey: targetKey, InputRef: inputRef, ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), Deployment: deploymentPlanForDispatch(ctx.Instance.Deployment), ExpectedChecksum: request.Checksum, Inputs: map[string]string{ "directoryKey": request.DirectoryKey, "relativePath": relativePath, "filename": request.Filename, "transfer": serverFileTransferChannel, }}, IdempotencyKey: request.IdempotencyKey, Progress: domain.JobProgress{Percent: 0, Phase: "queued", Message: "file upload staged; Run will pull input chunks"}, }) if err != nil { return domain.ServerFileUploadDispatch{}, err } return domain.CopyServerFileUploadDispatch(domain.ServerFileUploadDispatch{Status: "queued", ServerInstanceID: ctx.Instance.ID, DirectoryKey: request.DirectoryKey, RelativePath: relativePath, ArtifactID: artifact.ID, InputRef: inputRef, SizeBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Job: job}), nil } func (svc *CoreService) PrepareServerFileDownloadForSession(sessionID string, request domain.ServerFileDownloadRequest) (domain.ServerFileDownloadResult, error) { if err := validator.ValidateServerFileDownloadRequest(request); err != nil { return domain.ServerFileDownloadResult{}, err } ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, "", false) if err != nil { return domain.ServerFileDownloadResult{}, err } filename := serverFileDisplayName(ctx.Workspace, request.Key) job, hasJob, err := svc.latestFileReadJob(ctx.Instance.ID, request.Key) if err != nil { return domain.ServerFileDownloadResult{}, err } if hasJob && job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" { if strings.HasPrefix(job.ResultRef, "artifact://") { reference, openErr := svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: strings.TrimPrefix(job.ResultRef, "artifact://")}) if openErr != nil { return domain.ServerFileDownloadResult{}, openErr } return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: reference.ContentType, Checksum: reference.Checksum, SizeBytes: reference.SizeBytes, Artifact: &reference, Job: job, ReadAt: job.TerminalAt}), nil } if job.ExecutionResult.Content != "" { content := job.ExecutionResult.Content return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: "text/plain; charset=utf-8", Content: content, Checksum: job.ExecutionResult.Checksum, SizeBytes: int64(len([]byte(content))), Job: job, ReadAt: job.TerminalAt}), nil } } if hasJob && !isTerminalJobState(job.State) { return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "pending", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, Job: job, Reason: "文件读取任务仍在执行。"}), nil } dispatch, err := svc.ReadServerFileForSession(sessionID, domain.ServerFileReadRequest{ServerInstanceID: request.ServerInstanceID, PluginID: ctx.Plugin.ID, Key: request.Key, IdempotencyKey: request.IdempotencyKey}) if err != nil { return domain.ServerFileDownloadResult{}, err } return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "pending", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, Job: dispatch.Job, Reason: "文件读取任务已派发;完成后可再次下载。"}), nil } func (svc *CoreService) ReadRunFileInputChunk(request domain.RunFileInputChunkRequest) (domain.RunFileInputChunk, error) { request = domain.CopyRunFileInputChunkRequest(request) if err := validator.ValidateRunFileInputChunkRequest(request); err != nil { return domain.RunFileInputChunk{}, err } session, err := svc.validatedRunSession(request.RunEndpointID, request.SessionToken) if err != nil { return domain.RunFileInputChunk{}, err } svc.jobMu.Lock() job, err := svc.fencedJob(session, request.JobID, request.LeaseToken, request.Attempt) if err != nil { svc.jobMu.Unlock() return domain.RunFileInputChunk{}, err } if job.Capability != domain.JobCapabilityFilesWrite || !strings.HasPrefix(job.InputRef, "artifact://") { svc.jobMu.Unlock() return domain.RunFileInputChunk{}, validationError("job does not reference a file input artifact") } if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning { svc.jobMu.Unlock() return domain.RunFileInputChunk{}, validationError("job is not active") } artifactID := strings.TrimPrefix(job.InputRef, "artifact://") serverInstanceID := job.ServerInstanceID svc.jobMu.Unlock() artifact, err := svc.store.Artifacts().Get(artifactID) if err != nil { return domain.RunFileInputChunk{}, err } if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != serverInstanceID || artifact.State != domain.ArtifactStateAvailable { return domain.RunFileInputChunk{}, ErrForbidden } if request.Offset >= artifact.SizeBytes { return domain.RunFileInputChunk{}, validationError("offset must be inside artifact content") } length := request.Length remaining := artifact.SizeBytes - request.Offset if int64(length) > remaining { length = int(remaining) } payload, err := svc.artifactStore.ReadPayloadRange(artifact.ID, request.Offset, length) if err != nil { return domain.RunFileInputChunk{}, err } end := request.Offset + int64(length) chunk := domain.RunFileInputChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload, Complete: end == artifact.SizeBytes} return domain.CopyRunFileInputChunk(chunk), nil } func (svc *CoreService) serverFileContextForSession(sessionID string, serverInstanceID string, directoryKey string, requireWrite bool) (serverFileContext, error) { instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID) if err != nil { return serverFileContext{}, err } user, err := svc.GetCurrentUser(sessionID) if err != nil { return serverFileContext{}, err } plugin, err := svc.store.GamePlugins().Get(instance.PluginID) if err != nil { return serverFileContext{}, err } if plugin.Status != domain.GamePluginStatusInstalled { return serverFileContext{}, validationError("plugin must be installed") } if requireWrite { if !containsString(plugin.DeclaredPermissions, "server.files.write") { return serverFileContext{}, ErrForbidden } } 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 workspace.Directories { if candidate.Key == directoryKey { directory = candidate found = true break } } 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, Workspace: workspace, Scope: svc.runtimeProfileScope(instance.ID)}, nil } func effectiveServerFileWorkspace(plugin domain.GamePlugin) domain.PluginFileWorkspace { workspace := domain.CopyPluginFileWorkspace(plugin.FileWorkspace) if serverFileWorkspaceIsPluginDeclared(workspace) { if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) > 0 { workspace.DefaultDirectoryKey = workspace.Directories[0].Key } return workspace } return domain.PluginFileWorkspace{DefaultDirectoryKey: serverFileDefaultDirectoryKey, Directories: []domain.PluginLogicalDirectory{{Key: serverFileDefaultDirectoryKey, Label: "服务器根目录", Scope: "config"}}, Files: []domain.PluginLogicalFile{}, ConfigFields: []domain.PluginConfigField{}} } 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 { request.DirectoryKey = strings.TrimSpace(request.DirectoryKey) request.Path = cleanServerFileRelativePath(request.Path) request.Query = strings.TrimSpace(request.Query) return request } func serverFileEntriesFromDeclaredWorkspace(workspace domain.PluginFileWorkspace, directoryKey string) []domain.ServerFileEntry { entries := make([]domain.ServerFileEntry, 0) for _, directory := range workspace.Directories { if directoryKey == "" || directory.Key == directoryKey { continue } entries = append(entries, domain.ServerFileEntry{Name: directory.Label, Kind: domain.ServerFileEntryDirectory, DirectoryKey: directory.Key, RelativePath: "", Scope: directory.Scope, Editable: true, Downloadable: true, Remark: "目录"}) } for _, file := range workspace.Files { if directoryKey != "" && file.DirectoryKey != directoryKey { continue } entries = append(entries, domain.ServerFileEntry{Name: file.Label, Kind: domain.ServerFileEntryFile, DirectoryKey: file.DirectoryKey, RelativePath: file.Key, LogicalKey: file.Key, Scope: file.Kind, Editable: true, Downloadable: true, Remark: fileRemark(file)}) } sort.SliceStable(entries, func(i int, j int) bool { if entries[i].Kind == entries[j].Kind { return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name) } return entries[i].Kind == domain.ServerFileEntryDirectory }) return entries } func serverFileEntriesFromRunList(content string, fallbackDirectoryKey string, fallbackPath string) ([]domain.ServerFileEntry, error) { var envelope runFileListEnvelope if err := json.Unmarshal([]byte(content), &envelope); err != nil { return nil, err } entries := make([]domain.ServerFileEntry, 0, len(envelope.Entries)) for _, entry := range envelope.Entries { kind := domain.ServerFileEntryFile if entry.Kind == string(domain.ServerFileEntryDirectory) { kind = domain.ServerFileEntryDirectory } modifiedAt := time.Time{} if entry.ModifiedAt != "" { modifiedAt, _ = time.Parse(time.RFC3339, entry.ModifiedAt) } directoryKey := entry.DirectoryKey if directoryKey == "" { directoryKey = fallbackDirectoryKey } relativePath := cleanServerFileRelativePath(entry.RelativePath) if relativePath == "" && entry.Name != "" { relativePath = cleanServerFileRelativePath(path.Join(fallbackPath, entry.Name)) } else if relativePath == "" { relativePath = fallbackPath } editable := kind == domain.ServerFileEntryFile downloadable := kind == domain.ServerFileEntryFile entries = append(entries, domain.ServerFileEntry{Name: entry.Name, Kind: kind, DirectoryKey: directoryKey, RelativePath: relativePath, LogicalKey: entry.LogicalKey, Scope: entry.Scope, SizeBytes: entry.SizeBytes, ModifiedAt: modifiedAt, Checksum: entry.Checksum, Editable: editable, Downloadable: downloadable, Remark: entry.Remark}) } return entries, nil } func filterServerFileEntries(entries []domain.ServerFileEntry, query string) []domain.ServerFileEntry { query = strings.ToLower(strings.TrimSpace(query)) if query == "" { return domain.CopyServerFileEntries(entries) } filtered := make([]domain.ServerFileEntry, 0, len(entries)) for _, entry := range entries { if strings.Contains(strings.ToLower(entry.Name+" "+entry.RelativePath+" "+entry.LogicalKey+" "+entry.Remark), query) { filtered = append(filtered, entry) } } return filtered } func (svc *CoreService) latestFileListJob(serverInstanceID string, directoryKey string, relativePath string) (domain.Job, bool, error) { jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID}) if err != nil { return domain.Job{}, false, err } var latest domain.Job found := false for _, job := range jobs { if job.Capability != domain.JobCapabilityFilesList || job.TargetKey != directoryKey { continue } if job.ExecutionInput.Inputs["path"] != relativePath { continue } if !found || job.UpdatedAt.After(latest.UpdatedAt) || job.CreatedAt.After(latest.CreatedAt) { latest = job found = true } } return domain.CopyJob(latest), found, nil } func isServerFileCapability(capability string) bool { switch capability { case domain.JobCapabilityFilesList, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite: return true default: return false } } func (svc *CoreService) latestFileReadJob(serverInstanceID string, key string) (domain.Job, bool, error) { return svc.latestServerFileJob(serverInstanceID, domain.JobCapabilityFilesRead, key) } func (svc *CoreService) latestServerFileJob(serverInstanceID string, capability string, targetKey string) (domain.Job, bool, error) { jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID}) if err != nil { return domain.Job{}, false, err } var latest domain.Job found := false for _, job := range jobs { if job.Capability != capability || job.TargetKey != targetKey { continue } if !found || job.UpdatedAt.After(latest.UpdatedAt) || job.CreatedAt.After(latest.CreatedAt) { latest = job found = true } } return domain.CopyJob(latest), found, nil } func (svc *CoreService) putBrowserFileArtifact(artifact domain.Artifact, payload []byte) error { svc.artifactMu.Lock() defer svc.artifactMu.Unlock() if existing, err := svc.store.Artifacts().Get(artifact.ID); err == nil { if existing.OwnerKind != artifact.OwnerKind || existing.OwnerID != artifact.OwnerID || existing.SizeBytes != artifact.SizeBytes || existing.Checksum != artifact.Checksum || existing.State != domain.ArtifactStateAvailable { return validationError("file upload idempotency key conflicts with existing artifact") } } else if !errors.Is(err, repo.ErrNotFound) { return err } else { if err := validator.ValidateArtifact(artifact); err != nil { return err } if err := svc.store.Artifacts().Create(artifact); err != nil { return err } } if err := svc.artifactStore.PutPayload(artifact.ID, payload); err != nil { return err } svc.cacheArtifactPayload(artifact.ID, payload) return nil } func cleanServerFileRelativePath(value string) string { value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) if value == "" || value == "." { return "" } cleaned := path.Clean(value) if cleaned == "." { return "" } return strings.TrimPrefix(cleaned, "/") } func serverFileUploadArtifactID(serverInstanceID string, idempotencyKey string, relativePath string) string { raw := serverInstanceID + ":" + idempotencyKey + ":" + relativePath return "artifact-file-upload-" + sanitizeIDPart(serverInstanceID) + "-" + fmt.Sprint(stableStringNumber(raw)) } func fileManagerInlineInputRef(serverInstanceID string, key string, idempotencyKey string) string { return "input://server-files/" + sanitizeIDPart(serverInstanceID) + "/" + sanitizeIDPart(key) + "/" + fmt.Sprint(stableStringNumber(idempotencyKey)) } func fileRemark(file domain.PluginLogicalFile) string { if file.StreamKey != "" { return "日志流 " + file.StreamKey } if file.Editable { return "可编辑配置" } return "文件" } func serverFileDisplayName(workspace domain.PluginFileWorkspace, key string) string { for _, file := range workspace.Files { if file.Key == key && strings.TrimSpace(file.Label) != "" { return file.Label } } name := path.Base(key) if name == "." || name == "/" || name == "" { return "server-file.txt" } return name }