package app import ( "archive/zip" "bytes" "encoding/json" "errors" "fmt" "net/url" "strings" "time" "yingce/backend/internal/model" "gorm.io/gorm" ) const ( diagnosticSchemaVersion = 1 diagnosticRedactionVersion = 1 diagnosticMaxWindow = 24 * time.Hour diagnosticMaxClientEvents = 500 diagnosticMaxBundleBytes = 10 << 20 diagnosticMaxDescription = 1000 diagnosticMaxEventText = 4000 ) type DiagnosticClientEvent struct { ID string `json:"id"` Timestamp string `json:"timestamp"` Level string `json:"level"` Category string `json:"category"` Code string `json:"code,omitempty"` Message string `json:"message"` Route string `json:"route,omitempty"` DurationMs int64 `json:"durationMs,omitempty"` HTTPStatus int `json:"httpStatus,omitempty"` RequestID string `json:"requestId,omitempty"` TraceID string `json:"traceId,omitempty"` TaskID string `json:"taskId,omitempty"` ProjectID string `json:"projectId,omitempty"` CanvasID string `json:"canvasId,omitempty"` Stack string `json:"stack,omitempty"` } type DiagnosticRuntime struct { AppVersion string `json:"appVersion,omitempty"` BuildCommit string `json:"buildCommit,omitempty"` Browser string `json:"browser,omitempty"` OS string `json:"os,omitempty"` Timezone string `json:"timezone,omitempty"` } type DiagnosticExportRequest struct { From string `json:"from"` To string `json:"to"` TaskID string `json:"taskId,omitempty"` ProjectID string `json:"projectId,omitempty"` Description string `json:"description,omitempty"` Runtime DiagnosticRuntime `json:"runtime"` ClientEvents []DiagnosticClientEvent `json:"clientEvents"` } type DiagnosticPreview struct { ClientEventLimit int `json:"clientEventLimit"` TaskCount int `json:"taskCount"` TaskLogCount int `json:"taskLogCount"` APICallCount int `json:"apiCallCount"` EstimatedBytes int64 `json:"estimatedBytes"` WillTruncate bool `json:"willTruncate"` } type DiagnosticBundle struct { BundleID string FileName string Data []byte } type diagnosticWindow struct { From time.Time To time.Time } type diagnosticCollection struct { Window diagnosticWindow Description string TaskID string ProjectID string Runtime diagnosticRuntimeRecord ClientEvents []diagnosticClientEventRecord Tasks []diagnosticTaskRecord TaskLogs []diagnosticTaskLogRecord APICalls []diagnosticAPICallRecord Truncated bool } type diagnosticRuntimeRecord struct { AppVersion string `json:"appVersion,omitempty"` BuildCommit string `json:"buildCommit,omitempty"` Browser string `json:"browser,omitempty"` OS string `json:"os,omitempty"` Timezone string `json:"timezone,omitempty"` } type diagnosticClientEventRecord struct { ID string `json:"id,omitempty"` Timestamp string `json:"timestamp,omitempty"` Level string `json:"level,omitempty"` Category string `json:"category,omitempty"` Code string `json:"code,omitempty"` Message string `json:"message,omitempty"` Route string `json:"route,omitempty"` DurationMs int64 `json:"durationMs,omitempty"` HTTPStatus int `json:"httpStatus,omitempty"` RequestID string `json:"requestId,omitempty"` TraceID string `json:"traceId,omitempty"` TaskID string `json:"taskId,omitempty"` ProjectID string `json:"projectId,omitempty"` CanvasID string `json:"canvasId,omitempty"` Stack string `json:"stack,omitempty"` } type diagnosticTaskRecord struct { ID string `json:"id"` TraceID string `json:"traceId,omitempty"` RequestID string `json:"requestId,omitempty"` ProjectID string `json:"projectId,omitempty"` Type string `json:"type,omitempty"` Status string `json:"status,omitempty"` Stage string `json:"stage,omitempty"` Progress int `json:"progress,omitempty"` Operation string `json:"operation,omitempty"` Provider string `json:"provider,omitempty"` Model string `json:"model,omitempty"` LogicalModelID string `json:"logicalModelId,omitempty"` ProviderRequestID string `json:"providerRequestId,omitempty"` Error string `json:"error,omitempty"` Attempts int `json:"attempts,omitempty"` StartedAt *time.Time `json:"startedAt,omitempty"` CompletedAt *time.Time `json:"completedAt,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type diagnosticTaskLogRecord struct { ID string `json:"id"` TaskID string `json:"taskId,omitempty"` TraceID string `json:"traceId,omitempty"` RequestID string `json:"requestId,omitempty"` Level string `json:"level,omitempty"` Message string `json:"message,omitempty"` Payload string `json:"payload,omitempty"` CreatedAt time.Time `json:"createdAt"` } type diagnosticAPICallRecord struct { ID string `json:"id"` TraceID string `json:"traceId,omitempty"` RequestID string `json:"requestId,omitempty"` ChannelID string `json:"channelId,omitempty"` TaskID string `json:"taskId,omitempty"` Source string `json:"source,omitempty"` Capability string `json:"capability,omitempty"` Operation string `json:"operation,omitempty"` RequestKind string `json:"requestKind,omitempty"` APIFormat string `json:"apiFormat,omitempty"` Method string `json:"method,omitempty"` Path string `json:"path,omitempty"` Model string `json:"model,omitempty"` Status string `json:"status,omitempty"` StatusCode int `json:"statusCode,omitempty"` DurationMs int64 `json:"durationMs,omitempty"` PollCount int `json:"pollCount,omitempty"` ProviderStatus string `json:"providerStatus,omitempty"` ProviderRequestID string `json:"providerRequestId,omitempty"` ErrorCode string `json:"errorCode,omitempty"` Error string `json:"error,omitempty"` StartedAt time.Time `json:"startedAt"` CreatedAt time.Time `json:"createdAt"` } type diagnosticManifest struct { SchemaVersion int `json:"schemaVersion"` BundleID string `json:"bundleId"` GeneratedAt time.Time `json:"generatedAt"` AppVersion string `json:"appVersion,omitempty"` BuildCommit string `json:"buildCommit,omitempty"` TimeRange diagnosticTimeRange `json:"timeRange"` TaskID string `json:"taskId,omitempty"` ProjectID string `json:"projectId,omitempty"` Description string `json:"description,omitempty"` RedactionVersion int `json:"redactionVersion"` Truncated bool `json:"truncated,omitempty"` Counts diagnosticCounts `json:"counts"` } type diagnosticTimeRange struct { From time.Time `json:"from"` To time.Time `json:"to"` } type diagnosticCounts struct { ClientEvents int `json:"clientEvents"` Tasks int `json:"tasks"` TaskLogs int `json:"taskLogs"` APICalls int `json:"upstreamCalls"` } func (s *Service) PreviewDiagnosticBundle(userID string, req DiagnosticExportRequest) (*DiagnosticPreview, error) { collection, err := s.collectDiagnosticData(userID, req) if err != nil { return nil, err } return &DiagnosticPreview{ ClientEventLimit: diagnosticMaxClientEvents, TaskCount: len(collection.Tasks), TaskLogCount: len(collection.TaskLogs), APICallCount: len(collection.APICalls), EstimatedBytes: int64(2048 + len(collection.ClientEvents)*420 + len(collection.Tasks)*620 + len(collection.TaskLogs)*700 + len(collection.APICalls)*520), WillTruncate: collection.Truncated, }, nil } func (s *Service) ExportDiagnosticBundle(userID string, req DiagnosticExportRequest) (*DiagnosticBundle, error) { collection, err := s.collectDiagnosticData(userID, req) if err != nil { return nil, err } bundleID := "DIAG_" + strings.ToUpper(newID()[:8]) manifest := diagnosticManifest{ SchemaVersion: diagnosticSchemaVersion, BundleID: bundleID, GeneratedAt: time.Now().UTC(), AppVersion: collection.Runtime.AppVersion, BuildCommit: collection.Runtime.BuildCommit, TimeRange: diagnosticTimeRange{From: collection.Window.From, To: collection.Window.To}, TaskID: collection.TaskID, ProjectID: collection.ProjectID, Description: collection.Description, RedactionVersion: diagnosticRedactionVersion, Truncated: collection.Truncated, Counts: diagnosticCounts{ClientEvents: len(collection.ClientEvents), Tasks: len(collection.Tasks), TaskLogs: len(collection.TaskLogs), APICalls: len(collection.APICalls)}, } brandName, brandSlug := s.appearanceIdentity() data, err := buildDiagnosticZIP(brandName, bundleID, manifest, collection) if err != nil { return nil, err } if len(data) > diagnosticMaxBundleBytes { return nil, BadAuthRequest("诊断包超过 10 MB,请缩短时间范围后重试") } fileName := fmt.Sprintf("%s-diagnostics-%s-%s.zip", brandSlug, time.Now().UTC().Format("20060102-150405"), bundleID) return &DiagnosticBundle{BundleID: bundleID, FileName: fileName, Data: data}, nil } func (s *Service) collectDiagnosticData(userID string, req DiagnosticExportRequest) (*diagnosticCollection, error) { window, err := normalizeDiagnosticWindow(req.From, req.To) if err != nil { return nil, err } taskID := strings.TrimSpace(req.TaskID) projectID := strings.TrimSpace(req.ProjectID) if len(taskID) > 96 || len(projectID) > 96 { return nil, BadAuthRequest("诊断上下文无效") } if taskID != "" { task, taskErr := s.repo.TaskForUser(userID, taskID) if errors.Is(taskErr, gorm.ErrRecordNotFound) { return nil, BadAuthRequest("任务不存在或无权访问") } if taskErr != nil { return nil, taskErr } if projectID != "" && task.ProjectID != projectID { return nil, BadAuthRequest("任务不属于当前项目") } if projectID == "" { projectID = task.ProjectID } } tasks, err := s.repo.DiagnosticTasks(userID, window.From, window.To, taskID, projectID) if err != nil { return nil, err } taskLogs, err := s.repo.DiagnosticTaskLogs(userID, window.From, window.To, taskID, projectID) if err != nil { return nil, err } apiCalls, err := s.repo.DiagnosticAPICallLogs(userID, window.From, window.To, taskID, projectID) if err != nil { return nil, err } clientEvents := req.ClientEvents truncated := false if len(clientEvents) > diagnosticMaxClientEvents { clientEvents = clientEvents[len(clientEvents)-diagnosticMaxClientEvents:] truncated = true } collection := &diagnosticCollection{ Window: window, Description: redactDiagnosticText(req.Description, diagnosticMaxDescription), TaskID: taskID, ProjectID: projectID, Truncated: truncated, Runtime: diagnosticRuntimeRecord{ AppVersion: redactDiagnosticText(req.Runtime.AppVersion, 120), BuildCommit: redactDiagnosticText(req.Runtime.BuildCommit, 120), Browser: redactDiagnosticText(req.Runtime.Browser, 240), OS: redactDiagnosticText(req.Runtime.OS, 120), Timezone: redactDiagnosticText(req.Runtime.Timezone, 80), }, } for _, event := range clientEvents { collection.ClientEvents = append(collection.ClientEvents, sanitizeDiagnosticClientEvent(event)) } for _, task := range tasks { collection.Tasks = append(collection.Tasks, sanitizeDiagnosticTask(task)) } for _, taskLog := range taskLogs { collection.TaskLogs = append(collection.TaskLogs, sanitizeDiagnosticTaskLog(taskLog)) } for _, apiCall := range apiCalls { collection.APICalls = append(collection.APICalls, sanitizeDiagnosticAPICall(apiCall)) } return collection, nil } func normalizeDiagnosticWindow(fromRaw string, toRaw string) (diagnosticWindow, error) { now := time.Now().UTC() from, to := now.Add(-30*time.Minute), now var err error if strings.TrimSpace(fromRaw) != "" { from, err = time.Parse(time.RFC3339Nano, strings.TrimSpace(fromRaw)) if err != nil { return diagnosticWindow{}, BadAuthRequest("诊断开始时间格式无效") } } if strings.TrimSpace(toRaw) != "" { to, err = time.Parse(time.RFC3339Nano, strings.TrimSpace(toRaw)) if err != nil { return diagnosticWindow{}, BadAuthRequest("诊断结束时间格式无效") } } from, to = from.UTC(), to.UTC() if to.After(now) { to = now } if !to.After(from) { return diagnosticWindow{}, BadAuthRequest("诊断时间范围无效") } if to.Sub(from) > diagnosticMaxWindow { return diagnosticWindow{}, BadAuthRequest("诊断时间范围不能超过 24 小时") } return diagnosticWindow{From: from, To: to}, nil } func sanitizeDiagnosticClientEvent(event DiagnosticClientEvent) diagnosticClientEventRecord { return diagnosticClientEventRecord{ ID: sanitizeDiagnosticIdentifier(event.ID), Timestamp: redactDiagnosticText(event.Timestamp, 80), Level: redactDiagnosticText(event.Level, 24), Category: redactDiagnosticText(event.Category, 32), Code: redactDiagnosticText(event.Code, 120), Message: redactDiagnosticText(event.Message, diagnosticMaxEventText), Route: sanitizeDiagnosticPath(event.Route), DurationMs: boundedDiagnosticInt64(event.DurationMs, 0, 86_400_000), HTTPStatus: boundedDiagnosticInt(event.HTTPStatus, 0, 599), RequestID: sanitizeDiagnosticIdentifier(event.RequestID), TraceID: sanitizeDiagnosticIdentifier(event.TraceID), TaskID: sanitizeDiagnosticIdentifier(event.TaskID), ProjectID: sanitizeDiagnosticIdentifier(event.ProjectID), CanvasID: sanitizeDiagnosticIdentifier(event.CanvasID), Stack: redactDiagnosticText(event.Stack, diagnosticMaxEventText), } } func sanitizeDiagnosticTask(task model.Task) diagnosticTaskRecord { return diagnosticTaskRecord{ ID: task.ID, TraceID: sanitizeDiagnosticIdentifier(task.TraceID), RequestID: sanitizeDiagnosticIdentifier(task.RequestID), ProjectID: sanitizeDiagnosticIdentifier(task.ProjectID), Type: redactDiagnosticText(task.Type, 80), Status: redactDiagnosticText(string(task.Status), 32), Stage: redactDiagnosticText(task.Stage, 160), Progress: boundedDiagnosticInt(task.Progress, 0, 100), Operation: redactDiagnosticText(task.Operation, 120), Provider: redactDiagnosticText(task.Provider, 120), Model: redactDiagnosticText(task.Model, 160), LogicalModelID: sanitizeDiagnosticIdentifier(task.LogicalModelID), ProviderRequestID: sanitizeDiagnosticIdentifier(task.ProviderRequestID), Error: redactDiagnosticText(task.Error, diagnosticMaxEventText), Attempts: boundedDiagnosticInt(task.Attempts, 0, 100), StartedAt: task.StartedAt, CompletedAt: task.CompletedAt, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, } } func sanitizeDiagnosticTaskLog(taskLog model.TaskLog) diagnosticTaskLogRecord { return diagnosticTaskLogRecord{ID: taskLog.ID, TaskID: sanitizeDiagnosticIdentifier(taskLog.TaskID), TraceID: sanitizeDiagnosticIdentifier(taskLog.TraceID), RequestID: sanitizeDiagnosticIdentifier(taskLog.RequestID), Level: redactDiagnosticText(taskLog.Level, 24), Message: redactDiagnosticText(taskLog.Message, diagnosticMaxEventText), Payload: redactDiagnosticText(taskLog.Payload, diagnosticMaxEventText), CreatedAt: taskLog.CreatedAt} } func sanitizeDiagnosticAPICall(log model.ApiCallLog) diagnosticAPICallRecord { return diagnosticAPICallRecord{ ID: log.ID, TraceID: sanitizeDiagnosticIdentifier(log.TraceID), RequestID: sanitizeDiagnosticIdentifier(log.RequestID), ChannelID: sanitizeDiagnosticIdentifier(log.ChannelID), TaskID: sanitizeDiagnosticIdentifier(log.TaskID), Source: redactDiagnosticText(log.Source, 80), Capability: redactDiagnosticText(log.Capability, 48), Operation: redactDiagnosticText(log.Operation, 120), RequestKind: redactDiagnosticText(log.RequestKind, 48), APIFormat: redactDiagnosticText(log.APIFormat, 48), Method: redactDiagnosticText(log.Method, 16), Path: sanitizeDiagnosticPath(log.Path), Model: redactDiagnosticText(log.Model, 160), Status: redactDiagnosticText(string(log.Status), 32), StatusCode: boundedDiagnosticInt(log.StatusCode, 0, 599), DurationMs: boundedDiagnosticInt64(log.DurationMs, 0, 86_400_000), PollCount: boundedDiagnosticInt(log.PollCount, 0, 10000), ProviderStatus: redactDiagnosticText(log.ProviderStatus, 80), ProviderRequestID: sanitizeDiagnosticIdentifier(log.ProviderRequestID), ErrorCode: redactDiagnosticText(log.ErrorCode, 120), Error: redactDiagnosticText(log.Error, diagnosticMaxEventText), StartedAt: log.StartedAt, CreatedAt: log.CreatedAt, } } func buildDiagnosticZIP(brandName string, bundleID string, manifest diagnosticManifest, collection *diagnosticCollection) ([]byte, error) { var buffer bytes.Buffer archive := zip.NewWriter(&buffer) manifestData, err := json.MarshalIndent(manifest, "", " ") if err != nil { return nil, err } if err := writeDiagnosticZipFile(archive, "manifest.json", append(manifestData, byte(10))); err != nil { return nil, err } newline := string(rune(10)) readme := fmt.Sprintf("%s 用户诊断包%s%s诊断编号:%s%s时间范围:%s 至 %s%s%s该文件由用户主动导出,仅包含有限时间范围内的脱敏诊断摘要。%s", brandName, newline, newline, bundleID, newline, collection.Window.From.Format(time.RFC3339), collection.Window.To.Format(time.RFC3339), newline, newline, newline) if err := writeDiagnosticZipFile(archive, "README.txt", []byte(readme)); err != nil { return nil, err } if err := writeDiagnosticJSONL(archive, "client/events.jsonl", collection.ClientEvents); err != nil { return nil, err } if err := writeDiagnosticJSONL(archive, "backend/tasks.jsonl", collection.Tasks); err != nil { return nil, err } if err := writeDiagnosticJSONL(archive, "backend/task-logs.jsonl", collection.TaskLogs); err != nil { return nil, err } if err := writeDiagnosticJSONL(archive, "backend/upstream-calls.jsonl", collection.APICalls); err != nil { return nil, err } runtimeData, err := json.MarshalIndent(collection.Runtime, "", " ") if err != nil { return nil, err } if err := writeDiagnosticZipFile(archive, "context/runtime.json", append(runtimeData, byte(10))); err != nil { return nil, err } if err := archive.Close(); err != nil { return nil, err } return buffer.Bytes(), nil } func writeDiagnosticJSONL[T any](archive *zip.Writer, name string, records []T) error { var data bytes.Buffer for _, record := range records { encoded, err := json.Marshal(record) if err != nil { return err } data.Write(encoded) data.WriteByte(10) } return writeDiagnosticZipFile(archive, name, data.Bytes()) } func writeDiagnosticZipFile(archive *zip.Writer, name string, data []byte) error { file, err := archive.Create(name) if err != nil { return err } _, err = file.Write(data) return err } func sanitizeDiagnosticIdentifier(value string) string { value = strings.TrimSpace(value) if value == "" || len(value) > 96 { return "" } for _, char := range value { if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || strings.ContainsRune("._:-", char) { continue } return "" } return value } func sanitizeDiagnosticPath(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } if index := strings.IndexAny(value, "?#"); index >= 0 { value = value[:index] } return redactDiagnosticText(value, 300) } func redactDiagnosticText(value string, limit int) string { value = strings.TrimSpace(value) if value == "" { return "" } for _, marker := range []string{"authorization", "cookie", "set-cookie", "x-goog-api-key", "x-canvas-upstream-headers", "api_key", "api-key", "apikey", "access_key", "access-key", "secret_key", "secret-key", "password", "token="} { value = redactDiagnosticMarker(value, marker) } return truncateRunes(redactDiagnosticURLs(value), limit) } func redactDiagnosticMarker(value string, marker string) string { searchFrom := 0 for searchFrom < len(value) { lower := strings.ToLower(value) index := strings.Index(lower[searchFrom:], strings.ToLower(marker)) if index < 0 { break } index += searchFrom end := index + len(marker) for end < len(value) && isDiagnosticSeparator(value[end]) { end++ } valueEnd := end for valueEnd < len(value) && !isDiagnosticValueDelimiter(value[valueEnd]) { valueEnd++ } if valueEnd == end { searchFrom = end continue } value = value[:end] + "[REDACTED]" + value[valueEnd:] searchFrom = end + len("[REDACTED]") } return value } func redactDiagnosticURLs(value string) string { searchFrom := 0 for searchFrom < len(value) { startHTTP := strings.Index(value[searchFrom:], "http://") startHTTPS := strings.Index(value[searchFrom:], "https://") start := -1 if startHTTP >= 0 { start = startHTTP } if startHTTPS >= 0 && (start < 0 || startHTTPS < start) { start = startHTTPS } if start < 0 { break } start += searchFrom end := start for end < len(value) && !isDiagnosticURLDelimiter(value[end]) { end++ } raw := value[start:end] parsed, err := url.Parse(raw) if err != nil { searchFrom = end continue } parsed.RawQuery = "" parsed.Fragment = "" safe := parsed.String() value = value[:start] + safe + value[end:] searchFrom = start + len(safe) } return value } func isDiagnosticSeparator(value byte) bool { return value == ' ' || value == 9 || value == ':' || value == '=' } func isDiagnosticValueDelimiter(value byte) bool { switch value { case ' ', 9, 13, 10, ',', ';', '"', 39, '<', '>', '}', ']': return true default: return false } } func isDiagnosticURLDelimiter(value byte) bool { return value == ' ' || value == 9 || value == 13 || value == 10 || value == '"' || value == 39 || value == '<' || value == '>' } func boundedDiagnosticInt(value int, min int, max int) int { if value < min { return min } if value > max { return max } return value } func boundedDiagnosticInt64(value int64, min int64, max int64) int64 { if value < min { return min } if value > max { return max } return value }