Files
ttstd-infinite-canvas/backend/internal/service/diagnostics.go
QAyong 5b7f173102 feat: 完善创作工作台与用户诊断支持 (#295)
* feat(canvas): 参考素材 - 增加移除按钮并删除对应连线

* feat(canvas): 完善节点面板交互与拖拽反馈

* fix(canvas): 画布交互与小地图 - 统一浮层层级并完善节点识别

* feat(prompt-optimizer): 增加 AI 提示词优化器及宿主接入

* feat(canvas): 画布 - 增加自动整理节点按钮并补充文档

* chore(web): 前端 - 修正提示词优化器格式

* chore(backend): 后端 - 修正 Go 格式检查

* feat(workspace): 完善诊断导出与本地排障支持

* chore(web): 格式化诊断导出文件

* fix(web-test): 兼容 Bun 测试运行时

* fix(web-test): 对齐模型目录协议断言
2026-08-25 13:49:15 +08:00

571 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
"infinite-canvas/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)},
}
data, err := buildDiagnosticZIP(bundleID, manifest, collection)
if err != nil {
return nil, err
}
if len(data) > diagnosticMaxBundleBytes {
return nil, BadAuthRequest("诊断包超过 10 MB,请缩短时间范围后重试")
}
fileName := fmt.Sprintf("yingce-diagnostics-%s-%s.zip", 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(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", 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
}