226 lines
7.7 KiB
Go
226 lines
7.7 KiB
Go
package auth
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"infinite-canvas/backend/internal/kernel"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"infinite-canvas/backend/internal/outbound"
|
||
)
|
||
|
||
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, kernel.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, "", kernel.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")
|
||
outbound.ApplyDefaultOutboundHeaders(req)
|
||
client := outbound.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
|
||
}
|