package auth import ( "encoding/json" "errors" "fmt" "infinite-canvas/backend/internal/kernel" "math" "net/url" "strconv" "strings" "time" "github.com/google/uuid" ) func adaptTapNowDetail(detail *tapNowDetail, shareID string) (*TapNowImportResult, error) { if detail == nil { return nil, errors.New("TapNow 画布数据为空") } shareID = strings.TrimSpace(shareID) if !isTapNowShareID(shareID) { return nil, kernel.BadAuthRequest("TapNow 分享 ID 格式无效") } if len(detail.Nodes) > tapNowMaxNodes { return nil, fmt.Errorf("TapNow 画布节点过多(最多支持 %d 个)", tapNowMaxNodes) } now := time.Now() batchID := "tapnow-" + strconv.FormatInt(now.UnixMilli(), 36) + "-" + strings.ReplaceAll(uuid.NewString(), "-", "")[:8] result := &TapNowImportResult{ BatchID: batchID, BatchCreatedAt: now, ShareID: shareID, ProjectName: strings.TrimSpace(detail.Name), Nodes: make([]TapNowCanvasNode, 0, len(detail.Nodes)), Connections: make([]TapNowCanvasConnection, 0, minInt(len(detail.Connections), tapNowMaxConnections)), SkippedNodes: make([]TapNowImportIssue, 0), SkippedConnections: make([]TapNowImportIssue, 0), Warnings: make([]TapNowImportWarning, 0), } if result.ProjectName == "" { result.ProjectName = "TapNow 画布" } mapping := make(map[string]string, len(detail.Nodes)) seenNodeIDs := make(map[string]struct{}, len(detail.Nodes)) for _, raw := range detail.Nodes { if strings.TrimSpace(raw.DeletedAt) != "" { continue } nodeID := strings.TrimSpace(raw.ID) if nodeID == "" && raw.ShortID != 0 { nodeID = strconv.FormatInt(raw.ShortID, 10) } if nodeID == "" { result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{Reason: "节点缺少 ID"}) continue } if len(nodeID) > 128 { result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID[:128], Reason: "节点 ID 过长"}) continue } if _, exists := seenNodeIDs[nodeID]; exists { result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Reason: "节点 ID 重复"}) continue } seenNodeIDs[nodeID] = struct{}{} data, err := decodeTapNowNodeData(raw.Data) if err != nil { result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Reason: "节点数据格式无效"}) continue } kind := strings.ToLower(strings.TrimSpace(raw.Type)) if kind == "" { kind = strings.ToLower(strings.TrimSpace(data.Type)) } if kind != "image" && kind != "video" && kind != "audio" && kind != "text" { result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Name: strings.TrimSpace(data.Title), Reason: "暂不支持的节点类型"}) continue } if !finiteNumber(raw.Position.X) || !finiteNumber(raw.Position.Y) { result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Name: strings.TrimSpace(data.Title), Reason: "节点坐标无效"}) continue } content, mediaIndex := firstTapNowMediaURL(data) if kind == "text" { content = strings.TrimSpace(data.Text) if content == "" { content = strings.TrimSpace(data.Prompt) } mediaIndex = -1 } width, height := raw.Measured.Width, raw.Measured.Height if !positiveFiniteNumber(width) { width = 480 } if !positiveFiniteNumber(height) { height = 300 } item := TapNowCanvasNode{ ID: "tapnow-" + batchID + "-" + nodeID, Type: kind, Title: strings.TrimSpace(data.Title), X: raw.Position.X, Y: raw.Position.Y, Width: width, Height: height, Content: content, Prompt: kernel.FirstNonEmpty(strings.TrimSpace(data.Prompt), firstTapNowParam(data.Params, "prompt")), Model: tapNowMapString(data.Params, "model"), Size: firstTapNowParam(data.Params, "aspectRatio", "size"), Quality: tapNowImageQuality(data.Params), Seconds: firstTapNowParam(data.Params, "duration", "seconds"), VQuality: firstTapNowParam(data.Params, "resolution", "vquality"), GenerateAudio: tapNowBoolParam(data.Params, "generateAudio"), Metadata: TapNowImportMetadata{ Provider: "tapnow", ShareID: shareID, NodeID: nodeID, BatchID: batchID, SourceType: strings.TrimSpace(data.Type), }, } if item.Title == "" { item.Title = kind + " 节点" } item.NaturalWidth = data.Metadata.Width item.NaturalHeight = data.Metadata.Height if data.Metadata.Duration > 0 && !math.IsNaN(data.Metadata.Duration) && !math.IsInf(data.Metadata.Duration, 0) { item.DurationMs = int64(math.Round(data.Metadata.Duration * 1000)) } item.MimeType = inferTapNowMime(kind, content, kernel.FirstNonEmpty(data.Prompt, data.Title)) taskStatus := tapNowTaskStatus(data.TaskInfo) hasContent := strings.TrimSpace(content) != "" if hasContent { item.Status = "success" if tapNowTaskFailed(taskStatus) { result.ReusedFailedNodeCount++ } } else if tapNowTaskFailed(taskStatus) { item.Status = "error" item.ErrorDetails = tapNowErrorDetails(data) if item.ErrorDetails == "" { item.ErrorDetails = "TapNow 生成任务失败" } result.PlaceholderNodeCount++ } else { item.Status = "idle" result.PlaceholderNodeCount++ } mapping[nodeID] = item.ID if raw.ShortID != 0 { mapping[strconv.FormatInt(raw.ShortID, 10)] = item.ID } result.Nodes = append(result.Nodes, item) if !positiveFiniteNumber(raw.Measured.Width) || !positiveFiniteNumber(raw.Measured.Height) { result.Warnings = append(result.Warnings, TapNowImportWarning{ID: nodeID, Message: fmt.Sprintf("节点“%s”尺寸无效,已使用默认尺寸。", item.Title)}) } if mediaIndex >= 0 && len(data.Options) > 1 { result.MultiResultNodeCount++ result.Warnings = append(result.Warnings, TapNowImportWarning{ID: nodeID, Message: fmt.Sprintf("节点“%s”包含多个结果,已导入首个结果。", item.Title)}) } } if len(detail.Connections) > tapNowMaxConnections { result.Warnings = append(result.Warnings, TapNowImportWarning{Message: fmt.Sprintf("连接超过 %d 条,仅处理前 %d 条。", tapNowMaxConnections, tapNowMaxConnections)}) } seenConnectionIDs := make(map[string]struct{}, len(detail.Connections)) for index, raw := range detail.Connections { if index >= tapNowMaxConnections { break } if strings.TrimSpace(raw.DeletedAt) != "" { continue } connectionID := strings.TrimSpace(raw.ID) if connectionID == "" { result.SkippedConnections = append(result.SkippedConnections, TapNowImportIssue{Reason: "连接缺少 ID"}) continue } if _, exists := seenConnectionIDs[connectionID]; exists { result.SkippedConnections = append(result.SkippedConnections, TapNowImportIssue{ID: connectionID, Reason: "连接 ID 重复"}) continue } seenConnectionIDs[connectionID] = struct{}{} from, okFrom := mapping[strings.TrimSpace(raw.Source)] to, okTo := mapping[strings.TrimSpace(raw.Target)] if !okFrom || !okTo { result.SkippedConnections = append(result.SkippedConnections, TapNowImportIssue{ID: connectionID, Reason: "连接端点节点未导入"}) continue } fromHandleID := strings.TrimSpace(raw.SourceHandle) toHandleID := strings.TrimSpace(raw.TargetHandle) if toHandleID == "" { // TapNow 将图片输入句柄放在边 data.valueKey,而不是 targetHandle。 toHandleID = tapNowMapString(raw.Data, "valueKey") } result.Connections = append(result.Connections, TapNowCanvasConnection{ ID: "tapnow-" + batchID + "-" + connectionID, FromNodeID: from, ToNodeID: to, FromHandleID: fromHandleID, ToHandleID: toHandleID, }) } result.ImportedNodeCount = len(result.Nodes) result.ImportedConnectionCount = len(result.Connections) if result.ImportedNodeCount == 0 { return nil, errors.New("TapNow 画布没有可导入的有效节点") } return result, nil } func decodeTapNowNodeData(raw json.RawMessage) (tapNowNodeData, error) { var data tapNowNodeData trimmed := strings.TrimSpace(string(raw)) if strings.HasPrefix(trimmed, `"`) { var encoded string if err := json.Unmarshal(raw, &encoded); err != nil { return data, err } raw = json.RawMessage(encoded) } if err := json.Unmarshal(raw, &data); err != nil { return data, err } return data, nil } func firstTapNowMediaURL(data tapNowNodeData) (string, int) { values := make([]string, 0, len(data.Options)+2) values = append(values, data.Src) values = append(values, data.Options...) values = append(values, data.Metadata.URL) for index, value := range values { parsed, err := url.Parse(strings.TrimSpace(value)) if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || !isTapNowMediaHost(parsed.Hostname()) { continue } return parsed.String(), index } return "", -1 } func isTapNowMediaHost(host string) bool { host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") // 导入后的媒体还可能作为后续生成的参考素材,只接受已核验的官方文件域名, // 不能根据品牌前缀放行未经确认的同名域,避免把外部 URL 带入后端出网链路。 return host == "files.tapnow.media" } func inferTapNowMime(kind, rawURL, hint string) string { lower := strings.ToLower(rawURL + " " + hint) switch kind { case "image": switch { case strings.Contains(lower, ".png"): return "image/png" case strings.Contains(lower, ".jpg") || strings.Contains(lower, ".jpeg"): return "image/jpeg" case strings.Contains(lower, ".webp"): return "image/webp" case strings.Contains(lower, ".gif"): return "image/gif" default: return "image/*" } case "video": switch { case strings.Contains(lower, ".webm"): return "video/webm" case strings.Contains(lower, ".mov"): return "video/quicktime" default: return "video/mp4" } case "audio": switch { case strings.Contains(lower, ".wav"): return "audio/wav" case strings.Contains(lower, ".flac"): return "audio/flac" case strings.Contains(lower, ".ogg") || strings.Contains(lower, ".oga"): return "audio/ogg" case strings.Contains(lower, ".m4a"): return "audio/mp4" default: return "audio/mpeg" } case "text": return "text/plain" default: return "application/octet-stream" } } func tapNowMapString(values map[string]any, key string) string { if values == nil { return "" } value, ok := values[key] if !ok { return "" } if value == nil { return "" } if stringValue, ok := value.(string); ok { return strings.TrimSpace(stringValue) } return strings.TrimSpace(fmt.Sprint(value)) } func firstTapNowParam(values map[string]any, keys ...string) string { for _, key := range keys { if value := tapNowMapString(values, key); value != "" { return value } } return "" } // TapNow 部分渠道用 imageSize(例如 "2K"),画布合同则使用小写质量档位。 // 只迁移已知档位,避免把渠道私有值静默写成无效的本地质量设置。 func tapNowImageQuality(values map[string]any) string { value := firstTapNowParam(values, "quality") if value == "" { value = firstTapNowParam(values, "imageSize") } normalized := strings.ToLower(strings.TrimSpace(value)) switch normalized { case "1k", "2k", "4k", "auto", "low", "medium", "high": return normalized default: return "" } } func tapNowBoolParam(values map[string]any, key string) string { value, ok := values[key] if !ok { return "" } if boolean, ok := value.(bool); ok { return strconv.FormatBool(boolean) } return tapNowMapString(values, key) } func tapNowTaskStatus(values map[string]any) string { status := strings.ToLower(strings.TrimSpace(tapNowMapString(values, "status"))) return status } func tapNowTaskFailed(status string) bool { switch strings.ToLower(strings.TrimSpace(status)) { case "failed", "error", "failure": return true default: return false } } func tapNowErrorDetails(data tapNowNodeData) string { if value := strings.TrimSpace(data.Error); value != "" { return value } if value := strings.TrimSpace(data.FailedReason); value != "" { return value } for _, key := range []string{"error", "failedReason", "message"} { if value := tapNowMapString(data.TaskInfo, key); value != "" { return value } } return "" } func isTapNowShareID(value string) bool { value = strings.TrimSpace(value) if value == "" || len(value) > 64 { return false } for _, char := range value { if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-') { return false } } return true } func finiteNumber(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) } func positiveFiniteNumber(value float64) bool { return finiteNumber(value) && value > 0 } func minInt(left, right int) int { if left < right { return left } return right }