426 lines
14 KiB
Go
426 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"infinite-canvas/backend/internal/model"
|
|
"infinite-canvas/backend/internal/payment"
|
|
"infinite-canvas/backend/internal/repository"
|
|
)
|
|
|
|
type Service struct {
|
|
repo *repository.Repository
|
|
dataDir string
|
|
runtimeCapabilities RuntimeCapabilities
|
|
cancelMu sync.Mutex
|
|
registrationMu sync.Mutex
|
|
emailCodeMu sync.Mutex
|
|
redeemBatchMu sync.Mutex
|
|
storageMu sync.Mutex
|
|
storageTestMu sync.Mutex
|
|
workerRuntimeMu sync.Mutex
|
|
activeStorageTests map[string]bool
|
|
characterTaskMu sync.Mutex
|
|
activeCancels map[string]context.CancelFunc
|
|
pendingStorage map[string]int64
|
|
coordinator *runtimeCoordinator
|
|
taskBillingCoordinator *taskBillingCoordinator
|
|
taskTerminalCoordinator *taskTerminalCoordinator
|
|
taskRouteExecutor *taskRouteExecutor
|
|
taskWorkerCoordinator *taskWorkerCoordinator
|
|
taskLifecycleCoordinator *taskLifecycleCoordinator
|
|
sessionCreationCoordinator *sessionCreationCoordinator
|
|
sessionUploadCoordinator *sessionUploadCoordinator
|
|
runtimeErr error
|
|
pluginRuntime *pluginRuntime
|
|
pluginRuntimeErr error
|
|
paymentRegistry *payment.Registry
|
|
workerID string
|
|
routeCatalogMu sync.RWMutex
|
|
routeCatalogRefreshMu sync.Mutex
|
|
routeCatalog *routeCatalogSnapshot
|
|
routeCatalogTTL time.Duration
|
|
routeCatalogMaxStale time.Duration
|
|
routeCatalogVersion int64
|
|
routeHealthMu sync.Mutex
|
|
routeHealthBlocked map[string]time.Time
|
|
workers *workerRuntime
|
|
updateManager UpdateManager
|
|
mailSender func(emailSettingValue, string, string, string) error
|
|
}
|
|
|
|
const taskWorkerConcurrency = 3
|
|
const taskLogPayloadLimit = 4000
|
|
|
|
type CreateSessionRequest struct {
|
|
ProjectID string `json:"projectId"`
|
|
Prompt string `json:"prompt"`
|
|
CanvasSnapshot map[string]any `json:"canvasSnapshot"`
|
|
References []string `json:"references"`
|
|
Requirements string `json:"requirements"`
|
|
CanvasAssets []storyboardAsset `json:"canvasAssets"`
|
|
ProjectStyle storyboardProjectStyle `json:"projectStyle"`
|
|
Characters []storyboardCharacterCard `json:"characters"`
|
|
Config providerConfig `json:"config"`
|
|
LogicalModelID string `json:"logicalModelId"`
|
|
TraceID string `json:"-"`
|
|
RequestID string `json:"-"`
|
|
}
|
|
|
|
type CreateTaskRequest struct {
|
|
SessionID string `json:"sessionId"`
|
|
ProjectID string `json:"projectId"`
|
|
Type string `json:"type"`
|
|
Operation string `json:"operation"`
|
|
Prompt string `json:"prompt"`
|
|
Provider string `json:"provider"`
|
|
Model string `json:"model"`
|
|
LogicalModelID string `json:"logicalModelId"`
|
|
Input map[string]any `json:"input"`
|
|
TraceID string `json:"-"`
|
|
RequestID string `json:"-"`
|
|
}
|
|
|
|
type SessionDetail struct {
|
|
Session model.Session `json:"session"`
|
|
Messages []model.Message `json:"messages"`
|
|
Tasks []TaskSummary `json:"tasks"`
|
|
Results []model.Result `json:"results"`
|
|
}
|
|
|
|
type TaskListOptions struct {
|
|
Limit int
|
|
ProjectID string
|
|
ActiveOnly bool
|
|
}
|
|
|
|
func New(repo *repository.Repository, dataDir string) *Service {
|
|
return NewWithRuntimeCapabilities(repo, dataDir, RuntimeCapabilities{})
|
|
}
|
|
|
|
func NewWithRuntimeCapabilities(repo *repository.Repository, dataDir string, capabilities RuntimeCapabilities) *Service {
|
|
coordinator, err := newRuntimeCoordinator(repo.Dialect())
|
|
pluginRuntime, pluginRuntimeErr := newPluginRuntime(dataDir)
|
|
paymentRegistry, _ := payment.NewRegistry()
|
|
if pluginRuntime != nil {
|
|
if dynamic := pluginRuntime.paymentRegistrySnapshot(); dynamic != nil {
|
|
paymentRegistry = dynamic
|
|
}
|
|
}
|
|
service := &Service{repo: repo, dataDir: dataDir, runtimeCapabilities: capabilities, activeStorageTests: make(map[string]bool), activeCancels: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, pluginRuntime: pluginRuntime, pluginRuntimeErr: pluginRuntimeErr, paymentRegistry: paymentRegistry, workerID: newID(), routeCatalogTTL: 30 * time.Second, routeCatalogMaxStale: 5 * time.Minute, routeHealthBlocked: make(map[string]time.Time)}
|
|
service.taskBillingCoordinator = newTaskBillingCoordinator(service.repo)
|
|
service.taskTerminalCoordinator = newTaskTerminalCoordinator(service)
|
|
service.taskRouteExecutor = newTaskRouteExecutor(service)
|
|
service.taskWorkerCoordinator = newTaskWorkerCoordinator(service)
|
|
service.taskLifecycleCoordinator = newTaskLifecycleCoordinator(service)
|
|
service.sessionCreationCoordinator = newSessionCreationCoordinator(service)
|
|
service.sessionUploadCoordinator = newSessionUploadCoordinator(service)
|
|
return service
|
|
}
|
|
|
|
func (s *Service) taskBilling() *taskBillingCoordinator {
|
|
if s.taskBillingCoordinator != nil {
|
|
return s.taskBillingCoordinator
|
|
}
|
|
// 部分单元测试直接构造 Service 字面量;延迟创建保持这些测试和内部工具兼容。
|
|
return newTaskBillingCoordinator(s.repo)
|
|
}
|
|
|
|
func (s *Service) StartWorker() {
|
|
runtime := s.backgroundWorkers()
|
|
ctx, started := runtime.start()
|
|
if !started {
|
|
return
|
|
}
|
|
s.taskWorker().start(ctx)
|
|
s.startResourceDeletionWorker(ctx)
|
|
s.startSkillSyncWorker(ctx)
|
|
s.startPaymentWorker(ctx)
|
|
}
|
|
|
|
func (s *Service) BeginDrain() { s.backgroundWorkers().beginDrain() }
|
|
|
|
func (s *Service) StopWorker(ctx context.Context) error { return s.backgroundWorkers().stop(ctx) }
|
|
|
|
func (s *Service) IsDraining() bool { return s.backgroundWorkers().isDraining() }
|
|
|
|
func (s *Service) ActiveWorkerTasks() int64 { return s.backgroundWorkers().activeTaskCount() }
|
|
|
|
func (s *Service) backgroundWorkers() *workerRuntime {
|
|
s.workerRuntimeMu.Lock()
|
|
defer s.workerRuntimeMu.Unlock()
|
|
if s.workers == nil {
|
|
s.workers = &workerRuntime{}
|
|
}
|
|
return s.workers
|
|
}
|
|
|
|
func (s *Service) runWorkerLoop(fn func(context.Context)) bool {
|
|
return s.backgroundWorkers().goLoop(fn)
|
|
}
|
|
|
|
func (s *Service) runWorkerTask(fn func()) bool {
|
|
return s.backgroundWorkers().goTask(fn)
|
|
}
|
|
|
|
func (s *Service) CreateSession(userID string, req CreateSessionRequest) (*SessionDetail, error) {
|
|
return s.sessionCreation().create(userID, req)
|
|
}
|
|
|
|
func channelModelNames(channel model.ModelChannel) []string {
|
|
models := []string{}
|
|
_ = json.Unmarshal([]byte(channel.ModelsJSON), &models)
|
|
return uniqueNonEmpty(models)
|
|
}
|
|
|
|
func (s *Service) SessionDetail(userID string, id string) (*SessionDetail, error) {
|
|
session, err := s.repo.SessionForUser(userID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
messages, err := s.repo.SessionMessages(userID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tasks, err := s.repo.SessionTasks(userID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
taskSummaries := taskSummariesForOutput(tasks)
|
|
results, err := s.repo.SessionResults(userID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &SessionDetail{Session: *session, Messages: messages, Tasks: taskSummaries, Results: results}, nil
|
|
}
|
|
|
|
func (s *Service) Tasks(userID string, limit int) ([]TaskSummary, error) {
|
|
return s.TasksWithOptions(userID, TaskListOptions{Limit: limit})
|
|
}
|
|
|
|
func (s *Service) TasksWithOptions(userID string, options TaskListOptions) ([]TaskSummary, error) {
|
|
tasks, err := s.repo.Tasks(userID, options.Limit, options.ProjectID, options.ActiveOnly)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
orders, err := s.repo.BillingOrdersByTaskIDs(userID, taskBillingTaskIDs(tasks))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return taskSummariesForOutputWithBilling(tasks, orders), nil
|
|
}
|
|
|
|
func (s *Service) Task(userID string, id string) (*model.Task, error) {
|
|
task, err := s.repo.TaskForUser(userID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.hydrateTaskProviderRequestID(task)
|
|
return taskForOutput(*task), nil
|
|
}
|
|
|
|
func (s *Service) hydrateTaskProviderRequestID(task *model.Task) {
|
|
if task == nil || task.ProviderRequestID != "" {
|
|
return
|
|
}
|
|
if task.BillingOrderID != "" {
|
|
if order, err := s.repo.BillingOrder(task.BillingOrderID); err == nil {
|
|
task.ProviderRequestID = strings.TrimSpace(order.ProviderRequestID)
|
|
}
|
|
}
|
|
if task.ProviderRequestID == "" {
|
|
if providerRequestID, err := s.repo.LatestProviderRequestIDForTask(task.ID); err == nil {
|
|
task.ProviderRequestID = providerRequestID
|
|
}
|
|
}
|
|
}
|
|
|
|
// 上游请求日志会在任务执行期间更新 provider 状态,终态保存前必须重新合并,避免旧任务对象覆盖可恢复 ID。
|
|
func (s *Service) refreshTaskProviderState(task *model.Task) error {
|
|
if task == nil || task.ID == "" {
|
|
return errors.New("任务状态无效")
|
|
}
|
|
latest, err := s.repo.Task(task.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("刷新任务上游状态失败:%w", err)
|
|
}
|
|
if latest.ProviderRequestID != "" {
|
|
task.ProviderRequestID = latest.ProviderRequestID
|
|
}
|
|
task.PollStage = latest.PollStage
|
|
task.NextPollAt = latest.NextPollAt
|
|
task.ProviderCancelStatus = latest.ProviderCancelStatus
|
|
task.ProviderCancelError = latest.ProviderCancelError
|
|
task.ProviderCancelAttempts = latest.ProviderCancelAttempts
|
|
task.ProviderCancelRequestedAt = latest.ProviderCancelRequestedAt
|
|
task.ProviderCancelledAt = latest.ProviderCancelledAt
|
|
task.ProviderCancelNextCheckAt = latest.ProviderCancelNextCheckAt
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) RetryTask(userID string, id string) (*model.Task, error) {
|
|
return s.taskLifecycle().retryTask(userID, id)
|
|
}
|
|
|
|
func (s *Service) CancelTask(ctx context.Context, userID string, id string) (*model.Task, error) {
|
|
return s.taskLifecycle().cancelTask(ctx, userID, id)
|
|
}
|
|
|
|
func (s *Service) TaskLogs(userID string, id string) ([]model.TaskLog, error) {
|
|
return s.repo.TaskLogs(userID, id)
|
|
}
|
|
|
|
func (s *Service) ProcessNextTask() error {
|
|
return s.taskWorker().processNextTask()
|
|
}
|
|
|
|
func shotIDs(prefix string, count int) []string {
|
|
ids := make([]string, 0, count)
|
|
for index := 0; index < count; index++ {
|
|
ids = append(ids, fmt.Sprintf("%s-shot-%d", prefix, index+1))
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func stringSlice(value any) []string {
|
|
items, ok := value.([]interface{})
|
|
if !ok {
|
|
text := stringValue(value)
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
return []string{text}
|
|
}
|
|
result := make([]string, 0, len(items))
|
|
for _, item := range items {
|
|
if text := strings.TrimSpace(fmt.Sprint(item)); text != "" {
|
|
result = append(result, text)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func stringValue(value any) string {
|
|
text := strings.TrimSpace(fmt.Sprint(value))
|
|
if text == "<nil>" {
|
|
return ""
|
|
}
|
|
return text
|
|
}
|
|
|
|
func (s *Service) log(userID string, taskID string, level string, message string, payload string) error {
|
|
traceID := ""
|
|
requestID := ""
|
|
if taskID != "" {
|
|
if task, err := s.repo.Task(taskID); err == nil {
|
|
traceID = task.TraceID
|
|
requestID = task.RequestID
|
|
}
|
|
}
|
|
return s.repo.Create(&model.TaskLog{ID: newID(), UserID: userID, TaskID: taskID, TraceID: traceID, RequestID: requestID, Level: level, Message: message, Payload: truncateTaskLogPayload(payload)})
|
|
}
|
|
|
|
func truncateTaskLogPayload(payload string) string {
|
|
if len(payload) <= taskLogPayloadLimit {
|
|
return payload
|
|
}
|
|
end := taskLogPayloadLimit
|
|
for end > 0 && !utf8.ValidString(payload[:end]) {
|
|
end--
|
|
}
|
|
return payload[:end] + fmt.Sprintf("\n...(日志内容已截断,原始长度 %d 字符)", len(payload))
|
|
}
|
|
|
|
func (s *Service) registerActiveTask(id string, cancel context.CancelFunc) {
|
|
s.cancelMu.Lock()
|
|
defer s.cancelMu.Unlock()
|
|
s.activeCancels[id] = cancel
|
|
}
|
|
|
|
func (s *Service) unregisterActiveTask(id string) {
|
|
s.cancelMu.Lock()
|
|
defer s.cancelMu.Unlock()
|
|
delete(s.activeCancels, id)
|
|
}
|
|
|
|
func (s *Service) cancelActiveTask(id string) {
|
|
s.cancelMu.Lock()
|
|
cancel := s.activeCancels[id]
|
|
s.cancelMu.Unlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
}
|
|
|
|
func (s *Service) markSessionFailed(task model.Task, message string) error {
|
|
if task.SessionID == "" {
|
|
return nil
|
|
}
|
|
session, err := s.repo.SessionForUser(task.UserID, task.SessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
session.Status = model.SessionStatusFailed
|
|
if err := s.repo.Save(session); err != nil {
|
|
return err
|
|
}
|
|
return s.repo.Create(&model.Message{ID: newID(), UserID: task.UserID, SessionID: task.SessionID, Role: "assistant", Content: defaultString(message, "会话任务失败。")})
|
|
}
|
|
func nodeOp(id string, nodeType string, title string, x int, y int, workflowKind string, content string) map[string]any {
|
|
return nodeOpWithMetadata(id, nodeType, title, x, y, map[string]any{"content": content, "workflowKind": workflowKind, "status": "idle"})
|
|
}
|
|
|
|
func nodeOpWithMetadata(id string, nodeType string, title string, x int, y int, metadata map[string]any) map[string]any {
|
|
return map[string]any{
|
|
"type": "add_node",
|
|
"id": id,
|
|
"nodeType": nodeType,
|
|
"title": title,
|
|
"position": map[string]int{"x": x, "y": y},
|
|
"metadata": metadata,
|
|
}
|
|
}
|
|
|
|
func connectOp(from string, to string) map[string]any {
|
|
return map[string]any{"type": "connect_nodes", "fromNodeId": from, "toNodeId": to}
|
|
}
|
|
|
|
func ptr[T any](value T) *T {
|
|
return &value
|
|
}
|
|
|
|
func shortTitle(value string, max int) string {
|
|
title := strings.TrimSpace(value)
|
|
if title == "" {
|
|
title = "影视分镜"
|
|
}
|
|
if len([]rune(title)) > max {
|
|
return string([]rune(title)[:max]) + "..."
|
|
}
|
|
return title
|
|
}
|
|
|
|
func defaultString(value string, fallback string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func newID() string {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
|
}
|
|
return hex.EncodeToString(b[:])
|
|
}
|