feat(workflow): 工作流提供方 - 集成 RunningHub 与 ComfyUI Bridge
This commit is contained in:
583
backend/internal/service/comfy_bridge.go
Normal file
583
backend/internal/service/comfy_bridge.go
Normal 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
|
||||
}
|
||||
}
|
||||
100
backend/internal/service/comfy_bridge_persistence_test.go
Normal file
100
backend/internal/service/comfy_bridge_persistence_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "当前上游协议不支持取消,费用待核对")
|
||||
}
|
||||
|
||||
561
backend/internal/service/runninghub_management.go
Normal file
561
backend/internal/service/runninghub_management.go
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}:
|
||||
|
||||
28
backend/internal/service/task_creation_test.go
Normal file
28
backend/internal/service/task_creation_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"]) != "")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "上游已返回结果,但任务被取消")
|
||||
|
||||
@@ -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
|
||||
|
||||
2172
backend/internal/service/workflow_provider.go
Normal file
2172
backend/internal/service/workflow_provider.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user