Commit 53771922 by Calcium-Ion Committed by GitHub

feat: add system task runner (#5680)

parent acb52d0f
......@@ -158,10 +158,21 @@ var RetryTimes = 0
var IsMasterNode bool
// NodeName 节点名称,从 NODE_NAME 环境变量读取;
// 用于审计日志中标识节点身份,在容器/K8s 部署时比自动探测到的容器内网 IP 更具可读性。
const (
NodeNameSourceManual = "manual"
NodeNameSourceHostname = "hostname"
)
// NodeName 节点名称,优先从 NODE_NAME 环境变量读取,未配置时回退主机名。
// 用于审计日志和后台任务中标识节点身份;多实例部署时建议显式配置稳定 NODE_NAME。
var NodeName = ""
// NodeNameSource records how NodeName was chosen so future instance-management
// reporting can distinguish operator-configured names from automatic fallback.
var NodeNameSource = NodeNameSourceHostname
var NodeNameManuallyConfigured bool
var requestInterval int
var RequestInterval time.Duration
......
......@@ -82,9 +82,7 @@ func InitEnv() {
DebugEnabled = os.Getenv("DEBUG") == "true"
MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true"
IsMasterNode = os.Getenv("NODE_TYPE") != "slave"
// NodeName 优先用 NODE_NAME,未配置时回退主机名(容器下=容器 ID/Pod 名,自动扩容天然唯一)。
hostname, _ := os.Hostname()
NodeName = GetEnvOrDefaultString("NODE_NAME", hostname)
initNodeNameIdentity()
TLSInsecureSkipVerify = GetEnvOrDefaultBool("TLS_INSECURE_SKIP_VERIFY", false)
if TLSInsecureSkipVerify {
if tr, ok := http.DefaultTransport.(*http.Transport); ok && tr != nil {
......
package common
import "os"
type NodeIdentity struct {
Name string `json:"name"`
Source string `json:"source"`
ManuallyConfigured bool `json:"manually_configured"`
ShouldConfigureManually bool `json:"should_configure_manually"`
}
func initNodeNameIdentity() {
if envNodeName := os.Getenv("NODE_NAME"); envNodeName != "" {
NodeName = envNodeName
NodeNameSource = NodeNameSourceManual
NodeNameManuallyConfigured = true
return
}
hostname, _ := os.Hostname()
NodeName = hostname
NodeNameSource = NodeNameSourceHostname
NodeNameManuallyConfigured = false
}
func GetNodeIdentity() NodeIdentity {
return NodeIdentity{
Name: NodeName,
Source: NodeNameSource,
ManuallyConfigured: NodeNameManuallyConfigured,
ShouldConfigureManually: !NodeNameManuallyConfigured,
}
}
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(), "已有通道测试任务正在运行或等待中")
}
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 {
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 {
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)
task, created, err := service.EnqueueSystemTask(model.SystemTaskTypeModelUpdate, modelUpdateTaskPayload{Manual: true})
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 !created {
c.JSON(http.StatusConflict, gin.H{
"success": false,
"message": "已有模型更新任务正在运行或等待中,不能启动本次手动任务",
"data": gin.H{
"task_id": task.TaskID,
"status": task.Status,
"type": task.Type,
},
})
return
}
if len(channels) < channelUpstreamModelUpdateTaskBatchSize {
break
}
}
if refreshNeeded {
refreshChannelRuntimeCache()
}
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,
},
})
}
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(), "已有模型更新任务正在运行或等待中")
}
......@@ -3,7 +3,6 @@ package controller
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
......@@ -20,16 +19,29 @@ 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"`
}
// 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 {
continue
return summary
}
summary.UnfinishedTasks = len(tasks)
logger.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks)))
taskChannelM := make(map[int][]string)
......@@ -45,6 +57,7 @@ func UpdateMidjourneyTaskBulk() {
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%",
......@@ -56,10 +69,20 @@ func UpdateMidjourneyTaskBulk() {
}
}
if len(taskChannelM) == 0 {
continue
return summary
}
totalChannels := len(taskChannelM)
processedChannels := 0
for channelId, taskIds := range taskChannelM {
if ctx != nil && ctx.Err() != nil {
break
}
if report != nil {
report(processedChannels, totalChannels)
}
processedChannels++
summary.ChannelsScanned++
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
if len(taskIds) == 0 {
continue
......@@ -79,39 +102,48 @@ func UpdateMidjourneyTaskBulk() {
}
requestUrl := fmt.Sprintf("%s/mj/task/list-by-condition", *midjourneyChannel.BaseURL)
body, _ := json.Marshal(map[string]any{
body, err := common.Marshal(map[string]any{
"ids": taskIds,
})
req, err := http.NewRequest("POST", requestUrl, bytes.NewBuffer(body))
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Get Task error: %v", err))
logger.LogError(ctx, fmt.Sprintf("Get Task marshal body error: %v", err))
continue
}
// 设置超时时间
timeout := time.Second * 15
ctx, cancel := context.WithTimeout(context.Background(), timeout)
// 使用带有超时的 context 创建新的请求
req = req.WithContext(ctx)
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 = json.Unmarshal(responseBody, &responseItems)
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()
......@@ -120,6 +152,10 @@ func UpdateMidjourneyTaskBulk() {
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%,则认为任务失败
......@@ -142,11 +178,11 @@ func UpdateMidjourneyTaskBulk() {
task.Status = responseItem.Status
task.FailReason = responseItem.FailReason
if responseItem.Properties != nil {
propertiesStr, _ := json.Marshal(responseItem.Properties)
propertiesStr, _ := common.Marshal(responseItem.Properties)
task.Properties = string(propertiesStr)
}
if responseItem.Buttons != nil {
buttonStr, _ := json.Marshal(responseItem.Buttons)
buttonStr, _ := common.Marshal(responseItem.Buttons)
task.Buttons = string(buttonStr)
}
// 映射 VideoUrl
......@@ -154,7 +190,7 @@ func UpdateMidjourneyTaskBulk() {
// 映射 VideoUrls - 将数组序列化为 JSON 字符串
if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 {
videoUrlsStr, err := json.Marshal(responseItem.VideoUrls)
videoUrlsStr, err := common.Marshal(responseItem.VideoUrls)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err))
task.VideoUrls = "[]" // 失败时设置为空数组
......@@ -196,7 +232,10 @@ func UpdateMidjourneyTaskBulk() {
}
}
}
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 == "" {
......
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)
......
......@@ -111,18 +111,15 @@ func main() {
go controller.AutomaticallyUpdateChannels(frequency)
}
go controller.AutomaticallyTestChannels()
// Codex credential auto-refresh check every 10 minutes, refresh when expires within 1 day
service.StartCodexCredentialAutoRefreshTask()
// Subscription quota reset task (daily/weekly/monthly/custom)
service.StartSubscriptionQuotaResetTask()
// Persistent system maintenance task runner
service.StartSystemTaskRunner()
// Wire task polling adaptor factory (breaks service -> relay import cycle)
// Wire task polling adaptor factory (breaks service -> relay import cycle).
// Must run before the system task runner starts: the async_task_poll handler
// calls service.RunTaskPollingOnce, which needs this factory set.
service.GetTaskAdaptorFunc = func(platform constant.TaskPlatform) service.TaskPollingAdaptor {
a := relay.GetTaskAdaptor(platform)
if a == nil {
......@@ -131,17 +128,14 @@ func main() {
return a
}
// Channel upstream model update check task
controller.StartChannelUpstreamModelUpdateTask()
// Register the periodic channel test, upstream model update, and async task
// polling (Midjourney / Suno / video) jobs as scheduled system tasks
// (DB-lease dedup across masters + run history), then start the runner that
// schedules and executes them. Master-only execution and the UpdateTask
// switch are enforced inside the runner and each handler's Enabled().
controller.RegisterScheduledSystemTasks()
service.StartSystemTaskRunner()
if common.IsMasterNode && constant.UpdateTask {
gopool.Go(func() {
controller.UpdateMidjourneyTaskBulk()
})
gopool.Go(func() {
controller.UpdateTaskBulk()
})
}
if os.Getenv("BATCH_UPDATE_ENABLED") == "true" {
common.BatchUpdateEnabled = true
common.SysLog("batch update enabled with interval " + strconv.Itoa(common.BatchUpdateInterval) + "s")
......
......@@ -295,6 +295,7 @@ func migrateDB() error {
&UserOAuthBinding{},
&PerfMetric{},
&SystemTask{},
&SystemTaskLock{},
)
if err != nil {
return err
......@@ -345,6 +346,7 @@ func migrateDBFast() error {
{&UserOAuthBinding{}, "UserOAuthBinding"},
{&PerfMetric{}, "PerfMetric"},
{&SystemTask{}, "SystemTask"},
{&SystemTaskLock{}, "SystemTaskLock"},
}
// 动态计算migration数量,确保errChan缓冲区足够大
errChan := make(chan error, len(migrations))
......
......@@ -101,6 +101,19 @@ func GetAllUnFinishTasks() []*Midjourney {
return tasks
}
// HasUnfinishedMidjourneyTasks reports whether at least one Midjourney task is
// still in progress. It is a cheap existence check (LIMIT 1) used to decide
// whether the midjourney_poll system task needs to run; when no task is pending
// the scheduler skips creating a row entirely.
func HasUnfinishedMidjourneyTasks() bool {
var id int
err := DB.Model(&Midjourney{}).
Where("progress != ?", "100%").
Limit(1).
Pluck("id", &id).Error
return err == nil && id != 0
}
func GetByOnlyMJId(mjId string) *Midjourney {
var mj *Midjourney
var err error
......
......@@ -314,6 +314,21 @@ func GetAllUnFinishSyncTasks(limit int) []*Task {
return tasks
}
// HasUnfinishedSyncTasks reports whether at least one async (Suno/video) task is
// still in progress. It is a cheap existence check (LIMIT 1) used to decide
// whether the async_task_poll system task needs to run; when no task is pending
// the scheduler skips creating a row entirely.
func HasUnfinishedSyncTasks() bool {
var id int64
err := DB.Model(&Task{}).
Where("progress != ?", "100%").
Where("status != ?", TaskStatusFailure).
Where("status != ?", TaskStatusSuccess).
Limit(1).
Pluck("id", &id).Error
return err == nil && id != 0
}
func GetByOnlyTaskId(taskId string) (*Task, bool, error) {
if taskId == "" {
return nil, false, nil
......
......@@ -49,6 +49,7 @@ func TestMain(m *testing.M) {
&UserOAuthBinding{},
&PerfMetric{},
&SystemTask{},
&SystemTaskLock{},
); err != nil {
panic("failed to migrate: " + err.Error())
}
......@@ -72,6 +73,7 @@ func truncateTables(t *testing.T) {
DB.Exec("DELETE FROM user_subscriptions")
DB.Exec("DELETE FROM user_oauth_bindings")
DB.Exec("DELETE FROM perf_metrics")
DB.Exec("DELETE FROM system_task_locks")
DB.Exec("DELETE FROM system_tasks")
})
}
......
......@@ -318,6 +318,7 @@ func SetApiRouter(router *gin.Engine) {
systemTaskRoute.Use(middleware.RootAuth())
{
systemTaskRoute.POST("/log-cleanup", controller.CreateLogCleanupSystemTask)
systemTaskRoute.GET("/list", controller.ListSystemTasks)
systemTaskRoute.GET("/current", controller.GetCurrentSystemTask)
systemTaskRoute.GET("/:task_id", controller.GetSystemTask)
}
......
package service
import (
"context"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// withSystemTaskRegistry swaps the package registry for the given handlers for
// the duration of a test and restores the original registry afterward.
func withSystemTaskRegistry(t *testing.T, handlers ...SystemTaskHandler) {
t.Helper()
systemTaskHandlersMu.Lock()
saved := systemTaskHandlers
systemTaskHandlers = map[string]SystemTaskHandler{}
for _, h := range handlers {
systemTaskHandlers[h.Type()] = h
}
systemTaskHandlersMu.Unlock()
t.Cleanup(func() {
systemTaskHandlersMu.Lock()
systemTaskHandlers = saved
systemTaskHandlersMu.Unlock()
})
}
type stubScheduledHandler struct {
taskType string
enabled bool
interval time.Duration
onRun func(ctx context.Context, task *model.SystemTask, runnerID string)
}
type stubSystemTaskRunResult struct {
taskID string
taskType string
err error
}
func (h *stubScheduledHandler) Type() string { return h.taskType }
func (h *stubScheduledHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
if h.onRun != nil {
h.onRun(ctx, task, runnerID)
}
}
func (h *stubScheduledHandler) Enabled() bool { return h.enabled }
func (h *stubScheduledHandler) Interval() time.Duration { return h.interval }
func (h *stubScheduledHandler) NewPayload() any { return nil }
func countSystemTasks(t *testing.T, taskType string) int64 {
t.Helper()
var count int64
require.NoError(t, model.DB.Model(&model.SystemTask{}).Where("type = ?", taskType).Count(&count).Error)
return count
}
func TestSystemTaskSchedulerCreatesWhenDueAndDedups(t *testing.T) {
truncate(t)
handler := &stubScheduledHandler{taskType: "test_scheduled", enabled: true, interval: time.Minute}
withSystemTaskRegistry(t, handler)
runSystemTaskScheduler()
require.Equal(t, int64(1), countSystemTasks(t, handler.taskType))
// An active (pending) row already exists, so a second pass must not create
// another row.
runSystemTaskScheduler()
require.Equal(t, int64(1), countSystemTasks(t, handler.taskType))
// Finish the run; with a fresh updated_at the next run is not due yet.
latest, err := model.GetLatestSystemTask(handler.taskType)
require.NoError(t, err)
require.NotNil(t, latest)
_, claimed, err := model.ClaimSystemTask(latest.ID, handler.taskType, "runner-a", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, model.FinishSystemTask(latest.TaskID, "runner-a", model.SystemTaskStatusSucceeded, nil, ""))
runSystemTaskScheduler()
require.Equal(t, int64(1), countSystemTasks(t, handler.taskType))
// Backdate the finished row beyond the interval -> the job becomes due again.
require.NoError(t, model.DB.Model(&model.SystemTask{}).
Where("task_id = ?", latest.TaskID).
Update("updated_at", common.GetTimestamp()-120).Error)
runSystemTaskScheduler()
require.Equal(t, int64(2), countSystemTasks(t, handler.taskType))
}
func TestSystemTaskSchedulerSkipsDisabled(t *testing.T) {
truncate(t)
handler := &stubScheduledHandler{taskType: "test_disabled", enabled: false, interval: time.Minute}
withSystemTaskRegistry(t, handler)
runSystemTaskScheduler()
assert.Equal(t, int64(0), countSystemTasks(t, handler.taskType))
}
func TestSystemTaskClaimPassDispatchesByType(t *testing.T) {
truncate(t)
ran := make(chan stubSystemTaskRunResult, 1)
handler := &stubScheduledHandler{
taskType: "test_dispatch",
enabled: true,
interval: time.Minute,
onRun: func(_ context.Context, task *model.SystemTask, runnerID string) {
ran <- stubSystemTaskRunResult{
taskType: task.Type,
err: model.FinishSystemTask(task.TaskID, runnerID, model.SystemTaskStatusSucceeded, nil, ""),
}
},
}
withSystemTaskRegistry(t, handler)
_, err := model.CreateSystemTask(handler.taskType, nil, nil)
require.NoError(t, err)
runSystemTaskClaimPass("runner-dispatch")
select {
case got := <-ran:
require.NoError(t, got.err)
assert.Equal(t, handler.taskType, got.taskType)
case <-time.After(2 * time.Second):
t.Fatal("claimed task was not dispatched to its handler")
}
require.Eventually(t, func() bool {
latest, err := model.GetLatestSystemTask(handler.taskType)
return err == nil && latest != nil && latest.Status == model.SystemTaskStatusSucceeded
}, 2*time.Second, 20*time.Millisecond)
}
func TestSystemTaskClaimPassDispatchesEarliestPendingByType(t *testing.T) {
truncate(t)
ran := make(chan stubSystemTaskRunResult, 2)
handlerA := &stubScheduledHandler{
taskType: "test_dispatch_a",
enabled: true,
interval: time.Minute,
onRun: func(_ context.Context, task *model.SystemTask, runnerID string) {
ran <- stubSystemTaskRunResult{
taskID: task.TaskID,
err: model.FinishSystemTask(task.TaskID, runnerID, model.SystemTaskStatusSucceeded, nil, ""),
}
},
}
handlerB := &stubScheduledHandler{
taskType: "test_dispatch_b",
enabled: true,
interval: time.Minute,
onRun: func(_ context.Context, task *model.SystemTask, runnerID string) {
ran <- stubSystemTaskRunResult{
taskID: task.TaskID,
err: model.FinishSystemTask(task.TaskID, runnerID, model.SystemTaskStatusSucceeded, nil, ""),
}
},
}
withSystemTaskRegistry(t, handlerA, handlerB)
firstA, err := model.CreateSystemTask(handlerA.taskType, nil, nil)
require.NoError(t, err)
secondTaskID, err := model.GenerateSystemTaskID()
require.NoError(t, err)
secondA := &model.SystemTask{
TaskID: secondTaskID,
Type: handlerA.taskType,
Status: model.SystemTaskStatusPending,
}
require.NoError(t, model.DB.Create(secondA).Error)
firstB, err := model.CreateSystemTask(handlerB.taskType, nil, nil)
require.NoError(t, err)
runSystemTaskClaimPass("runner-dispatch")
got := map[string]bool{}
for range 2 {
select {
case result := <-ran:
require.NoError(t, result.err)
got[result.taskID] = true
case <-time.After(2 * time.Second):
t.Fatal("claimed tasks were not dispatched to their handlers")
}
}
assert.True(t, got[firstA.TaskID])
assert.True(t, got[firstB.TaskID])
assert.False(t, got[secondA.TaskID])
require.Eventually(t, func() bool {
reloaded, err := model.GetSystemTaskByTaskID(secondA.TaskID)
return err == nil && reloaded != nil && reloaded.Status == model.SystemTaskStatusPending
}, 2*time.Second, 20*time.Millisecond)
}
func TestEnqueueSystemTaskReportsCreatedAndExistingActive(t *testing.T) {
truncate(t)
first, created, err := EnqueueSystemTask("test_enqueue", map[string]bool{"manual": true})
require.NoError(t, err)
require.True(t, created)
require.NotNil(t, first)
existing, created, err := EnqueueSystemTask("test_enqueue", nil)
require.NoError(t, err)
require.False(t, created)
require.NotNil(t, existing)
assert.Equal(t, first.TaskID, existing.TaskID)
_, claimed, err := model.ClaimSystemTask(first.ID, first.Type, "runner-a", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, model.FinishSystemTask(first.TaskID, "runner-a", model.SystemTaskStatusSucceeded, nil, ""))
second, created, err := EnqueueSystemTask("test_enqueue", nil)
require.NoError(t, err)
require.True(t, created)
require.NotNil(t, second)
assert.NotEqual(t, first.TaskID, second.TaskID)
}
......@@ -45,6 +45,7 @@ func TestMain(m *testing.M) {
&model.TopUp{},
&model.UserSubscription{},
&model.SystemTask{},
&model.SystemTaskLock{},
); err != nil {
panic("failed to migrate: " + err.Error())
}
......@@ -66,6 +67,7 @@ func truncate(t *testing.T) {
model.DB.Exec("DELETE FROM channels")
model.DB.Exec("DELETE FROM top_ups")
model.DB.Exec("DELETE FROM user_subscriptions")
model.DB.Exec("DELETE FROM system_task_locks")
model.DB.Exec("DELETE FROM system_tasks")
})
}
......
......@@ -87,22 +87,51 @@ func sweepTimedOutTasks(ctx context.Context) {
}
}
// TaskPollingLoop 主轮询循环,每 15 秒检查一次未完成的任务
func TaskPollingLoop() {
for {
time.Sleep(time.Duration(15) * time.Second)
// TaskPollSummary is the result recorded on an async_task_poll system task row,
// summarizing one polling pass.
type TaskPollSummary struct {
UnfinishedTasks int `json:"unfinished_tasks"`
PlatformsScanned int `json:"platforms_scanned"`
NullTasksFailed int `json:"null_tasks_failed"`
}
// RunTaskPollingOnce performs one async-task (Suno/video) 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
// (processedPlatforms, totalPlatforms). It returns immediately if the task
// adaptor factory has not been wired yet, to avoid a nil call during startup.
func RunTaskPollingOnce(ctx context.Context, report func(processed, total int)) TaskPollSummary {
summary := TaskPollSummary{}
if GetTaskAdaptorFunc == nil {
return summary
}
if ctx == nil {
ctx = context.Background()
}
common.SysLog("任务进度轮询开始")
ctx := context.TODO()
sweepTimedOutTasks(ctx)
allTasks := model.GetAllUnFinishSyncTasks(constant.TaskQueryLimit)
summary.UnfinishedTasks = len(allTasks)
platformTask := make(map[constant.TaskPlatform][]*model.Task)
for _, t := range allTasks {
platformTask[t.Platform] = append(platformTask[t.Platform], t)
}
totalPlatforms := len(platformTask)
processedPlatforms := 0
for platform, tasks := range platformTask {
if ctx.Err() != nil {
break
}
if report != nil {
report(processedPlatforms, totalPlatforms)
}
processedPlatforms++
if len(tasks) == 0 {
continue
}
summary.PlatformsScanned++
taskChannelM := make(map[int][]string)
taskM := make(map[string]*model.Task)
nullTaskIds := make([]int64, 0)
......@@ -117,6 +146,7 @@ func TaskPollingLoop() {
taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], upstreamID)
}
if len(nullTaskIds) > 0 {
summary.NullTasksFailed += len(nullTaskIds)
err := model.TaskBulkUpdateByID(nullTaskIds, map[string]any{
"status": "FAILURE",
"progress": "100%",
......@@ -131,21 +161,27 @@ func TaskPollingLoop() {
continue
}
DispatchPlatformUpdate(platform, taskChannelM, taskM)
DispatchPlatformUpdate(ctx, platform, taskChannelM, taskM)
}
common.SysLog("任务进度轮询完成")
if report != nil && ctx.Err() == nil {
report(totalPlatforms, totalPlatforms)
}
common.SysLog("任务进度轮询完成")
return summary
}
// DispatchPlatformUpdate 按平台分发轮询更新
func DispatchPlatformUpdate(platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) {
func DispatchPlatformUpdate(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) {
if ctx == nil {
ctx = context.Background()
}
switch platform {
case constant.TaskPlatformMidjourney:
// MJ 轮询由其自身处理,这里预留入口
case constant.TaskPlatformSuno:
_ = UpdateSunoTasks(context.Background(), taskChannelM, taskM)
_ = UpdateSunoTasks(ctx, taskChannelM, taskM)
default:
if err := UpdateVideoTasks(context.Background(), platform, taskChannelM, taskM); err != nil {
if err := UpdateVideoTasks(ctx, platform, taskChannelM, taskM); err != nil {
common.SysLog(fmt.Sprintf("UpdateVideoTasks fail: %s", err))
}
}
......@@ -154,6 +190,9 @@ func DispatchPlatformUpdate(platform constant.TaskPlatform, taskChannelM map[int
// UpdateSunoTasks 按渠道更新所有 Suno 任务
func UpdateSunoTasks(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
for channelId, taskIds := range taskChannelM {
if ctx.Err() != nil {
return ctx.Err()
}
err := updateSunoTasks(ctx, channelId, taskIds, taskM)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %s", channelId, err.Error()))
......@@ -164,6 +203,9 @@ func UpdateSunoTasks(ctx context.Context, taskChannelM map[int][]string, taskM m
func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM map[string]*model.Task) error {
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
if ctx.Err() != nil {
return ctx.Err()
}
if len(taskIds) == 0 {
return nil
}
......@@ -221,7 +263,14 @@ func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM
}
for _, responseItem := range responseItems.Data {
if ctx.Err() != nil {
return ctx.Err()
}
task := taskM[responseItem.TaskID]
if task == nil {
logger.LogWarn(ctx, fmt.Sprintf("Suno task response ignored: unknown task_id=%s", responseItem.TaskID))
continue
}
if !taskNeedsUpdate(task, responseItem) {
continue
}
......@@ -290,6 +339,9 @@ func taskNeedsUpdate(oldTask *model.Task, newTask dto.SunoDataResponse) bool {
// UpdateVideoTasks 按渠道更新所有视频任务
func UpdateVideoTasks(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
for channelId, taskIds := range taskChannelM {
if ctx.Err() != nil {
return ctx.Err()
}
if err := updateVideoTasks(ctx, platform, channelId, taskIds, taskM); err != nil {
logger.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
}
......@@ -332,16 +384,26 @@ func updateVideoTasks(ctx context.Context, platform constant.TaskPlatform, chann
info.ApiKey = cacheGetChannel.Key
adaptor.Init(info)
for _, taskId := range taskIds {
if ctx.Err() != nil {
return ctx.Err()
}
if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil {
logger.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
}
// sleep 1 second between each task to avoid hitting rate limits of upstream platforms
time.Sleep(1 * time.Second)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(1 * time.Second):
}
}
return nil
}
func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *model.Channel, taskId string, taskM map[string]*model.Task) error {
if ctx.Err() != nil {
return ctx.Err()
}
baseURL := constant.ChannelBaseURLs[ch.Type]
if ch.GetBaseURL() != "" {
baseURL = ch.GetBaseURL()
......
......@@ -28,6 +28,12 @@ type BaseNavItem = {
icon?: React.ElementType
activeUrls?: (LinkProps['to'] | (string & {}))[]
configUrls?: (LinkProps['to'] | (string & {}))[]
/**
* Minimum role required to see this item in the sidebar. When set, the item
* is hidden for users whose role is below this threshold (see
* `useSidebarView`). Route-level guards still enforce access independently.
*/
requiredRole?: number
}
/**
......
......@@ -251,7 +251,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
{},
upstreamUpdateRequestConfig
)
const { success, message, data } = res.data || {}
const { success, message } = res.data || {}
if (!success) {
toast.error(message || t('Batch detection failed'))
return
......@@ -259,13 +259,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
toast.success(
t(
'Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed',
{
channels: data?.processed_channels || 0,
add: data?.detected_add_models || 0,
remove: data?.detected_remove_models || 0,
fails: (data?.failed_channel_ids || []).length,
}
'Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.'
)
)
await refresh()
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout'
import { SystemTasksPanel } from './components/system-tasks-panel'
export function SystemInfo() {
const { t } = useTranslation()
return (
<SectionPageLayout>
<SectionPageLayout.Title>{t('System Info')}</SectionPageLayout.Title>
<SectionPageLayout.Content>
<SystemTasksPanel />
</SectionPageLayout.Content>
</SectionPageLayout>
)
}
......@@ -22,6 +22,7 @@ import type {
FetchUpstreamRatiosRequest,
LogCleanupTask,
SystemOptionsResponse,
SystemTaskListResponse,
SystemTaskResponse,
UpdateOptionRequest,
UpdateOptionResponse,
......@@ -75,6 +76,13 @@ export async function getSystemTask(taskId: string) {
return res.data
}
export async function listSystemTasks(limit = 20) {
const res = await api.get<SystemTaskListResponse>('/api/system-task/list', {
params: { limit },
})
return res.data
}
export async function resetModelRatios() {
const res = await api.post<UpdateOptionResponse>(
'/api/option/rest_model_ratio'
......
......@@ -100,6 +100,12 @@ export type SystemTaskResponse<TTask = SystemTask | null> = {
data?: TTask
}
export type SystemTaskListResponse = {
success: boolean
message: string
data?: SystemTask[]
}
export type SiteSettings = {
'theme.frontend': string
Notice: string
......
......@@ -27,6 +27,7 @@ import {
ListTodo,
MessageSquare,
Radio,
ServerCog,
Settings,
Ticket,
User,
......@@ -34,6 +35,7 @@ import {
Wallet,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ROLE } from '@/lib/roles'
import { type SidebarData } from '@/components/layout/types'
/**
......@@ -142,6 +144,12 @@ export function useSidebarData(): SidebarData {
icon: CreditCard,
},
{
title: t('System Info'),
url: '/system-info',
icon: ServerCog,
requiredRole: ROLE.SUPER_ADMIN,
},
{
title: t('System Settings'),
url: '/system-settings/site',
activeUrls: ['/system-settings'],
......
......@@ -50,10 +50,16 @@ export function useSidebarView(): ResolvedSidebarView {
const configFilteredRoot = useSidebarConfig(rootSidebarData.navGroups)
const rootNavGroups = useMemo<NavGroup[]>(() => {
const isAdmin = userRole !== undefined && userRole >= ROLE.ADMIN
return configFilteredRoot.filter((group) =>
group.id === 'admin' ? isAdmin : true
const role = userRole ?? ROLE.GUEST
const isAdmin = role >= ROLE.ADMIN
return configFilteredRoot
.filter((group) => (group.id === 'admin' ? isAdmin : true))
.map((group) => {
const items = group.items.filter(
(item) => item.requiredRole === undefined || role >= item.requiredRole
)
return items.length === group.items.length ? group : { ...group, items }
})
}, [configFilteredRoot, userRole])
const view = resolveSidebarView(pathname)
......
......@@ -40,6 +40,7 @@ import { Route as AuthenticatedWalletIndexRouteImport } from './routes/_authenti
import { Route as AuthenticatedUsersIndexRouteImport } from './routes/_authenticated/users/index'
import { Route as AuthenticatedUsageLogsIndexRouteImport } from './routes/_authenticated/usage-logs/index'
import { Route as AuthenticatedSystemSettingsIndexRouteImport } from './routes/_authenticated/system-settings/index'
import { Route as AuthenticatedSystemInfoIndexRouteImport } from './routes/_authenticated/system-info/index'
import { Route as AuthenticatedSubscriptionsIndexRouteImport } from './routes/_authenticated/subscriptions/index'
import { Route as AuthenticatedRedemptionCodesIndexRouteImport } from './routes/_authenticated/redemption-codes/index'
import { Route as AuthenticatedProfileIndexRouteImport } from './routes/_authenticated/profile/index'
......@@ -226,6 +227,12 @@ const AuthenticatedSystemSettingsIndexRoute =
path: '/',
getParentRoute: () => AuthenticatedSystemSettingsRouteRoute,
} as any)
const AuthenticatedSystemInfoIndexRoute =
AuthenticatedSystemInfoIndexRouteImport.update({
id: '/system-info/',
path: '/system-info/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedSubscriptionsIndexRoute =
AuthenticatedSubscriptionsIndexRouteImport.update({
id: '/subscriptions/',
......@@ -431,6 +438,7 @@ export interface FileRoutesByFullPath {
'/profile/': typeof AuthenticatedProfileIndexRoute
'/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute
'/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
'/system-info/': typeof AuthenticatedSystemInfoIndexRoute
'/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
'/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
'/users/': typeof AuthenticatedUsersIndexRoute
......@@ -489,6 +497,7 @@ export interface FileRoutesByTo {
'/profile': typeof AuthenticatedProfileIndexRoute
'/redemption-codes': typeof AuthenticatedRedemptionCodesIndexRoute
'/subscriptions': typeof AuthenticatedSubscriptionsIndexRoute
'/system-info': typeof AuthenticatedSystemInfoIndexRoute
'/system-settings': typeof AuthenticatedSystemSettingsIndexRoute
'/usage-logs': typeof AuthenticatedUsageLogsIndexRoute
'/users': typeof AuthenticatedUsersIndexRoute
......@@ -551,6 +560,7 @@ export interface FileRoutesById {
'/_authenticated/profile/': typeof AuthenticatedProfileIndexRoute
'/_authenticated/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute
'/_authenticated/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
'/_authenticated/system-info/': typeof AuthenticatedSystemInfoIndexRoute
'/_authenticated/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
'/_authenticated/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
......@@ -612,6 +622,7 @@ export interface FileRouteTypes {
| '/profile/'
| '/redemption-codes/'
| '/subscriptions/'
| '/system-info/'
| '/system-settings/'
| '/usage-logs/'
| '/users/'
......@@ -670,6 +681,7 @@ export interface FileRouteTypes {
| '/profile'
| '/redemption-codes'
| '/subscriptions'
| '/system-info'
| '/system-settings'
| '/usage-logs'
| '/users'
......@@ -731,6 +743,7 @@ export interface FileRouteTypes {
| '/_authenticated/profile/'
| '/_authenticated/redemption-codes/'
| '/_authenticated/subscriptions/'
| '/_authenticated/system-info/'
| '/_authenticated/system-settings/'
| '/_authenticated/usage-logs/'
| '/_authenticated/users/'
......@@ -992,6 +1005,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSystemSettingsIndexRouteImport
parentRoute: typeof AuthenticatedSystemSettingsRouteRoute
}
'/_authenticated/system-info/': {
id: '/_authenticated/system-info/'
path: '/system-info'
fullPath: '/system-info/'
preLoaderRoute: typeof AuthenticatedSystemInfoIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/subscriptions/': {
id: '/_authenticated/subscriptions/'
path: '/subscriptions'
......@@ -1290,6 +1310,7 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedProfileIndexRoute: typeof AuthenticatedProfileIndexRoute
AuthenticatedRedemptionCodesIndexRoute: typeof AuthenticatedRedemptionCodesIndexRoute
AuthenticatedSubscriptionsIndexRoute: typeof AuthenticatedSubscriptionsIndexRoute
AuthenticatedSystemInfoIndexRoute: typeof AuthenticatedSystemInfoIndexRoute
AuthenticatedUsageLogsIndexRoute: typeof AuthenticatedUsageLogsIndexRoute
AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute
AuthenticatedWalletIndexRoute: typeof AuthenticatedWalletIndexRoute
......@@ -1313,6 +1334,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedRedemptionCodesIndexRoute:
AuthenticatedRedemptionCodesIndexRoute,
AuthenticatedSubscriptionsIndexRoute: AuthenticatedSubscriptionsIndexRoute,
AuthenticatedSystemInfoIndexRoute: AuthenticatedSystemInfoIndexRoute,
AuthenticatedUsageLogsIndexRoute: AuthenticatedUsageLogsIndexRoute,
AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute,
AuthenticatedWalletIndexRoute: AuthenticatedWalletIndexRoute,
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useAuthStore } from '@/stores/auth-store'
import { ROLE } from '@/lib/roles'
import { SystemInfo } from '@/features/system-info'
export const Route = createFileRoute('/_authenticated/system-info/')({
beforeLoad: () => {
const { auth } = useAuthStore.getState()
if (auth.user?.role !== ROLE.SUPER_ADMIN) {
throw redirect({
to: '/403',
})
}
},
component: SystemInfo,
})
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment