2156 lines
75 KiB
Go
2156 lines
75 KiB
Go
package app
|
||
|
||
// RunningHub 的云端工作流协议适配集中在这里。
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
cryptorand "crypto/rand"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"math/big"
|
||
"mime"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"net/textproto"
|
||
"net/url"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"infinite-canvas/backend/internal/model"
|
||
)
|
||
|
||
// WorkflowField 是云端工作流字段描述。Value 与 FieldValue 兼容来源项目
|
||
// 的两种命名;Source 可取 referenceImage/referenceVideo/referenceAudio/mask。
|
||
type WorkflowField struct {
|
||
ID string `json:"id"`
|
||
NodeID string `json:"nodeId"`
|
||
ClassType string `json:"classType,omitempty"`
|
||
FieldName string `json:"fieldName"`
|
||
Value interface{} `json:"value,omitempty"`
|
||
FieldValue interface{} `json:"fieldValue,omitempty"`
|
||
FieldType string `json:"fieldType,omitempty"`
|
||
Label string `json:"label,omitempty"`
|
||
Role string `json:"role,omitempty"`
|
||
SafeToOverride *bool `json:"safeToOverride,omitempty"`
|
||
OptionsSource string `json:"optionsSource,omitempty"`
|
||
Options []interface{} `json:"options,omitempty"`
|
||
Min interface{} `json:"min,omitempty"`
|
||
Max interface{} `json:"max,omitempty"`
|
||
Step interface{} `json:"step,omitempty"`
|
||
RandomEnabled bool `json:"randomEnabled,omitempty"`
|
||
BindPrompt bool `json:"bindPrompt,omitempty"`
|
||
// 指针用于区分“未配置(默认启用)”和明确传入 false。
|
||
Enabled *bool `json:"enabled,omitempty"`
|
||
Source string `json:"source,omitempty"`
|
||
SourceIndex int `json:"sourceIndex,omitempty"`
|
||
ImageOrder int `json:"imageOrder,omitempty"`
|
||
SourceFromUpstream bool `json:"sourceFromUpstream,omitempty"`
|
||
Required bool `json:"required,omitempty"`
|
||
// nil 兼容旧配置;true 表示来源由字段名推断,false 表示用户在映射面板明确选择。
|
||
SourceAutomatic *bool `json:"sourceAutomatic,omitempty"`
|
||
// 仅在本次反序列化期间保留,用于区分旧数据缺字段与用户明确选择“保留默认值”。
|
||
sourceConfigured bool
|
||
}
|
||
|
||
// UnmarshalJSON 兼容来源项目的字段配置格式。来源项目使用 node/input/default/
|
||
// bind_prompt,而画布内部使用 nodeId/fieldName/fieldValue/source;在入口统一归一化,
|
||
// 后续 RunningHub 和 Bridge 不需要各自维护一套别名解析。
|
||
func (f *WorkflowField) UnmarshalJSON(data []byte) error {
|
||
type plainWorkflowField WorkflowField
|
||
var parsed plainWorkflowField
|
||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||
return err
|
||
}
|
||
var raw map[string]json.RawMessage
|
||
if err := json.Unmarshal(data, &raw); err != nil {
|
||
return err
|
||
}
|
||
*f = WorkflowField(parsed)
|
||
if f.NodeID == "" {
|
||
f.NodeID = workflowFieldRawString(raw, "node", "node_id", "nodeID")
|
||
}
|
||
if f.ClassType == "" {
|
||
f.ClassType = workflowFieldRawString(raw, "class_type", "typeName", "nodeType")
|
||
}
|
||
if f.FieldName == "" {
|
||
f.FieldName = workflowFieldRawString(raw, "input", "inputName", "input_name", "name")
|
||
}
|
||
if f.ID == "" {
|
||
f.ID = workflowFieldRawString(raw, "fieldId", "field_id", "key")
|
||
}
|
||
if f.FieldType == "" {
|
||
f.FieldType = workflowFieldRawString(raw, "type")
|
||
}
|
||
if len(f.Options) == 0 {
|
||
if options, ok := workflowFieldRawSlice(raw, "options", "values", "choices", "enum", "fieldOptions", "field_options"); ok {
|
||
f.Options = options
|
||
}
|
||
}
|
||
if f.Min == nil {
|
||
f.Min, _ = workflowFieldRawAny(raw, "min", "minValue", "min_value")
|
||
if f.Min == nil {
|
||
f.Min, _ = workflowFieldRawNestedAny(raw, "min", "minValue", "min_value")
|
||
}
|
||
}
|
||
if f.Max == nil {
|
||
f.Max, _ = workflowFieldRawAny(raw, "max", "maxValue", "max_value")
|
||
if f.Max == nil {
|
||
f.Max, _ = workflowFieldRawNestedAny(raw, "max", "maxValue", "max_value")
|
||
}
|
||
}
|
||
if f.Step == nil {
|
||
f.Step, _ = workflowFieldRawAny(raw, "step", "stepValue", "step_value")
|
||
if f.Step == nil {
|
||
f.Step, _ = workflowFieldRawNestedAny(raw, "step", "stepValue", "step_value")
|
||
}
|
||
}
|
||
if f.Label == "" {
|
||
f.Label = workflowFieldRawString(raw, "title")
|
||
}
|
||
sourceConfigured := workflowFieldRawHas(raw, "source") || workflowFieldRawHas(raw, "bind") || workflowFieldRawHas(raw, "from")
|
||
if f.Source == "" {
|
||
f.Source = workflowFieldRawString(raw, "bind", "from")
|
||
}
|
||
if !workflowFieldRawHas(raw, "fieldValue") {
|
||
if value, ok := workflowFieldRawAny(raw, "defaultValue", "default_value", "default"); ok {
|
||
f.FieldValue = value
|
||
}
|
||
}
|
||
if !workflowFieldRawHas(raw, "sourceIndex") {
|
||
f.SourceIndex = workflowFieldRawInt(raw, f.SourceIndex, "source_index", "index")
|
||
}
|
||
if !workflowFieldRawHas(raw, "imageOrder") {
|
||
f.ImageOrder = workflowFieldRawInt(raw, f.ImageOrder, "image_order")
|
||
}
|
||
if !workflowFieldRawHas(raw, "required") {
|
||
f.Required = workflowFieldRawBool(raw, f.Required, "isRequired", "is_required")
|
||
}
|
||
if !workflowFieldRawHas(raw, "randomEnabled") {
|
||
f.RandomEnabled = workflowFieldRawBool(raw, f.RandomEnabled, "random_enabled")
|
||
}
|
||
if !workflowFieldRawHas(raw, "bindPrompt") {
|
||
f.BindPrompt = workflowFieldRawBool(raw, f.BindPrompt, "bind_prompt")
|
||
}
|
||
if f.BindPrompt && strings.TrimSpace(f.Source) == "" && !sourceConfigured {
|
||
f.Source = "prompt"
|
||
}
|
||
sourceFromUpstreamConfigured := workflowFieldRawHas(raw, "sourceFromUpstream") || workflowFieldRawHas(raw, "source_from_upstream")
|
||
f.sourceConfigured = sourceConfigured || sourceFromUpstreamConfigured
|
||
if !workflowFieldRawHas(raw, "sourceFromUpstream") && workflowFieldRawHas(raw, "source_from_upstream") {
|
||
f.SourceFromUpstream = workflowFieldRawBool(raw, f.SourceFromUpstream, "source_from_upstream")
|
||
}
|
||
if !sourceConfigured && !sourceFromUpstreamConfigured && strings.TrimSpace(f.Source) == "" {
|
||
// 旧工作流没有保存动态来源时,按字段语义恢复宽高、数量、媒体等画布输入绑定。
|
||
f.Source = workflowDynamicSource(f.FieldName, f.FieldType)
|
||
}
|
||
if !sourceConfigured && !sourceFromUpstreamConfigured && !f.SourceFromUpstream {
|
||
switch strings.ToLower(strings.TrimSpace(f.FieldType)) {
|
||
case "image", "video", "audio":
|
||
// 来源配置默认把媒体字段绑定到上游参考素材。
|
||
f.SourceFromUpstream = true
|
||
}
|
||
}
|
||
if f.ID == "" && f.NodeID != "" && f.FieldName != "" {
|
||
f.ID = f.NodeID + "::" + f.FieldName
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func workflowFieldRawHas(raw map[string]json.RawMessage, key string) bool {
|
||
_, ok := raw[key]
|
||
return ok
|
||
}
|
||
|
||
func workflowFieldRawString(raw map[string]json.RawMessage, keys ...string) string {
|
||
for _, key := range keys {
|
||
value, ok := raw[key]
|
||
if !ok {
|
||
continue
|
||
}
|
||
var text string
|
||
if json.Unmarshal(value, &text) == nil && strings.TrimSpace(text) != "" {
|
||
return strings.TrimSpace(text)
|
||
}
|
||
var generic interface{}
|
||
if json.Unmarshal(value, &generic) == nil && generic != nil {
|
||
text = strings.TrimSpace(fmt.Sprint(generic))
|
||
if text != "" && text != "<nil>" {
|
||
return text
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func workflowFieldRawAny(raw map[string]json.RawMessage, keys ...string) (interface{}, bool) {
|
||
for _, key := range keys {
|
||
value, ok := raw[key]
|
||
if !ok || string(value) == "null" {
|
||
continue
|
||
}
|
||
var decoded interface{}
|
||
if json.Unmarshal(value, &decoded) == nil {
|
||
return decoded, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
func workflowFieldRawSlice(raw map[string]json.RawMessage, keys ...string) ([]interface{}, bool) {
|
||
value, ok := workflowFieldRawAny(raw, keys...)
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
if items, ok := value.([]interface{}); ok {
|
||
return items, true
|
||
}
|
||
if object, ok := value.(map[string]interface{}); ok {
|
||
for _, key := range []string{"choices", "options", "values", "enum"} {
|
||
if items, ok := object[key].([]interface{}); ok {
|
||
return items, true
|
||
}
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
func workflowFieldRawNestedAny(raw map[string]json.RawMessage, keys ...string) (interface{}, bool) {
|
||
for _, containerKey := range []string{"options", "values", "choices", "range", "fieldOptions", "field_options"} {
|
||
value, ok := workflowFieldRawAny(raw, containerKey)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if found, ok := workflowNestedFieldValue(value, keys...); ok {
|
||
return found, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
func workflowNestedFieldValue(value interface{}, keys ...string) (interface{}, bool) {
|
||
if items, ok := value.([]interface{}); ok {
|
||
for _, item := range items {
|
||
if found, ok := workflowNestedFieldValue(item, keys...); ok {
|
||
return found, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
object, ok := value.(map[string]interface{})
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
for _, key := range keys {
|
||
if found, exists := object[key]; exists && found != nil {
|
||
return found, true
|
||
}
|
||
}
|
||
if nested, exists := object["range"]; exists {
|
||
return workflowNestedFieldValue(nested, keys...)
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
func workflowFieldRawInt(raw map[string]json.RawMessage, fallback int, keys ...string) int {
|
||
value, ok := workflowFieldRawAny(raw, keys...)
|
||
if !ok {
|
||
return fallback
|
||
}
|
||
switch item := value.(type) {
|
||
case float64:
|
||
return int(item)
|
||
case json.Number:
|
||
parsed, err := strconv.Atoi(string(item))
|
||
if err == nil {
|
||
return parsed
|
||
}
|
||
case string:
|
||
parsed, err := strconv.Atoi(strings.TrimSpace(item))
|
||
if err == nil {
|
||
return parsed
|
||
}
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
func workflowFieldRawBool(raw map[string]json.RawMessage, fallback bool, keys ...string) bool {
|
||
value, ok := workflowFieldRawAny(raw, keys...)
|
||
if !ok {
|
||
return fallback
|
||
}
|
||
switch item := value.(type) {
|
||
case bool:
|
||
return item
|
||
case string:
|
||
parsed, err := strconv.ParseBool(strings.TrimSpace(item))
|
||
if err == nil {
|
||
return parsed
|
||
}
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
func isRunningHubInterface(value string) bool {
|
||
pluginID, ok := workflowPluginIDForInterface(strings.ToLower(strings.TrimSpace(value)))
|
||
return ok && pluginID == WorkflowPluginRunningHub
|
||
}
|
||
|
||
func isWorkflowProviderInterface(value string) bool {
|
||
return isRunningHubInterface(value)
|
||
}
|
||
|
||
func validateWorkflowProviderConfig(mode string, config providerConfig) error {
|
||
if mode != "image" && mode != "video" && mode != "audio" {
|
||
return fmt.Errorf("工作流协议暂不支持%s生成", mode)
|
||
}
|
||
interfaceType := strings.ToLower(strings.TrimSpace(config.InterfaceType))
|
||
if !workflowInterfaceSupportsMode(interfaceType, mode) {
|
||
return fmt.Errorf("接口类型 %s 不支持%s生成", config.InterfaceType, mode)
|
||
}
|
||
if isRunningHubInterface(config.InterfaceType) {
|
||
if runningHubAPIKey(config) == "" {
|
||
return errors.New("RunningHub 工作流缺少积分 API Key")
|
||
}
|
||
if _, err := ValidateOutboundURL(runningHubRootURL(config.BaseURL)); err != nil {
|
||
return err
|
||
}
|
||
if strings.TrimSpace(config.WorkflowID) == "" && strings.TrimSpace(config.WebappID) == "" && strings.TrimSpace(config.Model) == "" {
|
||
return errors.New("RunningHub 缺少 workflowId 或 webappId")
|
||
}
|
||
return nil
|
||
}
|
||
return errors.New("未知工作流协议")
|
||
}
|
||
|
||
func workflowInterfaceSupportsMode(interfaceType string, mode string) bool {
|
||
switch mode {
|
||
case "image":
|
||
return interfaceType == string(model.ChannelInterfaceRunningHubImage)
|
||
case "video":
|
||
return interfaceType == string(model.ChannelInterfaceRunningHubVideo)
|
||
case "audio":
|
||
return interfaceType == string(model.ChannelInterfaceRunningHubAudio)
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (s *Service) runWorkflowProviderTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
|
||
if isRunningHubInterface(input.Config.InterfaceType) {
|
||
return s.runRunningHubWorkflow(ctx, input)
|
||
}
|
||
return nil, errors.New("未知工作流协议")
|
||
}
|
||
|
||
func (s *Service) updateWorkflowProviderState(ctx context.Context, requestID string, stage string, nextPollAt *time.Time) error {
|
||
metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
|
||
if !ok || metadata.TaskID == "" {
|
||
return nil
|
||
}
|
||
return s.repo.UpdateTaskProviderState(metadata.TaskID, requestID, stage, nextPollAt)
|
||
}
|
||
|
||
// recordWorkflowProviderRequest 只在供应商生成请求首次建立时调用,同时保存任务和账单关联。
|
||
// 轮询状态仍走 updateWorkflowProviderState,避免每次轮询都刷新账单 updated_at,掩盖长期未结算订单。
|
||
func (s *Service) recordWorkflowProviderRequest(ctx context.Context, requestID string, stage string, nextPollAt *time.Time) error {
|
||
metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
|
||
if !ok || metadata.TaskID == "" {
|
||
return nil
|
||
}
|
||
if err := s.repo.UpdateTaskProviderState(metadata.TaskID, requestID, stage, nextPollAt); err != nil {
|
||
return err
|
||
}
|
||
if metadata.BillingOrderID == "" || strings.TrimSpace(requestID) == "" {
|
||
return nil
|
||
}
|
||
return s.repo.UpdateBillingProviderRequestID(metadata.BillingOrderID, strings.TrimSpace(requestID))
|
||
}
|
||
|
||
func (s *Service) runRunningHubWorkflow(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
|
||
root := runningHubRootURL(input.Config.BaseURL)
|
||
apiKey := runningHubAPIKey(input.Config)
|
||
if resumed := resumedProviderRequestID(ctx); resumed != "" {
|
||
return s.pollRunningHubWorkflow(ctx, input.Config, root, resumed, input.Mode)
|
||
}
|
||
workflowID := strings.TrimSpace(input.Config.WorkflowID)
|
||
webappID := strings.TrimSpace(input.Config.WebappID)
|
||
if workflowID == "" {
|
||
workflowID = strings.TrimSpace(input.Config.Model)
|
||
}
|
||
mappingWorkflow := input.Config.WorkflowJSON
|
||
if webappID == "" && len(mappingWorkflow) == 0 && workflowID != "" {
|
||
// 获取当前 API 工作流既用于旧配置的字段推断,也用于删除缺失的可选媒体默认值。
|
||
// 某些兼容网关没有该接口,失败时仍允许仅依赖用户已经保存的字段映射提交。
|
||
if fetched, fetchErr := s.fetchRunningHubWorkflowJSON(ctx, root, input.Config, workflowID); fetchErr == nil {
|
||
mappingWorkflow = fetched
|
||
}
|
||
}
|
||
workflowFields := input.Config.WorkflowFields
|
||
if len(workflowFields) == 0 && len(mappingWorkflow) > 0 {
|
||
var inferErr error
|
||
workflowFields, inferErr = workflowFieldsFromManagement(mappingWorkflow, input.Mode)
|
||
if inferErr != nil {
|
||
return nil, inferErr
|
||
}
|
||
}
|
||
workflowFields = workflowFieldsForMode(workflowFields, input.Mode)
|
||
if err := validateWorkflowMediaInputs(workflowFields, input); err != nil {
|
||
return nil, err
|
||
}
|
||
files := map[string]string{}
|
||
for _, media := range append(append(append([]providerMedia{}, input.ReferenceImages...), input.ReferenceVideos...), input.ReferenceAudios...) {
|
||
name, err := s.uploadRunningHubMedia(ctx, root, input.Config, media)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
files[media.ID] = name
|
||
}
|
||
if input.Mask != nil {
|
||
name, err := s.uploadRunningHubMedia(ctx, root, input.Config, *input.Mask)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
files[input.Mask.ID] = name
|
||
}
|
||
nodeInfo, err := runningHubNodeInfoWithWorkflow(workflowFields, files, input, mappingWorkflow)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 旧条目可能保存了全部默认字段,却没有把文本节点绑定到任务 Prompt。只在没有任何显式
|
||
// Prompt 映射时回退,并替换同节点同字段的默认值,避免 nodeInfoList 出现互相冲突的重复项。
|
||
if !workflowFieldsBindPrompt(workflowFields) {
|
||
nodeInfo = upsertRunningHubNodeInfo(nodeInfo, runningHubPromptFallback(mappingWorkflow, input.Prompt))
|
||
}
|
||
body := map[string]any{"apiKey": apiKey}
|
||
endpoint := root + "/task/openapi/create"
|
||
if webappID != "" {
|
||
body["webappId"] = webappID
|
||
endpoint = root + "/task/openapi/ai-app/run"
|
||
} else {
|
||
body["workflowId"] = workflowID
|
||
}
|
||
if len(nodeInfo) > 0 {
|
||
body["nodeInfoList"] = nodeInfo
|
||
}
|
||
var submitted map[string]any
|
||
if err := s.runningHubJSON(ctx, input.Config, endpoint, body, &submitted); err != nil {
|
||
return nil, fmt.Errorf("RunningHub 工作流提交失败:%w", err)
|
||
}
|
||
code, validCode := runningHubPayloadCode(submitted)
|
||
if !validCode {
|
||
// 部分 RunningHub 兼容网关省略 code,但已经返回 taskId,按成功提交处理。
|
||
validCode = runningHubTaskID(submitted) != ""
|
||
code = 0
|
||
}
|
||
if !validCode || code != 0 {
|
||
return nil, fmt.Errorf("RunningHub 工作流提交失败:%s", runningHubWorkflowFailureMessage(submitted))
|
||
}
|
||
taskID := runningHubTaskID(submitted)
|
||
if taskID == "" {
|
||
return nil, errors.New("RunningHub 未返回 taskId")
|
||
}
|
||
if err := s.recordWorkflowProviderRequest(ctx, taskID, "submitted", nil); err != nil {
|
||
// 上游已经接受请求;继续轮询可在后续状态写入恢复后保住结果和 taskId。
|
||
metadata, _ := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
|
||
_ = s.log(metadata.UserID, metadata.TaskID, "error", "RunningHub 请求状态保存失败", taskID+":"+err.Error())
|
||
}
|
||
return s.pollRunningHubWorkflow(ctx, input.Config, root, taskID, input.Mode)
|
||
}
|
||
|
||
func runningHubWorkflowFailureMessage(response map[string]any) string {
|
||
message := runningHubFailureMessage(response)
|
||
normalized := strings.ToLower(message)
|
||
if strings.Contains(message, "企业版余额不足") || (strings.Contains(normalized, "enterprise") && strings.Contains(normalized, "balance")) {
|
||
return message + ";工作流提交固定使用积分 API Key,请确认提交 Key 不是企业级素材上传 Key"
|
||
}
|
||
return message
|
||
}
|
||
|
||
func (s *Service) fetchRunningHubWorkflowJSON(ctx context.Context, root string, config providerConfig, workflowID string) (map[string]interface{}, error) {
|
||
var response map[string]any
|
||
if err := s.runningHubJSON(withProviderRequestKind(ctx, "workflow-schema"), config, root+"/api/openapi/getJsonApiFormat", map[string]any{
|
||
"apiKey": runningHubAPIKey(config),
|
||
"workflowId": workflowID,
|
||
}, &response); err != nil {
|
||
return nil, err
|
||
}
|
||
code, valid := runningHubPayloadCode(response)
|
||
if valid && code != 0 {
|
||
return nil, errors.New(runningHubFailureMessage(response))
|
||
}
|
||
data, _ := response["data"].(map[string]interface{})
|
||
if data == nil {
|
||
return nil, errors.New("RunningHub 工作流参数响应缺少 data")
|
||
}
|
||
raw := data["prompt"]
|
||
if raw == nil {
|
||
return nil, errors.New("RunningHub 工作流参数响应缺少 prompt")
|
||
}
|
||
if text, ok := raw.(string); ok {
|
||
var parsed map[string]interface{}
|
||
if err := json.Unmarshal([]byte(text), &parsed); err != nil {
|
||
return nil, err
|
||
}
|
||
return parsed, nil
|
||
}
|
||
parsed, ok := raw.(map[string]interface{})
|
||
if !ok {
|
||
return nil, errors.New("RunningHub 工作流参数格式无效")
|
||
}
|
||
return parsed, nil
|
||
}
|
||
|
||
func (s *Service) uploadRunningHubMedia(ctx context.Context, root string, config providerConfig, media providerMedia) (string, error) {
|
||
raw, mimeType, err := mediaBytes(media)
|
||
if err != nil && isPublicMediaURL(strings.TrimSpace(media.URL)) {
|
||
// 任务里可能只携带了已公开的参考图/视频 URL;RunningHub 上传接口需要字节,
|
||
// 这里在 SSRF 白名单校验后下载一次,不把外部 URL 直接交给供应商。
|
||
raw, mimeType, err = getExternalBinary(withProviderRequestKind(ctx, "upload"), strings.TrimSpace(media.URL))
|
||
}
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if len(raw) == 0 {
|
||
return "", errors.New("RunningHub 参考素材为空")
|
||
}
|
||
body := &bytes.Buffer{}
|
||
writer := multipart.NewWriter(body)
|
||
apiKey := strings.TrimSpace(config.RunningHubUploadKey)
|
||
if apiKey == "" {
|
||
return "", errors.New("RunningHub 参考素材上传需要企业级 API Key,请在 RunningHub 设置中填写“素材上传 API Key(企业级)”")
|
||
}
|
||
_ = writer.WriteField("apiKey", apiKey)
|
||
_ = writer.WriteField("fileType", "input")
|
||
filename := providerMediaFilename(media, mimeType)
|
||
header := make(textproto.MIMEHeader)
|
||
header.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{"name": "file", "filename": filename}))
|
||
header.Set("Content-Type", mimeType)
|
||
part, err := writer.CreatePart(header)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if _, err := part.Write(raw); err != nil {
|
||
return "", err
|
||
}
|
||
if err := writer.Close(); err != nil {
|
||
return "", err
|
||
}
|
||
req, err := http.NewRequestWithContext(withProviderRequestKind(ctx, "upload"), http.MethodPost, root+"/task/openapi/upload", body)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||
ApplyOutboundHeaders(req, config.Headers)
|
||
data, _, err := doBinary(req)
|
||
if err != nil {
|
||
if message := runningHubUploadAuthFailure(err); message != "" {
|
||
return "", errors.New(message)
|
||
}
|
||
return "", fmt.Errorf("RunningHub 参考素材上传失败:%w", err)
|
||
}
|
||
var rawResponse map[string]any
|
||
if err := json.Unmarshal(data, &rawResponse); err != nil {
|
||
return "", errors.New("RunningHub 上传响应不是有效 JSON")
|
||
}
|
||
code, validCode := runningHubPayloadCode(rawResponse)
|
||
if !validCode {
|
||
// 上传接口有少量代理只返回 data.fileName;文件名存在即可视为成功。
|
||
validCode = runningHubFileName(rawResponse) != ""
|
||
code = 0
|
||
}
|
||
if !validCode || code != 0 {
|
||
return "", fmt.Errorf("RunningHub 上传素材失败:%s", runningHubFailureMessage(rawResponse))
|
||
}
|
||
fileName := runningHubFileName(rawResponse)
|
||
if fileName == "" {
|
||
return "", fmt.Errorf("RunningHub 上传素材失败:%s", runningHubFailureMessage(rawResponse))
|
||
}
|
||
return fileName, nil
|
||
}
|
||
|
||
func runningHubUploadAuthFailure(err error) string {
|
||
var httpErr providerHTTPError
|
||
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusUnauthorized {
|
||
return ""
|
||
}
|
||
if !strings.Contains(strings.ToLower(httpErr.Body), "apikey verification failed") {
|
||
return ""
|
||
}
|
||
return "RunningHub 参考素材上传接口认证失败(HTTP 401):ApiKey verification failed。请确认“素材上传 API Key(企业级)”有效,并且它与 Base URL 属于同一个 RunningHub 站点"
|
||
}
|
||
|
||
func workflowFieldsFromManagement(workflow map[string]interface{}, mode string) ([]WorkflowField, error) {
|
||
rawFields := collectManagementWorkflowFields(workflow, mode)
|
||
encoded, err := json.Marshal(rawFields)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("工作流字段映射编码失败:%w", err)
|
||
}
|
||
var fields []WorkflowField
|
||
if err := json.Unmarshal(encoded, &fields); err != nil {
|
||
return nil, fmt.Errorf("工作流字段映射解析失败:%w", err)
|
||
}
|
||
return fields, nil
|
||
}
|
||
|
||
func workflowFieldsForMode(fields []WorkflowField, mode string) []WorkflowField {
|
||
if len(fields) == 0 {
|
||
return fields
|
||
}
|
||
normalized := make([]WorkflowField, len(fields))
|
||
copy(normalized, fields)
|
||
for index := range normalized {
|
||
field := &normalized[index]
|
||
if canonical := workflowNamedDynamicSource(field.Source, mode); canonical != "" {
|
||
field.Source = canonical
|
||
}
|
||
inferred := workflowDynamicSourceForMode(field.FieldName, field.FieldType, mode)
|
||
if strings.TrimSpace(field.Source) == "" && !field.sourceConfigured && !field.BindPrompt {
|
||
field.Source = inferred
|
||
if inferred != "" {
|
||
field.SourceAutomatic = ptr(true)
|
||
}
|
||
continue
|
||
}
|
||
if shouldRepairLegacyWorkflowSource(field.FieldName, field.Source, inferred, mode) {
|
||
field.Source = inferred
|
||
}
|
||
}
|
||
return normalized
|
||
}
|
||
|
||
func shouldRepairLegacyWorkflowSource(fieldName string, source string, inferred string, mode string) bool {
|
||
sourceKey := normalizeManagementFieldName(source)
|
||
inferredKey := normalizeManagementFieldName(inferred)
|
||
if sourceKey == "" || inferredKey == "" || sourceKey == inferredKey {
|
||
return false
|
||
}
|
||
fieldKey := normalizeManagementFieldName(fieldName)
|
||
wasMistakenForMedia := sourceKey == "referenceimage" || sourceKey == "referencevideo" || sourceKey == "referenceaudio"
|
||
if wasMistakenForMedia && (workflowDimensionNameSource(fieldKey) != "" || inferredKey == "aspectratio" || inferredKey == "vquality") {
|
||
return true
|
||
}
|
||
mode = strings.ToLower(strings.TrimSpace(mode))
|
||
return (fieldKey == "resolution" && (mode == "video" && sourceKey == "size" || mode == "image" && sourceKey == "vquality")) || (fieldKey == "quality" && sourceKey == "vquality")
|
||
}
|
||
|
||
func validateWorkflowMediaInputs(fields []WorkflowField, input canvasGenerationInput) error {
|
||
capacities := map[string]int{"referenceimage": 0, "referencevideo": 0, "referenceaudio": 0}
|
||
hasMaskMapping := false
|
||
for _, field := range fields {
|
||
if field.Enabled != nil && !*field.Enabled {
|
||
continue
|
||
}
|
||
source := strings.ReplaceAll(strings.ReplaceAll(normalizeWorkflowFieldSource(field), "_", ""), "-", "")
|
||
if source == "mask" {
|
||
hasMaskMapping = true
|
||
continue
|
||
}
|
||
key := ""
|
||
switch source {
|
||
case "referenceimage", "image", "referenceimages":
|
||
key = "referenceimage"
|
||
case "referencevideo", "video", "referencevideos":
|
||
key = "referencevideo"
|
||
case "referenceaudio", "audio", "referenceaudios":
|
||
key = "referenceaudio"
|
||
}
|
||
if key == "" {
|
||
continue
|
||
}
|
||
index := field.SourceIndex
|
||
if field.ImageOrder > 0 {
|
||
index = field.ImageOrder - 1
|
||
}
|
||
if index < 0 {
|
||
index = 0
|
||
}
|
||
if index+1 > capacities[key] {
|
||
capacities[key] = index + 1
|
||
}
|
||
}
|
||
checks := []struct {
|
||
label string
|
||
count int
|
||
capacity int
|
||
}{
|
||
{label: "参考图片", count: len(input.ReferenceImages), capacity: capacities["referenceimage"]},
|
||
{label: "参考视频", count: len(input.ReferenceVideos), capacity: capacities["referencevideo"]},
|
||
{label: "参考音频", count: len(input.ReferenceAudios), capacity: capacities["referenceaudio"]},
|
||
}
|
||
for _, check := range checks {
|
||
if check.count > check.capacity {
|
||
return fmt.Errorf("工作流只配置了 %d 个%s槽位,但画布传入了 %d 个;请在工作流字段映射中补齐槽位", check.capacity, check.label, check.count)
|
||
}
|
||
}
|
||
if input.Mask != nil && !hasMaskMapping {
|
||
return errors.New("画布传入了蒙版,但工作流没有配置蒙版字段映射")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func runningHubNodeInfo(fields []WorkflowField, files map[string]string, input canvasGenerationInput) ([]map[string]any, error) {
|
||
return runningHubNodeInfoWithWorkflow(fields, files, input, nil)
|
||
}
|
||
|
||
func runningHubNodeInfoWithWorkflow(fields []WorkflowField, files map[string]string, input canvasGenerationInput, workflow map[string]interface{}) ([]map[string]any, error) {
|
||
items := make([]map[string]any, 0, len(fields)+3)
|
||
for _, field := range fields {
|
||
if field.Enabled != nil && !*field.Enabled || strings.TrimSpace(field.NodeID) == "" || strings.TrimSpace(field.FieldName) == "" {
|
||
continue
|
||
}
|
||
// RunningHub 的 nodeInfoList 只接受字符串 fieldValue。部分内部节点不会把
|
||
// 字符串恢复为数值,因此安全边界必须由服务端强制执行,不能依赖前端隐藏。
|
||
if isRunningHubUnsafeInternalField(workflow, field) {
|
||
continue
|
||
}
|
||
value, present, err := resolveWorkflowFieldValue(field, files, input)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !present {
|
||
if field.Required {
|
||
return nil, fmt.Errorf("工作流字段 %s 缺少值", firstNonEmptyString(field.ID, field.FieldName))
|
||
}
|
||
continue
|
||
}
|
||
if err := validateRunningHubWorkflowFieldValue(field, value); err != nil {
|
||
return nil, err
|
||
}
|
||
encoded := workflowScalarString(value)
|
||
if !shouldSendRunningHubWorkflowField(workflow, field, encoded) {
|
||
continue
|
||
}
|
||
items = append(items, map[string]any{"nodeId": field.NodeID, "fieldName": field.FieldName, "fieldValue": encoded})
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func shouldSendRunningHubWorkflowField(workflow map[string]interface{}, field WorkflowField, encoded string) bool {
|
||
if len(workflow) == 0 {
|
||
// AI App 只有公开参数列表,没有可用于比较的工作流 JSON。
|
||
return true
|
||
}
|
||
node, ok := workflow[strings.TrimSpace(field.NodeID)].(map[string]interface{})
|
||
if !ok {
|
||
return true
|
||
}
|
||
inputs, ok := node["inputs"].(map[string]interface{})
|
||
if !ok {
|
||
return true
|
||
}
|
||
original, exists := inputs[strings.TrimSpace(field.FieldName)]
|
||
if exists && isWorkflowLinkValue(original) {
|
||
// 已连接输入属于工作流拓扑,不能被旧字段映射拆成字符串覆盖。
|
||
return false
|
||
}
|
||
if normalizeWorkflowFieldSource(field) != "" || field.RandomEnabled {
|
||
return !isRunningHubAutomaticInternalBinding(node, field)
|
||
}
|
||
if !exists {
|
||
return true
|
||
}
|
||
// 拉取参数时会保存所有 widget 的默认值;未修改项无需重复覆盖工作流。
|
||
return encoded != workflowScalarString(original)
|
||
}
|
||
|
||
func isRunningHubAutomaticInternalBinding(node map[string]interface{}, field WorkflowField) bool {
|
||
if field.SourceAutomatic != nil && !*field.SourceAutomatic {
|
||
return false
|
||
}
|
||
classType := strings.ToLower(strings.TrimSpace(stringValue(node["class_type"])))
|
||
if classType != "imageresize+" {
|
||
return false
|
||
}
|
||
// 旧配置没有 sourceAutomatic,但 ImageResize+ 的宽高来源是按同名字段自动误判的。
|
||
// 保留节点自身尺寸链;用户仍可将来源设为默认并明确修改静态值。
|
||
source := strings.ReplaceAll(strings.ReplaceAll(normalizeWorkflowFieldSource(field), "_", ""), "-", "")
|
||
return source == "size" || source == "imagesize" || source == "sizewidth" || source == "width" || source == "imagewidth" || source == "videowidth" || source == "sizeheight" || source == "height" || source == "imageheight" || source == "videoheight"
|
||
}
|
||
|
||
func isRunningHubUnsafeInternalField(workflow map[string]interface{}, field WorkflowField) bool {
|
||
if field.SafeToOverride != nil && !*field.SafeToOverride {
|
||
return true
|
||
}
|
||
classType := strings.ToLower(strings.TrimSpace(field.ClassType))
|
||
if node, ok := workflow[strings.TrimSpace(field.NodeID)].(map[string]interface{}); ok {
|
||
classType = strings.ToLower(strings.TrimSpace(stringValue(node["class_type"])))
|
||
}
|
||
fieldName := strings.ToLower(strings.TrimSpace(field.FieldName))
|
||
if classType == "int" && fieldName == "value" {
|
||
return true
|
||
}
|
||
return classType == "imageresize+" && (fieldName == "width" || fieldName == "height" || fieldName == "multiple_of")
|
||
}
|
||
|
||
func validateRunningHubWorkflowFieldValue(field WorkflowField, value interface{}) error {
|
||
fieldID := firstNonEmptyString(strings.TrimSpace(field.ID), strings.TrimSpace(field.NodeID)+"."+strings.TrimSpace(field.FieldName))
|
||
fieldType := strings.ToUpper(strings.TrimSpace(field.FieldType))
|
||
if fieldType == "NUMBER" || fieldType == "FLOAT" || fieldType == "INTEGER" || fieldType == "INT" || fieldType == "SLIDER" {
|
||
numeric, ok := workflowNumericBound(value)
|
||
if !ok {
|
||
return fmt.Errorf("工作流字段 %s 不是有效数字", fieldID)
|
||
}
|
||
minValue, hasMin := workflowNumericBound(field.Min)
|
||
maxValue, hasMax := workflowNumericBound(field.Max)
|
||
stepValue, hasStep := workflowNumericBound(field.Step)
|
||
if hasMin && hasMax && minValue > maxValue {
|
||
return fmt.Errorf("工作流字段 %s 的最小值不能大于最大值", fieldID)
|
||
}
|
||
if hasStep && stepValue <= 0 {
|
||
return fmt.Errorf("工作流字段 %s 的步长必须大于 0", fieldID)
|
||
}
|
||
if (hasMin && numeric < minValue) || (hasMax && numeric > maxValue) {
|
||
return fmt.Errorf("工作流字段 %s 的值超出允许范围", fieldID)
|
||
}
|
||
if hasStep {
|
||
start := float64(0)
|
||
if hasMin {
|
||
start = minValue
|
||
}
|
||
steps := (numeric - start) / stepValue
|
||
if math.Abs(steps-math.Round(steps)) > 1e-7 {
|
||
return fmt.Errorf("工作流字段 %s 的值不符合步长", fieldID)
|
||
}
|
||
}
|
||
}
|
||
if fieldType == "BOOLEAN" || fieldType == "BOOL" {
|
||
switch item := value.(type) {
|
||
case bool:
|
||
case string:
|
||
normalized := strings.ToLower(strings.TrimSpace(item))
|
||
if normalized != "true" && normalized != "false" {
|
||
return fmt.Errorf("工作流字段 %s 不是有效开关值", fieldID)
|
||
}
|
||
default:
|
||
return fmt.Errorf("工作流字段 %s 不是有效开关值", fieldID)
|
||
}
|
||
}
|
||
if options := workflowFieldAllowedOptions(field); len(options) > 0 {
|
||
encoded := strings.TrimSpace(workflowScalarString(value))
|
||
matched := false
|
||
hasScalarOption := false
|
||
for _, option := range options {
|
||
if workflowOptionIsRange(option) {
|
||
continue
|
||
}
|
||
candidate := strings.TrimSpace(workflowOptionString(option))
|
||
if candidate == "" || candidate == "<nil>" {
|
||
continue
|
||
}
|
||
hasScalarOption = true
|
||
if candidate == encoded {
|
||
matched = true
|
||
break
|
||
}
|
||
}
|
||
if hasScalarOption && !matched {
|
||
return fmt.Errorf("工作流字段 %s 的值不在允许选项中", fieldID)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func workflowOptionIsRange(value interface{}) bool {
|
||
object, ok := value.(map[string]interface{})
|
||
if !ok {
|
||
return false
|
||
}
|
||
for _, key := range []string{"min", "max", "step", "minValue", "maxValue", "stepValue"} {
|
||
if _, exists := object[key]; exists {
|
||
return true
|
||
}
|
||
}
|
||
if nested, ok := object["range"].(map[string]interface{}); ok {
|
||
return workflowOptionIsRange(nested)
|
||
}
|
||
return false
|
||
}
|
||
|
||
func resolveWorkflowFieldValue(field WorkflowField, files map[string]string, input canvasGenerationInput) (interface{}, bool, error) {
|
||
source := normalizeWorkflowFieldSource(field)
|
||
// 画布参数按 nodeId + fieldName 独立保存时 source 可能为空;枚举字段仍必须
|
||
// 使用工作流原始 options 的完整值,不能把用户界面上的比例简称直接发给 ComfyUI。
|
||
if source == "" && strings.EqualFold(strings.TrimSpace(field.FieldName), "aspect_ratio") {
|
||
// 旧版前端只把比例写入 Config.Size,没有同步改写字段映射。此时不能
|
||
// 直接使用工作流保存的默认值,否则用户选择 9:16 会在提交前静默回到 1:1。
|
||
// 仅对非 1:1 的显式比例做回填,避免没有动态参数时覆盖工作流自己的 1:1 默认值。
|
||
requested := strings.TrimSpace(input.Config.Size)
|
||
if requested != "" && !strings.EqualFold(workflowAspectRatio(requested), "1:1") {
|
||
value := workflowAspectRatioValue(field, requested)
|
||
if strings.TrimSpace(fmt.Sprint(value)) != "" {
|
||
return value, true, nil
|
||
}
|
||
}
|
||
raw := strings.TrimSpace(workflowOptionString(firstNonNilWorkflowValue(field.FieldValue, field.Value)))
|
||
if raw != "" {
|
||
value := workflowAspectRatioValue(field, raw)
|
||
return value, true, nil
|
||
}
|
||
}
|
||
if source != "" {
|
||
switch strings.ReplaceAll(strings.ReplaceAll(source, "_", ""), "-", "") {
|
||
case "prompt", "text", "positiveprompt", "positive":
|
||
return input.Prompt, strings.TrimSpace(input.Prompt) != "", nil
|
||
case "size", "imagesize":
|
||
return input.Config.Size, strings.TrimSpace(input.Config.Size) != "", nil
|
||
case "resolution":
|
||
if strings.EqualFold(strings.TrimSpace(input.Mode), "video") {
|
||
value := workflowVideoResolutionValue(field, input.Config.VQuality)
|
||
return value, strings.TrimSpace(fmt.Sprint(value)) != "", nil
|
||
}
|
||
return input.Config.Size, strings.TrimSpace(input.Config.Size) != "", nil
|
||
case "aspectratio", "ratio", "imageaspectratio", "imageratio", "videoaspectratio", "videoratio":
|
||
value := workflowAspectRatioValue(field, input.Config.Size)
|
||
return value, value != "", nil
|
||
case "sizewidth", "width", "imagewidth", "videowidth":
|
||
value := workflowDimensionPart(input.Mode, input.Config.Size, input.Config.VQuality, 0)
|
||
return value, value != "", nil
|
||
case "sizeheight", "height", "imageheight", "videoheight":
|
||
value := workflowDimensionPart(input.Mode, input.Config.Size, input.Config.VQuality, 1)
|
||
return value, value != "", nil
|
||
case "quality":
|
||
return input.Config.Quality, strings.TrimSpace(input.Config.Quality) != "", nil
|
||
case "count", "batch", "batchsize":
|
||
return input.Config.Count, strings.TrimSpace(input.Config.Count) != "", nil
|
||
case "videoseconds", "video_seconds", "duration":
|
||
value := workflowVideoDurationValue(field, input.Config.VideoSeconds)
|
||
return value, strings.TrimSpace(fmt.Sprint(value)) != "", nil
|
||
case "vquality", "videoquality", "video_quality":
|
||
value := workflowVideoResolutionValue(field, input.Config.VQuality)
|
||
return value, strings.TrimSpace(fmt.Sprint(value)) != "", nil
|
||
case "videogenerateaudio", "video_generate_audio", "generateaudio":
|
||
return input.Config.VideoGenerateAudio, strings.TrimSpace(input.Config.VideoGenerateAudio) != "", nil
|
||
case "videowatermark", "video_watermark", "watermark":
|
||
return input.Config.VideoWatermark, strings.TrimSpace(input.Config.VideoWatermark) != "", nil
|
||
case "audioformat", "audio_format":
|
||
return input.Config.AudioFormat, strings.TrimSpace(input.Config.AudioFormat) != "", nil
|
||
case "systemprompt", "system_prompt":
|
||
return input.Config.SystemPrompt, strings.TrimSpace(input.Config.SystemPrompt) != "", nil
|
||
case "transparentbackground", "transparent_background":
|
||
return input.Config.TransparentBackground, strings.TrimSpace(input.Config.TransparentBackground) != "", nil
|
||
case "audiovoice", "audio_voice", "voice":
|
||
return input.Config.AudioVoice, strings.TrimSpace(input.Config.AudioVoice) != "", nil
|
||
case "audiospeed", "audio_speed":
|
||
return input.Config.AudioSpeed, strings.TrimSpace(input.Config.AudioSpeed) != "", nil
|
||
case "audioinstructions", "audio_instructions":
|
||
return input.Config.AudioInstructions, strings.TrimSpace(input.Config.AudioInstructions) != "", nil
|
||
}
|
||
index := field.SourceIndex
|
||
if field.ImageOrder > 0 {
|
||
index = field.ImageOrder - 1
|
||
}
|
||
var media providerMedia
|
||
switch strings.ReplaceAll(strings.ReplaceAll(source, "_", ""), "-", "") {
|
||
case "referenceimage", "image", "referenceimages":
|
||
if index < 0 || index >= len(input.ReferenceImages) {
|
||
return nil, false, nil
|
||
}
|
||
media = input.ReferenceImages[index]
|
||
case "referencevideo", "video", "referencevideos":
|
||
if index < 0 || index >= len(input.ReferenceVideos) {
|
||
return nil, false, nil
|
||
}
|
||
media = input.ReferenceVideos[index]
|
||
case "referenceaudio", "audio", "referenceaudios":
|
||
if index < 0 || index >= len(input.ReferenceAudios) {
|
||
return nil, false, nil
|
||
}
|
||
media = input.ReferenceAudios[index]
|
||
case "mask":
|
||
if input.Mask == nil {
|
||
return nil, false, nil
|
||
}
|
||
media = *input.Mask
|
||
default:
|
||
return nil, false, fmt.Errorf("不支持的工作流字段来源:%s", field.Source)
|
||
}
|
||
name := files[media.ID]
|
||
if name == "" {
|
||
return nil, false, errors.New("工作流参考素材尚未上传")
|
||
}
|
||
return name, true, nil
|
||
}
|
||
if field.RandomEnabled {
|
||
randomMax := field.Max
|
||
if isRunningHubInterface(input.Config.InterfaceType) && isWorkflowSeedField(field.FieldName) {
|
||
// RunningHub 的随机 Seed 按 uint32 传输,不能沿用 JavaScript 安全整数上限。
|
||
const runningHubSeedMax int64 = 1<<32 - 1
|
||
maxValue, err := workflowIntegerBound(field.Max, runningHubSeedMax)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
if maxValue > runningHubSeedMax {
|
||
maxValue = runningHubSeedMax
|
||
}
|
||
randomMax = maxValue
|
||
}
|
||
value, err := randomWorkflowInteger(field.Min, randomMax)
|
||
return value, err == nil, err
|
||
}
|
||
if field.FieldValue != nil {
|
||
return field.FieldValue, true, nil
|
||
}
|
||
if field.Value != nil {
|
||
return field.Value, true, nil
|
||
}
|
||
return nil, false, nil
|
||
}
|
||
|
||
func firstNonNilWorkflowValue(values ...interface{}) interface{} {
|
||
for _, value := range values {
|
||
if value != nil {
|
||
return value
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeWorkflowFieldSource(field WorkflowField) string {
|
||
source := strings.ToLower(strings.TrimSpace(field.Source))
|
||
if source == "" && field.BindPrompt {
|
||
return "prompt"
|
||
}
|
||
if source != "" {
|
||
return source
|
||
}
|
||
if !field.SourceFromUpstream {
|
||
return ""
|
||
}
|
||
fieldName := strings.ToLower(strings.TrimSpace(field.FieldName))
|
||
if fieldName == "prompt" || fieldName == "text" || fieldName == "positive_prompt" || fieldName == "positiveprompt" {
|
||
return "prompt"
|
||
}
|
||
switch strings.ToLower(strings.TrimSpace(field.FieldType)) {
|
||
case "image", "img", "photo", "picture":
|
||
return "referenceimage"
|
||
case "video", "movie":
|
||
return "referencevideo"
|
||
case "audio", "sound", "music", "voice":
|
||
return "referenceaudio"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func workflowDimensionPart(mode string, size string, videoQuality string, index int) string {
|
||
if index < 0 || index > 1 {
|
||
return ""
|
||
}
|
||
if dimensions, ok := workflowPixelDimensions(size); ok {
|
||
return strconv.Itoa(dimensions[index])
|
||
}
|
||
if strings.EqualFold(strings.TrimSpace(mode), "video") {
|
||
dimensions, ok := workflowVideoDimensions(size, videoQuality)
|
||
if !ok {
|
||
return ""
|
||
}
|
||
return strconv.Itoa(dimensions[index])
|
||
}
|
||
dimensions, ok := workflowImageDimensions(size)
|
||
if !ok {
|
||
return ""
|
||
}
|
||
return strconv.Itoa(dimensions[index])
|
||
}
|
||
|
||
func workflowPixelDimensions(value string) ([2]int, bool) {
|
||
var result [2]int
|
||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||
if !strings.Contains(normalized, "x") {
|
||
return result, false
|
||
}
|
||
parts := strings.FieldsFunc(normalized, func(r rune) bool { return r == 'x' || r == ' ' || r == ',' })
|
||
if len(parts) != 2 {
|
||
return result, false
|
||
}
|
||
for index, part := range parts {
|
||
parsed, err := strconv.Atoi(strings.TrimSpace(part))
|
||
if err != nil || parsed <= 0 {
|
||
return result, false
|
||
}
|
||
result[index] = parsed
|
||
}
|
||
return result, true
|
||
}
|
||
|
||
func workflowAspectRatio(value string) string {
|
||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||
// RunningHub 的 ResolutionSelector 选项带有展示文案(例如
|
||
// "9:16 (Portrait Widescreen)"),协议比较只需要比例前缀。
|
||
if cut := strings.IndexAny(normalized, " ("); cut >= 0 {
|
||
normalized = strings.TrimSpace(normalized[:cut])
|
||
}
|
||
for _, suffix := range []string{"-1k", "-2k", "-4k"} {
|
||
if strings.HasSuffix(normalized, suffix) {
|
||
normalized = strings.TrimSuffix(normalized, suffix)
|
||
break
|
||
}
|
||
}
|
||
if width, height, ok := workflowRatioParts(normalized); ok {
|
||
divisor := workflowGreatestCommonDivisor(width, height)
|
||
return fmt.Sprintf("%d:%d", width/divisor, height/divisor)
|
||
}
|
||
dimensions, ok := workflowPixelDimensions(normalized)
|
||
if !ok {
|
||
return ""
|
||
}
|
||
ratio := float64(dimensions[0]) / float64(dimensions[1])
|
||
known := [][2]int{{1, 1}, {3, 2}, {2, 3}, {4, 3}, {3, 4}, {4, 5}, {5, 4}, {16, 9}, {9, 16}, {2, 1}, {1, 2}, {21, 9}}
|
||
bestDifference := math.MaxFloat64
|
||
best := [2]int{}
|
||
for _, candidate := range known {
|
||
expected := float64(candidate[0]) / float64(candidate[1])
|
||
difference := math.Abs(ratio-expected) / expected
|
||
if difference < bestDifference {
|
||
bestDifference = difference
|
||
best = candidate
|
||
}
|
||
}
|
||
if bestDifference <= 0.03 {
|
||
return fmt.Sprintf("%d:%d", best[0], best[1])
|
||
}
|
||
divisor := workflowGreatestCommonDivisor(dimensions[0], dimensions[1])
|
||
return fmt.Sprintf("%d:%d", dimensions[0]/divisor, dimensions[1]/divisor)
|
||
}
|
||
|
||
func workflowAspectRatioValue(field WorkflowField, value string) string {
|
||
raw := strings.TrimSpace(value)
|
||
if options := workflowFieldAllowedOptions(field); len(options) > 0 {
|
||
requested := workflowAspectRatio(raw)
|
||
for _, option := range options {
|
||
candidate := strings.TrimSpace(workflowOptionString(option))
|
||
if candidate != "" && strings.EqualFold(workflowAspectRatio(candidate), requested) {
|
||
return candidate
|
||
}
|
||
}
|
||
if fallback := workflowFieldConfiguredDefault(field); fallback != "" {
|
||
return fallback
|
||
}
|
||
return raw
|
||
}
|
||
// “auto/adaptive” 是工作流的真实模式,不应被画布当前像素尺寸反推成
|
||
// 一个并不存在的比例;宽高字段仍按工作流自身的默认值或显式尺寸处理。
|
||
if fallback := strings.TrimSpace(workflowFieldConfiguredDefault(field)); strings.EqualFold(fallback, "auto") || strings.EqualFold(fallback, "adaptive") {
|
||
return fallback
|
||
}
|
||
return workflowAspectRatio(raw)
|
||
}
|
||
|
||
func workflowFieldAllowedOptions(field WorkflowField) []interface{} {
|
||
classType := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(field.ClassType), "_", ""), "-", ""))
|
||
fieldName := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(field.FieldName), "_", ""), "-", ""))
|
||
if classType == "resolutionselector" && fieldName == "aspectratio" {
|
||
// RunningHub 的工作流 API 不返回 object_info;ResolutionSelector 的完整枚举
|
||
// 是节点协议的一部分,通用比例预设(9:16 等)不能直接提交给该节点。
|
||
return []interface{}{
|
||
"1:1 (Square)",
|
||
"2:3 (Portrait Photo)",
|
||
"3:2 (Photo)",
|
||
"3:4 (Portrait Standard)",
|
||
"4:3 (Standard)",
|
||
"9:16 (Portrait Widescreen)",
|
||
"16:9 (Widescreen)",
|
||
"21:9 (Ultrawide)",
|
||
}
|
||
}
|
||
return field.Options
|
||
}
|
||
|
||
func workflowRatioParts(value string) (int, int, bool) {
|
||
parts := strings.Split(strings.TrimSpace(value), ":")
|
||
if len(parts) != 2 {
|
||
return 0, 0, false
|
||
}
|
||
width, widthErr := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||
height, heightErr := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||
return width, height, widthErr == nil && heightErr == nil && width > 0 && height > 0
|
||
}
|
||
|
||
func workflowGreatestCommonDivisor(left int, right int) int {
|
||
for right != 0 {
|
||
left, right = right, left%right
|
||
}
|
||
if left <= 0 {
|
||
return 1
|
||
}
|
||
return left
|
||
}
|
||
|
||
func workflowImageDimensions(value string) ([2]int, bool) {
|
||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||
preset := map[string][2]int{
|
||
"1:1": {1024, 1024}, "3:2": {1536, 1024}, "2:3": {1024, 1536},
|
||
"4:3": {1360, 1024}, "3:4": {1024, 1360}, "4:5": {1024, 1280}, "5:4": {1280, 1024},
|
||
"16:9": {1824, 1024}, "9:16": {1024, 1824}, "2:1": {2048, 1024}, "1:2": {1024, 2048},
|
||
"21:9": {2352, 1008}, "1:1-2k": {2048, 2048}, "16:9-2k": {2048, 1152},
|
||
"9:16-2k": {1152, 2048}, "16:9-4k": {3840, 2160}, "9:16-4k": {2160, 3840},
|
||
}
|
||
if dimensions, ok := preset[normalized]; ok {
|
||
return dimensions, true
|
||
}
|
||
ratio := workflowAspectRatio(normalized)
|
||
widthRatio, heightRatio, ok := workflowRatioParts(ratio)
|
||
if !ok {
|
||
return [2]int{}, false
|
||
}
|
||
tier := "1k"
|
||
if strings.HasSuffix(normalized, "-2k") {
|
||
tier = "2k"
|
||
} else if strings.HasSuffix(normalized, "-4k") {
|
||
tier = "4k"
|
||
}
|
||
if tier == "1k" {
|
||
if widthRatio >= heightRatio {
|
||
return [2]int{workflowRoundToStep(1024*float64(widthRatio)/float64(heightRatio), 16), 1024}, true
|
||
}
|
||
return [2]int{1024, workflowRoundToStep(1024*float64(heightRatio)/float64(widthRatio), 16)}, true
|
||
}
|
||
longEdge := 2048
|
||
if tier == "4k" {
|
||
longEdge = 3840
|
||
}
|
||
if widthRatio >= heightRatio {
|
||
return [2]int{longEdge, workflowRoundToStep(float64(longEdge)*float64(heightRatio)/float64(widthRatio), 16)}, true
|
||
}
|
||
return [2]int{workflowRoundToStep(float64(longEdge)*float64(widthRatio)/float64(heightRatio), 16), longEdge}, true
|
||
}
|
||
|
||
func workflowVideoDimensions(size string, quality string) ([2]int, bool) {
|
||
ratio := workflowAspectRatio(size)
|
||
widthRatio, heightRatio, ok := workflowRatioParts(ratio)
|
||
shortEdge := workflowVideoResolutionPixels(quality)
|
||
if !ok || shortEdge <= 0 {
|
||
return [2]int{}, false
|
||
}
|
||
if widthRatio >= heightRatio {
|
||
return [2]int{workflowRoundToStep(float64(shortEdge)*float64(widthRatio)/float64(heightRatio), 2), shortEdge}, true
|
||
}
|
||
return [2]int{shortEdge, workflowRoundToStep(float64(shortEdge)*float64(heightRatio)/float64(widthRatio), 2)}, true
|
||
}
|
||
|
||
func workflowRoundToStep(value float64, step int) int {
|
||
if step <= 1 {
|
||
return int(math.Round(value))
|
||
}
|
||
return int(math.Round(value/float64(step))) * step
|
||
}
|
||
|
||
func workflowVideoResolutionPixels(value string) int {
|
||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||
switch normalized {
|
||
case "low":
|
||
return 480
|
||
case "auto", "default", "medium", "high":
|
||
return 720
|
||
case "2k":
|
||
return 1440
|
||
case "4k":
|
||
return 2160
|
||
}
|
||
parsed, err := strconv.Atoi(strings.TrimSuffix(normalized, "p"))
|
||
if err != nil || parsed <= 0 {
|
||
return 0
|
||
}
|
||
return parsed
|
||
}
|
||
|
||
func workflowVideoResolutionValue(field WorkflowField, value string) interface{} {
|
||
raw := strings.TrimSpace(value)
|
||
if raw == "" {
|
||
return ""
|
||
}
|
||
if len(field.Options) > 0 {
|
||
requested := normalizeWorkflowResolutionToken(raw)
|
||
for _, option := range field.Options {
|
||
candidate := strings.TrimSpace(workflowOptionString(option))
|
||
if candidate != "" && normalizeWorkflowResolutionToken(candidate) == requested {
|
||
// nodeInfoList 的 fieldValue 是字符串;对象选项只取其 value/id 等标量。
|
||
return candidate
|
||
}
|
||
}
|
||
// 工作流明确声明了可选项但画布值不在其中时,保留工作流默认值,
|
||
// 不再把 2K/4K/720 等普通模型别名硬塞给工作流。
|
||
if fallback := workflowFieldConfiguredDefault(field); fallback != "" {
|
||
for _, option := range field.Options {
|
||
candidate := strings.TrimSpace(workflowOptionString(option))
|
||
if candidate != "" && normalizeWorkflowResolutionToken(candidate) == normalizeWorkflowResolutionToken(fallback) {
|
||
return candidate
|
||
}
|
||
}
|
||
return fallback
|
||
}
|
||
return raw
|
||
}
|
||
if numeric := workflowNumericResolutionValue(field, raw); numeric != nil {
|
||
return numeric
|
||
}
|
||
if fallback := workflowFieldConfiguredDefault(field); fallback != "" && !workflowResolutionShapeCompatible(fallback, raw) {
|
||
return fallback
|
||
}
|
||
return raw
|
||
}
|
||
|
||
func workflowVideoDurationValue(field WorkflowField, value string) interface{} {
|
||
raw := strings.TrimSpace(value)
|
||
if raw == "" {
|
||
return ""
|
||
}
|
||
if len(field.Options) > 0 {
|
||
requested := normalizeWorkflowDurationToken(raw)
|
||
for _, option := range field.Options {
|
||
candidate := strings.TrimSpace(workflowOptionString(option))
|
||
if candidate != "" && normalizeWorkflowDurationToken(candidate) == requested {
|
||
return candidate
|
||
}
|
||
}
|
||
if fallback := workflowFieldConfiguredDefault(field); fallback != "" {
|
||
for _, option := range field.Options {
|
||
candidate := strings.TrimSpace(workflowOptionString(option))
|
||
if candidate != "" && normalizeWorkflowDurationToken(candidate) == normalizeWorkflowDurationToken(fallback) {
|
||
return candidate
|
||
}
|
||
}
|
||
return fallback
|
||
}
|
||
return raw
|
||
}
|
||
if numeric := workflowNumericResolutionValue(field, raw); numeric != nil {
|
||
return numeric
|
||
}
|
||
if fallback := workflowFieldConfiguredDefault(field); fallback != "" && !workflowResolutionShapeCompatible(fallback, raw) {
|
||
return fallback
|
||
}
|
||
return raw
|
||
}
|
||
|
||
func normalizeWorkflowDurationToken(value string) string {
|
||
return strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(value, "s"), "秒")))
|
||
}
|
||
|
||
func workflowFieldConfiguredDefault(field WorkflowField) string {
|
||
value := field.FieldValue
|
||
if value == nil {
|
||
value = field.Value
|
||
}
|
||
if value == nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(workflowOptionString(value))
|
||
}
|
||
|
||
func normalizeWorkflowResolutionToken(value string) string {
|
||
return strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(value, "p"), "P")))
|
||
}
|
||
|
||
func workflowNumericValue(value string) *float64 {
|
||
parsed, err := strconv.ParseFloat(strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(value, "p"), "P")), 64)
|
||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||
return nil
|
||
}
|
||
return &parsed
|
||
}
|
||
|
||
func workflowResolutionShapeCompatible(defaultValue string, requested string) bool {
|
||
defaultNumeric := workflowNumericValue(defaultValue)
|
||
requestedNumeric := workflowNumericValue(requested)
|
||
if defaultNumeric != nil && requestedNumeric != nil {
|
||
return true
|
||
}
|
||
return strings.EqualFold(strings.TrimSpace(defaultValue), strings.TrimSpace(requested))
|
||
}
|
||
|
||
func workflowNumericResolutionValue(field WorkflowField, raw string) interface{} {
|
||
parsed := workflowNumericValue(raw)
|
||
if parsed == nil {
|
||
return nil
|
||
}
|
||
min, minOK := workflowNumericBound(field.Min)
|
||
max, maxOK := workflowNumericBound(field.Max)
|
||
step, stepOK := workflowNumericBound(field.Step)
|
||
if !minOK && !maxOK && !stepOK && !strings.EqualFold(strings.TrimSpace(field.FieldType), "NUMBER") {
|
||
return nil
|
||
}
|
||
value := *parsed
|
||
if minOK && value < min {
|
||
value = min
|
||
}
|
||
if maxOK && value > max {
|
||
value = max
|
||
}
|
||
if stepOK && step > 0 {
|
||
anchor := min
|
||
if !minOK {
|
||
anchor = 0
|
||
}
|
||
value = anchor + math.Round((value-anchor)/step)*step
|
||
}
|
||
if strings.EqualFold(strings.TrimSpace(field.FieldType), "NUMBER") && math.Trunc(value) == value {
|
||
return int64(value)
|
||
}
|
||
return value
|
||
}
|
||
|
||
func workflowNumericBound(value interface{}) (float64, bool) {
|
||
parsed, err := strconv.ParseFloat(strings.TrimSpace(fmt.Sprint(value)), 64)
|
||
return parsed, err == nil && !math.IsNaN(parsed) && !math.IsInf(parsed, 0)
|
||
}
|
||
|
||
func workflowOptionString(value interface{}) string {
|
||
if object, ok := value.(map[string]interface{}); ok {
|
||
for _, key := range []string{"value", "id", "key", "label", "name"} {
|
||
if candidate := strings.TrimSpace(fmt.Sprint(object[key])); candidate != "" && candidate != "<nil>" {
|
||
return candidate
|
||
}
|
||
}
|
||
}
|
||
return fmt.Sprint(value)
|
||
}
|
||
|
||
func runningHubPromptFallback(workflow map[string]interface{}, prompt string) []map[string]any {
|
||
if strings.TrimSpace(prompt) == "" || len(workflow) == 0 {
|
||
return nil
|
||
}
|
||
bestNodeID := ""
|
||
bestFieldName := ""
|
||
bestScore := -1000
|
||
nodeIDs := make([]string, 0, len(workflow))
|
||
for nodeID := range workflow {
|
||
nodeIDs = append(nodeIDs, nodeID)
|
||
}
|
||
sort.Slice(nodeIDs, func(i, j int) bool { return managementNodeIDLess(nodeIDs[i], nodeIDs[j]) })
|
||
for _, nodeID := range nodeIDs {
|
||
node, ok := workflow[nodeID].(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
inputs, ok := node["inputs"].(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
fieldNames := make([]string, 0, len(inputs))
|
||
for fieldName := range inputs {
|
||
fieldNames = append(fieldNames, fieldName)
|
||
}
|
||
sort.Strings(fieldNames)
|
||
classType := strings.ToLower(strings.TrimSpace(fmt.Sprint(node["class_type"])))
|
||
metaTitle := strings.ToLower(strings.TrimSpace(fmt.Sprint(workflowNodeMetaTitle(node))))
|
||
for _, fieldName := range fieldNames {
|
||
if !isWorkflowPromptCandidate(fieldName, classType, metaTitle) {
|
||
continue
|
||
}
|
||
if isWorkflowLinkValue(inputs[fieldName]) {
|
||
continue
|
||
}
|
||
score := workflowPromptCandidateScore(fieldName, classType, metaTitle)
|
||
if bestNodeID == "" || score > bestScore {
|
||
bestNodeID = nodeID
|
||
bestFieldName = fieldName
|
||
bestScore = score
|
||
}
|
||
}
|
||
}
|
||
if bestNodeID != "" {
|
||
return []map[string]any{{"nodeId": bestNodeID, "fieldName": bestFieldName, "fieldValue": prompt}}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func isWorkflowMediaSource(source string) bool {
|
||
normalized := strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(strings.TrimSpace(source)), "_", ""), "-", "")
|
||
switch normalized {
|
||
case "referenceimage", "referenceimages", "image", "referencevideo", "referencevideos", "video", "referenceaudio", "referenceaudios", "audio", "mask":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func workflowPromptCandidateScore(fieldName string, classType string, metaTitle string) int {
|
||
descriptor := strings.ToLower(strings.TrimSpace(fieldName + " " + metaTitle))
|
||
score := 0
|
||
for _, marker := range []string{"negative", "neg prompt", "负面", "反向", "负向"} {
|
||
if strings.Contains(descriptor, marker) {
|
||
score -= 100
|
||
break
|
||
}
|
||
}
|
||
for _, marker := range []string{"positive", "正面", "正向"} {
|
||
if strings.Contains(descriptor, marker) {
|
||
score += 20
|
||
break
|
||
}
|
||
}
|
||
if strings.Contains(classType, "cliptextencode") {
|
||
score += 5
|
||
}
|
||
if isWorkflowPromptFieldName(fieldName) {
|
||
score += 2
|
||
}
|
||
return score
|
||
}
|
||
|
||
func randomWorkflowInteger(rawMin interface{}, rawMax interface{}) (int64, error) {
|
||
const defaultMax int64 = 9007199254740991
|
||
minValue, err := workflowIntegerBound(rawMin, 0)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
maxValue, err := workflowIntegerBound(rawMax, defaultMax)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if maxValue < minValue {
|
||
return 0, errors.New("工作流随机值最大值不能小于最小值")
|
||
}
|
||
rangeSize := new(big.Int).Sub(big.NewInt(maxValue), big.NewInt(minValue))
|
||
rangeSize.Add(rangeSize, big.NewInt(1))
|
||
offset, err := cryptorand.Int(cryptorand.Reader, rangeSize)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("生成工作流随机值失败:%w", err)
|
||
}
|
||
return new(big.Int).Add(offset, big.NewInt(minValue)).Int64(), nil
|
||
}
|
||
|
||
func workflowIntegerBound(value interface{}, fallback int64) (int64, error) {
|
||
if value == nil || strings.TrimSpace(fmt.Sprint(value)) == "" {
|
||
return fallback, nil
|
||
}
|
||
parsed, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(value)), 10, 64)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("工作流随机值范围不是有效整数:%v", value)
|
||
}
|
||
return parsed, nil
|
||
}
|
||
|
||
func workflowFieldsBindPrompt(fields []WorkflowField) bool {
|
||
for _, field := range fields {
|
||
if field.Enabled != nil && !*field.Enabled || strings.TrimSpace(field.NodeID) == "" || strings.TrimSpace(field.FieldName) == "" {
|
||
continue
|
||
}
|
||
source := strings.ReplaceAll(strings.ReplaceAll(normalizeWorkflowFieldSource(field), "_", ""), "-", "")
|
||
switch source {
|
||
case "prompt", "text", "positiveprompt", "positive":
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func upsertRunningHubNodeInfo(items []map[string]any, overrides []map[string]any) []map[string]any {
|
||
for _, override := range overrides {
|
||
nodeID := strings.TrimSpace(stringValue(override["nodeId"]))
|
||
fieldName := strings.TrimSpace(stringValue(override["fieldName"]))
|
||
replaced := false
|
||
for index, item := range items {
|
||
if strings.TrimSpace(stringValue(item["nodeId"])) == nodeID && strings.TrimSpace(stringValue(item["fieldName"])) == fieldName {
|
||
items[index] = override
|
||
replaced = true
|
||
break
|
||
}
|
||
}
|
||
if !replaced {
|
||
items = append(items, override)
|
||
}
|
||
}
|
||
return items
|
||
}
|
||
|
||
func isWorkflowLinkValue(value interface{}) bool {
|
||
items, ok := value.([]interface{})
|
||
return ok && len(items) == 2 && fmt.Sprint(items[0]) != "" && isIntegerLike(items[1])
|
||
}
|
||
|
||
func isIntegerLike(value interface{}) bool {
|
||
switch item := value.(type) {
|
||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||
return true
|
||
case float64:
|
||
return item == float64(int(item))
|
||
case json.Number:
|
||
_, err := strconv.Atoi(string(item))
|
||
return err == nil
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isWorkflowPromptFieldName(value string) bool {
|
||
normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.TrimSpace(value)))
|
||
switch normalized {
|
||
case "text", "prompt", "positiveprompt", "positive", "caption", "description":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isWorkflowPromptCandidate(fieldName string, classType string, metaTitle string) bool {
|
||
if isWorkflowPromptFieldName(fieldName) {
|
||
return true
|
||
}
|
||
normalizedField := strings.ToLower(strings.TrimSpace(fieldName))
|
||
if normalizedField != "value" {
|
||
return false
|
||
}
|
||
return strings.Contains(classType, "text") || strings.Contains(classType, "string") || strings.Contains(classType, "prompt") || strings.Contains(metaTitle, "text") || strings.Contains(metaTitle, "prompt") || strings.Contains(metaTitle, "提示词") || strings.Contains(metaTitle, "文本")
|
||
}
|
||
|
||
func workflowNodeMetaTitle(node map[string]interface{}) string {
|
||
meta, _ := node["_meta"].(map[string]interface{})
|
||
return fmt.Sprint(meta["title"])
|
||
}
|
||
|
||
func workflowScalarString(value interface{}) string {
|
||
switch item := value.(type) {
|
||
case string:
|
||
return item
|
||
case nil:
|
||
return ""
|
||
default:
|
||
encoded, err := json.Marshal(item)
|
||
if err == nil {
|
||
return string(encoded)
|
||
}
|
||
return fmt.Sprint(item)
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|