Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
1146 lines
36 KiB
Go
1146 lines
36 KiB
Go
package controller
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"regexp"
|
||
"slices"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/QuantumNous/new-api/common"
|
||
"github.com/QuantumNous/new-api/constant"
|
||
"github.com/QuantumNous/new-api/model"
|
||
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
||
"github.com/QuantumNous/new-api/relay/channel/advancedcustom"
|
||
"github.com/QuantumNous/new-api/relay/channel/gemini"
|
||
"github.com/QuantumNous/new-api/relay/channel/ollama"
|
||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||
"github.com/QuantumNous/new-api/relaykit/types"
|
||
"github.com/QuantumNous/new-api/service"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/samber/lo"
|
||
)
|
||
|
||
const (
|
||
channelUpstreamModelUpdateTaskDefaultIntervalMinutes = 30
|
||
channelUpstreamModelUpdateTaskBatchSize = 100
|
||
channelUpstreamModelUpdateMinCheckIntervalSeconds = 300
|
||
channelUpstreamModelUpdateNotifySuppressWindowSeconds = 86400
|
||
channelUpstreamModelUpdateNotifyMaxChannelDetails = 8
|
||
channelUpstreamModelUpdateNotifyMaxModelDetails = 12
|
||
channelUpstreamModelUpdateNotifyMaxFailedChannelIDs = 10
|
||
)
|
||
|
||
var channelUpstreamModelUpdateSelectFields = []string{
|
||
"id",
|
||
"name",
|
||
"type",
|
||
"key",
|
||
"status",
|
||
"base_url",
|
||
"models",
|
||
"model_mapping",
|
||
"settings",
|
||
"setting",
|
||
"other",
|
||
"group",
|
||
"priority",
|
||
"weight",
|
||
"tag",
|
||
"channel_info",
|
||
"header_override",
|
||
}
|
||
|
||
var channelUpstreamModelUpdateNotifyState = struct {
|
||
sync.Mutex
|
||
lastNotifiedAt int64
|
||
lastChangedChannels int
|
||
lastFailedChannels int
|
||
}{}
|
||
|
||
type applyChannelUpstreamModelUpdatesRequest struct {
|
||
ID int `json:"id"`
|
||
AddModels []string `json:"add_models"`
|
||
RemoveModels []string `json:"remove_models"`
|
||
IgnoreModels []string `json:"ignore_models"`
|
||
}
|
||
|
||
type applyAllChannelUpstreamModelUpdatesResult struct {
|
||
ChannelID int `json:"channel_id"`
|
||
ChannelName string `json:"channel_name"`
|
||
AddedModels []string `json:"added_models"`
|
||
RemovedModels []string `json:"removed_models"`
|
||
RemainingModels []string `json:"remaining_models"`
|
||
RemainingRemoveModels []string `json:"remaining_remove_models"`
|
||
}
|
||
|
||
type detectChannelUpstreamModelUpdatesResult struct {
|
||
ChannelID int `json:"channel_id"`
|
||
ChannelName string `json:"channel_name"`
|
||
AddModels []string `json:"add_models"`
|
||
RemoveModels []string `json:"remove_models"`
|
||
LastCheckTime int64 `json:"last_check_time"`
|
||
AutoAddedModels int `json:"auto_added_models"`
|
||
}
|
||
|
||
type upstreamModelUpdateChannelSummary struct {
|
||
ChannelName string
|
||
AddCount int
|
||
RemoveCount int
|
||
}
|
||
|
||
func normalizeModelNames(models []string) []string {
|
||
return lo.Uniq(lo.FilterMap(models, func(model string, _ int) (string, bool) {
|
||
trimmed := strings.TrimSpace(model)
|
||
return trimmed, trimmed != ""
|
||
}))
|
||
}
|
||
|
||
func mergeModelNames(base []string, appended []string) []string {
|
||
merged := normalizeModelNames(base)
|
||
seen := make(map[string]struct{}, len(merged))
|
||
for _, model := range merged {
|
||
seen[model] = struct{}{}
|
||
}
|
||
for _, model := range normalizeModelNames(appended) {
|
||
if _, ok := seen[model]; ok {
|
||
continue
|
||
}
|
||
seen[model] = struct{}{}
|
||
merged = append(merged, model)
|
||
}
|
||
return merged
|
||
}
|
||
|
||
func subtractModelNames(base []string, removed []string) []string {
|
||
removeSet := make(map[string]struct{}, len(removed))
|
||
for _, model := range normalizeModelNames(removed) {
|
||
removeSet[model] = struct{}{}
|
||
}
|
||
return lo.Filter(normalizeModelNames(base), func(model string, _ int) bool {
|
||
_, ok := removeSet[model]
|
||
return !ok
|
||
})
|
||
}
|
||
|
||
func intersectModelNames(base []string, allowed []string) []string {
|
||
allowedSet := make(map[string]struct{}, len(allowed))
|
||
for _, model := range normalizeModelNames(allowed) {
|
||
allowedSet[model] = struct{}{}
|
||
}
|
||
return lo.Filter(normalizeModelNames(base), func(model string, _ int) bool {
|
||
_, ok := allowedSet[model]
|
||
return ok
|
||
})
|
||
}
|
||
|
||
func applySelectedModelChanges(originModels []string, addModels []string, removeModels []string) []string {
|
||
// Add wins when the same model appears in both selected lists.
|
||
normalizedAdd := normalizeModelNames(addModels)
|
||
normalizedRemove := subtractModelNames(normalizeModelNames(removeModels), normalizedAdd)
|
||
return subtractModelNames(mergeModelNames(originModels, normalizedAdd), normalizedRemove)
|
||
}
|
||
|
||
func normalizeChannelModelMapping(channel *model.Channel) map[string]string {
|
||
if channel == nil || channel.ModelMapping == nil {
|
||
return nil
|
||
}
|
||
rawMapping := strings.TrimSpace(*channel.ModelMapping)
|
||
if rawMapping == "" || rawMapping == "{}" {
|
||
return nil
|
||
}
|
||
parsed := make(map[string]string)
|
||
if err := common.UnmarshalJsonStr(rawMapping, &parsed); err != nil {
|
||
return nil
|
||
}
|
||
normalized := make(map[string]string, len(parsed))
|
||
for source, target := range parsed {
|
||
normalizedSource := strings.TrimSpace(source)
|
||
normalizedTarget := strings.TrimSpace(target)
|
||
if normalizedSource == "" || normalizedTarget == "" {
|
||
continue
|
||
}
|
||
normalized[normalizedSource] = normalizedTarget
|
||
}
|
||
if len(normalized) == 0 {
|
||
return nil
|
||
}
|
||
return normalized
|
||
}
|
||
|
||
func collectPendingUpstreamModelChangesFromModels(
|
||
localModels []string,
|
||
upstreamModels []string,
|
||
ignoredModels []string,
|
||
modelMapping map[string]string,
|
||
) (pendingAddModels []string, pendingRemoveModels []string) {
|
||
localSet := make(map[string]struct{})
|
||
localModels = normalizeModelNames(localModels)
|
||
upstreamModels = normalizeModelNames(upstreamModels)
|
||
for _, modelName := range localModels {
|
||
localSet[modelName] = struct{}{}
|
||
}
|
||
upstreamSet := make(map[string]struct{}, len(upstreamModels))
|
||
for _, modelName := range upstreamModels {
|
||
upstreamSet[modelName] = struct{}{}
|
||
}
|
||
|
||
normalizedIgnoredModels := normalizeModelNames(ignoredModels)
|
||
|
||
redirectSourceSet := make(map[string]struct{}, len(modelMapping))
|
||
redirectTargetSet := make(map[string]struct{}, len(modelMapping))
|
||
for source, target := range modelMapping {
|
||
redirectSourceSet[source] = struct{}{}
|
||
redirectTargetSet[target] = struct{}{}
|
||
}
|
||
|
||
coveredUpstreamSet := make(map[string]struct{}, len(localSet)+len(redirectTargetSet))
|
||
for modelName := range localSet {
|
||
coveredUpstreamSet[modelName] = struct{}{}
|
||
}
|
||
for modelName := range redirectTargetSet {
|
||
coveredUpstreamSet[modelName] = struct{}{}
|
||
}
|
||
|
||
pendingAdd := lo.Filter(upstreamModels, func(modelName string, _ int) bool {
|
||
if _, ok := coveredUpstreamSet[modelName]; ok {
|
||
return false
|
||
}
|
||
if lo.ContainsBy(normalizedIgnoredModels, func(ignoredModel string) bool {
|
||
if regexBody, ok := strings.CutPrefix(ignoredModel, "regex:"); ok {
|
||
matched, err := regexp.MatchString(strings.TrimSpace(regexBody), modelName)
|
||
return err == nil && matched
|
||
}
|
||
return ignoredModel == modelName
|
||
}) {
|
||
return false
|
||
}
|
||
return true
|
||
})
|
||
pendingRemove := lo.Filter(localModels, func(modelName string, _ int) bool {
|
||
// Redirect source models are virtual aliases and should not be removed
|
||
// only because they are absent from upstream model list.
|
||
if _, ok := redirectSourceSet[modelName]; ok {
|
||
return false
|
||
}
|
||
_, ok := upstreamSet[modelName]
|
||
return !ok
|
||
})
|
||
return normalizeModelNames(pendingAdd), normalizeModelNames(pendingRemove)
|
||
}
|
||
|
||
func collectPendingUpstreamModelChanges(channel *model.Channel, settings dto.ChannelOtherSettings) (pendingAddModels []string, pendingRemoveModels []string, err error) {
|
||
upstreamModels, err := fetchChannelUpstreamModelIDs(channel)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
pendingAddModels, pendingRemoveModels = collectPendingUpstreamModelChangesFromModels(
|
||
channel.GetModels(),
|
||
upstreamModels,
|
||
settings.UpstreamModelUpdateIgnoredModels,
|
||
normalizeChannelModelMapping(channel),
|
||
)
|
||
return pendingAddModels, pendingRemoveModels, nil
|
||
}
|
||
|
||
func getUpstreamModelUpdateMinCheckIntervalSeconds() int64 {
|
||
interval := int64(common.GetEnvOrDefault(
|
||
"CHANNEL_UPSTREAM_MODEL_UPDATE_MIN_CHECK_INTERVAL_SECONDS",
|
||
channelUpstreamModelUpdateMinCheckIntervalSeconds,
|
||
))
|
||
if interval < 0 {
|
||
return channelUpstreamModelUpdateMinCheckIntervalSeconds
|
||
}
|
||
return interval
|
||
}
|
||
|
||
func parseOpenAIModelIDs(body []byte) ([]string, error) {
|
||
var result struct {
|
||
Data *[]OpenAIModel `json:"data"`
|
||
}
|
||
if err := common.Unmarshal(body, &result); err != nil {
|
||
return nil, fmt.Errorf("invalid OpenAI Models response: %w", err)
|
||
}
|
||
if result.Data == nil {
|
||
return nil, fmt.Errorf("invalid OpenAI Models response: data is required")
|
||
}
|
||
ids := normalizeModelNames(lo.Map(*result.Data, func(item OpenAIModel, _ int) string {
|
||
return item.ID
|
||
}))
|
||
if len(ids) == 0 {
|
||
return nil, fmt.Errorf("OpenAI Models response contains no valid model IDs")
|
||
}
|
||
return ids, nil
|
||
}
|
||
|
||
func sanitizeFetchModelsError(err error, key string) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
|
||
// net/http includes the complete request URL in url.Error. Discovery routes
|
||
// may put the API key in a custom query name or value, so never return that
|
||
// wrapper to an API client.
|
||
var urlErr *url.Error
|
||
if errors.As(err, &urlErr) && urlErr.Err != nil {
|
||
err = urlErr.Err
|
||
}
|
||
|
||
message := err.Error()
|
||
key = strings.TrimSpace(key)
|
||
if key != "" {
|
||
message = strings.ReplaceAll(message, key, "[REDACTED]")
|
||
message = strings.ReplaceAll(message, url.QueryEscape(key), "[REDACTED]")
|
||
message = strings.ReplaceAll(message, url.PathEscape(key), "[REDACTED]")
|
||
}
|
||
return errors.New(message)
|
||
}
|
||
|
||
func sanitizeAdvancedCustomRequestError(err error, key string, requestURL string) error {
|
||
err = sanitizeFetchModelsError(err, key)
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
parsedURL, parseErr := url.Parse(requestURL)
|
||
if parseErr != nil {
|
||
return err
|
||
}
|
||
message := err.Error()
|
||
for _, value := range parsedURL.Query() {
|
||
for _, secret := range value {
|
||
if secret == "" {
|
||
continue
|
||
}
|
||
message = strings.ReplaceAll(message, secret, "[REDACTED]")
|
||
message = strings.ReplaceAll(message, url.QueryEscape(secret), "[REDACTED]")
|
||
message = strings.ReplaceAll(message, url.PathEscape(secret), "[REDACTED]")
|
||
}
|
||
}
|
||
if key != "" {
|
||
message = strings.ReplaceAll(message, key, "[REDACTED]")
|
||
message = strings.ReplaceAll(message, url.QueryEscape(key), "[REDACTED]")
|
||
message = strings.ReplaceAll(message, url.PathEscape(key), "[REDACTED]")
|
||
}
|
||
return errors.New(message)
|
||
}
|
||
|
||
func getFetchModelsResponseBody(method string, requestURL string, channel *model.Channel, headers http.Header) ([]byte, error) {
|
||
request, err := http.NewRequest(method, requestURL, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for name, values := range headers {
|
||
for _, value := range values {
|
||
request.Header.Add(name, value)
|
||
}
|
||
if strings.EqualFold(name, "Host") {
|
||
request.Host = headers.Get(name)
|
||
}
|
||
}
|
||
client, err := service.NewProxyHttpClient(channel.GetSetting().Proxy)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
response, err := client.Do(request)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer response.Body.Close()
|
||
if response.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("status code: %d", response.StatusCode)
|
||
}
|
||
return io.ReadAll(response.Body)
|
||
}
|
||
|
||
func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
|
||
if channel.Type == constant.ChannelTypeTaskPlugin {
|
||
plugin, ok := jsplugin.DefaultRegistry.Get(channel.GetSetting().TaskPluginKey)
|
||
if !ok {
|
||
return nil, fmt.Errorf("task plugin %q is not registered", channel.GetSetting().TaskPluginKey)
|
||
}
|
||
return normalizeModelNames(plugin.Meta.Models), nil
|
||
}
|
||
baseURL := constant.GetChannelBaseURL(channel.Type)
|
||
if channel.GetBaseURL() != "" {
|
||
baseURL = channel.GetBaseURL()
|
||
}
|
||
|
||
if channel.Type == constant.ChannelTypeOllama {
|
||
key := strings.TrimSpace(strings.Split(channel.Key, "\n")[0])
|
||
models, err := ollama.FetchOllamaModels(baseURL, key)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return normalizeModelNames(lo.Map(models, func(item ollama.OllamaModel, _ int) string {
|
||
return item.Name
|
||
})), nil
|
||
}
|
||
|
||
if channel.Type == constant.ChannelTypeGemini {
|
||
key, _, apiErr := channel.GetNextEnabledKey()
|
||
if apiErr != nil {
|
||
return nil, fmt.Errorf("获取渠道密钥失败: %w", apiErr)
|
||
}
|
||
key = strings.TrimSpace(key)
|
||
models, err := gemini.FetchGeminiModels(baseURL, key, channel.GetSetting().Proxy)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return normalizeModelNames(models), nil
|
||
}
|
||
|
||
if constant.IsAdvancedCustomChannel(channel.Type) {
|
||
return fetchAdvancedCustomUpstreamModelIDs(channel, baseURL)
|
||
}
|
||
|
||
if channel.Type == constant.ChannelTypeCodex {
|
||
return service.FetchCodexChannelModels(channel)
|
||
}
|
||
|
||
var url string
|
||
switch channel.Type {
|
||
case constant.ChannelTypeAli:
|
||
url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL)
|
||
case constant.ChannelTypeZhipu_v4:
|
||
if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
|
||
url = fmt.Sprintf("%s/models", plan.OpenAIBaseURL)
|
||
} else {
|
||
url = fmt.Sprintf("%s/api/paas/v4/models", baseURL)
|
||
}
|
||
case constant.ChannelTypeVolcEngine:
|
||
// 火山方舟 OpenAI 兼容 API 根路径是 /api/v3(chat/embeddings 均为
|
||
// {base}/api/v3/...,见 relay/channel/volcengine 的 GetRequestURL),
|
||
// 不存在 /v1/models 端点。原拼接会导致「获取模型列表」固定 404 失败。
|
||
if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
|
||
url = fmt.Sprintf("%s/v1/models", plan.OpenAIBaseURL)
|
||
} else {
|
||
url = fmt.Sprintf("%s/api/v3/models", baseURL)
|
||
}
|
||
case constant.ChannelTypeMoonshot:
|
||
if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
|
||
url = fmt.Sprintf("%s/models", plan.OpenAIBaseURL)
|
||
} else {
|
||
url = fmt.Sprintf("%s/v1/models", baseURL)
|
||
}
|
||
default:
|
||
url = fmt.Sprintf("%s/v1/models", baseURL)
|
||
}
|
||
|
||
key, _, apiErr := channel.GetNextEnabledKey()
|
||
if apiErr != nil {
|
||
return nil, fmt.Errorf("获取渠道密钥失败: %w", apiErr)
|
||
}
|
||
key = strings.TrimSpace(key)
|
||
|
||
headers, err := buildFetchModelsHeaders(channel, key)
|
||
if err != nil {
|
||
return nil, sanitizeFetchModelsError(err, key)
|
||
}
|
||
|
||
body, err := getFetchModelsResponseBody(http.MethodGet, url, channel, headers)
|
||
if err != nil {
|
||
return nil, sanitizeAdvancedCustomRequestError(err, key, url)
|
||
}
|
||
|
||
var result OpenAIModelsResponse
|
||
if err := common.Unmarshal(body, &result); err != nil {
|
||
return nil, err
|
||
}
|
||
ids := lo.Map(result.Data, func(item OpenAIModel, _ int) string {
|
||
if channel.Type == constant.ChannelTypeGemini {
|
||
return strings.TrimPrefix(item.ID, "models/")
|
||
}
|
||
return item.ID
|
||
})
|
||
return normalizeModelNames(ids), nil
|
||
}
|
||
|
||
func fetchAdvancedCustomUpstreamModelIDs(channel *model.Channel, baseURL string) ([]string, error) {
|
||
key, _, apiErr := channel.GetNextEnabledKey()
|
||
if apiErr != nil {
|
||
return nil, fmt.Errorf("获取渠道密钥失败: %w", apiErr)
|
||
}
|
||
key = strings.TrimSpace(key)
|
||
|
||
info := &relaycommon.RelayInfo{
|
||
RelayFormat: types.RelayFormatOpenAI,
|
||
RelayMode: relayconstant.RelayModeUnknown,
|
||
RequestURLPath: dto.AdvancedCustomModelListPath,
|
||
ChannelMeta: &relaycommon.ChannelMeta{
|
||
ChannelType: channel.Type,
|
||
ChannelBaseUrl: baseURL,
|
||
ApiKey: key,
|
||
ChannelOtherSettings: channel.GetOtherSettings(),
|
||
},
|
||
}
|
||
|
||
adaptor := &advancedcustom.Adaptor{}
|
||
url, headers, err := adaptor.BuildModelListRequest(info)
|
||
if err != nil {
|
||
return nil, sanitizeFetchModelsError(err, key)
|
||
}
|
||
if err := applyFetchModelsHeaderOverrides(channel, key, headers); err != nil {
|
||
return nil, sanitizeFetchModelsError(err, key)
|
||
}
|
||
|
||
body, err := getFetchModelsResponseBody(http.MethodGet, url, channel, headers)
|
||
if err != nil {
|
||
return nil, sanitizeFetchModelsError(err, key)
|
||
}
|
||
return parseOpenAIModelIDs(body)
|
||
}
|
||
|
||
func updateChannelUpstreamModelSettings(channel *model.Channel, settings dto.ChannelOtherSettings, updateModels bool) error {
|
||
channel.SetOtherSettings(settings)
|
||
updates := map[string]any{
|
||
"settings": channel.OtherSettings,
|
||
}
|
||
if updateModels {
|
||
updates["models"] = channel.Models
|
||
}
|
||
return model.DB.Model(&model.Channel{}).Where("id = ?", channel.Id).Updates(updates).Error
|
||
}
|
||
|
||
func checkAndPersistChannelUpstreamModelUpdates(
|
||
channel *model.Channel,
|
||
settings *dto.ChannelOtherSettings,
|
||
force bool,
|
||
allowAutoApply bool,
|
||
) (modelsChanged bool, autoAdded int, err error) {
|
||
now := common.GetTimestamp()
|
||
if !force {
|
||
minInterval := getUpstreamModelUpdateMinCheckIntervalSeconds()
|
||
if settings.UpstreamModelUpdateLastCheckTime > 0 &&
|
||
now-settings.UpstreamModelUpdateLastCheckTime < minInterval {
|
||
return false, 0, nil
|
||
}
|
||
}
|
||
|
||
pendingAddModels, pendingRemoveModels, fetchErr := collectPendingUpstreamModelChanges(channel, *settings)
|
||
settings.UpstreamModelUpdateLastCheckTime = now
|
||
if fetchErr != nil {
|
||
if err = updateChannelUpstreamModelSettings(channel, *settings, false); err != nil {
|
||
return false, 0, err
|
||
}
|
||
return false, 0, fetchErr
|
||
}
|
||
|
||
if allowAutoApply && settings.UpstreamModelUpdateAutoSyncEnabled && len(pendingAddModels) > 0 {
|
||
originModels := normalizeModelNames(channel.GetModels())
|
||
mergedModels := mergeModelNames(originModels, pendingAddModels)
|
||
if len(mergedModels) > len(originModels) {
|
||
channel.Models = strings.Join(mergedModels, ",")
|
||
autoAdded = len(mergedModels) - len(originModels)
|
||
modelsChanged = true
|
||
}
|
||
settings.UpstreamModelUpdateLastDetectedModels = []string{}
|
||
} else {
|
||
settings.UpstreamModelUpdateLastDetectedModels = pendingAddModels
|
||
}
|
||
settings.UpstreamModelUpdateLastRemovedModels = pendingRemoveModels
|
||
|
||
if err = updateChannelUpstreamModelSettings(channel, *settings, modelsChanged); err != nil {
|
||
return false, autoAdded, err
|
||
}
|
||
if modelsChanged {
|
||
if err = channel.UpdateAbilities(nil); err != nil {
|
||
return true, autoAdded, err
|
||
}
|
||
}
|
||
return modelsChanged, autoAdded, nil
|
||
}
|
||
|
||
func refreshChannelRuntimeCache() {
|
||
if common.MemoryCacheEnabled {
|
||
func() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
common.SysLog(fmt.Sprintf("InitChannelCache panic: %v", r))
|
||
}
|
||
}()
|
||
model.InitChannelCache()
|
||
}()
|
||
}
|
||
}
|
||
|
||
func shouldSendUpstreamModelUpdateNotification(now int64, changedChannels int, failedChannels int) bool {
|
||
if changedChannels <= 0 && failedChannels <= 0 {
|
||
return true
|
||
}
|
||
|
||
channelUpstreamModelUpdateNotifyState.Lock()
|
||
defer channelUpstreamModelUpdateNotifyState.Unlock()
|
||
|
||
if channelUpstreamModelUpdateNotifyState.lastNotifiedAt > 0 &&
|
||
now-channelUpstreamModelUpdateNotifyState.lastNotifiedAt < channelUpstreamModelUpdateNotifySuppressWindowSeconds &&
|
||
channelUpstreamModelUpdateNotifyState.lastChangedChannels == changedChannels &&
|
||
channelUpstreamModelUpdateNotifyState.lastFailedChannels == failedChannels {
|
||
return false
|
||
}
|
||
|
||
channelUpstreamModelUpdateNotifyState.lastNotifiedAt = now
|
||
channelUpstreamModelUpdateNotifyState.lastChangedChannels = changedChannels
|
||
channelUpstreamModelUpdateNotifyState.lastFailedChannels = failedChannels
|
||
return true
|
||
}
|
||
|
||
func buildUpstreamModelUpdateTaskNotificationContent(
|
||
checkedChannels int,
|
||
changedChannels int,
|
||
detectedAddModels int,
|
||
detectedRemoveModels int,
|
||
autoAddedModels int,
|
||
failedChannelIDs []int,
|
||
channelSummaries []upstreamModelUpdateChannelSummary,
|
||
addModelSamples []string,
|
||
removeModelSamples []string,
|
||
) string {
|
||
var builder strings.Builder
|
||
failedChannels := len(failedChannelIDs)
|
||
builder.WriteString(fmt.Sprintf(
|
||
"上游模型巡检摘要:检测渠道 %d 个,发现变更 %d 个,新增 %d 个,删除 %d 个,自动同步新增 %d 个,失败 %d 个。",
|
||
checkedChannels,
|
||
changedChannels,
|
||
detectedAddModels,
|
||
detectedRemoveModels,
|
||
autoAddedModels,
|
||
failedChannels,
|
||
))
|
||
|
||
if len(channelSummaries) > 0 {
|
||
displayCount := min(len(channelSummaries), channelUpstreamModelUpdateNotifyMaxChannelDetails)
|
||
builder.WriteString(fmt.Sprintf("\n\n变更渠道明细(展示 %d/%d):", displayCount, len(channelSummaries)))
|
||
for _, summary := range channelSummaries[:displayCount] {
|
||
builder.WriteString(fmt.Sprintf("\n- %s (+%d / -%d)", summary.ChannelName, summary.AddCount, summary.RemoveCount))
|
||
}
|
||
if len(channelSummaries) > displayCount {
|
||
builder.WriteString(fmt.Sprintf("\n- 其余 %d 个渠道已省略", len(channelSummaries)-displayCount))
|
||
}
|
||
}
|
||
|
||
normalizedAddModelSamples := normalizeModelNames(addModelSamples)
|
||
if len(normalizedAddModelSamples) > 0 {
|
||
displayCount := min(len(normalizedAddModelSamples), channelUpstreamModelUpdateNotifyMaxModelDetails)
|
||
builder.WriteString(fmt.Sprintf("\n\n新增模型示例(展示 %d/%d):%s",
|
||
displayCount,
|
||
len(normalizedAddModelSamples),
|
||
strings.Join(normalizedAddModelSamples[:displayCount], ", "),
|
||
))
|
||
if len(normalizedAddModelSamples) > displayCount {
|
||
builder.WriteString(fmt.Sprintf("(其余 %d 个已省略)", len(normalizedAddModelSamples)-displayCount))
|
||
}
|
||
}
|
||
|
||
normalizedRemoveModelSamples := normalizeModelNames(removeModelSamples)
|
||
if len(normalizedRemoveModelSamples) > 0 {
|
||
displayCount := min(len(normalizedRemoveModelSamples), channelUpstreamModelUpdateNotifyMaxModelDetails)
|
||
builder.WriteString(fmt.Sprintf("\n\n删除模型示例(展示 %d/%d):%s",
|
||
displayCount,
|
||
len(normalizedRemoveModelSamples),
|
||
strings.Join(normalizedRemoveModelSamples[:displayCount], ", "),
|
||
))
|
||
if len(normalizedRemoveModelSamples) > displayCount {
|
||
builder.WriteString(fmt.Sprintf("(其余 %d 个已省略)", len(normalizedRemoveModelSamples)-displayCount))
|
||
}
|
||
}
|
||
|
||
if failedChannels > 0 {
|
||
displayCount := min(failedChannels, channelUpstreamModelUpdateNotifyMaxFailedChannelIDs)
|
||
displayIDs := lo.Map(failedChannelIDs[:displayCount], func(channelID int, _ int) string {
|
||
return fmt.Sprintf("%d", channelID)
|
||
})
|
||
builder.WriteString(fmt.Sprintf(
|
||
"\n\n失败渠道 ID(展示 %d/%d):%s",
|
||
displayCount,
|
||
failedChannels,
|
||
strings.Join(displayIDs, ", "),
|
||
))
|
||
if failedChannels > displayCount {
|
||
builder.WriteString(fmt.Sprintf("(其余 %d 个已省略)", failedChannels-displayCount))
|
||
}
|
||
}
|
||
return builder.String()
|
||
}
|
||
|
||
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)
|
||
changedChannels := 0
|
||
detectedAddModels := 0
|
||
detectedRemoveModels := 0
|
||
autoAddedModels := 0
|
||
channelSummaries := make([]upstreamModelUpdateChannelSummary, 0)
|
||
addModelSamples := make([]string, 0)
|
||
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).
|
||
Where("status = ?", common.ChannelStatusEnabled).
|
||
Order("id asc").
|
||
Limit(channelUpstreamModelUpdateTaskBatchSize)
|
||
if lastID > 0 {
|
||
query = query.Where("id > ?", lastID)
|
||
}
|
||
err := query.Find(&channels).Error
|
||
if err != nil {
|
||
common.SysLog(fmt.Sprintf("upstream model update task query failed: %v", err))
|
||
break
|
||
}
|
||
if len(channels) == 0 {
|
||
break
|
||
}
|
||
lastID = channels[len(channels)-1].Id
|
||
|
||
for _, channel := range channels {
|
||
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 {
|
||
continue
|
||
}
|
||
|
||
checkedChannels++
|
||
modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, force, allowAutoApply)
|
||
if err != nil {
|
||
failedChannels++
|
||
failedChannelIDs = append(failedChannelIDs, channel.Id)
|
||
common.SysLog(fmt.Sprintf("upstream model update check failed: channel_id=%d channel_name=%s err=%v", channel.Id, channel.Name, err))
|
||
continue
|
||
}
|
||
currentAddModels := normalizeModelNames(settings.UpstreamModelUpdateLastDetectedModels)
|
||
currentRemoveModels := normalizeModelNames(settings.UpstreamModelUpdateLastRemovedModels)
|
||
currentAddCount := len(currentAddModels) + autoAdded
|
||
currentRemoveCount := len(currentRemoveModels)
|
||
detectedAddModels += currentAddCount
|
||
detectedRemoveModels += currentRemoveCount
|
||
if currentAddCount > 0 || currentRemoveCount > 0 {
|
||
changedChannels++
|
||
channelSummaries = append(channelSummaries, upstreamModelUpdateChannelSummary{
|
||
ChannelName: channel.Name,
|
||
AddCount: currentAddCount,
|
||
RemoveCount: currentRemoveCount,
|
||
})
|
||
}
|
||
addModelSamples = mergeModelNames(addModelSamples, currentAddModels)
|
||
removeModelSamples = mergeModelNames(removeModelSamples, currentRemoveModels)
|
||
if modelsChanged {
|
||
refreshNeeded = true
|
||
}
|
||
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):
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if len(channels) < channelUpstreamModelUpdateTaskBatchSize {
|
||
break
|
||
}
|
||
}
|
||
|
||
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",
|
||
checkedChannels,
|
||
changedChannels,
|
||
detectedAddModels,
|
||
detectedRemoveModels,
|
||
failedChannels,
|
||
autoAddedModels,
|
||
))
|
||
}
|
||
if changedChannels > 0 || failedChannels > 0 {
|
||
now := common.GetTimestamp()
|
||
if !shouldSendUpstreamModelUpdateNotification(now, changedChannels, failedChannels) {
|
||
common.SysLog(fmt.Sprintf(
|
||
"upstream model update notification skipped in 24h window: changed_channels=%d failed_channels=%d",
|
||
changedChannels,
|
||
failedChannels,
|
||
))
|
||
return summary
|
||
}
|
||
service.NotifyUpstreamModelUpdateWatchers(
|
||
"上游模型巡检通知",
|
||
buildUpstreamModelUpdateTaskNotificationContent(
|
||
checkedChannels,
|
||
changedChannels,
|
||
detectedAddModels,
|
||
detectedRemoveModels,
|
||
autoAddedModels,
|
||
failedChannelIDs,
|
||
channelSummaries,
|
||
addModelSamples,
|
||
removeModelSamples,
|
||
),
|
||
)
|
||
}
|
||
return summary
|
||
}
|
||
|
||
func ApplyChannelUpstreamModelUpdates(c *gin.Context) {
|
||
var req applyChannelUpstreamModelUpdatesRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
common.ApiError(c, err)
|
||
return
|
||
}
|
||
if req.ID <= 0 {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "invalid channel id",
|
||
})
|
||
return
|
||
}
|
||
|
||
channel, err := model.GetChannelById(req.ID, true)
|
||
if err != nil {
|
||
common.ApiError(c, err)
|
||
return
|
||
}
|
||
beforeSettings := channel.GetOtherSettings()
|
||
ignoredModels := intersectModelNames(req.IgnoreModels, beforeSettings.UpstreamModelUpdateLastDetectedModels)
|
||
|
||
addedModels, removedModels, remainingModels, remainingRemoveModels, modelsChanged, err := applyChannelUpstreamModelUpdates(
|
||
channel,
|
||
req.AddModels,
|
||
req.IgnoreModels,
|
||
req.RemoveModels,
|
||
)
|
||
if err != nil {
|
||
common.ApiError(c, err)
|
||
return
|
||
}
|
||
|
||
if modelsChanged {
|
||
refreshChannelRuntimeCache()
|
||
}
|
||
|
||
recordManageAudit(c, "channel.upstream_apply", map[string]any{
|
||
"id": channel.Id,
|
||
})
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"message": "",
|
||
"data": gin.H{
|
||
"id": channel.Id,
|
||
"added_models": addedModels,
|
||
"removed_models": removedModels,
|
||
"ignored_models": ignoredModels,
|
||
"remaining_models": remainingModels,
|
||
"remaining_remove_models": remainingRemoveModels,
|
||
"models": channel.Models,
|
||
"settings": channel.OtherSettings,
|
||
},
|
||
})
|
||
}
|
||
|
||
func DetectChannelUpstreamModelUpdates(c *gin.Context) {
|
||
var req applyChannelUpstreamModelUpdatesRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
common.ApiError(c, err)
|
||
return
|
||
}
|
||
if req.ID <= 0 {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "invalid channel id",
|
||
})
|
||
return
|
||
}
|
||
|
||
channel, err := model.GetChannelById(req.ID, true)
|
||
if err != nil {
|
||
common.ApiError(c, err)
|
||
return
|
||
}
|
||
|
||
settings := channel.GetOtherSettings()
|
||
modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, true, false)
|
||
if err != nil {
|
||
common.ApiError(c, err)
|
||
return
|
||
}
|
||
if modelsChanged {
|
||
refreshChannelRuntimeCache()
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"message": "",
|
||
"data": detectChannelUpstreamModelUpdatesResult{
|
||
ChannelID: channel.Id,
|
||
ChannelName: channel.Name,
|
||
AddModels: normalizeModelNames(settings.UpstreamModelUpdateLastDetectedModels),
|
||
RemoveModels: normalizeModelNames(settings.UpstreamModelUpdateLastRemovedModels),
|
||
LastCheckTime: settings.UpstreamModelUpdateLastCheckTime,
|
||
AutoAddedModels: autoAdded,
|
||
},
|
||
})
|
||
}
|
||
|
||
func applyChannelUpstreamModelUpdates(
|
||
channel *model.Channel,
|
||
addModelsInput []string,
|
||
ignoreModelsInput []string,
|
||
removeModelsInput []string,
|
||
) (
|
||
addedModels []string,
|
||
removedModels []string,
|
||
remainingModels []string,
|
||
remainingRemoveModels []string,
|
||
modelsChanged bool,
|
||
err error,
|
||
) {
|
||
settings := channel.GetOtherSettings()
|
||
pendingAddModels := normalizeModelNames(settings.UpstreamModelUpdateLastDetectedModels)
|
||
pendingRemoveModels := normalizeModelNames(settings.UpstreamModelUpdateLastRemovedModels)
|
||
addModels := intersectModelNames(addModelsInput, pendingAddModels)
|
||
ignoreModels := intersectModelNames(ignoreModelsInput, pendingAddModels)
|
||
removeModels := intersectModelNames(removeModelsInput, pendingRemoveModels)
|
||
removeModels = subtractModelNames(removeModels, addModels)
|
||
|
||
originModels := normalizeModelNames(channel.GetModels())
|
||
nextModels := applySelectedModelChanges(originModels, addModels, removeModels)
|
||
modelsChanged = !slices.Equal(originModels, nextModels)
|
||
if modelsChanged {
|
||
channel.Models = strings.Join(nextModels, ",")
|
||
}
|
||
|
||
settings.UpstreamModelUpdateIgnoredModels = mergeModelNames(settings.UpstreamModelUpdateIgnoredModels, ignoreModels)
|
||
if len(addModels) > 0 {
|
||
settings.UpstreamModelUpdateIgnoredModels = subtractModelNames(settings.UpstreamModelUpdateIgnoredModels, addModels)
|
||
}
|
||
remainingModels = subtractModelNames(pendingAddModels, append(addModels, ignoreModels...))
|
||
remainingRemoveModels = subtractModelNames(pendingRemoveModels, removeModels)
|
||
settings.UpstreamModelUpdateLastDetectedModels = remainingModels
|
||
settings.UpstreamModelUpdateLastRemovedModels = remainingRemoveModels
|
||
settings.UpstreamModelUpdateLastCheckTime = common.GetTimestamp()
|
||
|
||
if err := updateChannelUpstreamModelSettings(channel, settings, modelsChanged); err != nil {
|
||
return nil, nil, nil, nil, false, err
|
||
}
|
||
|
||
if modelsChanged {
|
||
if err := channel.UpdateAbilities(nil); err != nil {
|
||
return addModels, removeModels, remainingModels, remainingRemoveModels, true, err
|
||
}
|
||
}
|
||
return addModels, removeModels, remainingModels, remainingRemoveModels, modelsChanged, nil
|
||
}
|
||
|
||
func collectPendingApplyUpstreamModelChanges(settings dto.ChannelOtherSettings) (pendingAddModels []string, pendingRemoveModels []string) {
|
||
return normalizeModelNames(settings.UpstreamModelUpdateLastDetectedModels), normalizeModelNames(settings.UpstreamModelUpdateLastRemovedModels)
|
||
}
|
||
|
||
func findEnabledChannelsAfterID(lastID int, batchSize int) ([]*model.Channel, error) {
|
||
var channels []*model.Channel
|
||
query := model.DB.
|
||
Select(channelUpstreamModelUpdateSelectFields).
|
||
Where("status = ?", common.ChannelStatusEnabled).
|
||
Order("id asc").
|
||
Limit(batchSize)
|
||
if lastID > 0 {
|
||
query = query.Where("id > ?", lastID)
|
||
}
|
||
return channels, query.Find(&channels).Error
|
||
}
|
||
|
||
func ApplyAllChannelUpstreamModelUpdates(c *gin.Context) {
|
||
results := make([]applyAllChannelUpstreamModelUpdatesResult, 0)
|
||
failed := make([]int, 0)
|
||
refreshNeeded := false
|
||
addedModelCount := 0
|
||
removedModelCount := 0
|
||
|
||
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
|
||
}
|
||
|
||
pendingAddModels, pendingRemoveModels := collectPendingApplyUpstreamModelChanges(settings)
|
||
if len(pendingAddModels) == 0 && len(pendingRemoveModels) == 0 {
|
||
continue
|
||
}
|
||
|
||
addedModels, removedModels, remainingModels, remainingRemoveModels, modelsChanged, err := applyChannelUpstreamModelUpdates(
|
||
channel,
|
||
pendingAddModels,
|
||
nil,
|
||
pendingRemoveModels,
|
||
)
|
||
if err != nil {
|
||
failed = append(failed, channel.Id)
|
||
continue
|
||
}
|
||
if modelsChanged {
|
||
refreshNeeded = true
|
||
}
|
||
addedModelCount += len(addedModels)
|
||
removedModelCount += len(removedModels)
|
||
results = append(results, applyAllChannelUpstreamModelUpdatesResult{
|
||
ChannelID: channel.Id,
|
||
ChannelName: channel.Name,
|
||
AddedModels: addedModels,
|
||
RemovedModels: removedModels,
|
||
RemainingModels: remainingModels,
|
||
RemainingRemoveModels: remainingRemoveModels,
|
||
})
|
||
}
|
||
|
||
if len(channels) < channelUpstreamModelUpdateTaskBatchSize {
|
||
break
|
||
}
|
||
}
|
||
|
||
if refreshNeeded {
|
||
refreshChannelRuntimeCache()
|
||
}
|
||
|
||
recordManageAudit(c, "channel.upstream_apply_all", map[string]any{
|
||
"count": len(results),
|
||
})
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"message": "",
|
||
"data": gin.H{
|
||
"processed_channels": len(results),
|
||
"added_models": addedModelCount,
|
||
"removed_models": removedModelCount,
|
||
"failed_channel_ids": failed,
|
||
"results": results,
|
||
},
|
||
})
|
||
}
|
||
|
||
// 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) {
|
||
task, created, err := service.EnqueueSystemTask(model.SystemTaskTypeModelUpdate, modelUpdateTaskPayload{Manual: 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
|
||
}
|
||
|
||
recordManageAudit(c, "channel.upstream_detect_all", map[string]any{
|
||
"task_id": task.TaskID,
|
||
})
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"message": "",
|
||
"data": gin.H{
|
||
"task_id": task.TaskID,
|
||
"status": task.Status,
|
||
},
|
||
})
|
||
}
|