feat(workflow): 工作流提供方 - 集成 RunningHub 与 ComfyUI Bridge

This commit is contained in:
yepwait
2026-08-26 15:02:24 +08:00
committed by ddCat
parent 6141bd688c
commit 86fc3fda2f
82 changed files with 11405 additions and 248 deletions

View File

@@ -0,0 +1,583 @@
package service
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"gorm.io/gorm"
)
const (
// 心跳会携带本机工作流字段和拓扑;允许大型 ComfyUI 工作流,但保留硬上限。
comfyBridgeCapabilitiesLimit = 4 << 20
// 请求主要是工作流和参考素材描述,保持较小上限控制排队内存;结果可能含 base64 视频,单独放宽但仍有硬上限。
comfyBridgeRequestPayloadLimit = 16 << 20
comfyBridgeResultPayloadLimit = 64 << 20
comfyBridgeMaxPending = 32
comfyBridgeRequestTTL = 2 * time.Hour
comfyBridgePollMaxWait = 60 * time.Second
)
const (
// ComfyBridgeRequestKindGenerate 是现有生成任务的请求类型。空 Kind 会在入队时归一化为该值,
// 这样可以继续兼容已有调用方。
ComfyBridgeRequestKindGenerate = "generate"
// ComfyBridgeRequestKindWorkflowList 和 ComfyBridgeRequestKindWorkflowGet 只用于管理页按需发现。
ComfyBridgeRequestKindWorkflowList = "workflow.list"
ComfyBridgeRequestKindWorkflowGet = "workflow.get"
)
// CreateComfyBridgeRequest 是用户注册本地 Bridge 时提交的公开信息。
// Bridge Token 由服务端生成,不接受客户端自带 Token,避免复用或弱 Token。
type CreateComfyBridgeRequest struct {
Name string `json:"name"`
Capabilities map[string]any `json:"capabilities,omitempty"`
}
type ComfyBridgeSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Online bool `json:"online"`
LastSeenAt *time.Time `json:"lastSeenAt,omitempty"`
LastTaskAt *time.Time `json:"lastTaskAt,omitempty"`
Capabilities map[string]any `json:"capabilities,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ComfyBridgeRegistration struct {
Bridge ComfyBridgeSummary `json:"bridge"`
// Token 只在注册响应中出现一次,后续接口不会重新返回明文。
Token string `json:"token"`
}
// ComfyBridgeRequest 是云端后端交给本地 Bridge 的一次请求。
// Payload 的协议由 Bridge 按 Kind 解释;生成请求和管理页发现请求共用同一条长轮询通道。
type ComfyBridgeRequest struct {
ID string `json:"id"`
Kind string `json:"kind"`
TaskID string `json:"taskId,omitempty"`
BridgeID string `json:"bridgeId"`
Payload map[string]any `json:"payload"`
CreatedAt time.Time `json:"createdAt"`
}
type ComfyBridgeCompletion struct {
RequestID string `json:"requestId,omitempty"`
Status string `json:"status"`
Result map[string]any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
type comfyBridgeEnvelope struct {
request ComfyBridgeRequest
done chan ComfyBridgeCompletion
}
// comfyBridgeQueue 只保存长轮询唤醒和当前进程等待者;请求状态与结果以数据库为准。
type comfyBridgeQueue struct {
mu sync.Mutex
pending map[string]*comfyBridgeEnvelope
notify map[string]chan struct{}
}
var comfyBridgeRuntimes sync.Map // map[*Service]*comfyBridgeQueue
func (s *Service) comfyBridgeRuntime() *comfyBridgeQueue {
if value, ok := comfyBridgeRuntimes.Load(s); ok {
return value.(*comfyBridgeQueue)
}
runtime := &comfyBridgeQueue{
pending: make(map[string]*comfyBridgeEnvelope),
notify: make(map[string]chan struct{}),
}
actual, _ := comfyBridgeRuntimes.LoadOrStore(s, runtime)
return actual.(*comfyBridgeQueue)
}
func (s *Service) CreateComfyBridge(userID string, req CreateComfyBridgeRequest) (*ComfyBridgeRegistration, error) {
if strings.TrimSpace(userID) == "" {
return nil, Unauthorized("请先登录")
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = "本地 ComfyUI Bridge"
}
if len([]rune(name)) > 80 {
return nil, BadAuthRequest("Bridge 名称不能超过 80 个字符")
}
capabilitiesJSON := ""
if req.Capabilities != nil {
encoded, err := json.Marshal(req.Capabilities)
if err != nil {
return nil, BadAuthRequest("Bridge 能力信息格式无效")
}
if len(encoded) > comfyBridgeCapabilitiesLimit {
return nil, BadAuthRequest("Bridge 能力信息不能超过 4MB")
}
capabilitiesJSON = string(encoded)
}
token := randomToken()
now := time.Now()
bridge := &model.ComfyBridge{
ID: newID(),
UserID: userID,
Name: name,
TokenHash: hashToken(token),
Enabled: true,
CapabilitiesJSON: capabilitiesJSON,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.repo.Create(bridge); err != nil {
return nil, err
}
return &ComfyBridgeRegistration{Bridge: comfyBridgeSummary(*bridge), Token: token}, nil
}
func (s *Service) ComfyBridges(userID string) ([]ComfyBridgeSummary, error) {
bridges, err := s.repo.ComfyBridgesForUser(userID)
if err != nil {
return nil, err
}
items := make([]ComfyBridgeSummary, 0, len(bridges))
for _, bridge := range bridges {
items = append(items, comfyBridgeSummary(bridge))
}
return items, nil
}
func (s *Service) RevokeComfyBridge(userID string, bridgeID string) error {
bridgeID = strings.TrimSpace(bridgeID)
if bridgeID == "" {
return BadAuthRequest("Bridge ID 不能为空")
}
if err := s.repo.DisableComfyBridge(userID, bridgeID, time.Now()); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return BadAuthRequest("Bridge 不存在或已撤销")
}
return err
}
_ = s.repo.FailComfyBridgeRequests(bridgeID, "Bridge 已撤销", time.Now())
s.comfyBridgeRuntime().failBridge(bridgeID, errors.New("Bridge 已撤销"))
return nil
}
// AuthenticateComfyBridge 校验 Bridge 专用 Token,并刷新心跳。
func (s *Service) AuthenticateComfyBridge(token string) (*model.ComfyBridge, error) {
token = strings.TrimSpace(token)
if len(token) < 32 || len(token) > 256 {
return nil, Unauthorized("Bridge 令牌无效或已撤销")
}
bridge, err := s.repo.ComfyBridgeByTokenHash(hashToken(token))
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, Unauthorized("Bridge 令牌无效或已撤销")
}
if err != nil {
return nil, err
}
if err := s.repo.TouchComfyBridge(bridge.ID, time.Now()); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, Unauthorized("Bridge 令牌无效或已撤销")
}
return nil, err
}
return bridge, nil
}
func (s *Service) TouchComfyBridgeHeartbeat(bridgeID string, capabilities map[string]any) error {
capabilitiesJSON := ""
if capabilities != nil {
encoded, err := json.Marshal(capabilities)
if err != nil || len(encoded) > comfyBridgeCapabilitiesLimit {
return BadAuthRequest("Bridge 能力信息格式无效或超过 4MB")
}
capabilitiesJSON = string(encoded)
}
if err := s.repo.UpdateComfyBridgeHeartbeat(strings.TrimSpace(bridgeID), capabilitiesJSON, time.Now()); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Unauthorized("Bridge 令牌无效或已撤销")
}
return err
}
return nil
}
// EnqueueComfyBridgeRequest 将工作流请求持久化,并返回请求 ID。
func (s *Service) EnqueueComfyBridgeRequest(ctx context.Context, userID string, bridgeID string, taskID string, payload map[string]any) (*ComfyBridgeRequest, error) {
return s.enqueueComfyBridgeRequest(ctx, userID, bridgeID, taskID, "", payload)
}
func (s *Service) enqueueComfyBridgeRequest(ctx context.Context, userID string, bridgeID string, taskID string, requestID string, payload map[string]any) (*ComfyBridgeRequest, error) {
if err := contextError(ctx); err != nil {
return nil, err
}
bridgeID = strings.TrimSpace(bridgeID)
taskID = strings.TrimSpace(taskID)
if bridgeID == "" || taskID == "" {
return nil, BadAuthRequest("Bridge ID 和任务 ID 不能为空")
}
if payload == nil {
return nil, BadAuthRequest("Bridge 请求内容不能为空")
}
bridge, err := s.repo.ComfyBridgeForUser(userID, bridgeID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, BadAuthRequest("Bridge 不存在或已撤销")
}
return nil, err
}
if !bridge.Enabled {
return nil, BadAuthRequest("Bridge 不存在或已撤销")
}
encoded, err := json.Marshal(payload)
if err != nil {
return nil, BadAuthRequest("Bridge 请求内容格式无效")
}
if len(encoded) > comfyBridgeRequestPayloadLimit {
return nil, BadAuthRequest("Bridge 请求内容超过 16MB")
}
// Marshal/Unmarshal 生成独立副本,避免调用方在入队后继续修改 map 导致任务内容漂移。
var copied map[string]any
if err := json.Unmarshal(encoded, &copied); err != nil {
return nil, BadAuthRequest("Bridge 请求内容必须是 JSON 对象")
}
requestID = strings.TrimSpace(requestID)
if requestID == "" {
if count, err := s.repo.PendingComfyBridgeRequestCount(bridgeID, time.Now()); err != nil {
return nil, err
} else if count >= comfyBridgeMaxPending {
return nil, BadAuthRequest("该 Bridge 待处理请求过多,请稍后重试")
}
requestID = newID()
}
now := time.Now()
record, created, err := s.repo.CreateOrGetComfyBridgeRequest(&model.ComfyBridgeRequest{
ID: requestID, TaskID: taskID, UserID: userID, BridgeID: bridgeID, Kind: ComfyBridgeRequestKindGenerate,
Status: "queued", PayloadJSON: string(encoded), ExpiresAt: now.Add(comfyBridgeRequestTTL), CreatedAt: now, UpdatedAt: now,
})
if err != nil {
return nil, err
}
if record.UserID != userID || record.BridgeID != bridgeID || record.TaskID != taskID {
return nil, Forbidden("Bridge 请求 ID 已被其他任务使用")
}
request, err := comfyBridgeRequestFromModel(*record)
if err != nil {
return nil, err
}
runtime := s.comfyBridgeRuntime()
runtime.ensure(request)
if created {
runtime.signal(bridgeID)
}
if err := s.repo.MarkComfyBridgeTask(bridgeID, now); err != nil {
if created {
_ = s.repo.CancelComfyBridgeRequest(request.ID, time.Now())
runtime.remove(request.ID)
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, BadAuthRequest("Bridge 不存在或已撤销")
}
return nil, err
}
return request, nil
}
// PollComfyBridgeRequest 由本地 Bridge 调用。wait 为 0 时只检查当前队列。
func (s *Service) PollComfyBridgeRequest(ctx context.Context, bridge *model.ComfyBridge, wait time.Duration) (*ComfyBridgeRequest, error) {
if ctx == nil {
ctx = context.Background()
}
if bridge == nil || strings.TrimSpace(bridge.ID) == "" || !bridge.Enabled {
return nil, Unauthorized("Bridge 令牌无效或已撤销")
}
if wait < 0 {
wait = 0
}
if wait > comfyBridgePollMaxWait {
wait = comfyBridgePollMaxWait
}
runtime := s.comfyBridgeRuntime()
deadline := time.Now().Add(wait)
for {
record, err := s.repo.ClaimNextComfyBridgeRequest(bridge.ID, time.Now())
if err != nil {
return nil, err
}
if record != nil {
request, err := comfyBridgeRequestFromModel(*record)
if err != nil {
_ = s.repo.CancelComfyBridgeRequest(record.ID, time.Now())
return nil, err
}
runtime.ensure(request)
if err := s.repo.MarkComfyBridgeTask(bridge.ID, time.Now()); err != nil {
_ = s.repo.CancelComfyBridgeRequest(request.ID, time.Now())
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, Unauthorized("Bridge 令牌无效或已撤销")
}
return nil, err
}
return request, nil
}
if wait == 0 {
return nil, nil
}
remaining := time.Until(deadline)
if remaining <= 0 {
return nil, nil
}
channel := runtime.channel(bridge.ID)
timer := time.NewTimer(remaining)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return nil, ctx.Err()
case <-timer.C:
return nil, nil
case <-channel:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
}
}
// CompleteComfyBridgeRequest 将 Bridge 结果投递给等待该请求的后端 worker。
func (s *Service) CompleteComfyBridgeRequest(bridgeID string, completion ComfyBridgeCompletion) error {
bridgeID = strings.TrimSpace(bridgeID)
completion.RequestID = strings.TrimSpace(completion.RequestID)
if bridgeID == "" || completion.RequestID == "" {
return BadAuthRequest("Bridge ID 和请求 ID 不能为空")
}
completion.Status = strings.ToLower(strings.TrimSpace(completion.Status))
if completion.Status == "" {
completion.Status = "succeeded"
}
switch completion.Status {
case "succeeded", "failed", "cancelled":
default:
return BadAuthRequest("Bridge 结果状态无效")
}
if len([]rune(completion.Error)) > 4000 {
completion.Error = truncateRunes(completion.Error, 4000)
}
if completion.Result != nil {
encoded, err := json.Marshal(completion.Result)
if err != nil || len(encoded) > comfyBridgeResultPayloadLimit {
return BadAuthRequest("Bridge 结果内容无效或超过 64MB")
}
}
resultJSON := ""
if completion.Result != nil {
encoded, _ := json.Marshal(completion.Result)
resultJSON = string(encoded)
}
if _, err := s.repo.CompleteComfyBridgeRequest(bridgeID, completion.RequestID, completion.Status, resultJSON, completion.Error, time.Now()); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return BadAuthRequest("Bridge 请求不存在或已过期")
}
if errors.Is(err, repository.ErrComfyBridgeRequestOwnership) {
return Forbidden("Bridge 不能提交其他设备的请求")
}
if errors.Is(err, repository.ErrComfyBridgeRequestState) {
return BadAuthRequest("Bridge 请求尚未领取")
}
return err
}
runtime := s.comfyBridgeRuntime()
runtime.mu.Lock()
envelope := runtime.pending[completion.RequestID]
if envelope != nil {
select {
case envelope.done <- completion:
default:
}
}
runtime.mu.Unlock()
return nil
}
// WaitComfyBridgeRequest 由生成 worker 调用,直到 Bridge 回传结果或任务被取消。
func (s *Service) WaitComfyBridgeRequest(ctx context.Context, requestID string) (ComfyBridgeCompletion, error) {
if ctx == nil {
ctx = context.Background()
}
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return ComfyBridgeCompletion{}, BadAuthRequest("Bridge 请求 ID 不能为空")
}
runtime := s.comfyBridgeRuntime()
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
record, err := s.repo.ComfyBridgeRequest(requestID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return ComfyBridgeCompletion{}, BadAuthRequest("Bridge 请求不存在或已过期")
}
if err != nil {
return ComfyBridgeCompletion{}, err
}
completion, terminal, err := comfyBridgeCompletionFromModel(*record)
if err != nil {
return ComfyBridgeCompletion{}, err
}
if terminal {
runtime.remove(requestID)
return completion, nil
}
request, err := comfyBridgeRequestFromModel(*record)
if err != nil {
return ComfyBridgeCompletion{}, err
}
envelope := runtime.ensure(request)
select {
case <-envelope.done:
// 数据库已经在结果接口事务中落盘;下一轮统一从持久化记录读取。
case <-ticker.C:
case <-ctx.Done():
_ = s.repo.CancelComfyBridgeRequest(requestID, time.Now())
runtime.remove(requestID)
return ComfyBridgeCompletion{}, ctx.Err()
}
}
}
// CancelComfyBridgeRequest 供任务取消路径清理尚未完成的 Bridge 请求。
func (s *Service) CancelComfyBridgeRequest(requestID string) {
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return
}
_ = s.repo.CancelComfyBridgeRequest(requestID, time.Now())
runtime := s.comfyBridgeRuntime()
runtime.remove(requestID)
}
func comfyBridgeRequestFromModel(record model.ComfyBridgeRequest) (*ComfyBridgeRequest, error) {
payload := map[string]any{}
if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil {
return nil, errors.New("Bridge 持久化请求内容无效")
}
return &ComfyBridgeRequest{ID: record.ID, Kind: record.Kind, TaskID: record.TaskID, BridgeID: record.BridgeID, Payload: payload, CreatedAt: record.CreatedAt}, nil
}
func comfyBridgeCompletionFromModel(record model.ComfyBridgeRequest) (ComfyBridgeCompletion, bool, error) {
if record.Status != "succeeded" && record.Status != "failed" && record.Status != "cancelled" {
return ComfyBridgeCompletion{}, false, nil
}
completion := ComfyBridgeCompletion{RequestID: record.ID, Status: record.Status, Error: record.Error}
if strings.TrimSpace(record.ResultJSON) != "" {
if err := json.Unmarshal([]byte(record.ResultJSON), &completion.Result); err != nil {
return ComfyBridgeCompletion{}, false, errors.New("Bridge 持久化结果内容无效")
}
}
return completion, true, nil
}
func (q *comfyBridgeQueue) ensure(request *ComfyBridgeRequest) *comfyBridgeEnvelope {
q.mu.Lock()
defer q.mu.Unlock()
if envelope := q.pending[request.ID]; envelope != nil {
return envelope
}
envelope := &comfyBridgeEnvelope{request: *request, done: make(chan ComfyBridgeCompletion, 1)}
q.pending[request.ID] = envelope
return envelope
}
func (q *comfyBridgeQueue) channel(bridgeID string) <-chan struct{} {
q.mu.Lock()
defer q.mu.Unlock()
channel := q.notify[bridgeID]
if channel == nil {
channel = make(chan struct{}, 1)
q.notify[bridgeID] = channel
}
return channel
}
func (q *comfyBridgeQueue) signal(bridgeID string) {
q.mu.Lock()
q.signalLocked(bridgeID)
q.mu.Unlock()
}
func (q *comfyBridgeQueue) remove(requestID string) {
q.mu.Lock()
defer q.mu.Unlock()
delete(q.pending, requestID)
}
func (q *comfyBridgeQueue) failBridge(bridgeID string, err error) {
q.mu.Lock()
defer q.mu.Unlock()
message := "Bridge 请求失败"
if err != nil {
message = err.Error()
}
for id, envelope := range q.pending {
if envelope == nil {
delete(q.pending, id)
continue
}
if envelope.request.BridgeID != bridgeID {
continue
}
select {
case envelope.done <- ComfyBridgeCompletion{RequestID: id, Status: "failed", Error: message}:
default:
}
delete(q.pending, id)
}
q.signalLocked(bridgeID)
}
func (q *comfyBridgeQueue) signalLocked(bridgeID string) {
channel := q.notify[bridgeID]
if channel == nil {
channel = make(chan struct{}, 1)
q.notify[bridgeID] = channel
}
select {
case channel <- struct{}{}:
default:
}
}
func comfyBridgeSummary(bridge model.ComfyBridge) ComfyBridgeSummary {
result := ComfyBridgeSummary{ID: bridge.ID, Name: bridge.Name, Enabled: bridge.Enabled, LastSeenAt: bridge.LastSeenAt, LastTaskAt: bridge.LastTaskAt, CreatedAt: bridge.CreatedAt, UpdatedAt: bridge.UpdatedAt}
result.Online = bridge.Enabled && bridge.LastSeenAt != nil && time.Since(*bridge.LastSeenAt) <= 90*time.Second
if strings.TrimSpace(bridge.CapabilitiesJSON) != "" {
var capabilities map[string]any
if json.Unmarshal([]byte(bridge.CapabilitiesJSON), &capabilities) == nil && capabilities != nil {
result.Capabilities = capabilities
}
}
return result
}
func contextError(ctx context.Context) error {
if ctx == nil {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
default:
return nil
}
}

View File

@@ -0,0 +1,100 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestComfyBridgeRequestSurvivesServiceRestart(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.ComfyBridge{}, &model.ComfyBridgeRequest{}); err != nil {
t.Fatal(err)
}
repo := repository.New(db)
bridge := model.ComfyBridge{ID: "bridge-1", UserID: "user-1", Name: "Local", TokenHash: "hash", Enabled: true, CreatedAt: time.Now(), UpdatedAt: time.Now()}
if err := repo.Create(&bridge); err != nil {
t.Fatal(err)
}
first := New(repo, t.TempDir())
request, err := first.EnqueueComfyBridgeRequest(context.Background(), bridge.UserID, bridge.ID, "task-1", map[string]any{"prompt": "hello"})
if err != nil {
t.Fatal(err)
}
// 新 Service 没有旧进程的内存队列,仍应从数据库领取同一请求。
second := New(repo, t.TempDir())
claimed, err := second.PollComfyBridgeRequest(context.Background(), &bridge, 0)
if err != nil {
t.Fatal(err)
}
if claimed == nil || claimed.ID != request.ID {
t.Fatalf("claimed request = %#v, want %q", claimed, request.ID)
}
if err := second.CompleteComfyBridgeRequest(bridge.ID, ComfyBridgeCompletion{RequestID: request.ID, Status: "succeeded", Result: map[string]any{"url": "https://example.test/result.png"}}); err != nil {
t.Fatal(err)
}
// 完成结果先于任务 worker 恢复也不会丢失,恢复时复用原请求 ID 且不重复入队。
third := New(repo, t.TempDir())
resumed, err := third.enqueueComfyBridgeRequest(context.Background(), bridge.UserID, bridge.ID, "task-1", request.ID, map[string]any{"prompt": "hello"})
if err != nil {
t.Fatal(err)
}
if resumed.ID != request.ID {
t.Fatalf("resumed request ID = %q, want %q", resumed.ID, request.ID)
}
completion, err := third.WaitComfyBridgeRequest(context.Background(), request.ID)
if err != nil {
t.Fatal(err)
}
if completion.Status != "succeeded" || completion.Result["url"] != "https://example.test/result.png" {
t.Fatalf("completion = %#v", completion)
}
if duplicate, err := third.PollComfyBridgeRequest(context.Background(), &bridge, 0); err != nil || duplicate != nil {
t.Fatalf("completed request was queued again: request=%#v err=%v", duplicate, err)
}
}
func TestFetchRunningHubAppInfoKeepsAPIKeyOutOfURL(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost {
t.Errorf("method = %s, want POST", request.Method)
}
if request.URL.RawQuery != "" {
t.Errorf("API Key leaked into URL query: %s", request.URL.RawQuery)
}
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Errorf("decode body: %v", err)
}
if body["apiKey"] != "secret-key" || body["webappId"] != "app-1" {
t.Errorf("body = %#v", body)
}
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{"code":0,"data":{"nodeInfoList":[]}}`))
}))
defer server.Close()
result, err := (&Service{}).FetchRunningHubAppInfo(context.Background(), RunningHubWorkflowFetchRequest{BaseURL: server.URL, APIKey: "secret-key", WebappID: "app-1"})
if err != nil {
t.Fatal(err)
}
if result["webappId"] != "app-1" {
t.Fatalf("result = %#v", result)
}
}

View File

@@ -535,7 +535,14 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error {
return BadAuthRequest("任务输入格式无效")
}
var taskInput canvasGenerationInput
if err := json.Unmarshal(encoded, &taskInput); err != nil || (taskInput.Mode != "image" && taskInput.Mode != "video") {
if err := json.Unmarshal(encoded, &taskInput); err != nil || (taskInput.Mode != "image" && taskInput.Mode != "video" && taskInput.Mode != "audio") {
return nil
}
if isWorkflowProviderInterface(taskInput.Config.InterfaceType) {
return validateWorkflowProviderConfig(taskInput.Mode, taskInput.Config)
}
// 普通音频模型沿用主线的能力校验路径;当前专用能力表只覆盖图片和视频。
if taskInput.Mode == "audio" {
return nil
}
channelID := strings.TrimSpace(taskInput.Config.ChannelID)

View File

@@ -81,6 +81,14 @@ type providerConfig struct {
AudioInstructions string `json:"audioInstructions"`
SystemPrompt string `json:"systemPrompt"`
CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig"`
WorkflowID string `json:"workflowId"`
WebappID string `json:"webappId"`
WorkflowJSON map[string]interface{} `json:"workflowJson"`
WorkflowFields []WorkflowField `json:"workflowFields"`
BridgeID string `json:"bridgeId"`
RunningHubUseWallet bool `json:"runningHubUseWallet"`
RunningHubWalletKey string `json:"runningHubWalletApiKey"`
RunningHubUploadKey string `json:"runningHubUploadApiKey"`
}
const providerHTTPTimeout = 5 * time.Minute
@@ -286,6 +294,18 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string
}
input.Config = config
ctx = withProviderOutboundPolicy(ctx, input.Config)
if isWorkflowProviderInterface(input.Config.InterfaceType) {
if err := validateWorkflowProviderConfig(input.Mode, input.Config); err != nil {
return nil, err
}
// 工作流参数由工作流字段定义校验,普通模型能力配置不能覆盖它们。
if resumedProviderRequestID(ctx) == "" {
if err := s.hydrateGenerationMedia(userID, &input, false); err != nil {
return nil, err
}
}
return s.runWorkflowProviderTask(ctx, input)
}
if input.Mode == "image" && input.Metadata != nil {
if err := s.applyGenerationStyleProfile(userID, taskProjectID, &input); err != nil {
return nil, err
@@ -995,6 +1015,14 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig,
return providerConfig{}, err
}
config.Headers = headers
if isComfyBridgeInterface(config.InterfaceType) {
config.BaseURL = "bridge://local"
config.APIKey = ""
return config, nil
}
if isRunningHubInterface(config.InterfaceType) && strings.TrimSpace(config.BaseURL) == "" {
config.BaseURL = "https://www.runninghub.cn"
}
channelID := strings.TrimSpace(config.ChannelID)
if channelID == "" {
channelID = systemChannelIDFromBaseURL(config.BaseURL)

View File

@@ -73,6 +73,22 @@ func (s *Service) requestProviderCancellation(ctx context.Context, task *model.T
if err != nil {
return s.markProviderCancellationUncertain(task, "读取上游取消配置失败,费用待核对:"+err.Error())
}
if isComfyBridgeInterface(input.Config.InterfaceType) {
// 尚未领取的请求可以确定取消并退款;已经在本机执行的工作流只能丢弃迟到结果并转人工核对。
request, requestErr := s.repo.ComfyBridgeRequest(task.ProviderRequestID)
s.CancelComfyBridgeRequest(task.ProviderRequestID)
if requestErr == nil && request.Status == "queued" {
if err := s.taskBilling().RefundBilling(task.BillingOrderID, "本地 ComfyUI Bridge 请求在领取前取消"); err != nil {
return s.markProviderCancellationUncertain(task, "Bridge 请求已取消,但积分退回失败:"+err.Error())
}
now := time.Now()
if err := s.repo.UpdateTaskProviderCancellation(task.ID, model.ProviderCancelStatusRequested, model.ProviderCancelStatusConfirmed, "", nil, &now); err != nil {
return err
}
return nil
}
return s.markProviderCancellationUncertain(task, "本地 ComfyUI Bridge 不支持取消已领取工作流,执行状态待核对")
}
if !supportsProviderCancellation(input.Config.InterfaceType) {
return s.markProviderCancellationUncertain(task, "当前上游协议不支持取消,费用待核对")
}

View File

@@ -0,0 +1,561 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
)
// RunningHubWorkflowFetchRequest 只用于独立工作流设置页,不进入 ModelChannel。
type RunningHubWorkflowFetchRequest struct {
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
WalletAPIKey string `json:"walletApiKey"`
UseWallet bool `json:"useWallet"`
WorkflowID string `json:"workflowId"`
WebappID string `json:"webappId,omitempty"`
Title string `json:"title,omitempty"`
Capability string `json:"capability,omitempty"`
}
func (s *Service) FetchRunningHubWorkflowInfo(ctx context.Context, req RunningHubWorkflowFetchRequest) (map[string]any, error) {
workflowID := strings.TrimSpace(req.WorkflowID)
if workflowID == "" {
return nil, BadAuthRequest("workflowId 不能为空")
}
config, err := runningHubManagementConfig(req)
if err != nil {
return nil, err
}
root := runningHubRootURL(config.BaseURL)
var response map[string]any
if err := s.runningHubJSON(ctx, config, root+"/api/openapi/getJsonApiFormat", map[string]any{"apiKey": runningHubAPIKey(config), "workflowId": workflowID}, &response); err != nil {
return nil, fmt.Errorf("拉取 RunningHub 工作流参数失败:%w", err)
}
data, _ := response["data"].(map[string]any)
if data == nil {
return nil, errors.New("RunningHub 工作流参数响应缺少 data")
}
workflow := map[string]any{}
if prompt, ok := data["prompt"].(string); ok && strings.TrimSpace(prompt) != "" {
if err := json.Unmarshal([]byte(prompt), &workflow); err != nil {
return nil, fmt.Errorf("RunningHub 工作流 JSON 解析失败:%w", err)
}
} else if prompt, ok := data["prompt"].(map[string]any); ok {
workflow = prompt
}
return map[string]any{"workflowId": workflowID, "kind": "workflow", "title": firstNonEmptyString(req.Title, workflowID), "fields": collectManagementWorkflowFields(workflow, req.Capability), "workflowJson": workflow, "raw": response}, nil
}
func (s *Service) FetchRunningHubAppInfo(ctx context.Context, req RunningHubWorkflowFetchRequest) (map[string]any, error) {
webappID := strings.TrimSpace(req.WebappID)
if webappID == "" {
return nil, BadAuthRequest("webappId 不能为空")
}
config, err := runningHubManagementConfig(req)
if err != nil {
return nil, err
}
root := runningHubRootURL(config.BaseURL)
var response map[string]any
if err := s.runningHubJSON(ctx, config, root+"/api/webapp/apiCallDemo", map[string]any{
"apiKey": runningHubAPIKey(config),
"webappId": webappID,
}, &response); err != nil {
return nil, fmt.Errorf("拉取 RunningHub App 参数失败:%w", err)
}
if code, valid := runningHubPayloadCode(response); valid && code != 0 {
return nil, fmt.Errorf("拉取 RunningHub App 参数失败:%s", runningHubFailureMessage(response))
}
rawData, _ := response["data"].(map[string]any)
result := map[string]any{"kind": "app", "webappId": webappID, "workflowId": webappID, "title": firstNonEmptyString(req.Title, webappID), "fields": []map[string]any{}, "raw": response}
if rawData != nil {
result["fields"] = normalizeManagementAppFields(rawData["nodeInfoList"], req.Capability)
}
return result, nil
}
func normalizeManagementAppFields(raw any, capability string) []map[string]any {
items, _ := raw.([]any)
fields := make([]map[string]any, 0, len(items))
for _, item := range items {
original, ok := item.(map[string]any)
if !ok {
continue
}
field := make(map[string]any, len(original)+3)
for key, value := range original {
field[key] = value
}
nodeID := strings.TrimSpace(stringValue(field["nodeId"]))
fieldName := strings.TrimSpace(stringValue(field["fieldName"]))
if nodeID == "" || fieldName == "" {
continue
}
if stringValue(field["id"]) == "" {
field["id"] = nodeID + "::" + fieldName
}
if _, exists := field["enabled"]; !exists {
field["enabled"] = true
}
fieldType := strings.ToUpper(strings.TrimSpace(stringValue(field["fieldType"])))
if fieldType == "" {
fieldType = managementWorkflowFieldType(fieldName, field["fieldValue"], "")
field["fieldType"] = fieldType
}
if strings.TrimSpace(stringValue(field["source"])) == "" {
if isWorkflowPromptFieldName(fieldName) {
field["source"] = "prompt"
field["sourceAutomatic"] = true
} else if source := managementAutomaticInputSource(fieldName, fieldType); source != "" {
field["source"] = source
field["sourceAutomatic"] = true
} else {
field["source"] = ""
field["sourceAutomatic"] = false
}
}
field["role"] = managementWorkflowFieldRole("", fieldName, fieldType, stringValue(field["source"]))
field["safeToOverride"] = true
fields = append(fields, field)
}
return applyManagementFieldDefaults(fields, capability)
}
func runningHubManagementConfig(req RunningHubWorkflowFetchRequest) (providerConfig, error) {
config := providerConfig{BaseURL: strings.TrimSpace(req.BaseURL), APIKey: strings.TrimSpace(req.APIKey), RunningHubWalletKey: strings.TrimSpace(req.WalletAPIKey), RunningHubUseWallet: req.UseWallet}
if config.BaseURL == "" {
config.BaseURL = "https://www.runninghub.cn"
}
if _, err := ValidateOutboundURL(runningHubRootURL(config.BaseURL)); err != nil {
return config, err
}
if runningHubAPIKey(config) == "" {
return config, BadAuthRequest("请先填写 RunningHub 积分 API Key")
}
return config, nil
}
func collectManagementWorkflowFields(workflow map[string]any, capability string) []map[string]any {
fields := make([]map[string]any, 0)
promptNodeID := ""
promptFieldName := ""
if promptFields := runningHubPromptFallback(workflow, "prompt"); len(promptFields) > 0 {
promptNodeID = strings.TrimSpace(stringValue(promptFields[0]["nodeId"]))
promptFieldName = strings.TrimSpace(stringValue(promptFields[0]["fieldName"]))
}
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 {
rawNode := workflow[nodeID]
node, ok := rawNode.(map[string]any)
if !ok {
continue
}
inputs, ok := node["inputs"].(map[string]any)
if !ok {
continue
}
classType := strings.TrimSpace(stringValue(node["class_type"]))
fieldNames := make([]string, 0, len(inputs))
for fieldName := range inputs {
fieldNames = append(fieldNames, fieldName)
}
sort.Strings(fieldNames)
for _, fieldName := range fieldNames {
value := inputs[fieldName]
if isWorkflowLinkValue(value) {
continue
}
// 所有静态输入都保留在设置页,但内部参数默认关闭;危险数值字段
// 额外标记为不可覆盖,并由提交端再次强制拦截。
fieldType := managementWorkflowFieldType(fieldName, value, classType)
label := fieldName
if title := strings.TrimSpace(workflowNodeMetaTitle(node)); title != "" {
label = title + " · " + fieldName
}
safeToOverride := managementWorkflowFieldSafeToOverride(classType, fieldName)
role := managementWorkflowFieldRole(classType, fieldName, fieldType, "")
field := map[string]any{"id": nodeID + "::" + fieldName, "nodeId": nodeID, "classType": classType, "fieldName": fieldName, "fieldValue": value, "fieldType": fieldType, "label": label, "role": role, "safeToOverride": safeToOverride, "enabled": safeToOverride && role != "internal", "source": "", "sourceAutomatic": false}
if nodeID == promptNodeID && fieldName == promptFieldName {
field["source"] = "prompt"
field["sourceAutomatic"] = true
field["required"] = true
field["role"] = "prompt"
field["enabled"] = true
} else if source := managementAutomaticInputSource(fieldName, fieldType); source != "" {
field["source"] = source
field["sourceAutomatic"] = true
field["role"] = "media"
field["enabled"] = true
}
if isManagementSeedField(fieldName) {
field["randomEnabled"] = true
}
fields = append(fields, field)
}
}
return applyManagementFieldDefaults(fields, capability)
}
func managementWorkflowFieldSafeToOverride(classType string, fieldName string) bool {
classKey := normalizeManagementFieldName(classType)
fieldKey := normalizeManagementFieldName(fieldName)
if classKey == "int" && fieldKey == "value" {
return false
}
return classKey != "imageresize+" || (fieldKey != "width" && fieldKey != "height" && fieldKey != "multipleof")
}
func managementWorkflowFieldRole(classType string, fieldName string, fieldType string, source string) string {
normalizedSource := normalizeManagementFieldName(source)
if normalizedSource == "prompt" || normalizedSource == "text" || normalizedSource == "positiveprompt" || normalizedSource == "positive" {
return "prompt"
}
if normalizedSource == "referenceimage" || normalizedSource == "image" || normalizedSource == "referencevideo" || normalizedSource == "video" || normalizedSource == "referenceaudio" || normalizedSource == "audio" || normalizedSource == "mask" {
return "media"
}
if fieldType == "IMAGE" || fieldType == "VIDEO" || fieldType == "AUDIO" {
return "media"
}
if !managementWorkflowFieldSafeToOverride(classType, fieldName) {
return "internal"
}
key := normalizeManagementFieldName(fieldName)
for _, candidate := range []string{"aspectratio", "ratio", "duration", "durationseconds", "seconds", "videoseconds", "quality", "resolution", "seed", "noiseseed", "steps", "step", "sigmapoints", "cfg", "cfgscale", "guidance", "guidancescale", "sampler", "samplername", "scheduler", "fps", "count", "batch", "batchsize", "generateaudio", "watermark", "negativeprompt", "systemprompt"} {
if key == candidate {
return "business"
}
}
if strings.TrimSpace(classType) == "" {
// AI App 返回的是公开参数列表,没有内部节点类型。
return "business"
}
return "internal"
}
func applyManagementFieldDefaults(fields []map[string]any, capability string) []map[string]any {
sort.SliceStable(fields, func(i, j int) bool {
leftNode := strings.TrimSpace(stringValue(fields[i]["nodeId"]))
rightNode := strings.TrimSpace(stringValue(fields[j]["nodeId"]))
if leftNode != rightNode {
return managementNodeIDLess(leftNode, rightNode)
}
return strings.TrimSpace(stringValue(fields[i]["fieldName"])) < strings.TrimSpace(stringValue(fields[j]["fieldName"]))
})
imageOrder := 0
videoOrder := 0
audioOrder := 0
for _, field := range fields {
fieldName := strings.TrimSpace(stringValue(field["fieldName"]))
fieldType := strings.ToUpper(strings.TrimSpace(stringValue(field["fieldType"])))
source := strings.TrimSpace(stringValue(field["source"]))
sourceAutomatic, sourceAutomaticConfigured := field["sourceAutomatic"].(bool)
if source == "" && (!sourceAutomaticConfigured || sourceAutomatic) {
source = workflowDynamicSourceForMode(fieldName, fieldType, capability)
if source != "" {
field["source"] = source
field["sourceAutomatic"] = true
}
}
switch normalizeManagementFieldName(source) {
case "prompt", "text", "positiveprompt", "positive":
if _, exists := field["required"]; !exists {
field["required"] = true
}
case "referenceimage", "image":
configuredOrder := managementIntValue(field["imageOrder"], 0)
if configuredOrder <= 0 {
imageOrder++
configuredOrder = imageOrder
field["imageOrder"] = configuredOrder
} else if configuredOrder > imageOrder {
imageOrder = configuredOrder
}
if _, exists := field["sourceIndex"]; !exists {
field["sourceIndex"] = configuredOrder - 1
}
if _, exists := field["required"]; !exists {
field["required"] = configuredOrder == 1
}
case "referencevideo", "video":
configuredIndex, exists := field["sourceIndex"]
index := managementIntValue(configuredIndex, videoOrder)
if !exists {
field["sourceIndex"] = index
}
if index >= videoOrder {
videoOrder = index + 1
}
if _, exists := field["required"]; !exists {
field["required"] = index == 0
}
case "referenceaudio", "audio":
configuredIndex, exists := field["sourceIndex"]
index := managementIntValue(configuredIndex, audioOrder)
if !exists {
field["sourceIndex"] = index
}
if index >= audioOrder {
audioOrder = index + 1
}
if _, exists := field["required"]; !exists {
field["required"] = index == 0
}
case "mask":
if _, exists := field["required"]; !exists {
field["required"] = false
}
}
if isManagementSeedField(fieldName) {
field["randomEnabled"] = true
}
}
return fields
}
func managementAutomaticInputSource(fieldName string, fieldType string) string {
if strings.Contains(normalizeManagementFieldName(fieldName), "mask") {
return "mask"
}
switch strings.ToUpper(strings.TrimSpace(fieldType)) {
case "IMAGE":
return "referenceImage"
case "VIDEO":
return "referenceVideo"
case "AUDIO":
return "referenceAudio"
default:
return ""
}
}
func managementNodeIDLess(left string, right string) bool {
leftNumber, leftErr := strconv.ParseInt(strings.TrimSpace(left), 10, 64)
rightNumber, rightErr := strconv.ParseInt(strings.TrimSpace(right), 10, 64)
if leftErr == nil && rightErr == nil {
return leftNumber < rightNumber
}
return left < right
}
func managementIntValue(value any, fallback int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(fmt.Sprint(value)))
if err != nil {
return fallback
}
return parsed
}
func workflowDynamicSource(name string, fieldType string) string {
return workflowDynamicSourceForMode(name, fieldType, "")
}
func workflowDynamicSourceForMode(name string, fieldType string, mode string) string {
if source := workflowNamedDynamicSource(name, mode); source != "" {
return source
}
normalizedFieldType := strings.ToUpper(strings.TrimSpace(fieldType))
switch normalizedFieldType {
case "IMAGE":
return "referenceImage"
case "VIDEO":
return "referenceVideo"
case "AUDIO":
return "referenceAudio"
default:
return ""
}
}
func workflowNamedDynamicSource(name string, mode string) string {
key := normalizeManagementFieldName(name)
switch {
case strings.Contains(key, "mask"):
return "mask"
case workflowDimensionNameSource(key) != "":
return workflowDimensionNameSource(key)
case key == "ratio" || key == "aspectratio" || key == "imageaspectratio" || key == "imageratio" || key == "videoaspectratio" || key == "videoratio":
return "aspectRatio"
case key == "videoresolution" || key == "videoquality" || key == "vquality":
return "vquality"
case key == "size" || key == "imagesize" || key == "imageresolution":
return "size"
case key == "resolution" && strings.EqualFold(strings.TrimSpace(mode), "video"):
return "vquality"
case key == "resolution" && strings.EqualFold(strings.TrimSpace(mode), "image"):
return "size"
case key == "batch" || key == "batchsize" || key == "count" || key == "numimages" || key == "numberofimages" || key == "imagecount" || key == "imagescount":
return "count"
case key == "quality":
return "quality"
case key == "duration" || key == "seconds" || key == "durationseconds" || key == "videoseconds" || key == "videoduration" || key == "videodurationseconds" || key == "videolength" || key == "clipduration":
return "videoSeconds"
case key == "generateaudio" || key == "videogenerateaudio":
return "videoGenerateAudio"
case key == "watermark" || key == "videowatermark":
return "videoWatermark"
case key == "audioformat":
return "audioFormat"
case key == "voice" || key == "audiovoice":
return "audioVoice"
case key == "audiospeed":
return "audioSpeed"
case key == "audioinstructions":
return "audioInstructions"
case key == "transparentbackground" || key == "transparent":
return "transparentBackground"
default:
return ""
}
}
func workflowDimensionNameSource(key string) string {
prefixes := []string{"", "image", "video", "size", "output", "target", "latent", "frame", "canvas", "source", "resolution", "final"}
for _, prefix := range prefixes {
if key == prefix+"width" {
return "width"
}
if key == prefix+"height" {
return "height"
}
}
if key == "pixelwidth" || key == "widthpixels" {
return "width"
}
if key == "pixelheight" || key == "heightpixels" {
return "height"
}
return ""
}
func normalizeManagementFieldName(value string) string {
return strings.ToLower(strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.TrimSpace(value)))
}
func isManagementSeedField(value string) bool {
key := normalizeManagementFieldName(value)
return key == "seed" || strings.HasSuffix(key, "seed") || strings.Contains(key, "noiseseed")
}
func managementFieldType(name string, value any) string {
return managementWorkflowFieldType(name, value, "")
}
func managementWorkflowFieldType(name string, value any, classType string) string {
key := normalizeManagementFieldName(name)
classKey := normalizeManagementFieldName(classType)
if strings.Contains(key, "mask") {
return "IMAGE"
}
if strings.Contains(classKey, "loadimage") && (key == "file" || key == "path") {
return "IMAGE"
}
if strings.Contains(classKey, "loadvideo") && (key == "file" || key == "path") {
return "VIDEO"
}
if strings.Contains(classKey, "loadaudio") && (key == "file" || key == "path") {
return "AUDIO"
}
if source := workflowNamedDynamicSource(name, ""); source != "" && source != "mask" {
return managementScalarFieldType(value)
}
switch value.(type) {
case bool:
return "BOOLEAN"
case float64, int, int64:
return "NUMBER"
}
if managementMediaFieldName(key, "image") {
return "IMAGE"
}
if managementMediaFieldName(key, "video") {
return "VIDEO"
}
if managementMediaFieldName(key, "audio") {
return "AUDIO"
}
// RunningHub 的部分自定义节点不会把素材字段命名为 image/video/audio,
// 但默认值仍是带扩展名的输入文件;与参考项目一致,使用文件类型补充识别。
if mediaType := managementMediaValueType(value); mediaType != "" {
return mediaType
}
return "TEXT"
}
func managementMediaValueType(value any) string {
switch items := value.(type) {
case []any:
if len(items) == 0 {
return ""
}
value = items[0]
case []string:
if len(items) == 0 {
return ""
}
value = items[0]
}
text := strings.ToLower(strings.TrimSpace(stringValue(value)))
if text == "" {
return ""
}
if index := strings.IndexAny(text, "?#"); index >= 0 {
text = text[:index]
}
for _, suffix := range []string{".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".avif"} {
if strings.HasSuffix(text, suffix) {
return "IMAGE"
}
}
for _, suffix := range []string{".mp4", ".webm", ".mov", ".m4v", ".mkv"} {
if strings.HasSuffix(text, suffix) {
return "VIDEO"
}
}
for _, suffix := range []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac"} {
if strings.HasSuffix(text, suffix) {
return "AUDIO"
}
}
return ""
}
func managementScalarFieldType(value any) string {
switch value.(type) {
case bool:
return "BOOLEAN"
case float32, float64, int, int32, int64:
return "NUMBER"
default:
return "TEXT"
}
}
func managementMediaFieldName(key string, mediaType string) bool {
if key == mediaType {
return true
}
prefixes := []string{"input", "reference", "ref", "source", "init", "start", "end", "first", "last", "control", "controlnet", "style", "subject"}
for _, prefix := range prefixes {
if key == prefix+mediaType {
return true
}
}
for _, suffix := range []string{"file", "path", "filename", "upload"} {
if key == mediaType+suffix {
return true
}
}
if mediaType == "image" {
return key == "firstframe" || key == "lastframe" || key == "startframe" || key == "endframe"
}
return false
}

View File

@@ -376,7 +376,7 @@ func (s *Service) protectTaskSecrets(value interface{}) error {
switch item := value.(type) {
case map[string]interface{}:
for key, child := range item {
if key == "apiKey" {
if isTaskSecretField(key) {
secret, _ := child.(string)
if secret != "" && secret != "system" && !strings.HasPrefix(secret, encryptedSettingPrefix) {
encrypted, err := s.encryptSettingSecret(secret)
@@ -420,7 +420,7 @@ func (s *Service) decryptTaskSecrets(value interface{}) error {
switch item := value.(type) {
case map[string]interface{}:
for key, child := range item {
if key == "apiKey" {
if isTaskSecretField(key) {
secret, _ := child.(string)
if strings.HasPrefix(secret, encryptedSettingPrefix) {
plain, err := s.decryptSettingSecret(secret)
@@ -445,6 +445,15 @@ func (s *Service) decryptTaskSecrets(value interface{}) error {
return nil
}
func isTaskSecretField(key string) bool {
switch key {
case "apiKey", "secretKey", "runningHubWalletApiKey", "runningHubUploadApiKey":
return true
default:
return false
}
}
func ossSettingFromRequest(req OSSSettingRequest, current ossSettingValue) (ossSettingValue, error) {
next := normalizeOSSSetting(ossSettingValue{
Enabled: req.Enabled,

View File

@@ -12,7 +12,7 @@ import (
"gorm.io/gorm"
)
func TestCancelTaskRejectsRunningTask(t *testing.T) {
func TestCancelTaskCancelsRunningTask(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
@@ -33,20 +33,24 @@ func TestCancelTaskRejectsRunningTask(t *testing.T) {
}
svc := &Service{repo: repository.New(db)}
if _, err := svc.CancelTask(context.Background(), task.UserID, task.ID); err == nil || err.Error() != "任务已发起,无法取消,请等待任务完成" {
cancelled, err := svc.CancelTask(context.Background(), task.UserID, task.ID)
if err != nil {
t.Fatalf("CancelTask() error = %v", err)
}
if cancelled.Status != model.TaskStatusCancelled {
t.Fatalf("CancelTask() status = %s, want cancelled", cancelled.Status)
}
stored, err := svc.repo.TaskForUser(task.UserID, task.ID)
if err != nil {
t.Fatal(err)
}
if stored.Status != model.TaskStatusRunning || stored.CompletedAt != nil {
t.Fatalf("running task changed after cancellation attempt: status=%s completedAt=%v", stored.Status, stored.CompletedAt)
if stored.Status != model.TaskStatusCancelled || stored.CompletedAt == nil {
t.Fatalf("running task was not cancelled: status=%s completedAt=%v", stored.Status, stored.CompletedAt)
}
}
func TestCancelTaskRejectsQueuedTaskWithoutChangingBillingState(t *testing.T) {
func TestCancelTaskCancelsQueuedTask(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
@@ -60,16 +64,20 @@ func TestCancelTaskRejectsQueuedTaskWithoutChangingBillingState(t *testing.T) {
}
svc := &Service{repo: repository.New(db)}
if _, err := svc.CancelTask(context.Background(), task.UserID, task.ID); err == nil || err.Error() != "任务已发起,无法取消,请等待任务完成" {
cancelled, err := svc.CancelTask(context.Background(), task.UserID, task.ID)
if err != nil {
t.Fatalf("CancelTask() error = %v", err)
}
if cancelled.Status != model.TaskStatusCancelled {
t.Fatalf("CancelTask() status = %s, want cancelled", cancelled.Status)
}
storedTask, err := svc.repo.TaskForUser(task.UserID, task.ID)
if err != nil {
t.Fatal(err)
}
if storedTask.Status != model.TaskStatusQueued || storedTask.CompletedAt != nil {
t.Fatalf("queued task changed after cancellation attempt: status=%s completedAt=%v", storedTask.Status, storedTask.CompletedAt)
if storedTask.Status != model.TaskStatusCancelled || storedTask.CompletedAt == nil {
t.Fatalf("queued task was not cancelled: status=%s completedAt=%v", storedTask.Status, storedTask.CompletedAt)
}
}
@@ -92,7 +100,7 @@ func TestCancelTaskRejectsTaskWithProviderRequestID(t *testing.T) {
}
svc := &Service{repo: repository.New(db)}
if _, err := svc.CancelTask(context.Background(), task.UserID, task.ID); err == nil || err.Error() != "任务已发起,无法取消,请等待任务完成" {
if _, err := svc.CancelTask(context.Background(), task.UserID, task.ID); err == nil || err.Error() != "任务当前状态为 failed,无法取消" {
t.Fatalf("CancelTask() error = %v", err)
}

View File

@@ -26,38 +26,42 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task,
return nil, err
}
// 根据 frontendModelsEnabled 开关强制分流模型选择
frontendEnabled, err := s.FeatureEnabled(FeatureFrontendModels)
if err != nil {
return nil, err
}
var routed *RoutedModel
logicalModelID := strings.TrimSpace(req.LogicalModelID)
if frontendEnabled {
// 前台模型模式:必须有 logicalModelId
if logicalModelID == "" {
return nil, InvalidModelSelection("前台模型模式下必须指定 logicalModelId")
}
intent := ModelRequestIntentFromTaskInput(normalizedInput, taskType, req.Operation)
routed, err = s.ResolveLogicalModel(logicalModelID, intent)
workflowProviderTask := taskInputUsesWorkflowProvider(normalizedInput)
frontendEnabled := false
if !workflowProviderTask {
// 工作流是独立执行器;普通模型仍严格使用主线的目录和路由校验。
frontendEnabled, err = s.FeatureEnabled(FeatureFrontendModels)
if err != nil {
return nil, err
}
normalizedInput = applyRoutedProviderSelection(normalizedInput, routed)
} else {
// 系统渠道模型模式:禁止 logicalModelId
if logicalModelID != "" {
return nil, ModelCatalogMismatch("模型目录已更新,请重新选择")
}
// 自定义渠道没有系统 channelId;它会在后续由自定义渠道功能开关、
// 能力校验和 provider 配置校验共同处理,不能误报为“缺少系统渠道”。
if !taskInputUsesCustomChannel(normalizedInput) {
// 非自定义任务必须校验 channelId + model 属于启用的系统渠道模型。
if err := s.validateSystemChannelModelSelection(normalizedInput); err != nil {
}
if !workflowProviderTask {
if frontendEnabled {
// 前台模型模式:必须有 logicalModelId
if logicalModelID == "" {
return nil, InvalidModelSelection("前台模型模式下必须指定 logicalModelId")
}
intent := ModelRequestIntentFromTaskInput(normalizedInput, taskType, req.Operation)
routed, err = s.ResolveLogicalModel(logicalModelID, intent)
if err != nil {
return nil, err
}
normalizedInput = applyRoutedProviderSelection(normalizedInput, routed)
} else {
// 系统渠道模型模式:禁止 logicalModelId
if logicalModelID != "" {
return nil, ModelCatalogMismatch("模型目录已更新,请重新选择")
}
// 自定义渠道没有系统 channelId;它会在后续由自定义渠道功能开关、
// 能力校验和 provider 配置校验共同处理,不能误报为“缺少系统渠道”。
if !taskInputUsesCustomChannel(normalizedInput) {
if err := s.validateSystemChannelModelSelection(normalizedInput); err != nil {
return nil, err
}
}
}
}
@@ -306,6 +310,9 @@ func (s *Service) validateSystemChannelModelSelection(input map[string]any) erro
}
func taskInputUsesCustomChannel(input map[string]any) bool {
if taskInputUsesWorkflowProvider(input) {
return false
}
config, ok := input["config"].(map[string]any)
if !ok {
return false
@@ -319,6 +326,14 @@ func taskInputUsesCustomChannel(input map[string]any) bool {
return strings.TrimSpace(baseURL) != "" && strings.TrimSpace(apiKey) != ""
}
func taskInputUsesWorkflowProvider(input map[string]any) bool {
config, ok := input["config"].(map[string]any)
if !ok {
return false
}
return isWorkflowProviderInterface(strings.TrimSpace(fmt.Sprint(config["interfaceType"])))
}
func compactPersistedValue(value interface{}) interface{} {
switch item := value.(type) {
case map[string]interface{}:

View File

@@ -0,0 +1,28 @@
package service
import "testing"
func TestTaskInputUsesWorkflowProvider(t *testing.T) {
tests := []struct {
name string
input map[string]any
want bool
}{
{name: "runninghub workflow", input: map[string]any{"config": map[string]any{"interfaceType": "runninghub-workflow-video"}}, want: true},
{name: "comfy bridge", input: map[string]any{"config": map[string]any{"interfaceType": "comfyui-bridge-image"}}, want: true},
{name: "case insensitive", input: map[string]any{"config": map[string]any{"interfaceType": "RunningHub-Workflow-Audio"}}, want: true},
{name: "ordinary model", input: map[string]any{"config": map[string]any{"interfaceType": "openai-image", "channelId": "system-1", "model": "image-model"}}, want: false},
{name: "missing config", input: map[string]any{}, want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := taskInputUsesWorkflowProvider(test.input); got != test.want {
t.Fatalf("taskInputUsesWorkflowProvider() = %v, want %v", got, test.want)
}
if test.want && taskInputUsesCustomChannel(test.input) {
t.Fatal("workflow input must not be classified as a custom channel")
}
})
}
}

View File

@@ -57,7 +57,30 @@ func canRunProviderTask(task model.Task) bool {
func hasExecutableProviderVideoConfig(input map[string]any) bool {
mode, _ := input["mode"].(string)
config, ok := input["config"].(map[string]any)
if mode != "video" || !ok || stringValue(config["model"]) == "" {
if mode != "video" || !ok {
return false
}
interfaceType := stringValue(config["interfaceType"])
if isComfyBridgeInterface(interfaceType) {
workflowJSON, hasWorkflowJSON := config["workflowJson"]
workflowReady := false
if hasWorkflowJSON {
switch value := workflowJSON.(type) {
case map[string]interface{}:
workflowReady = len(value) > 0
case string:
workflowReady = strings.TrimSpace(value) != ""
}
}
return stringValue(config["bridgeId"]) != "" && (stringValue(config["workflowId"]) != "" || workflowReady)
}
if isRunningHubInterface(interfaceType) {
if stringValue(config["workflowId"]) == "" && stringValue(config["webappId"]) == "" && stringValue(config["model"]) == "" {
return false
}
return stringValue(config["baseUrl"]) != "" && stringValue(config["apiKey"]) != ""
}
if stringValue(config["model"]) == "" {
return false
}
return stringValue(config["channelId"]) != "" || (stringValue(config["baseUrl"]) != "" && stringValue(config["apiKey"]) != "")

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
@@ -108,10 +109,73 @@ func (w *taskLifecycleCoordinator) cancelTask(_ context.Context, userID string,
if err != nil {
return nil, err
}
// 任务创建后可能已经产生上游费用。取消接口保留用于兼容旧客户端,
// 但不能再改变任务状态、退款或结束关联会话;页面离开只应停止监听。
if task.Status == model.TaskStatusQueued || task.Status == model.TaskStatusRunning || task.ProviderRequestID != "" {
return nil, errors.New("任务已发起,无法取消,请等待任务完成")
if task.Status != model.TaskStatusQueued && task.Status != model.TaskStatusRunning {
if task.Status == model.TaskStatusCancelled {
return taskForOutput(*task), nil
}
return nil, fmt.Errorf("任务当前状态为 %s,无法取消", task.Status)
}
return nil, fmt.Errorf("任务当前状态为 %s,无法取消", task.Status)
// 先从账单和请求日志补齐上游 ID,再做条件更新。取消与 worker 完成之间
// 以数据库终态为准,避免“用户已取消但迟到结果又把任务写成成功”。
s.hydrateTaskProviderRequestID(task)
originalStatus := task.Status
now := time.Now()
cancelled, err := s.repo.CancelTaskIfStatus(userID, id, task.Status, now)
if err != nil {
return nil, err
}
if !cancelled {
latest, latestErr := s.repo.TaskForUser(userID, id)
if latestErr != nil {
return nil, latestErr
}
if latest.Status == model.TaskStatusCancelled {
return taskForOutput(*latest), nil
}
return nil, errors.New("任务状态已变化,请刷新后重试")
}
task.Status = model.TaskStatusCancelled
task.Stage = "任务已取消"
task.Error = "任务已取消"
task.CompletedAt = &now
s.cancelActiveTask(task.ID)
// 这些收尾操作必须幂等;任何单项失败都记录日志,但不能让已经落库的
// cancelled 状态重新对用户表现为“取消失败”。
if err := s.markSessionFailed(*task, "会话任务已取消。"); err != nil {
_ = s.log(task.UserID, task.ID, "error", "取消任务后更新会话状态失败", err.Error())
}
if err := s.finalizeTaskTextReplay(task.ID, model.TaskStatusCancelled); err != nil {
_ = s.log(task.UserID, task.ID, "error", "取消任务后归并文本回放失败", err.Error())
}
_ = s.log(task.UserID, task.ID, "warn", "用户主动取消任务", "")
if task.ProviderRequestID != "" {
// 上游取消可能需要轮询确认,不能阻塞取消接口;请求上下文也不能因
// 浏览器关闭而中断。后台对账会继续负责退款或费用核对。
cancelTask := *task
go func() {
requestCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.requestProviderCancellation(requestCtx, &cancelTask); err != nil {
_ = s.log(cancelTask.UserID, cancelTask.ID, "error", "发送上游取消请求失败", err.Error())
}
}()
} else {
var billingErr error
if originalStatus == model.TaskStatusQueued {
billingErr = s.taskBilling().RefundBilling(task.BillingOrderID, "用户主动取消,且任务尚未开始执行")
} else {
// running 任务可能已经发起上游调用但尚未把 request ID 写回,不能
// 直接退款后放任上游继续生成,先冻结为待核对更安全。
billingErr = s.taskBilling().MarkBillingUncertain(task.BillingOrderID, "用户取消时上游请求 ID 尚未确认,费用待核对")
}
if billingErr != nil {
_ = s.log(task.UserID, task.ID, "error", "取消任务后处理积分失败,已保留人工核对线索", billingErr.Error())
}
}
return taskForOutput(*task), nil
}

View File

@@ -95,6 +95,11 @@ func (c *taskTerminalCoordinator) markPreparationFailure(task *model.Task, stage
// 让 worker 保留重试/监控所需的失败语义。
func (c *taskTerminalCoordinator) handleExecutionFailure(task *model.Task, err error, providerSucceeded bool, channelSlotFailedBeforeRequest bool) error {
if errors.Is(err, context.Canceled) {
// 用户取消会先把数据库任务置为 cancelled,再停止 worker context。
// 此时不再重复退款/核对,只补齐 worker 侧的会话、回放和日志收尾。
if latest, latestErr := c.repo.Task(task.ID); latestErr == nil && latest.Status == model.TaskStatusCancelled {
return c.handleAlreadyCancelled(*latest)
}
task.Status = model.TaskStatusCancelled
task.Stage = "任务已取消"
task.Error = "任务已取消"
@@ -145,6 +150,16 @@ func (c *taskTerminalCoordinator) handleExecutionFailure(task *model.Task, err e
return resultErr
}
func (c *taskTerminalCoordinator) handleAlreadyCancelled(task model.Task) error {
c.finalizeReplay(&task, model.TaskStatusCancelled, "文本回放草稿归并失败")
sessionErr := c.sessions.markSessionFailed(task, "会话任务已取消。")
_ = c.logger.log(task.UserID, task.ID, "warn", "任务已取消,worker 已停止执行", "")
if sessionErr != nil {
return fmt.Errorf("任务取消后的会话状态更新失败:%w", sessionErr)
}
return nil
}
func (c *taskTerminalCoordinator) handleCancelledResult(task model.Task) error {
c.finalizeReplay(&task, model.TaskStatusCancelled, "文本回放草稿归并失败")
billingErr := c.billing.MarkBillingUncertain(task.BillingOrderID, "上游已返回结果,但任务被取消")

View File

@@ -110,6 +110,11 @@ func (w *taskWorkerCoordinator) processClaimedTask(task *model.Task) error {
defer close(leaseDone)
s.registerActiveTask(task.ID, cancel)
defer s.unregisterActiveTask(task.ID)
// 取消请求可能在任务领取和注册 worker context 之间到达;再次读取终态
// 可以避免这种极窄窗口仍然向上游发起调用。
if latest, latestErr := s.repo.Task(task.ID); latestErr == nil && latest.Status == model.TaskStatusCancelled {
return terminal.handleAlreadyCancelled(*latest)
}
task.Stage = "调用生成模型"
task.Progress = 35

File diff suppressed because it is too large Load Diff