554 lines
18 KiB
Go
554 lines
18 KiB
Go
// RunningHub 工作流的结果轮询、输出下载与错误码解析。
|
||
//
|
||
// 上游输出地址可能是相对路径或内网主机,统一改写到配置的根地址后再下载;
|
||
// 失败信息尽量翻译成用户可操作的提示(额度不足、队列满、参数非法等)。
|
||
|
||
package app
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
func (s *Service) runningHubJSON(ctx context.Context, config providerConfig, endpoint string, body interface{}, target *map[string]any) error {
|
||
encoded, err := json.Marshal(body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(encoded))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
ApplyOutboundHeaders(req, config.Headers)
|
||
data, _, err := doBinary(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := json.Unmarshal(data, target); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) pollRunningHubWorkflow(ctx context.Context, config providerConfig, root string, taskID string, mode string) (map[string]interface{}, error) {
|
||
if mode == "video" {
|
||
return s.pollRunningHubVideoWorkflowWithPolicy(ctx, config, root, taskID, defaultVideoPollPolicy())
|
||
}
|
||
return s.pollRunningHubWorkflowLegacy(ctx, config, root, taskID)
|
||
}
|
||
|
||
func (s *Service) pollRunningHubVideoWorkflowWithPolicy(ctx context.Context, config providerConfig, root string, taskID string, policy videoPollPolicy) (map[string]interface{}, error) {
|
||
return runVideoPollLoop(ctx, taskID, policy, func(ctx context.Context) (videoPollOutcome, error) {
|
||
var response map[string]any
|
||
err := s.runningHubJSON(withProviderRequestKind(ctx, "poll"), config, root+"/task/openapi/outputs", map[string]any{"apiKey": runningHubAPIKey(config), "taskId": taskID}, &response)
|
||
if err != nil {
|
||
return videoPollOutcome{}, fmt.Errorf("RunningHub 查询任务失败:%w", err)
|
||
}
|
||
code, validCode := runningHubPayloadCode(response)
|
||
if !validCode {
|
||
validCode = len(runningHubOutputURLs(response["data"])) > 0
|
||
code = 0
|
||
}
|
||
if !validCode {
|
||
return videoPollOutcome{}, errors.New("RunningHub 查询响应缺少可识别状态")
|
||
}
|
||
if code == 0 {
|
||
urls := runningHubOutputURLs(response["data"])
|
||
if len(urls) == 0 {
|
||
return videoPollOutcome{}, errors.New("RunningHub 任务成功但没有返回产物")
|
||
}
|
||
for index, rawURL := range urls {
|
||
urls[index] = resolveRunningHubOutputURL(root, rawURL)
|
||
}
|
||
result, err := s.downloadWorkflowVideoOutputs(ctx, urls, taskID, policy)
|
||
if err != nil {
|
||
return videoPollOutcome{}, err
|
||
}
|
||
_ = s.updateWorkflowProviderState(ctx, taskID, "succeeded", nil)
|
||
return videoPollOutcome{Done: true, Result: result}, nil
|
||
}
|
||
if code == 805 || code == 806 {
|
||
return videoPollOutcome{}, fmt.Errorf("RunningHub 任务失败:%s", runningHubFailureMessage(response))
|
||
}
|
||
stage := "running"
|
||
if code == 813 {
|
||
stage = "queued"
|
||
} else if code != 804 {
|
||
stage = "pending"
|
||
}
|
||
next := time.Now().Add(policy.Interval)
|
||
_ = s.updateWorkflowProviderState(ctx, taskID, stage, &next)
|
||
return videoPollOutcome{}, nil
|
||
})
|
||
}
|
||
|
||
func (s *Service) pollRunningHubWorkflowLegacy(ctx context.Context, config providerConfig, root string, taskID string) (map[string]interface{}, error) {
|
||
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
|
||
var response map[string]any
|
||
err := s.runningHubJSON(withProviderRequestKind(ctx, "poll"), config, root+"/task/openapi/outputs", map[string]any{"apiKey": runningHubAPIKey(config), "taskId": taskID}, &response)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("RunningHub 查询任务失败:%w", err)
|
||
}
|
||
code, validCode := runningHubPayloadCode(response)
|
||
if !validCode {
|
||
// 成功响应有时只有 outputs/results,没有显式状态码。
|
||
validCode = len(runningHubOutputURLs(response["data"])) > 0
|
||
code = 0
|
||
}
|
||
if !validCode {
|
||
return nil, errors.New("RunningHub 查询响应缺少可识别状态")
|
||
}
|
||
if code == 0 {
|
||
urls := runningHubOutputURLs(response["data"])
|
||
if len(urls) == 0 {
|
||
return nil, errors.New("RunningHub 任务成功但没有返回产物")
|
||
}
|
||
for index, rawURL := range urls {
|
||
urls[index] = resolveRunningHubOutputURL(root, rawURL)
|
||
}
|
||
_ = s.updateWorkflowProviderState(ctx, taskID, "succeeded", nil)
|
||
return s.downloadWorkflowOutputs(ctx, urls)
|
||
}
|
||
if code == 805 || code == 806 {
|
||
return nil, fmt.Errorf("RunningHub 任务失败:%s", runningHubFailureMessage(response))
|
||
}
|
||
stage := "running"
|
||
if code == 813 {
|
||
stage = "queued"
|
||
} else if code != 804 {
|
||
// RunningHub 会扩展暂态码;只对明确失败码结束任务,其余状态继续轮询到终态或超时。
|
||
stage = "pending"
|
||
}
|
||
_ = s.updateWorkflowProviderState(ctx, taskID, stage, ptr(time.Now().Add(2500*time.Millisecond)))
|
||
if err := sleepContext(ctx, 2500*time.Millisecond); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("RunningHub 任务超时(%s)", taskID)
|
||
}
|
||
|
||
// RunningHub 工作流的管理、提交和轮询固定使用积分 API Key;企业级 Key 仅由上传接口单独读取。
|
||
func runningHubAPIKey(config providerConfig) string {
|
||
return strings.TrimSpace(config.APIKey)
|
||
}
|
||
|
||
func (s *Service) downloadWorkflowOutputs(ctx context.Context, urls []string) (map[string]interface{}, error) {
|
||
return s.downloadWorkflowOutputsWithPolicy(ctx, urls, "", nil)
|
||
}
|
||
|
||
func (s *Service) downloadWorkflowVideoOutputs(ctx context.Context, urls []string, taskID string, policy videoPollPolicy) (map[string]interface{}, error) {
|
||
return s.downloadWorkflowOutputsWithPolicy(ctx, urls, taskID, &policy)
|
||
}
|
||
|
||
func (s *Service) downloadWorkflowOutputsWithPolicy(ctx context.Context, urls []string, taskID string, policy *videoPollPolicy) (map[string]interface{}, error) {
|
||
images := make([]map[string]interface{}, 0)
|
||
var video, audio map[string]interface{}
|
||
for _, rawURL := range urls {
|
||
if strings.HasPrefix(rawURL, "data:") {
|
||
mimeType, data, err := decodeProviderDataURL(rawURL)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if item := workflowOutputValue(mimeType, data); item != nil {
|
||
switch value := item.(type) {
|
||
case map[string]interface{}:
|
||
if strings.HasPrefix(mimeType, "image/") {
|
||
images = append(images, value)
|
||
} else if strings.HasPrefix(mimeType, "video/") {
|
||
video = value
|
||
} else if strings.HasPrefix(mimeType, "audio/") {
|
||
audio = value
|
||
}
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
if !isPublicMediaURL(rawURL) {
|
||
continue
|
||
}
|
||
var data []byte
|
||
var mimeType string
|
||
var err error
|
||
if policy == nil {
|
||
data, mimeType, err = getExternalBinary(withProviderRequestKind(ctx, "download"), rawURL)
|
||
} else {
|
||
data, mimeType, err = runVideoDownload(ctx, taskID, *policy, func(ctx context.Context) ([]byte, string, error) {
|
||
return getExternalBinary(withProviderRequestKind(ctx, "download"), rawURL)
|
||
})
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("下载 RunningHub 产物失败:%w", err)
|
||
}
|
||
mimeType = runningHubOutputMimeType(rawURL, mimeType)
|
||
item := workflowOutputValue(mimeType, data)
|
||
if item == nil {
|
||
continue
|
||
}
|
||
value := item.(map[string]interface{})
|
||
switch {
|
||
case strings.HasPrefix(mimeType, "image/"):
|
||
images = append(images, value)
|
||
case strings.HasPrefix(mimeType, "video/"):
|
||
video = value
|
||
case strings.HasPrefix(mimeType, "audio/"):
|
||
audio = value
|
||
}
|
||
}
|
||
result := map[string]interface{}{"mode": "image"}
|
||
if len(images) > 0 {
|
||
result["images"] = images
|
||
}
|
||
if video != nil {
|
||
result["mode"] = "video"
|
||
result["video"] = video
|
||
}
|
||
if audio != nil {
|
||
result["mode"] = "audio"
|
||
result["audio"] = audio
|
||
}
|
||
if len(images) == 0 && video == nil && audio == nil {
|
||
return nil, errors.New("RunningHub 返回的产物类型不受支持")
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func runningHubOutputMimeType(rawURL string, declared string) string {
|
||
declared = strings.TrimSpace(strings.Split(declared, ";")[0])
|
||
if declared != "" && declared != "application/octet-stream" {
|
||
return declared
|
||
}
|
||
pathValue := rawURL
|
||
if parsed, err := url.Parse(rawURL); err == nil && parsed.Path != "" {
|
||
pathValue = parsed.Path
|
||
}
|
||
pathValue = strings.ToLower(strings.Split(pathValue, "?")[0])
|
||
for suffix, mimeType := range map[string]string{
|
||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif",
|
||
".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime", ".m4v": "video/mp4",
|
||
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".m4a": "audio/mp4", ".flac": "audio/flac",
|
||
} {
|
||
if strings.HasSuffix(pathValue, suffix) {
|
||
return mimeType
|
||
}
|
||
}
|
||
return declared
|
||
}
|
||
|
||
func workflowOutputValue(mimeType string, data []byte) interface{} {
|
||
mimeType = strings.TrimSpace(strings.Split(mimeType, ";")[0])
|
||
if mimeType == "" {
|
||
mimeType = "application/octet-stream"
|
||
}
|
||
if len(data) == 0 {
|
||
return nil
|
||
}
|
||
return map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType, "bytes": len(data)}
|
||
}
|
||
|
||
func runningHubRootURL(value string) string {
|
||
base := strings.TrimRight(strings.TrimSpace(value), "/")
|
||
if base == "" {
|
||
base = "https://www.runninghub.cn"
|
||
}
|
||
// 设置页可能保存根地址、/openapi/v2 或带尾部斜杠的任一形式;任务
|
||
// OpenAPI 使用根地址下的 /task/openapi/*,统一剥掉 API 前缀。
|
||
lower := strings.ToLower(base)
|
||
for _, suffix := range []string{"/openapi/v2", "/openapi"} {
|
||
if strings.HasSuffix(lower, suffix) {
|
||
base = base[:len(base)-len(suffix)]
|
||
break
|
||
}
|
||
}
|
||
return strings.TrimRight(base, "/")
|
||
}
|
||
|
||
func resolveRunningHubOutputURL(root string, rawURL string) string {
|
||
rawURL = rewriteRunningHubOutputHost(strings.TrimSpace(rawURL))
|
||
if rawURL == "" || strings.HasPrefix(rawURL, "data:") || isPublicMediaURL(rawURL) {
|
||
return rawURL
|
||
}
|
||
if strings.HasPrefix(rawURL, "//") {
|
||
return "https:" + rawURL
|
||
}
|
||
if strings.HasPrefix(rawURL, "/") {
|
||
return strings.TrimRight(root, "/") + rawURL
|
||
}
|
||
// API 可能返回 output/foo、assets/foo 这类无前导斜杠的相对路径。
|
||
for _, prefix := range []string{"output/", "assets/", "input/"} {
|
||
if strings.HasPrefix(strings.ToLower(rawURL), prefix) {
|
||
return strings.TrimRight(root, "/") + "/" + rawURL
|
||
}
|
||
}
|
||
if runningHubRelativeOutputPath(rawURL) {
|
||
return strings.TrimRight(root, "/") + "/" + strings.TrimLeft(rawURL, "/")
|
||
}
|
||
return rawURL
|
||
}
|
||
|
||
// RunningHub 某些区域会返回旧 COS 域名,参考项目已将其迁移到可访问域名;
|
||
// 这里只做固定 host 映射,不接受响应内容提供任意代理目标。
|
||
func rewriteRunningHubOutputHost(rawURL string) string {
|
||
parsed, err := url.Parse(rawURL)
|
||
if err != nil || parsed.Host == "" {
|
||
return rawURL
|
||
}
|
||
if strings.EqualFold(parsed.Host, "rh-images-1252422369.cos.ap-beijing.myqcloud.com") {
|
||
parsed.Host = "rh-images.xiaoyaoyou.com"
|
||
return parsed.String()
|
||
}
|
||
return rawURL
|
||
}
|
||
|
||
func runningHubTaskID(payload map[string]any) string {
|
||
for _, keys := range [][]string{{"data", "taskId"}, {"data", "task_id"}, {"data", "taskID"}, {"data", "id"}, {"taskId"}, {"task_id"}, {"taskID"}, {"id"}} {
|
||
if len(keys) == 2 {
|
||
if value := nestedString(payload, keys[0], keys[1]); value != "" {
|
||
return value
|
||
}
|
||
} else if value := stringValue(payload[keys[0]]); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func runningHubOutputURLs(value interface{}) []string {
|
||
result := make([]string, 0)
|
||
var visit func(interface{})
|
||
visit = func(current interface{}) {
|
||
switch item := current.(type) {
|
||
case string:
|
||
if strings.HasPrefix(item, "http://") || strings.HasPrefix(item, "https://") || strings.HasPrefix(item, "data:") || (strings.HasPrefix(item, "/") && !strings.HasPrefix(item, "//")) || runningHubRelativeOutputPath(item) {
|
||
result = append(result, item)
|
||
}
|
||
case []interface{}:
|
||
for _, child := range item {
|
||
visit(child)
|
||
}
|
||
case map[string]interface{}:
|
||
for key, child := range item {
|
||
lowerKey := strings.ToLower(strings.TrimSpace(key))
|
||
if lowerKey == "fileurl" || lowerKey == "file_url" || lowerKey == "url" || lowerKey == "downloadurl" || lowerKey == "download_url" || lowerKey == "src" || lowerKey == "output" || lowerKey == "outputs" || lowerKey == "results" || lowerKey == "files" || lowerKey == "data" || lowerKey == "images" || lowerKey == "videos" || lowerKey == "audio" || lowerKey == "audios" || lowerKey == "result" {
|
||
visit(child)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
visit(value)
|
||
seen := map[string]bool{}
|
||
deduped := make([]string, 0, len(result))
|
||
for _, item := range result {
|
||
if !seen[item] {
|
||
seen[item] = true
|
||
deduped = append(deduped, item)
|
||
}
|
||
}
|
||
return deduped
|
||
}
|
||
|
||
func runningHubRelativeOutputPath(value string) bool {
|
||
value = strings.ToLower(strings.TrimSpace(value))
|
||
if value == "" || strings.Contains(value, "://") || strings.HasPrefix(value, "//") {
|
||
return false
|
||
}
|
||
for _, segment := range strings.Split(strings.Split(value, "?")[0], "/") {
|
||
if segment == ".." {
|
||
return false
|
||
}
|
||
}
|
||
for _, prefix := range []string{"output/", "assets/", "input/"} {
|
||
if strings.HasPrefix(value, prefix) {
|
||
return true
|
||
}
|
||
}
|
||
for _, suffix := range []string{".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm", ".mov", ".m4v", ".mp3", ".wav", ".ogg", ".m4a", ".flac"} {
|
||
if strings.HasSuffix(strings.Split(value, "?")[0], suffix) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func runningHubFailureMessage(payload map[string]any) string {
|
||
for _, key := range []string{"msg", "message", "error", "failReason", "failedReason", "errorMessage"} {
|
||
if value := stringValue(payload[key]); value != "" {
|
||
return runningHubActionableFailureMessage(value)
|
||
}
|
||
}
|
||
if nested, ok := payload["data"].(map[string]interface{}); ok {
|
||
return runningHubFailureMessage(nested)
|
||
}
|
||
return "上游未提供失败原因"
|
||
}
|
||
|
||
func isWorkflowSeedField(value string) bool {
|
||
normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.TrimSpace(value)))
|
||
return normalized == "seed" || strings.HasSuffix(normalized, "seed") || strings.Contains(normalized, "noiseseed")
|
||
}
|
||
|
||
func runningHubActionableFailureMessage(message string) string {
|
||
normalized := strings.ToLower(message)
|
||
if strings.Contains(normalized, "node_info_mismatch") || strings.Contains(normalized, "node_not_found_in_workflow") {
|
||
return "RunningHub AI 应用的公开参数已变化,请到“设置 → RunningHub 工作流”重新选择该 App 并点击“拉取参数”后再试(上游详情:" + message + ")"
|
||
}
|
||
return message
|
||
}
|
||
|
||
// runningHubPayloadCode 兼容 code/statusCode 以及部分网关返回的字符串状态。
|
||
// 统一映射到 RunningHub 公开状态码:0 成功、804 运行中、813 排队中、805/806 失败。
|
||
func runningHubPayloadCode(payload map[string]any) (int, bool) {
|
||
if payload == nil {
|
||
return 0, false
|
||
}
|
||
topCode, topValid := runningHubDirectCode(payload)
|
||
if nested, ok := payload["data"].(map[string]interface{}); ok {
|
||
if nestedCode, nestedValid := runningHubPayloadCode(nested); nestedValid {
|
||
// 有些网关把 HTTP 成功包装成顶层 code=0,同时把真实任务状态放在
|
||
// data.code/status;真实任务状态优先,避免把排队任务误判成完成。
|
||
if !topValid || topCode == 0 || nestedCode != 0 {
|
||
return nestedCode, true
|
||
}
|
||
}
|
||
}
|
||
return topCode, topValid
|
||
}
|
||
|
||
func runningHubDirectCode(payload map[string]any) (int, bool) {
|
||
primaryCode := 0
|
||
primaryValid := false
|
||
for _, key := range []string{"code", "statusCode", "status_code"} {
|
||
if value, ok := payload[key]; ok {
|
||
if code, valid := runningHubCode(value); valid {
|
||
if (key == "statusCode" || key == "status_code") && code >= 200 && code < 300 {
|
||
code = 0
|
||
}
|
||
primaryCode, primaryValid = code, true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
for _, key := range []string{"status", "state", "taskStatus", "task_status"} {
|
||
if code, valid := runningHubStatusCode(payload[key]); valid {
|
||
if !primaryValid || primaryCode == 0 || code != 0 {
|
||
return code, true
|
||
}
|
||
break
|
||
}
|
||
}
|
||
if primaryValid {
|
||
return primaryCode, true
|
||
}
|
||
// errorCode=0 通常只是“无错误”标记,不能覆盖 data.status=running。
|
||
for _, key := range []string{"errorCode", "error_code"} {
|
||
if value, ok := payload[key]; ok {
|
||
if code, valid := runningHubCode(value); valid && code != 0 {
|
||
return code, true
|
||
}
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func runningHubStatusCode(value interface{}) (int, bool) {
|
||
text := strings.ToLower(strings.TrimSpace(fmt.Sprint(value)))
|
||
if text == "" || text == "<nil>" {
|
||
return 0, false
|
||
}
|
||
if numeric, ok := runningHubCode(text); ok {
|
||
switch numeric {
|
||
case 0, 804, 813, 805, 806:
|
||
return numeric, true
|
||
}
|
||
}
|
||
text = strings.NewReplacer("_", "", "-", "", " ", "").Replace(text)
|
||
switch text {
|
||
case "success", "succeeded", "complete", "completed", "done", "finished", "finish", "3":
|
||
return 0, true
|
||
case "queued", "queue", "pending", "waiting", "created", "submitted":
|
||
return 813, true
|
||
case "running", "processing", "executing", "inprogress", "started", "working", "1", "2":
|
||
return 804, true
|
||
case "failed", "failure", "error", "rejected", "cancelled", "canceled", "expired", "aborted", "4", "5":
|
||
return 805, true
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|
||
|
||
func nestedString(payload map[string]any, first string, second string) string {
|
||
nested, _ := payload[first].(map[string]interface{})
|
||
return stringValue(nested[second])
|
||
}
|
||
|
||
func runningHubFileName(payload map[string]any) string {
|
||
for _, key := range []string{"fileName", "file_name", "filename", "name"} {
|
||
if value := stringValue(payload[key]); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
for _, container := range []string{"data", "result", "file", "files"} {
|
||
if nested, ok := payload[container].(map[string]interface{}); ok {
|
||
for _, key := range []string{"fileName", "file_name", "filename", "name"} {
|
||
if value := stringValue(nested[key]); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func runningHubCode(value interface{}) (int, bool) {
|
||
switch item := value.(type) {
|
||
case int:
|
||
if item < 0 {
|
||
return 0, false
|
||
}
|
||
return item, true
|
||
case int64:
|
||
if item < 0 {
|
||
return 0, false
|
||
}
|
||
return int(item), true
|
||
case float64:
|
||
code := int(item)
|
||
if item < 0 || float64(code) != item {
|
||
return 0, false
|
||
}
|
||
return code, true
|
||
case json.Number, string:
|
||
code := mapNumber(item)
|
||
return code, code >= 0
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|
||
|
||
func mapNumber(value interface{}) int {
|
||
switch item := value.(type) {
|
||
case int:
|
||
return item
|
||
case int64:
|
||
return int(item)
|
||
case float64:
|
||
return int(item)
|
||
case json.Number:
|
||
parsed, _ := strconv.Atoi(string(item))
|
||
return parsed
|
||
case string:
|
||
parsed, _ := strconv.Atoi(strings.TrimSpace(item))
|
||
return parsed
|
||
default:
|
||
return -1
|
||
}
|
||
}
|