feat: add system task runner (#5680)
This commit is contained in:
+144
-124
@@ -2,6 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -9,10 +10,8 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -30,7 +29,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
"github.com/samber/lo"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
@@ -74,7 +72,10 @@ func resolveChannelTestUserID(c *gin.Context) (int, error) {
|
||||
return rootUser.Id, nil
|
||||
}
|
||||
|
||||
func testChannel(channel *model.Channel, testUserID int, testModel string, endpointType string, isStream bool) testResult {
|
||||
func testChannel(ctx context.Context, channel *model.Channel, testUserID int, testModel string, endpointType string, isStream bool) testResult {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
tik := time.Now()
|
||||
var unsupportedTestChannelTypes = []int{
|
||||
constant.ChannelTypeMidjourney,
|
||||
@@ -153,12 +154,7 @@ func testChannel(channel *model.Channel, testUserID int, testModel string, endpo
|
||||
testModel = ratio_setting.WithCompactModelSuffix(testModel)
|
||||
}
|
||||
|
||||
c.Request = &http.Request{
|
||||
Method: "POST",
|
||||
URL: &url.URL{Path: requestPath}, // 使用动态路径
|
||||
Body: nil,
|
||||
Header: make(http.Header),
|
||||
}
|
||||
c.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, requestPath, nil)
|
||||
|
||||
cache, err := model.GetUserCache(testUserID)
|
||||
if err != nil {
|
||||
@@ -857,7 +853,11 @@ func TestChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
tik := time.Now()
|
||||
result := testChannel(channel, testUserID, testModel, endpointType, isStream)
|
||||
requestCtx := context.Background()
|
||||
if c.Request != nil {
|
||||
requestCtx = c.Request.Context()
|
||||
}
|
||||
result := testChannel(requestCtx, channel, testUserID, testModel, endpointType, isStream)
|
||||
if result.localErr != nil {
|
||||
resp := gin.H{
|
||||
"success": false,
|
||||
@@ -890,74 +890,129 @@ func TestChannel(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
var testAllChannelsLock sync.Mutex
|
||||
var testAllChannelsRunning bool = false
|
||||
// channelTestSummary records the outcome of one channel test cycle so the
|
||||
// system task can persist a per-run result for history.
|
||||
type channelTestSummary struct {
|
||||
Tested int `json:"tested"`
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
Disabled int `json:"disabled"`
|
||||
Enabled int `json:"enabled"`
|
||||
}
|
||||
|
||||
func testChannels(channels []*model.Channel, testUserID int, notify bool, allowDisable bool) error {
|
||||
testAllChannelsLock.Lock()
|
||||
if testAllChannelsRunning {
|
||||
testAllChannelsLock.Unlock()
|
||||
return errors.New("测试已在运行中")
|
||||
}
|
||||
testAllChannelsRunning = true
|
||||
testAllChannelsLock.Unlock()
|
||||
// performChannelTests runs the channel test loop synchronously, honoring ctx
|
||||
// cancellation so a system-task runner that loses its lease stops promptly. When
|
||||
// report is non-nil it is called after each channel with (processed, total) so
|
||||
// the system task can surface progress.
|
||||
func performChannelTests(ctx context.Context, channels []*model.Channel, testUserID int, allowDisable bool, report func(processed, total int)) channelTestSummary {
|
||||
summary := channelTestSummary{}
|
||||
var disableThreshold = int64(common.ChannelDisableThreshold * 1000)
|
||||
if disableThreshold == 0 {
|
||||
disableThreshold = 10000000 // a impossible value
|
||||
}
|
||||
gopool.Go(func() {
|
||||
// 使用 defer 确保无论如何都会重置运行状态,防止死锁
|
||||
defer func() {
|
||||
testAllChannelsLock.Lock()
|
||||
testAllChannelsRunning = false
|
||||
testAllChannelsLock.Unlock()
|
||||
}()
|
||||
|
||||
for _, channel := range channels {
|
||||
if channel.Status == common.ChannelStatusManuallyDisabled {
|
||||
continue
|
||||
total := len(channels)
|
||||
for index, channel := range channels {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
if report != nil {
|
||||
report(index, total) // channels completed before this one
|
||||
}
|
||||
if channel.Status == common.ChannelStatusManuallyDisabled {
|
||||
continue
|
||||
}
|
||||
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
|
||||
tik := time.Now()
|
||||
result := testChannel(ctx, channel, testUserID, "", "", shouldUseStreamForAutomaticChannelTest(channel))
|
||||
tok := time.Now()
|
||||
milliseconds := tok.Sub(tik).Milliseconds()
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
|
||||
summary.Tested++
|
||||
|
||||
shouldBanChannel := false
|
||||
newAPIError := result.newAPIError
|
||||
// request error disables the channel
|
||||
if newAPIError != nil {
|
||||
shouldBanChannel = service.ShouldDisableChannel(result.newAPIError)
|
||||
}
|
||||
|
||||
// 当错误检查通过,才检查响应时间
|
||||
if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
|
||||
if milliseconds > disableThreshold {
|
||||
err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)
|
||||
newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
|
||||
shouldBanChannel = true
|
||||
}
|
||||
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
|
||||
tik := time.Now()
|
||||
result := testChannel(channel, testUserID, "", "", shouldUseStreamForAutomaticChannelTest(channel))
|
||||
tok := time.Now()
|
||||
milliseconds := tok.Sub(tik).Milliseconds()
|
||||
}
|
||||
|
||||
shouldBanChannel := false
|
||||
newAPIError := result.newAPIError
|
||||
// request error disables the channel
|
||||
if newAPIError != nil {
|
||||
shouldBanChannel = service.ShouldDisableChannel(result.newAPIError)
|
||||
}
|
||||
if newAPIError == nil {
|
||||
summary.Succeeded++
|
||||
} else {
|
||||
summary.Failed++
|
||||
}
|
||||
|
||||
// 当错误检查通过,才检查响应时间
|
||||
if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
|
||||
if milliseconds > disableThreshold {
|
||||
err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)
|
||||
newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
|
||||
shouldBanChannel = true
|
||||
// disable channel
|
||||
if allowDisable && isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
|
||||
processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
|
||||
summary.Disabled++
|
||||
}
|
||||
|
||||
// enable channel
|
||||
if result.localErr == nil && !isChannelEnabled && service.ShouldEnableChannel(newAPIError, channel.Status) {
|
||||
service.EnableChannel(channel.Id, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.Name)
|
||||
summary.Enabled++
|
||||
}
|
||||
|
||||
channel.UpdateResponseTime(milliseconds)
|
||||
if common.RequestInterval > 0 {
|
||||
if ctx == nil {
|
||||
time.Sleep(common.RequestInterval)
|
||||
} else {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return summary
|
||||
case <-time.After(common.RequestInterval):
|
||||
}
|
||||
}
|
||||
|
||||
// disable channel
|
||||
if allowDisable && isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
|
||||
processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
|
||||
}
|
||||
|
||||
// enable channel
|
||||
if result.localErr == nil && !isChannelEnabled && service.ShouldEnableChannel(newAPIError, channel.Status) {
|
||||
service.EnableChannel(channel.Id, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.Name)
|
||||
}
|
||||
|
||||
channel.UpdateResponseTime(milliseconds)
|
||||
time.Sleep(common.RequestInterval)
|
||||
}
|
||||
}
|
||||
if report != nil && (ctx == nil || ctx.Err() == nil) {
|
||||
report(total, total) // mark complete only when the full set was tested
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
if notify {
|
||||
service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
// runChannelTestTask runs one synchronous channel test cycle for the system task
|
||||
// runner (both the scheduled job and the manual "test all channels" trigger go
|
||||
// through here). It honors ctx cancellation so a runner that loses its lease
|
||||
// stops promptly. mode selects the channel set: an empty mode falls back to the
|
||||
// configured monitor ChannelTestMode (scheduled behavior), while a manual
|
||||
// trigger passes ChannelTestModeScheduledAll to test every channel. When notify
|
||||
// is set the root user is notified on completion. Cross-instance execution is
|
||||
// guarded by the system task per-type lock, so no process-local guard is needed.
|
||||
func runChannelTestTask(ctx context.Context, mode string, notify bool, report func(processed, total int)) (channelTestSummary, error) {
|
||||
testUserID, err := resolveChannelTestUserID(nil)
|
||||
if err != nil {
|
||||
return channelTestSummary{}, err
|
||||
}
|
||||
channels, err := model.GetAllChannels(0, 0, true, false)
|
||||
if err != nil {
|
||||
return channelTestSummary{}, err
|
||||
}
|
||||
if strings.TrimSpace(mode) == "" {
|
||||
mode = operation_setting.GetMonitorSetting().ChannelTestMode
|
||||
}
|
||||
selected := selectChannelsForAutomaticTest(channels, mode)
|
||||
allowDisable := mode != operation_setting.ChannelTestModePassiveRecovery
|
||||
summary := performChannelTests(ctx, selected, testUserID, allowDisable, report)
|
||||
if notify && (ctx == nil || ctx.Err() == nil) {
|
||||
service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成")
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func selectChannelsForAutomaticTest(channels []*model.Channel, mode string) []*model.Channel {
|
||||
@@ -974,71 +1029,36 @@ func selectChannelsForAutomaticTest(channels []*model.Channel, mode string) []*m
|
||||
return selected
|
||||
}
|
||||
|
||||
func testAllChannels(notify bool) error {
|
||||
testUserID, err := resolveChannelTestUserID(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
channels, getChannelErr := model.GetAllChannels(0, 0, true, false)
|
||||
if getChannelErr != nil {
|
||||
return getChannelErr
|
||||
}
|
||||
return testChannels(selectChannelsForAutomaticTest(channels, operation_setting.ChannelTestModeScheduledAll), testUserID, notify, true)
|
||||
}
|
||||
|
||||
func testAutoDisabledChannels(notify bool) error {
|
||||
testUserID, err := resolveChannelTestUserID(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
channels, getChannelErr := model.GetAllChannels(0, 0, true, false)
|
||||
if getChannelErr != nil {
|
||||
return getChannelErr
|
||||
}
|
||||
return testChannels(selectChannelsForAutomaticTest(channels, operation_setting.ChannelTestModePassiveRecovery), testUserID, notify, false)
|
||||
}
|
||||
|
||||
// TestAllChannels enqueues a channel_test system task instead of running the
|
||||
// test loop inline. If any channel_test task is already active, the manual run is
|
||||
// rejected so the caller does not mistake a scheduled run for this manual one.
|
||||
func TestAllChannels(c *gin.Context) {
|
||||
err := testAllChannels(true)
|
||||
task, created, err := service.EnqueueSystemTask(model.SystemTaskTypeChannelTest, channelTestTaskPayload{
|
||||
Mode: operation_setting.ChannelTestModeScheduledAll,
|
||||
Notify: true,
|
||||
})
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"success": false,
|
||||
"message": "已有通道测试任务正在运行或等待中,不能启动本次手动任务",
|
||||
"data": gin.H{
|
||||
"task_id": task.TaskID,
|
||||
"status": task.Status,
|
||||
"type": task.Type,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
}
|
||||
|
||||
var autoTestChannelsOnce sync.Once
|
||||
|
||||
func AutomaticallyTestChannels() {
|
||||
// 只在Master节点定时测试渠道
|
||||
if !common.IsMasterNode {
|
||||
return
|
||||
}
|
||||
autoTestChannelsOnce.Do(func() {
|
||||
for {
|
||||
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
|
||||
time.Sleep(1 * time.Minute)
|
||||
continue
|
||||
}
|
||||
for {
|
||||
frequency := operation_setting.GetMonitorSetting().AutoTestChannelMinutes
|
||||
time.Sleep(time.Duration(int(math.Round(frequency))) * time.Minute)
|
||||
common.SysLog(fmt.Sprintf("automatically test channels with interval %f minutes", frequency))
|
||||
if operation_setting.GetMonitorSetting().ChannelTestMode == operation_setting.ChannelTestModePassiveRecovery {
|
||||
common.SysLog("automatically testing auto-disabled channels")
|
||||
_ = testAutoDisabledChannels(false)
|
||||
} else {
|
||||
common.SysLog("automatically testing all channels")
|
||||
_ = testAllChannels(false)
|
||||
}
|
||||
common.SysLog("automatically channel test finished")
|
||||
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
"data": gin.H{
|
||||
"task_id": task.TaskID,
|
||||
"status": task.Status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
@@ -109,3 +110,21 @@ func TestSelectChannelsForAutomaticTestScheduledSkipsManualDisabled(t *testing.T
|
||||
require.Equal(t, 1, selected[0].Id)
|
||||
require.Equal(t, 2, selected[1].Id)
|
||||
}
|
||||
|
||||
func TestTestAllChannelsRejectsExistingActiveTask(t *testing.T) {
|
||||
db := setupModelListControllerTestDB(t)
|
||||
require.NoError(t, db.AutoMigrate(&model.SystemTask{}, &model.SystemTaskLock{}))
|
||||
|
||||
existing, err := model.CreateSystemTask(model.SystemTaskTypeChannelTest, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/test", nil)
|
||||
|
||||
TestAllChannels(ctx)
|
||||
|
||||
require.Equal(t, http.StatusConflict, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), existing.TaskID)
|
||||
require.Contains(t, recorder.Body.String(), "已有通道测试任务正在运行或等待中")
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -52,16 +52,12 @@ var channelUpstreamModelUpdateSelectFields = []string{
|
||||
"header_override",
|
||||
}
|
||||
|
||||
var (
|
||||
channelUpstreamModelUpdateTaskOnce sync.Once
|
||||
channelUpstreamModelUpdateTaskRunning atomic.Bool
|
||||
channelUpstreamModelUpdateNotifyState = struct {
|
||||
sync.Mutex
|
||||
lastNotifiedAt int64
|
||||
lastChangedChannels int
|
||||
lastFailedChannels int
|
||||
}{}
|
||||
)
|
||||
var channelUpstreamModelUpdateNotifyState = struct {
|
||||
sync.Mutex
|
||||
lastNotifiedAt int64
|
||||
lastChangedChannels int
|
||||
lastFailedChannels int
|
||||
}{}
|
||||
|
||||
type applyChannelUpstreamModelUpdatesRequest struct {
|
||||
ID int `json:"id"`
|
||||
@@ -519,12 +515,24 @@ func buildUpstreamModelUpdateTaskNotificationContent(
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func runChannelUpstreamModelUpdateTaskOnce() {
|
||||
if !channelUpstreamModelUpdateTaskRunning.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
defer channelUpstreamModelUpdateTaskRunning.Store(false)
|
||||
type upstreamModelUpdateSummary struct {
|
||||
CheckedChannels int `json:"checked_channels"`
|
||||
ChangedChannels int `json:"changed_channels"`
|
||||
DetectedAddModels int `json:"detected_add_models"`
|
||||
DetectedRemoveModels int `json:"detected_remove_models"`
|
||||
FailedChannels int `json:"failed_channels"`
|
||||
AutoAddedModels int `json:"auto_added_models"`
|
||||
}
|
||||
|
||||
// runChannelUpstreamModelUpdateTaskOnce runs one synchronous upstream model
|
||||
// detection cycle and returns a summary for system task history. It honors ctx
|
||||
// cancellation between batches so a runner that loses its lease stops promptly.
|
||||
// force bypasses the per-channel minimum check interval and allowAutoApply lets
|
||||
// channels with auto-sync enabled adopt detected models automatically. The
|
||||
// scheduled job calls (force=false, allowAutoApply=true); the manual "detect
|
||||
// all" trigger calls (force=true, allowAutoApply=false) so it always re-checks
|
||||
// and only stages changes for explicit review.
|
||||
func runChannelUpstreamModelUpdateTaskOnce(ctx context.Context, force bool, allowAutoApply bool, report func(processed, total int)) upstreamModelUpdateSummary {
|
||||
checkedChannels := 0
|
||||
failedChannels := 0
|
||||
failedChannelIDs := make([]int, 0)
|
||||
@@ -537,8 +545,20 @@ func runChannelUpstreamModelUpdateTaskOnce() {
|
||||
removeModelSamples := make([]string, 0)
|
||||
refreshNeeded := false
|
||||
|
||||
// Count the enabled channels up front so progress can be reported as a
|
||||
// percentage; a count error is non-fatal (progress just won't show a %).
|
||||
var totalChannels int64
|
||||
if err := model.DB.Model(&model.Channel{}).Where("status = ?", common.ChannelStatusEnabled).Count(&totalChannels).Error; err != nil {
|
||||
totalChannels = 0
|
||||
}
|
||||
processed := 0
|
||||
|
||||
lastID := 0
|
||||
scanLoop:
|
||||
for {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
var channels []*model.Channel
|
||||
query := model.DB.
|
||||
Select(channelUpstreamModelUpdateSelectFields).
|
||||
@@ -562,6 +582,14 @@ func runChannelUpstreamModelUpdateTaskOnce() {
|
||||
if channel == nil {
|
||||
continue
|
||||
}
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
break scanLoop
|
||||
}
|
||||
|
||||
processed++
|
||||
if report != nil {
|
||||
report(processed, int(totalChannels))
|
||||
}
|
||||
|
||||
settings := channel.GetOtherSettings()
|
||||
if !settings.UpstreamModelUpdateCheckEnabled {
|
||||
@@ -569,7 +597,7 @@ func runChannelUpstreamModelUpdateTaskOnce() {
|
||||
}
|
||||
|
||||
checkedChannels++
|
||||
modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, false, true)
|
||||
modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, force, allowAutoApply)
|
||||
if err != nil {
|
||||
failedChannels++
|
||||
failedChannelIDs = append(failedChannelIDs, channel.Id)
|
||||
@@ -598,7 +626,15 @@ func runChannelUpstreamModelUpdateTaskOnce() {
|
||||
autoAddedModels += autoAdded
|
||||
|
||||
if common.RequestInterval > 0 {
|
||||
time.Sleep(common.RequestInterval)
|
||||
if ctx == nil {
|
||||
time.Sleep(common.RequestInterval)
|
||||
} else {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break scanLoop
|
||||
case <-time.After(common.RequestInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,10 +643,23 @@ func runChannelUpstreamModelUpdateTaskOnce() {
|
||||
}
|
||||
}
|
||||
|
||||
if report != nil && (ctx == nil || ctx.Err() == nil) {
|
||||
report(int(totalChannels), int(totalChannels)) // mark complete only when the full scan finished
|
||||
}
|
||||
|
||||
if refreshNeeded {
|
||||
refreshChannelRuntimeCache()
|
||||
}
|
||||
|
||||
summary := upstreamModelUpdateSummary{
|
||||
CheckedChannels: checkedChannels,
|
||||
ChangedChannels: changedChannels,
|
||||
DetectedAddModels: detectedAddModels,
|
||||
DetectedRemoveModels: detectedRemoveModels,
|
||||
FailedChannels: failedChannels,
|
||||
AutoAddedModels: autoAddedModels,
|
||||
}
|
||||
|
||||
if checkedChannels > 0 || common.DebugEnabled {
|
||||
common.SysLog(fmt.Sprintf(
|
||||
"upstream model update task done: checked_channels=%d changed_channels=%d detected_add_models=%d detected_remove_models=%d failed_channels=%d auto_added_models=%d",
|
||||
@@ -630,7 +679,7 @@ func runChannelUpstreamModelUpdateTaskOnce() {
|
||||
changedChannels,
|
||||
failedChannels,
|
||||
))
|
||||
return
|
||||
return summary
|
||||
}
|
||||
service.NotifyUpstreamModelUpdateWatchers(
|
||||
"上游模型巡检通知",
|
||||
@@ -647,37 +696,7 @@ func runChannelUpstreamModelUpdateTaskOnce() {
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func StartChannelUpstreamModelUpdateTask() {
|
||||
channelUpstreamModelUpdateTaskOnce.Do(func() {
|
||||
if !common.IsMasterNode {
|
||||
return
|
||||
}
|
||||
if !common.GetEnvOrDefaultBool("CHANNEL_UPSTREAM_MODEL_UPDATE_TASK_ENABLED", true) {
|
||||
common.SysLog("upstream model update task disabled by CHANNEL_UPSTREAM_MODEL_UPDATE_TASK_ENABLED")
|
||||
return
|
||||
}
|
||||
|
||||
intervalMinutes := common.GetEnvOrDefault(
|
||||
"CHANNEL_UPSTREAM_MODEL_UPDATE_TASK_INTERVAL_MINUTES",
|
||||
channelUpstreamModelUpdateTaskDefaultIntervalMinutes,
|
||||
)
|
||||
if intervalMinutes < 1 {
|
||||
intervalMinutes = channelUpstreamModelUpdateTaskDefaultIntervalMinutes
|
||||
}
|
||||
interval := time.Duration(intervalMinutes) * time.Minute
|
||||
|
||||
go func() {
|
||||
common.SysLog(fmt.Sprintf("upstream model update task started: interval=%s", interval))
|
||||
runChannelUpstreamModelUpdateTaskOnce()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
runChannelUpstreamModelUpdateTaskOnce()
|
||||
}
|
||||
}()
|
||||
})
|
||||
return summary
|
||||
}
|
||||
|
||||
func ApplyChannelUpstreamModelUpdates(c *gin.Context) {
|
||||
@@ -931,75 +950,40 @@ func ApplyAllChannelUpstreamModelUpdates(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// DetectAllChannelUpstreamModelUpdates enqueues a model_update system task
|
||||
// (manual variant) instead of scanning inline. Routing the manual trigger
|
||||
// through the framework gives it the same cross-instance lease dedup and run
|
||||
// history as the scheduled scan. If any model_update task is already active, the
|
||||
// manual run is rejected so the caller does not mistake a scheduled run for this
|
||||
// manual one.
|
||||
func DetectAllChannelUpstreamModelUpdates(c *gin.Context) {
|
||||
results := make([]detectChannelUpstreamModelUpdatesResult, 0)
|
||||
failed := make([]int, 0)
|
||||
detectedAddCount := 0
|
||||
detectedRemoveCount := 0
|
||||
refreshNeeded := false
|
||||
|
||||
lastID := 0
|
||||
for {
|
||||
channels, err := findEnabledChannelsAfterID(lastID, channelUpstreamModelUpdateTaskBatchSize)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
break
|
||||
}
|
||||
lastID = channels[len(channels)-1].Id
|
||||
|
||||
for _, channel := range channels {
|
||||
if channel == nil {
|
||||
continue
|
||||
}
|
||||
settings := channel.GetOtherSettings()
|
||||
if !settings.UpstreamModelUpdateCheckEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, true, false)
|
||||
if err != nil {
|
||||
failed = append(failed, channel.Id)
|
||||
continue
|
||||
}
|
||||
if modelsChanged {
|
||||
refreshNeeded = true
|
||||
}
|
||||
|
||||
addModels := normalizeModelNames(settings.UpstreamModelUpdateLastDetectedModels)
|
||||
removeModels := normalizeModelNames(settings.UpstreamModelUpdateLastRemovedModels)
|
||||
detectedAddCount += len(addModels)
|
||||
detectedRemoveCount += len(removeModels)
|
||||
results = append(results, detectChannelUpstreamModelUpdatesResult{
|
||||
ChannelID: channel.Id,
|
||||
ChannelName: channel.Name,
|
||||
AddModels: addModels,
|
||||
RemoveModels: removeModels,
|
||||
LastCheckTime: settings.UpstreamModelUpdateLastCheckTime,
|
||||
AutoAddedModels: autoAdded,
|
||||
})
|
||||
}
|
||||
|
||||
if len(channels) < channelUpstreamModelUpdateTaskBatchSize {
|
||||
break
|
||||
}
|
||||
task, created, err := service.EnqueueSystemTask(model.SystemTaskTypeModelUpdate, modelUpdateTaskPayload{Manual: true})
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if refreshNeeded {
|
||||
refreshChannelRuntimeCache()
|
||||
if !created {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"success": false,
|
||||
"message": "已有模型更新任务正在运行或等待中,不能启动本次手动任务",
|
||||
"data": gin.H{
|
||||
"task_id": task.TaskID,
|
||||
"status": task.Status,
|
||||
"type": task.Type,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
recordManageAudit(c, "channel.upstream_detect_all", map[string]interface{}{
|
||||
"task_id": task.TaskID,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": gin.H{
|
||||
"processed_channels": len(results),
|
||||
"failed_channel_ids": failed,
|
||||
"detected_add_models": detectedAddCount,
|
||||
"detected_remove_models": detectedRemoveCount,
|
||||
"channel_detected_results": results,
|
||||
"task_id": task.TaskID,
|
||||
"status": task.Status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -177,3 +180,21 @@ func TestShouldSendUpstreamModelUpdateNotification(t *testing.T) {
|
||||
require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime+90000, 7, 0))
|
||||
require.True(t, shouldSendUpstreamModelUpdateNotification(baseTime+90001, 0, 0))
|
||||
}
|
||||
|
||||
func TestDetectAllChannelUpstreamModelUpdatesRejectsExistingActiveTask(t *testing.T) {
|
||||
db := setupModelListControllerTestDB(t)
|
||||
require.NoError(t, db.AutoMigrate(&model.SystemTask{}, &model.SystemTaskLock{}))
|
||||
|
||||
existing, err := model.CreateSystemTask(model.SystemTaskTypeModelUpdate, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/upstream-models/detect-all", nil)
|
||||
|
||||
DetectAllChannelUpstreamModelUpdates(ctx)
|
||||
|
||||
require.Equal(t, http.StatusConflict, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), existing.TaskID)
|
||||
require.Contains(t, recorder.Body.String(), "已有模型更新任务正在运行或等待中")
|
||||
}
|
||||
|
||||
+198
-159
@@ -3,7 +3,6 @@ package controller
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -20,183 +19,223 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func UpdateMidjourneyTaskBulk() {
|
||||
//imageModel := "midjourney"
|
||||
ctx := context.TODO()
|
||||
for {
|
||||
time.Sleep(time.Duration(15) * time.Second)
|
||||
// midjourneyPollSummary is the result recorded on a midjourney_poll system task
|
||||
// row, summarizing one polling pass.
|
||||
type midjourneyPollSummary struct {
|
||||
UnfinishedTasks int `json:"unfinished_tasks"`
|
||||
ChannelsScanned int `json:"channels_scanned"`
|
||||
NullTasksFailed int `json:"null_tasks_failed"`
|
||||
}
|
||||
|
||||
tasks := model.GetAllUnFinishTasks()
|
||||
if len(tasks) == 0 {
|
||||
// runMidjourneyTaskUpdateOnce performs one Midjourney polling pass synchronously.
|
||||
// It honors ctx cancellation (the system-task runner cancels it when the lease
|
||||
// is lost) and, when report is non-nil, reports progress as (processedChannels,
|
||||
// totalChannels) so the system task surfaces a percentage.
|
||||
func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, total int)) midjourneyPollSummary {
|
||||
summary := midjourneyPollSummary{}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
tasks := model.GetAllUnFinishTasks()
|
||||
if len(tasks) == 0 {
|
||||
return summary
|
||||
}
|
||||
summary.UnfinishedTasks = len(tasks)
|
||||
|
||||
logger.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks)))
|
||||
taskChannelM := make(map[int][]string)
|
||||
taskM := make(map[string]*model.Midjourney)
|
||||
nullTaskIds := make([]int, 0)
|
||||
for _, task := range tasks {
|
||||
if task.MjId == "" {
|
||||
// 统计失败的未完成任务
|
||||
nullTaskIds = append(nullTaskIds, task.Id)
|
||||
continue
|
||||
}
|
||||
taskM[task.MjId] = task
|
||||
taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], task.MjId)
|
||||
}
|
||||
if len(nullTaskIds) > 0 {
|
||||
summary.NullTasksFailed = len(nullTaskIds)
|
||||
err := model.MjBulkUpdateByTaskIds(nullTaskIds, map[string]any{
|
||||
"status": "FAILURE",
|
||||
"progress": "100%",
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err))
|
||||
} else {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds))
|
||||
}
|
||||
}
|
||||
if len(taskChannelM) == 0 {
|
||||
return summary
|
||||
}
|
||||
|
||||
logger.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks)))
|
||||
taskChannelM := make(map[int][]string)
|
||||
taskM := make(map[string]*model.Midjourney)
|
||||
nullTaskIds := make([]int, 0)
|
||||
for _, task := range tasks {
|
||||
if task.MjId == "" {
|
||||
// 统计失败的未完成任务
|
||||
nullTaskIds = append(nullTaskIds, task.Id)
|
||||
continue
|
||||
}
|
||||
taskM[task.MjId] = task
|
||||
taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], task.MjId)
|
||||
totalChannels := len(taskChannelM)
|
||||
processedChannels := 0
|
||||
for channelId, taskIds := range taskChannelM {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
if len(nullTaskIds) > 0 {
|
||||
err := model.MjBulkUpdateByTaskIds(nullTaskIds, map[string]any{
|
||||
"status": "FAILURE",
|
||||
"progress": "100%",
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err))
|
||||
} else {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds))
|
||||
}
|
||||
if report != nil {
|
||||
report(processedChannels, totalChannels)
|
||||
}
|
||||
if len(taskChannelM) == 0 {
|
||||
processedChannels++
|
||||
summary.ChannelsScanned++
|
||||
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
|
||||
if len(taskIds) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for channelId, taskIds := range taskChannelM {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
|
||||
if len(taskIds) == 0 {
|
||||
continue
|
||||
}
|
||||
midjourneyChannel, err := model.CacheGetChannel(channelId)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("CacheGetChannel: %v", err))
|
||||
err := model.MjBulkUpdate(taskIds, map[string]any{
|
||||
"fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId),
|
||||
"status": "FAILURE",
|
||||
"progress": "100%",
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("UpdateMidjourneyTask error: %v", err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
requestUrl := fmt.Sprintf("%s/mj/task/list-by-condition", *midjourneyChannel.BaseURL)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"ids": taskIds,
|
||||
midjourneyChannel, err := model.CacheGetChannel(channelId)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("CacheGetChannel: %v", err))
|
||||
err := model.MjBulkUpdate(taskIds, map[string]any{
|
||||
"fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId),
|
||||
"status": "FAILURE",
|
||||
"progress": "100%",
|
||||
})
|
||||
req, err := http.NewRequest("POST", requestUrl, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Task error: %v", err))
|
||||
continue
|
||||
logger.LogInfo(ctx, fmt.Sprintf("UpdateMidjourneyTask error: %v", err))
|
||||
}
|
||||
// 设置超时时间
|
||||
timeout := time.Second * 15
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
// 使用带有超时的 context 创建新的请求
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("mj-api-secret", midjourneyChannel.Key)
|
||||
resp, err := service.GetHttpClient().Do(req)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err))
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
|
||||
continue
|
||||
}
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error: %v", err))
|
||||
continue
|
||||
}
|
||||
var responseItems []dto.MidjourneyDto
|
||||
err = json.Unmarshal(responseBody, &responseItems)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error2: %v, body: %s", err, string(responseBody)))
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
req.Body.Close()
|
||||
continue
|
||||
}
|
||||
requestUrl := fmt.Sprintf("%s/mj/task/list-by-condition", *midjourneyChannel.BaseURL)
|
||||
|
||||
body, err := common.Marshal(map[string]any{
|
||||
"ids": taskIds,
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Task marshal body error: %v", err))
|
||||
continue
|
||||
}
|
||||
timeout := time.Second * 15
|
||||
requestCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
req, err := http.NewRequestWithContext(requestCtx, "POST", requestUrl, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
cancel()
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Task error: %v", err))
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("mj-api-secret", midjourneyChannel.Key)
|
||||
resp, err := service.GetHttpClient().Do(req)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err))
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error: %v", err))
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
var responseItems []dto.MidjourneyDto
|
||||
err = common.Unmarshal(responseBody, &responseItems)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error2: %v, body: %s", err, string(responseBody)))
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
req.Body.Close()
|
||||
cancel()
|
||||
|
||||
for _, responseItem := range responseItems {
|
||||
task := taskM[responseItem.MjId]
|
||||
for _, responseItem := range responseItems {
|
||||
task := taskM[responseItem.MjId]
|
||||
if task == nil {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Midjourney task response ignored: unknown mj_id=%s", responseItem.MjId))
|
||||
continue
|
||||
}
|
||||
|
||||
useTime := (time.Now().UnixNano() / int64(time.Millisecond)) - task.SubmitTime
|
||||
// 如果时间超过一小时,且进度不是100%,则认为任务失败
|
||||
if useTime > 3600000 && task.Progress != "100%" {
|
||||
responseItem.FailReason = "上游任务超时(超过1小时)"
|
||||
responseItem.Status = "FAILURE"
|
||||
}
|
||||
if !checkMjTaskNeedUpdate(task, responseItem) {
|
||||
continue
|
||||
}
|
||||
preStatus := task.Status
|
||||
task.Code = 1
|
||||
task.Progress = responseItem.Progress
|
||||
task.PromptEn = responseItem.PromptEn
|
||||
task.State = responseItem.State
|
||||
task.SubmitTime = responseItem.SubmitTime
|
||||
task.StartTime = responseItem.StartTime
|
||||
task.FinishTime = responseItem.FinishTime
|
||||
task.ImageUrl = responseItem.ImageUrl
|
||||
task.Status = responseItem.Status
|
||||
task.FailReason = responseItem.FailReason
|
||||
if responseItem.Properties != nil {
|
||||
propertiesStr, _ := json.Marshal(responseItem.Properties)
|
||||
task.Properties = string(propertiesStr)
|
||||
}
|
||||
if responseItem.Buttons != nil {
|
||||
buttonStr, _ := json.Marshal(responseItem.Buttons)
|
||||
task.Buttons = string(buttonStr)
|
||||
}
|
||||
// 映射 VideoUrl
|
||||
task.VideoUrl = responseItem.VideoUrl
|
||||
useTime := (time.Now().UnixNano() / int64(time.Millisecond)) - task.SubmitTime
|
||||
// 如果时间超过一小时,且进度不是100%,则认为任务失败
|
||||
if useTime > 3600000 && task.Progress != "100%" {
|
||||
responseItem.FailReason = "上游任务超时(超过1小时)"
|
||||
responseItem.Status = "FAILURE"
|
||||
}
|
||||
if !checkMjTaskNeedUpdate(task, responseItem) {
|
||||
continue
|
||||
}
|
||||
preStatus := task.Status
|
||||
task.Code = 1
|
||||
task.Progress = responseItem.Progress
|
||||
task.PromptEn = responseItem.PromptEn
|
||||
task.State = responseItem.State
|
||||
task.SubmitTime = responseItem.SubmitTime
|
||||
task.StartTime = responseItem.StartTime
|
||||
task.FinishTime = responseItem.FinishTime
|
||||
task.ImageUrl = responseItem.ImageUrl
|
||||
task.Status = responseItem.Status
|
||||
task.FailReason = responseItem.FailReason
|
||||
if responseItem.Properties != nil {
|
||||
propertiesStr, _ := common.Marshal(responseItem.Properties)
|
||||
task.Properties = string(propertiesStr)
|
||||
}
|
||||
if responseItem.Buttons != nil {
|
||||
buttonStr, _ := common.Marshal(responseItem.Buttons)
|
||||
task.Buttons = string(buttonStr)
|
||||
}
|
||||
// 映射 VideoUrl
|
||||
task.VideoUrl = responseItem.VideoUrl
|
||||
|
||||
// 映射 VideoUrls - 将数组序列化为 JSON 字符串
|
||||
if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 {
|
||||
videoUrlsStr, err := json.Marshal(responseItem.VideoUrls)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err))
|
||||
task.VideoUrls = "[]" // 失败时设置为空数组
|
||||
} else {
|
||||
task.VideoUrls = string(videoUrlsStr)
|
||||
}
|
||||
} else {
|
||||
task.VideoUrls = "" // 空值时清空字段
|
||||
}
|
||||
|
||||
shouldReturnQuota := false
|
||||
if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") {
|
||||
logger.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
|
||||
task.Progress = "100%"
|
||||
if task.Quota != 0 {
|
||||
shouldReturnQuota = true
|
||||
}
|
||||
}
|
||||
won, err := task.UpdateWithStatus(preStatus)
|
||||
// 映射 VideoUrls - 将数组序列化为 JSON 字符串
|
||||
if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 {
|
||||
videoUrlsStr, err := common.Marshal(responseItem.VideoUrls)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error())
|
||||
} else if won && shouldReturnQuota {
|
||||
err = model.IncreaseUserQuota(task.UserId, task.Quota, false)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, "fail to increase user quota: "+err.Error())
|
||||
}
|
||||
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
|
||||
UserId: task.UserId,
|
||||
LogType: model.LogTypeRefund,
|
||||
Content: "",
|
||||
ChannelId: task.ChannelId,
|
||||
ModelName: service.CovertMjpActionToModelName(task.Action),
|
||||
Quota: task.Quota,
|
||||
Other: map[string]interface{}{
|
||||
"task_id": task.MjId,
|
||||
"reason": "构图失败",
|
||||
},
|
||||
})
|
||||
logger.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err))
|
||||
task.VideoUrls = "[]" // 失败时设置为空数组
|
||||
} else {
|
||||
task.VideoUrls = string(videoUrlsStr)
|
||||
}
|
||||
} else {
|
||||
task.VideoUrls = "" // 空值时清空字段
|
||||
}
|
||||
|
||||
shouldReturnQuota := false
|
||||
if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") {
|
||||
logger.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
|
||||
task.Progress = "100%"
|
||||
if task.Quota != 0 {
|
||||
shouldReturnQuota = true
|
||||
}
|
||||
}
|
||||
won, err := task.UpdateWithStatus(preStatus)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error())
|
||||
} else if won && shouldReturnQuota {
|
||||
err = model.IncreaseUserQuota(task.UserId, task.Quota, false)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, "fail to increase user quota: "+err.Error())
|
||||
}
|
||||
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
|
||||
UserId: task.UserId,
|
||||
LogType: model.LogTypeRefund,
|
||||
Content: "",
|
||||
ChannelId: task.ChannelId,
|
||||
ModelName: service.CovertMjpActionToModelName(task.Action),
|
||||
Quota: task.Quota,
|
||||
Other: map[string]interface{}{
|
||||
"task_id": task.MjId,
|
||||
"reason": "构图失败",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if report != nil && (ctx == nil || ctx.Err() == nil) {
|
||||
report(totalChannels, totalChannels)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto) bool {
|
||||
@@ -242,7 +281,7 @@ func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto)
|
||||
}
|
||||
// 检查 VideoUrls 是否需要更新
|
||||
if newTask.VideoUrls != nil && len(newTask.VideoUrls) > 0 {
|
||||
newVideoUrlsStr, _ := json.Marshal(newTask.VideoUrls)
|
||||
newVideoUrlsStr, _ := common.Marshal(newTask.VideoUrls)
|
||||
if oldTask.VideoUrls != string(newVideoUrlsStr) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -65,6 +65,27 @@ func GetCurrentSystemTask(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func ListSystemTasks(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||
|
||||
tasks, err := model.ListSystemTasks(limit)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
responses := make([]model.SystemTaskResponse, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
responses = append(responses, task.ToResponse())
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": responses,
|
||||
})
|
||||
}
|
||||
|
||||
func GetSystemTask(c *gin.Context) {
|
||||
taskID := c.Param("task_id")
|
||||
if taskID == "" {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
)
|
||||
|
||||
// RegisterScheduledSystemTasks wires the periodic channel test, upstream model
|
||||
// update, and async task polling (Midjourney / Suno / video) jobs into the
|
||||
// system task framework so a DB lease dedups execution across multiple master
|
||||
// instances and each run is recorded as one task row. Call this before
|
||||
// service.StartSystemTaskRunner.
|
||||
func RegisterScheduledSystemTasks() {
|
||||
service.RegisterSystemTaskHandler(channelTestHandler{})
|
||||
service.RegisterSystemTaskHandler(modelUpdateHandler{})
|
||||
service.RegisterSystemTaskHandler(midjourneyPollHandler{})
|
||||
service.RegisterSystemTaskHandler(asyncTaskPollHandler{})
|
||||
}
|
||||
|
||||
// channelTestHandler runs the scheduled "test all channels" job. Enablement and
|
||||
// cadence still come from the monitor settings; only the execution path moved
|
||||
// into the system task runner.
|
||||
type channelTestHandler struct{}
|
||||
|
||||
func (channelTestHandler) Type() string { return model.SystemTaskTypeChannelTest }
|
||||
|
||||
func (channelTestHandler) Enabled() bool {
|
||||
return operation_setting.GetMonitorSetting().AutoTestChannelEnabled
|
||||
}
|
||||
|
||||
func (channelTestHandler) Interval() time.Duration {
|
||||
minutes := operation_setting.GetMonitorSetting().AutoTestChannelMinutes
|
||||
if minutes <= 0 {
|
||||
minutes = 10
|
||||
}
|
||||
return time.Duration(minutes * float64(time.Minute))
|
||||
}
|
||||
|
||||
func (channelTestHandler) NewPayload() any { return nil }
|
||||
|
||||
// channelTestTaskPayload controls one channel_test run. A nil/empty payload is a
|
||||
// scheduled run, which uses the configured monitor ChannelTestMode and does not
|
||||
// notify. A manual "test all channels" trigger sets Mode=scheduled_all and
|
||||
// Notify=true to reproduce the legacy manual behavior (test every channel and
|
||||
// notify root on completion).
|
||||
type channelTestTaskPayload struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Notify bool `json:"notify,omitempty"`
|
||||
}
|
||||
|
||||
func (channelTestHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
|
||||
payload := channelTestTaskPayload{}
|
||||
if err := task.DecodePayload(&payload); err != nil {
|
||||
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, nil, err)
|
||||
return
|
||||
}
|
||||
summary, err := runChannelTestTask(ctx, payload.Mode, payload.Notify, service.NewSystemTaskProgressReporter(task, runnerID))
|
||||
if err != nil {
|
||||
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, nil, err)
|
||||
return
|
||||
}
|
||||
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, summary, nil)
|
||||
}
|
||||
|
||||
// modelUpdateHandler runs the scheduled upstream model update detection job.
|
||||
type modelUpdateHandler struct{}
|
||||
|
||||
func (modelUpdateHandler) Type() string { return model.SystemTaskTypeModelUpdate }
|
||||
|
||||
func (modelUpdateHandler) Enabled() bool {
|
||||
return common.GetEnvOrDefaultBool("CHANNEL_UPSTREAM_MODEL_UPDATE_TASK_ENABLED", true)
|
||||
}
|
||||
|
||||
func (modelUpdateHandler) Interval() time.Duration {
|
||||
intervalMinutes := common.GetEnvOrDefault(
|
||||
"CHANNEL_UPSTREAM_MODEL_UPDATE_TASK_INTERVAL_MINUTES",
|
||||
channelUpstreamModelUpdateTaskDefaultIntervalMinutes,
|
||||
)
|
||||
if intervalMinutes < 1 {
|
||||
intervalMinutes = channelUpstreamModelUpdateTaskDefaultIntervalMinutes
|
||||
}
|
||||
return time.Duration(intervalMinutes) * time.Minute
|
||||
}
|
||||
|
||||
func (modelUpdateHandler) NewPayload() any { return nil }
|
||||
|
||||
// modelUpdateTaskPayload controls one model_update run. A scheduled run
|
||||
// (Manual=false) respects the per-channel minimum check interval and may
|
||||
// auto-apply detected models when a channel has auto-sync enabled. A manual
|
||||
// "detect all" trigger sets Manual=true to reproduce the legacy detect-all
|
||||
// semantics: force a re-check regardless of the interval and never auto-apply,
|
||||
// so the admin reviews and applies changes explicitly.
|
||||
type modelUpdateTaskPayload struct {
|
||||
Manual bool `json:"manual,omitempty"`
|
||||
}
|
||||
|
||||
func (modelUpdateHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
|
||||
payload := modelUpdateTaskPayload{}
|
||||
if err := task.DecodePayload(&payload); err != nil {
|
||||
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, nil, err)
|
||||
return
|
||||
}
|
||||
summary := runChannelUpstreamModelUpdateTaskOnce(ctx, payload.Manual, !payload.Manual, service.NewSystemTaskProgressReporter(task, runnerID))
|
||||
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, summary, nil)
|
||||
}
|
||||
|
||||
// midjourneyPollHandler runs one Midjourney polling pass per scheduled run.
|
||||
// Enabled() folds the "are there unfinished tasks?" check into enablement so the
|
||||
// scheduler creates no row when the system is idle; only when at least one
|
||||
// Midjourney task is in progress does a row get scheduled.
|
||||
type midjourneyPollHandler struct{}
|
||||
|
||||
func (midjourneyPollHandler) Type() string { return model.SystemTaskTypeMidjourneyPoll }
|
||||
|
||||
func (midjourneyPollHandler) Enabled() bool {
|
||||
return constant.UpdateTask && model.HasUnfinishedMidjourneyTasks()
|
||||
}
|
||||
|
||||
func (midjourneyPollHandler) Interval() time.Duration { return 15 * time.Second }
|
||||
|
||||
func (midjourneyPollHandler) NewPayload() any { return nil }
|
||||
|
||||
func (midjourneyPollHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
|
||||
summary := runMidjourneyTaskUpdateOnce(ctx, service.NewSystemTaskProgressReporter(task, runnerID))
|
||||
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, summary, nil)
|
||||
}
|
||||
|
||||
// asyncTaskPollHandler runs one async-task (Suno/video) polling pass per
|
||||
// scheduled run. Like midjourneyPollHandler, Enabled() folds in the unfinished
|
||||
// task existence check so an idle system schedules no rows.
|
||||
type asyncTaskPollHandler struct{}
|
||||
|
||||
func (asyncTaskPollHandler) Type() string { return model.SystemTaskTypeAsyncTaskPoll }
|
||||
|
||||
func (asyncTaskPollHandler) Enabled() bool {
|
||||
return constant.UpdateTask && model.HasUnfinishedSyncTasks()
|
||||
}
|
||||
|
||||
func (asyncTaskPollHandler) Interval() time.Duration { return 15 * time.Second }
|
||||
|
||||
func (asyncTaskPollHandler) NewPayload() any { return nil }
|
||||
|
||||
func (asyncTaskPollHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
|
||||
summary := service.RunTaskPollingOnce(ctx, service.NewSystemTaskProgressReporter(task, runnerID))
|
||||
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, summary, nil)
|
||||
}
|
||||
|
||||
func finishSystemTaskHandler(task *model.SystemTask, runnerID string, status model.SystemTaskStatus, result any, runErr error) {
|
||||
errorMessage := ""
|
||||
if runErr != nil {
|
||||
errorMessage = runErr.Error()
|
||||
}
|
||||
if err := model.FinishSystemTask(task.TaskID, runnerID, status, result, errorMessage); err != nil {
|
||||
common.SysLog(fmt.Sprintf("system task %s failed to persist result: %v", task.TaskID, err))
|
||||
}
|
||||
}
|
||||
@@ -8,17 +8,11 @@ import (
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/relay"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// UpdateTaskBulk 薄入口,实际轮询逻辑在 service 层
|
||||
func UpdateTaskBulk() {
|
||||
service.TaskPollingLoop()
|
||||
}
|
||||
|
||||
func GetAllTask(c *gin.Context) {
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user