revert(canvas): TapNow 公开画布导入 - 撤销 main 误合并
This commit is contained in:
1 parent
6fc49b4712
commit
1c4ce44112
13 files changed
+2
-1087
No files matched your search
@@ -80,7 +80,6 @@ func main() {
|
||||
handler.RegisterAdminAnalyticsRoutes(api, svc)
|
||||
handler.RegisterAnnouncementRoutes(api, svc)
|
||||
handler.RegisterFinanceRoutes(api, svc)
|
||||
handler.RegisterTapNowRoutes(api, svc)
|
||||
// 登录态模型目录代理:避免浏览器直连各上游时分别处理 CORS。
|
||||
handler.RegisterChannelModelRoutes(api, svc)
|
||||
handler.RegisterSystemProxyRoutes(api, svc)
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterTapNowRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.POST("/canvas-projects/:id/import/tapnow", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
failService(c, service.Unauthorized("请先登录"))
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
|
||||
var req service.TapNowImportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.ImportTapNow(user.ID, c.Param("id"), strings.TrimSpace(req.ShareID))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, result)
|
||||
})
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
tapNowShareURLPrefix = "https://app.tapnow.media/api/conversation/v1/canvas-share/"
|
||||
tapNowMaxResponseBytes = 16 << 20
|
||||
tapNowMaxNodes = 1000
|
||||
tapNowMaxConnections = 5000
|
||||
)
|
||||
|
||||
type TapNowImportRequest struct {
|
||||
ShareID string `json:"shareId"`
|
||||
}
|
||||
|
||||
type TapNowImportResult struct {
|
||||
BatchID string `json:"batchId"`
|
||||
BatchCreatedAt time.Time `json:"batchCreatedAt"`
|
||||
ShareID string `json:"shareId"`
|
||||
ProjectName string `json:"projectName"`
|
||||
Nodes []TapNowCanvasNode `json:"nodes"`
|
||||
Connections []TapNowCanvasConnection `json:"connections"`
|
||||
ImportedNodeCount int `json:"importedNodeCount"`
|
||||
ImportedConnectionCount int `json:"importedConnectionCount"`
|
||||
SkippedNodes []TapNowImportIssue `json:"skippedNodes"`
|
||||
SkippedConnections []TapNowImportIssue `json:"skippedConnections"`
|
||||
Warnings []TapNowImportWarning `json:"warnings"`
|
||||
MultiResultNodeCount int `json:"multiResultNodeCount"`
|
||||
ReusedFailedNodeCount int `json:"reusedFailedNodeCount"`
|
||||
PlaceholderNodeCount int `json:"placeholderNodeCount"`
|
||||
}
|
||||
|
||||
type TapNowCanvasNode struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
Content string `json:"content"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
Quality string `json:"quality,omitempty"`
|
||||
Seconds string `json:"seconds,omitempty"`
|
||||
VQuality string `json:"vquality,omitempty"`
|
||||
GenerateAudio string `json:"generateAudio,omitempty"`
|
||||
NaturalWidth int `json:"naturalWidth,omitempty"`
|
||||
NaturalHeight int `json:"naturalHeight,omitempty"`
|
||||
DurationMs int64 `json:"durationMs,omitempty"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ErrorDetails string `json:"errorDetails,omitempty"`
|
||||
Metadata TapNowImportMetadata `json:"metadata"`
|
||||
}
|
||||
|
||||
type TapNowImportMetadata struct {
|
||||
Provider string `json:"provider"`
|
||||
ShareID string `json:"shareId"`
|
||||
NodeID string `json:"nodeId"`
|
||||
BatchID string `json:"batchId"`
|
||||
SourceType string `json:"sourceType,omitempty"`
|
||||
}
|
||||
|
||||
type TapNowCanvasConnection struct {
|
||||
ID string `json:"id"`
|
||||
FromNodeID string `json:"fromNodeId"`
|
||||
ToNodeID string `json:"toNodeId"`
|
||||
FromHandleID string `json:"fromHandleId,omitempty"`
|
||||
ToHandleID string `json:"toHandleId,omitempty"`
|
||||
}
|
||||
|
||||
type TapNowImportIssue struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type TapNowImportWarning struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type tapNowEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type tapNowDetail struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Nodes []tapNowRawNode `json:"nodes"`
|
||||
Connections []tapNowRawConnection `json:"connections"`
|
||||
}
|
||||
|
||||
type tapNowRawNode struct {
|
||||
ID string `json:"id"`
|
||||
ShortID int64 `json:"short_id"`
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
DeletedAt string `json:"deleted_at"`
|
||||
Position struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
} `json:"position"`
|
||||
Measured struct {
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
} `json:"measured"`
|
||||
}
|
||||
|
||||
type tapNowRawConnection struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
SourceHandle string `json:"sourceHandle"`
|
||||
TargetHandle string `json:"targetHandle"`
|
||||
Data map[string]any `json:"data"`
|
||||
DeletedAt string `json:"deleted_at"`
|
||||
}
|
||||
|
||||
type tapNowNodeData struct {
|
||||
Title string `json:"title"`
|
||||
Prompt string `json:"prompt"`
|
||||
Text string `json:"text"`
|
||||
Src string `json:"src"`
|
||||
Options []string `json:"options"`
|
||||
Type string `json:"type"`
|
||||
Params map[string]any `json:"params"`
|
||||
TaskInfo map[string]any `json:"taskInfo"`
|
||||
Metadata struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Duration float64 `json:"duration"`
|
||||
} `json:"__metadata"`
|
||||
Error string `json:"error"`
|
||||
FailedReason string `json:"failedReason"`
|
||||
}
|
||||
|
||||
// 读取公开分享前先校验目标画布归属,避免把导入结果写入他人画布。
|
||||
func (s *Service) ImportTapNow(userID, canvasProjectID, shareID string) (*TapNowImportResult, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
canvasProjectID = strings.TrimSpace(canvasProjectID)
|
||||
if userID == "" || canvasProjectID == "" {
|
||||
return nil, Unauthorized("请先打开已同步的故事创作画布")
|
||||
}
|
||||
if _, err := s.repo.CanvasProjectForUser(userID, canvasProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detail, normalizedShareID, err := fetchTapNowDetail(strings.TrimSpace(shareID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return adaptTapNowDetail(detail, normalizedShareID)
|
||||
}
|
||||
|
||||
func fetchTapNowDetail(shareID string) (*tapNowDetail, string, error) {
|
||||
if !isTapNowShareID(shareID) {
|
||||
return nil, "", BadAuthRequest("TapNow 分享 ID 格式无效")
|
||||
}
|
||||
endpoint, err := url.Parse(tapNowShareURLPrefix + url.PathEscape(shareID))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
ApplyDefaultOutboundHeaders(req)
|
||||
client := OutboundHTTPClient(20 * time.Second)
|
||||
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
||||
return errors.New("TapNow API 不允许重定向")
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("TapNow 请求失败,请检查网络或分享链接")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, tapNowMaxResponseBytes+1))
|
||||
if err != nil {
|
||||
return nil, "", errors.New("读取 TapNow 响应失败")
|
||||
}
|
||||
if len(body) > tapNowMaxResponseBytes {
|
||||
return nil, "", errors.New("TapNow 画布响应过大")
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, "", fmt.Errorf("TapNow 请求失败(HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
var envelope tapNowEnvelope
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, "", errors.New("TapNow 响应格式无效")
|
||||
}
|
||||
if envelope.Code != 0 {
|
||||
msg := strings.TrimSpace(envelope.Msg)
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(envelope.Message)
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "TapNow 返回业务错误"
|
||||
}
|
||||
return nil, "", errors.New(msg)
|
||||
}
|
||||
var detail tapNowDetail
|
||||
if err := json.Unmarshal(envelope.Data, &detail); err != nil {
|
||||
return nil, "", errors.New("TapNow 画布数据格式无效")
|
||||
}
|
||||
return &detail, shareID, nil
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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, 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: firstNonEmptyString(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, firstNonEmptyString(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
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAdaptTapNowDetailSupportsMediaAndTextNodes(t *testing.T) {
|
||||
detail := &tapNowDetail{
|
||||
Name: "TapNow 测试画布",
|
||||
Nodes: []tapNowRawNode{
|
||||
{ID: "image-1", Type: "image", Data: []byte(`{"title":"图片","type":"generate","src":"https://files.tapnow.media/api/conversation/storage/uploads/image.png","prompt":"画面提示词","params":{"model":"gpt-image-2","imageSize":"2K"},"__metadata":{"width":1024,"height":768}}`), Position: struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}{X: 10, Y: 20}, Measured: struct {
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}{Width: 448, Height: 250}},
|
||||
{ID: "video-1", Type: "video", Data: []byte(`{"title":"视频","type":"generate","src":"","taskInfo":{"status":"failed","failedReason":"上游失败"},"params":{"model":"seedance-2.0","aspectRatio":"16:9","duration":8,"resolution":"720p","generateAudio":true},"__metadata":{"width":1280,"height":720,"duration":4.5}}`), Position: struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}{X: 500, Y: 20}, Measured: struct {
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}{Width: 444, Height: 250}},
|
||||
{ID: "audio-1", Type: "audio", Data: []byte(`{"title":"音频","type":"upload","src":"https://files.tapnow.media/api/conversation/storage/uploads/audio-file","prompt":"就这样.wav"}`), Position: struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}{X: 10, Y: 400}, Measured: struct {
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}{Width: 444, Height: 250}},
|
||||
{ID: "text-1", Type: "text", Data: []byte(`{"title":"文本","type":"generate","text":"故事文本"}`), Position: struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}{X: 500, Y: 400}, Measured: struct {
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}{Width: 300, Height: 200}},
|
||||
},
|
||||
Connections: []tapNowRawConnection{{ID: "edge-1", Source: "image-1", Target: "video-1", SourceHandle: "images", TargetHandle: "prompt"}},
|
||||
}
|
||||
|
||||
result, err := adaptTapNowDetail(detail, "8872a294")
|
||||
if err != nil {
|
||||
t.Fatalf("adaptTapNowDetail() error = %v", err)
|
||||
}
|
||||
if result.ImportedNodeCount != 4 || result.ImportedConnectionCount != 1 {
|
||||
t.Fatalf("counts = (%d, %d), want (4, 1)", result.ImportedNodeCount, result.ImportedConnectionCount)
|
||||
}
|
||||
if result.Nodes[0].Metadata.Provider != "tapnow" || result.Nodes[0].Model != "gpt-image-2" || result.Nodes[0].Quality != "2k" || result.Nodes[0].NaturalWidth != 1024 {
|
||||
t.Fatalf("image mapping = %#v", result.Nodes[0])
|
||||
}
|
||||
if result.Nodes[1].Status != "error" || result.Nodes[1].ErrorDetails != "上游失败" || result.PlaceholderNodeCount != 1 {
|
||||
t.Fatalf("failed video mapping = %#v", result.Nodes[1])
|
||||
}
|
||||
if result.Nodes[1].Size != "16:9" || result.Nodes[1].Seconds != "8" || result.Nodes[1].VQuality != "720p" || result.Nodes[1].GenerateAudio != "true" {
|
||||
t.Fatalf("video generation params = %#v", result.Nodes[1])
|
||||
}
|
||||
if result.Nodes[2].MimeType != "audio/wav" || result.Nodes[3].Type != "text" || result.Nodes[3].Content != "故事文本" {
|
||||
t.Fatalf("audio/text mapping = %#v %#v", result.Nodes[2], result.Nodes[3])
|
||||
}
|
||||
if result.Connections[0].FromHandleID != "images" || result.Connections[0].ToHandleID != "prompt" {
|
||||
t.Fatalf("connection handles = %#v", result.Connections[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptTapNowDetailUsesValueKeyAsTargetHandle(t *testing.T) {
|
||||
detail := &tapNowDetail{
|
||||
Nodes: []tapNowRawNode{
|
||||
{ID: "source", Type: "image", Data: []byte(`{"title":"源图","src":"https://files.tapnow.media/source"}`)},
|
||||
{ID: "target", Type: "image", Data: []byte(`{"title":"目标图","src":"https://files.tapnow.media/target"}`)},
|
||||
},
|
||||
Connections: []tapNowRawConnection{{ID: "edge", Source: "source", Target: "target", Data: map[string]any{"valueKey": "images"}}},
|
||||
}
|
||||
result, err := adaptTapNowDetail(detail, "8872a294")
|
||||
if err != nil {
|
||||
t.Fatalf("adaptTapNowDetail() error = %v", err)
|
||||
}
|
||||
if got := result.Connections[0].ToHandleID; got != "images" {
|
||||
t.Fatalf("target handle = %q, want images", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptTapNowDetailRejectsUntrustedMediaAndKeepsPlaceholder(t *testing.T) {
|
||||
detail := &tapNowDetail{Nodes: []tapNowRawNode{{ID: "image-1", Type: "image", Data: []byte(`{"title":"图片","type":"generate","src":"https://evil.example/image.png"}`)}}}
|
||||
result, err := adaptTapNowDetail(detail, "share_1")
|
||||
if err != nil {
|
||||
t.Fatalf("adaptTapNowDetail() error = %v", err)
|
||||
}
|
||||
if result.Nodes[0].Content != "" || result.Nodes[0].Status != "idle" || result.PlaceholderNodeCount != 1 {
|
||||
t.Fatalf("untrusted media handling = %#v", result.Nodes[0])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user