mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-10 02:17:24 +08:00
A New API (type 60) channel proxies another gateway that may host many task plugins, so the channel setting gains task_extend_plugin_keys; the single task_plugin_key stays valid there as well. Channel selection, shared-model candidate rebinding, the task_plugin.bind permission gate, plugin usage counting and the disable cascade (which now unbinds gateway channels instead of disabling them) all honour the union of both fields, and plugins may no longer claim channel type 60 in channelTypes. Plugins declare meta.upstreams and the host injects ctx.upstream into every driver hook context. On a type-60 channel the driver addresses its own prefixed native routes (doubao, alibaba, kling, jimeng) and the host sends the channel key as a Bearer header for every auth type; polling threads the channel type through ChannelMeta and tasks keep the key that submitted them. Only plugins declaring new_api can be bound. The channel form offers an "Upstream task plugins" selector for type-60 channels, MultiSelect options can carry an icon, and the plugin API docs, type declarations and schema describe the additive apiVersion 1 extension.
275 lines
8.7 KiB
Go
275 lines
8.7 KiB
Go
package model
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"slices"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/constant"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
type TaskPluginChannelRef struct {
|
|
Id int `json:"id"`
|
|
Name string `json:"name"`
|
|
Type int `json:"type"`
|
|
}
|
|
|
|
func GetTaskPluginUsage(key string) ([]TaskPluginChannelRef, int64, error) {
|
|
var channels []Channel
|
|
boundTypes := []int{constant.ChannelTypeTaskPlugin, constant.ChannelTypeNewAPI}
|
|
if err := DB.Where("type IN ? AND status = ?", boundTypes, common.ChannelStatusEnabled).Find(&channels).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
refs := make([]TaskPluginChannelRef, 0)
|
|
for _, channel := range channels {
|
|
if channel.GetSetting().BindsTaskPlugin(key) {
|
|
refs = append(refs, TaskPluginChannelRef{Id: channel.Id, Name: channel.Name, Type: channel.Type})
|
|
}
|
|
}
|
|
var inFlight int64
|
|
err := DB.Model(&Task{}).Where("platform = ? AND status NOT IN ?", key, []TaskStatus{TaskStatusSuccess, TaskStatusFailure}).Count(&inFlight).Error
|
|
return refs, inFlight, err
|
|
}
|
|
|
|
// UnbindTaskPlugin removes one plugin from a channel's bindings and reports
|
|
// whether the channel changed. A New API gateway channel keeps serving its other
|
|
// plugins and its ordinary traffic.
|
|
func UnbindTaskPlugin(channelID int, key string) (bool, error) {
|
|
channel, err := GetChannelById(channelID, false)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
setting := channel.GetSetting()
|
|
if !setting.BindsTaskPlugin(key) {
|
|
return false, nil
|
|
}
|
|
if setting.TaskPluginKey == key {
|
|
setting.TaskPluginKey = ""
|
|
}
|
|
setting.TaskExtendPluginKeys = slices.DeleteFunc(setting.TaskExtendPluginKeys, func(bound string) bool { return bound == key })
|
|
channel.SetSetting(setting)
|
|
return true, DB.Model(&Channel{}).Where("id = ?", channelID).Update("setting", channel.Setting).Error
|
|
}
|
|
|
|
type TaskPlugin struct {
|
|
Id int64 `json:"id"`
|
|
Key string `json:"key" gorm:"size:128;not null;uniqueIndex:uk_task_plugin_key_version,priority:1"`
|
|
APIVersion int `json:"api_version" gorm:"not null"`
|
|
Version string `json:"version" gorm:"size:64;not null;uniqueIndex:uk_task_plugin_key_version,priority:2"`
|
|
Source string `json:"source" gorm:"type:text;not null"`
|
|
SourceHash string `json:"source_hash" gorm:"size:64;not null"`
|
|
// Icon is the plugin logo shipped as a sidecar icon.svg / icon.png next to
|
|
// plugin.js, stored as a data URI so one column carries both the media
|
|
// type and the bytes. It never travels inside list or detail JSON; the UI
|
|
// loads it through GET /api/plugin/task/:key/icon. size matches the
|
|
// 512 KiB icon cap and makes GORM emit mediumtext on MySQL (a bare TEXT
|
|
// column there holds only 64 KiB), varchar(524288) on PostgreSQL, and text
|
|
// on SQLite.
|
|
Icon string `json:"-" gorm:"size:524288"`
|
|
Enabled bool `json:"enabled" gorm:"not null"`
|
|
Active bool `json:"active" gorm:"not null;index"`
|
|
CreatedAt int64 `json:"created_at" gorm:"not null"`
|
|
Remark string `json:"remark" gorm:"type:text"`
|
|
}
|
|
|
|
// HasIcon reports whether this version ships a logo.
|
|
func (plugin TaskPlugin) HasIcon() bool {
|
|
return plugin.Icon != ""
|
|
}
|
|
|
|
func SaveTaskPlugin(plugin *TaskPlugin) error {
|
|
return DB.Transaction(func(tx *gorm.DB) error {
|
|
var existing TaskPlugin
|
|
err := tx.Where(&TaskPlugin{Key: plugin.Key, Version: plugin.Version}).First(&existing).Error
|
|
if err == nil {
|
|
if existing.SourceHash != plugin.SourceHash {
|
|
return errors.New("plugin key and version already exist with different source")
|
|
}
|
|
updates := map[string]any{"enabled": plugin.Enabled, "remark": plugin.Remark}
|
|
if plugin.Icon != "" {
|
|
updates["icon"] = plugin.Icon
|
|
existing.Icon = plugin.Icon
|
|
}
|
|
if err = tx.Model(&existing).Updates(updates).Error; err != nil {
|
|
return err
|
|
}
|
|
existing.Enabled = plugin.Enabled
|
|
existing.Remark = plugin.Remark
|
|
*plugin = existing
|
|
return nil
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
plugin.CreatedAt = time.Now().Unix()
|
|
var count int64
|
|
if err = tx.Model(&TaskPlugin{}).Where(&TaskPlugin{Key: plugin.Key, Active: true}).Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
plugin.Active = count == 0
|
|
return tx.Create(plugin).Error
|
|
})
|
|
}
|
|
|
|
func ListTaskPluginVersions(key string) ([]TaskPlugin, error) {
|
|
var plugins []TaskPlugin
|
|
err := DB.Where(&TaskPlugin{Key: key}).Order("created_at DESC, id DESC").Find(&plugins).Error
|
|
return plugins, err
|
|
}
|
|
|
|
func ListTaskPlugins() ([]TaskPlugin, error) {
|
|
var plugins []TaskPlugin
|
|
err := DB.
|
|
Order(clause.OrderByColumn{Column: clause.Column{Name: "key"}}).
|
|
Order(clause.OrderByColumn{Column: clause.Column{Name: "created_at"}, Desc: true}).
|
|
Order(clause.OrderByColumn{Column: clause.Column{Name: "id"}, Desc: true}).
|
|
Find(&plugins).Error
|
|
return plugins, err
|
|
}
|
|
|
|
func GetTaskPluginVersion(key, version string) (*TaskPlugin, error) {
|
|
var plugin TaskPlugin
|
|
query := DB.Where(&TaskPlugin{Key: key})
|
|
if version == "" {
|
|
query = query.Where(&TaskPlugin{Active: true})
|
|
} else {
|
|
query = query.Where(&TaskPlugin{Version: version})
|
|
}
|
|
if err := query.First(&plugin).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &plugin, nil
|
|
}
|
|
|
|
func ListActiveTaskPlugins() ([]TaskPlugin, error) {
|
|
snapshot, err := GetTaskPluginSyncSnapshot()
|
|
return snapshot.Plugins, err
|
|
}
|
|
|
|
type TaskPluginSyncSnapshot struct {
|
|
Plugins []TaskPlugin
|
|
Revision string
|
|
}
|
|
|
|
// GetTaskPluginSyncSnapshot returns the enabled override set together with a
|
|
// deterministic revision of every active database override. Nodes can compare
|
|
// the revision even though their local routing-generation counters differ.
|
|
func GetTaskPluginSyncSnapshot() (TaskPluginSyncSnapshot, error) {
|
|
var activePlugins []TaskPlugin
|
|
if err := DB.Where(&TaskPlugin{Active: true}).
|
|
Order(clause.OrderByColumn{Column: clause.Column{Name: "key"}}).
|
|
Order(clause.OrderByColumn{Column: clause.Column{Name: "version"}}).
|
|
Order(clause.OrderByColumn{Column: clause.Column{Name: "id"}}).
|
|
Find(&activePlugins).Error; err != nil {
|
|
return TaskPluginSyncSnapshot{}, err
|
|
}
|
|
|
|
type revisionEntry struct {
|
|
Key string `json:"key"`
|
|
APIVersion int `json:"api_version"`
|
|
Version string `json:"version"`
|
|
SourceHash string `json:"source_hash"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
entries := make([]revisionEntry, 0, len(activePlugins))
|
|
enabledPlugins := make([]TaskPlugin, 0, len(activePlugins))
|
|
for _, plugin := range activePlugins {
|
|
entries = append(entries, revisionEntry{
|
|
Key: plugin.Key,
|
|
APIVersion: plugin.APIVersion,
|
|
Version: plugin.Version,
|
|
SourceHash: plugin.SourceHash,
|
|
Enabled: plugin.Enabled,
|
|
})
|
|
if plugin.Enabled {
|
|
enabledPlugins = append(enabledPlugins, plugin)
|
|
}
|
|
}
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
if entries[i].Key != entries[j].Key {
|
|
return entries[i].Key < entries[j].Key
|
|
}
|
|
return entries[i].Version < entries[j].Version
|
|
})
|
|
payload, err := common.Marshal(entries)
|
|
if err != nil {
|
|
return TaskPluginSyncSnapshot{}, err
|
|
}
|
|
digest := sha256.Sum256(payload)
|
|
return TaskPluginSyncSnapshot{
|
|
Plugins: enabledPlugins,
|
|
Revision: hex.EncodeToString(digest[:]),
|
|
}, nil
|
|
}
|
|
|
|
func ActivateTaskPlugin(key, version string) error {
|
|
return DB.Transaction(func(tx *gorm.DB) error {
|
|
var target TaskPlugin
|
|
if err := tx.Where(&TaskPlugin{Key: key, Version: version}).First(&target).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&TaskPlugin{}).Where(&TaskPlugin{Key: key}).Update("active", false).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Model(&target).Updates(map[string]any{"active": true, "enabled": true}).Error
|
|
})
|
|
}
|
|
|
|
func SetTaskPluginEnabled(key string, enabled bool) error {
|
|
result := DB.Model(&TaskPlugin{}).Where(&TaskPlugin{Key: key, Active: true}).Update("enabled", enabled)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type TaskPluginDeleteResult struct {
|
|
DeletedActive bool
|
|
Promoted *TaskPlugin
|
|
}
|
|
|
|
func DeleteTaskPluginVersion(key, version string) (TaskPluginDeleteResult, error) {
|
|
result := TaskPluginDeleteResult{}
|
|
err := DB.Transaction(func(tx *gorm.DB) error {
|
|
var plugin TaskPlugin
|
|
if err := lockForUpdate(tx).Where(&TaskPlugin{Key: key, Version: version}).First(&plugin).Error; err != nil {
|
|
return err
|
|
}
|
|
result.DeletedActive = plugin.Active
|
|
if err := tx.Delete(&plugin).Error; err != nil {
|
|
return err
|
|
}
|
|
if !plugin.Active {
|
|
return nil
|
|
}
|
|
|
|
var promoted TaskPlugin
|
|
err := lockForUpdate(tx).
|
|
Where(&TaskPlugin{Key: key}).
|
|
Order("created_at DESC, id DESC").
|
|
First(&promoted).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = tx.Model(&promoted).Update("active", true).Error; err != nil {
|
|
return err
|
|
}
|
|
promoted.Active = true
|
|
result.Promoted = &promoted
|
|
return nil
|
|
})
|
|
return result, err
|
|
}
|