383 lines
14 KiB
Go
383 lines
14 KiB
Go
package service
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"infinite-canvas/backend/internal/model"
|
||
"infinite-canvas/backend/internal/repository"
|
||
)
|
||
|
||
// CreateTask 收敛任务进入系统前的 admission 流程:输入标准化、逻辑模型路由、
|
||
// 能力/额度校验和持久化。执行阶段由 worker 与 provider 相关模块负责。
|
||
func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, error) {
|
||
prompt := strings.TrimSpace(req.Prompt)
|
||
if prompt == "" {
|
||
return nil, errors.New("prompt is required")
|
||
}
|
||
taskType := strings.TrimSpace(req.Type)
|
||
if err := validateTaskType(taskType); err != nil {
|
||
return nil, err
|
||
}
|
||
normalizedInput, err := normalizeTaskInput(req.Input)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var routed *RoutedModel
|
||
logicalModelID := strings.TrimSpace(req.LogicalModelID)
|
||
workflowProviderTask := taskInputUsesWorkflowProvider(normalizedInput)
|
||
frontendEnabled := false
|
||
if workflowProviderTask {
|
||
config, _ := normalizedInput["config"].(map[string]any)
|
||
if err := s.RequireWorkflowPluginForUser(userID, strings.TrimSpace(fmt.Sprint(config["interfaceType"]))); err != nil {
|
||
return nil, err
|
||
}
|
||
} else {
|
||
// 工作流是独立执行器;普通模型仍严格使用主线的目录和路由校验。
|
||
frontendEnabled, err = s.FeatureEnabled(FeatureFrontendModels)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
if !workflowProviderTask {
|
||
routed, normalizedInput, err = s.resolveTaskModelSelection(normalizedInput, logicalModelID, taskType, req.Operation, frontendEnabled)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
if strings.HasPrefix(taskType, "video_") && !hasExecutableProviderVideoConfig(normalizedInput) {
|
||
if mode, _ := normalizedInput["mode"].(string); mode != "video" {
|
||
return nil, errors.New("视频任务必须使用 video 模式")
|
||
}
|
||
return nil, errors.New("视频任务缺少可执行的模型配置")
|
||
}
|
||
// 前端自管的文本持久化任务:直连模型生成、增量上报 text-deltas,不排入 worker 队列生成。
|
||
if isTextReplayTaskRequest(normalizedInput) {
|
||
return s.createTextReplayTask(userID, req, normalizedInput)
|
||
}
|
||
if err := s.requireCustomChannelsForTaskInput(normalizedInput); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.ValidateTaskCapability(normalizedInput); err != nil {
|
||
return nil, err
|
||
}
|
||
if containsInlineMediaDataURL(normalizedInput) {
|
||
return nil, BadAuthRequest("任务输入不能包含内嵌媒体,请先上传到资源存储")
|
||
}
|
||
policy, err := s.RuntimePolicy()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
activeTasks, err := s.repo.ActiveTaskCountForUser(userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if activeTasks >= int64(policy.Task.ActiveTaskLimit) {
|
||
return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit))
|
||
}
|
||
task := model.Task{ID: newID(), UserID: userID, TraceID: req.TraceID, RequestID: req.RequestID, SessionID: req.SessionID, ProjectID: req.ProjectID, Type: taskType, Status: model.TaskStatusQueued, Stage: "等待队列调度", Progress: 5, Prompt: prompt, Operation: req.Operation, Provider: req.Provider, Model: req.Model}
|
||
if routed != nil {
|
||
task.LogicalModelID = routed.LogicalModel.ID
|
||
task.LogicalModelRevisionID = routed.Revision.ID
|
||
task.RouteID = routed.Route.ID
|
||
task.ChannelModelID = routed.ChannelModel.ID
|
||
task.RouteRun = 1
|
||
task.Model = routed.LogicalModel.Code
|
||
task.Provider = "managed"
|
||
}
|
||
if err := s.ensureTaskProjectActive(userID, req.ProjectID); err != nil {
|
||
return nil, err
|
||
}
|
||
billingOrder, err := s.taskBillingOrder(userID, &task, normalizedInput)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.protectTaskSecrets(normalizedInput); err != nil {
|
||
return nil, err
|
||
}
|
||
inputJSON, err := json.Marshal(normalizedInput)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("序列化任务输入失败:%w", err)
|
||
}
|
||
task.InputJSON = string(inputJSON)
|
||
if billingOrder != nil {
|
||
task.BillingOrderID = billingOrder.ID
|
||
}
|
||
err = s.createTaskWithinStorageQuota(&task, billingOrder, policy)
|
||
if errors.Is(err, repository.ErrActiveTaskLimit) {
|
||
return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit))
|
||
}
|
||
if errors.Is(err, repository.ErrInsufficientCredits) {
|
||
return nil, BadAuthRequest("积分不足,请先使用兑换码充值")
|
||
}
|
||
if errors.Is(err, repository.ErrLogicalModelUnavailable) {
|
||
return nil, BadAuthRequest("所选模型已停用、归档或配置已更新,请重新选择")
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.recordActivity(userID, "task", 1)
|
||
_ = s.log(userID, task.ID, "info", "任务已进入队列", "")
|
||
return taskForOutput(task), nil
|
||
}
|
||
|
||
// resolveTaskModelSelection 根据请求实际携带的模型选择决定路由方式。
|
||
// 显式系统渠道和用户自定义渠道请求不能被全局前台模型开关误判;
|
||
// 它们仍分别进入系统目录校验或自定义渠道的功能、能力与安全校验。
|
||
func (s *Service) resolveTaskModelSelection(input map[string]any, logicalModelID string, taskType string, operation string, frontendEnabled bool) (*RoutedModel, map[string]any, error) {
|
||
customChannelTask := taskInputUsesCustomChannel(input)
|
||
if frontendEnabled && !taskInputUsesSystemChannel(input) && !customChannelTask {
|
||
if logicalModelID == "" {
|
||
return nil, input, InvalidModelSelection("前台模型模式下必须指定 logicalModelId")
|
||
}
|
||
intent := ModelRequestIntentFromTaskInput(input, taskType, operation)
|
||
routed, err := s.ResolveLogicalModel(logicalModelID, intent)
|
||
if err != nil {
|
||
return nil, input, err
|
||
}
|
||
return routed, applyRoutedProviderSelection(input, routed), nil
|
||
}
|
||
|
||
if logicalModelID != "" {
|
||
return nil, input, ModelCatalogMismatch("模型目录已更新,请重新选择")
|
||
}
|
||
// 自定义渠道没有系统 channelId;它会在后续由自定义渠道功能开关、
|
||
// 能力校验和 provider 配置校验共同处理,不能误报为“缺少系统渠道”。
|
||
if !customChannelTask {
|
||
if err := s.validateSystemChannelModelSelection(input); err != nil {
|
||
return nil, input, err
|
||
}
|
||
}
|
||
return nil, input, nil
|
||
}
|
||
|
||
func applyRoutedProviderSelection(input map[string]any, routed *RoutedModel) map[string]any {
|
||
config, _ := input["config"].(map[string]any)
|
||
nextConfig := make(map[string]any, len(config)+2)
|
||
for key, value := range config {
|
||
switch key {
|
||
case "channelId", "channelModelKey", "priceTierId", "providerModelKey", "apiFormat", "interfaceType", "baseUrl", "allowLocalChannel", "apiKey", "secretKey", "headers", "model", "capabilityConfig":
|
||
continue
|
||
default:
|
||
nextConfig[key] = value
|
||
}
|
||
}
|
||
for key, value := range routed.Defaults {
|
||
canonical := canonicalCapabilityOptionName(key)
|
||
if existing, exists := nextConfig[canonical]; !exists || existing == nil || strings.TrimSpace(fmt.Sprint(existing)) == "" {
|
||
nextConfig[canonical] = providerConfigOptionValue(value)
|
||
}
|
||
}
|
||
// 路由匹配和真实请求必须使用同一组参数;逻辑能力参数覆盖空的页面配置,但不携带供应链字段。
|
||
if options, ok := input["capabilityOptions"].(map[string]any); ok {
|
||
for key, value := range options {
|
||
canonical := canonicalCapabilityOptionName(key)
|
||
if isProviderCapabilityOption(canonical) {
|
||
nextConfig[canonical] = providerConfigOptionValue(value)
|
||
}
|
||
}
|
||
}
|
||
nextConfig["channelId"] = routed.ChannelModel.ChannelID
|
||
nextConfig["model"] = routed.ChannelModel.ModelKey
|
||
nextConfig["channelModelKey"] = routed.ChannelModel.ModelKey
|
||
if routed.PriceTier != nil {
|
||
nextConfig["priceTierId"] = routed.PriceTier.ID
|
||
nextConfig["providerModelKey"] = routed.PriceTier.ProviderModelKey
|
||
}
|
||
input["config"] = nextConfig
|
||
return input
|
||
}
|
||
|
||
func providerConfigOptionValue(value any) string {
|
||
switch typed := value.(type) {
|
||
case string:
|
||
return typed
|
||
case json.Number:
|
||
return typed.String()
|
||
default:
|
||
return fmt.Sprint(value)
|
||
}
|
||
}
|
||
|
||
// 所有任务输入先收敛为 JSON 对象,确保计费与密钥保护不会因 Go 结构体类型不同而被绕过。
|
||
func normalizeTaskInput(input map[string]any) (map[string]any, error) {
|
||
if input == nil {
|
||
return map[string]any{}, nil
|
||
}
|
||
encoded, err := json.Marshal(input)
|
||
if err != nil {
|
||
return nil, BadAuthRequest("任务输入格式无效")
|
||
}
|
||
var normalized map[string]any
|
||
if err := json.Unmarshal(encoded, &normalized); err != nil {
|
||
return nil, BadAuthRequest("任务输入格式无效")
|
||
}
|
||
if snapshot, ok := normalized["canvasSnapshot"]; ok {
|
||
normalized["canvasSnapshot"] = compactPersistedValue(snapshot)
|
||
}
|
||
return normalized, nil
|
||
}
|
||
|
||
// createTextReplayTask 创建前端自管的文本持久化任务:状态为 text_replay,
|
||
// 不排队执行、不计 active 队列、不产生计费,仅作为正文增量(text-deltas)的存储容器。
|
||
func (s *Service) createTextReplayTask(userID string, req CreateTaskRequest, normalizedInput map[string]any) (*model.Task, error) {
|
||
prompt := strings.TrimSpace(req.Prompt)
|
||
if prompt == "" {
|
||
prompt = strings.TrimSpace(fmt.Sprint(normalizedInput["prompt"]))
|
||
}
|
||
if prompt == "" {
|
||
return nil, errors.New("prompt is required")
|
||
}
|
||
taskType := strings.TrimSpace(req.Type)
|
||
if err := validateTaskType(taskType); err != nil {
|
||
return nil, err
|
||
}
|
||
task := model.Task{
|
||
ID: newID(), UserID: userID, TraceID: req.TraceID, RequestID: req.RequestID, SessionID: req.SessionID, ProjectID: req.ProjectID,
|
||
Type: taskType, Status: model.TaskStatusTextReplay, Stage: "文本持久化(前端自管)", Progress: 5,
|
||
Prompt: prompt, Operation: req.Operation, Provider: req.Provider, Model: strings.TrimSpace(req.Model),
|
||
}
|
||
if err := s.protectTaskSecrets(normalizedInput); err != nil {
|
||
return nil, err
|
||
}
|
||
inputJSON, _ := json.Marshal(normalizedInput)
|
||
task.InputJSON = string(inputJSON)
|
||
policy, err := s.RuntimePolicy()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.createTaskWithinStorageQuota(&task, nil, policy); err != nil {
|
||
return nil, err
|
||
}
|
||
_ = s.log(userID, task.ID, "info", "文本持久化任务已创建(前端自管)", "")
|
||
return taskForOutput(task), nil
|
||
}
|
||
|
||
// validateTaskType 是任务进入队列前的边界校验。视频任务允许携带具体操作后缀,
|
||
// 其他任务类型必须是已实现的执行分支,避免未知类型落入假成功工作流。
|
||
func validateTaskType(taskType string) error {
|
||
switch taskType {
|
||
case "text", "agent_storyboard", "agent_storyboard_rows", "canvas_text", "canvas_image", "canvas_video", "canvas_audio":
|
||
return nil
|
||
}
|
||
if strings.HasPrefix(taskType, "video_") && strings.TrimPrefix(taskType, "video_") != "" {
|
||
return nil
|
||
}
|
||
if taskType == "" {
|
||
return errors.New("task type is required")
|
||
}
|
||
return fmt.Errorf("不支持的任务类型:%s", taskType)
|
||
}
|
||
|
||
func (s *Service) requireCustomChannelsForTaskInput(input map[string]any) error {
|
||
if !taskInputUsesCustomChannel(input) {
|
||
return nil
|
||
}
|
||
return s.RequireFeature(FeatureCustomChannels)
|
||
}
|
||
|
||
// validateSystemChannelModelSelection 校验系统渠道模型选择的有效性
|
||
func (s *Service) validateSystemChannelModelSelection(input map[string]any) error {
|
||
config, ok := input["config"].(map[string]any)
|
||
if !ok {
|
||
return InvalidModelSelection("缺少模型配置")
|
||
}
|
||
|
||
channelID, _ := config["channelId"].(string)
|
||
modelKey, _ := config["model"].(string)
|
||
|
||
channelID = strings.TrimSpace(channelID)
|
||
modelKey = strings.TrimSpace(modelKey)
|
||
|
||
if channelID == "" || modelKey == "" {
|
||
return InvalidModelSelection("必须指定系统渠道和模型")
|
||
}
|
||
|
||
// 验证渠道存在且启用
|
||
channel, err := s.repo.SystemChannel(channelID)
|
||
if err != nil {
|
||
return InvalidModelSelection("指定的渠道不存在")
|
||
}
|
||
if !channel.Enabled || channel.Scope != model.ChannelScopeSystem {
|
||
return InvalidModelSelection("指定的渠道不可用")
|
||
}
|
||
|
||
// 验证渠道模型存在且启用
|
||
channelModel, err := s.repo.ChannelModelByKey(channelID, modelKey)
|
||
if err != nil {
|
||
return InvalidModelSelection("指定的模型不存在")
|
||
}
|
||
if !channelModel.Enabled {
|
||
return InvalidModelSelection("指定的模型已停用")
|
||
}
|
||
|
||
// 验证价格配置
|
||
if !HasValidPrice(channelModel) {
|
||
return ModelPriceNotConfigured("指定的模型未配置有效价格")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func taskInputUsesCustomChannel(input map[string]any) bool {
|
||
if taskInputUsesWorkflowProvider(input) {
|
||
return false
|
||
}
|
||
config, ok := input["config"].(map[string]any)
|
||
if !ok {
|
||
return false
|
||
}
|
||
channelID, _ := config["channelId"].(string)
|
||
baseURL, _ := config["baseUrl"].(string)
|
||
apiKey, _ := config["apiKey"].(string)
|
||
if strings.TrimSpace(channelID) != "" || systemChannelIDFromBaseURL(baseURL) != "" {
|
||
return false
|
||
}
|
||
return strings.TrimSpace(baseURL) != "" && strings.TrimSpace(apiKey) != ""
|
||
}
|
||
|
||
func taskInputUsesSystemChannel(input map[string]any) bool {
|
||
config, ok := input["config"].(map[string]any)
|
||
if !ok {
|
||
return false
|
||
}
|
||
channelID, _ := config["channelId"].(string)
|
||
return strings.TrimSpace(channelID) != ""
|
||
}
|
||
|
||
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{}:
|
||
result := make(map[string]interface{}, len(item))
|
||
for key, child := range item {
|
||
if text, ok := child.(string); ok && strings.HasPrefix(text, "data:") {
|
||
result[key] = ""
|
||
continue
|
||
}
|
||
result[key] = compactPersistedValue(child)
|
||
}
|
||
return result
|
||
case []interface{}:
|
||
result := make([]interface{}, len(item))
|
||
for index, child := range item {
|
||
result[index] = compactPersistedValue(child)
|
||
}
|
||
return result
|
||
default:
|
||
return value
|
||
}
|
||
}
|