merge: integrate PR #430 channel model batch operations

This commit is contained in:
ddCat committed 2026-09-07 15:31:43 +08:00
commit ad5b2d5a14
9 files changed
+361 -39

No files matched your search

+21
View File
@@ -269,6 +269,27 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) {
r.POST("/admin/channels/:id/models", func(c *gin.Context) {
saveChannelModel(c, svc, "")
})
r.POST("/admin/channels/:id/models/batch-delete", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10)
var req struct {
ModelIDs []string `json:"modelIds"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
deleted, err := svc.DeleteAdminChannelModels(user, c.Param("id"), req.ModelIDs)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"deleted": deleted})
})
r.PATCH("/admin/channels/:id/models/:modelId", func(c *gin.Context) {
saveChannelModel(c, svc, c.Param("modelId"))
})
@@ -0,0 +1,23 @@
package handler
import (
"testing"
"infinite-canvas/backend/internal/service"
"github.com/gin-gonic/gin"
)
func TestFinanceRoutesExposeChannelModelBatchDelete(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
RegisterFinanceRoutes(router.Group("/api"), &service.Service{})
const wanted = "POST /api/admin/channels/:id/models/batch-delete"
for _, route := range router.Routes() {
if route.Method+" "+route.Path == wanted {
return
}
}
t.Fatalf("route %s is not registered", wanted)
}
+36 -6
View File
@@ -231,11 +231,39 @@ func (r *Repository) PopulateChannelModelPriceTier(item *model.ChannelModel) err
}
func (r *Repository) DeleteChannelModel(channelID string, id string, modelsJSON string, now time.Time) error {
return r.db.Transaction(func(tx *gorm.DB) error {
deleted, err := r.DeleteChannelModels(channelID, []string{id}, modelsJSON, now)
if err != nil {
return err
}
if deleted != 1 {
return gorm.ErrRecordNotFound
}
return nil
}
// DeleteChannelModels atomically removes a selection and refreshes the channel's
// compatibility model list. Any active route or task reference aborts the whole
// transaction so a bulk action cannot leave the administrator with a partial result.
func (r *Repository) DeleteChannelModels(channelID string, ids []string, modelsJSON string, now time.Time) (int64, error) {
ids = uniqueStrings(ids)
if len(ids) == 0 {
return 0, gorm.ErrRecordNotFound
}
var deleted int64
err := r.db.Transaction(func(tx *gorm.DB) error {
var existing int64
if err := tx.Model(&model.ChannelModel{}).
Where("channel_id = ? AND id IN ?", channelID, ids).
Count(&existing).Error; err != nil {
return err
}
if existing != int64(len(ids)) {
return gorm.ErrRecordNotFound
}
var activeReferences int64
if err := tx.Table("logical_model_routes AS route").
Joins("JOIN logical_models AS logical_model ON logical_model.active_revision_id = route.logical_model_revision_id").
Where("route.channel_model_id = ?", id).
Where("route.channel_model_id IN ?", ids).
Count(&activeReferences).Error; err != nil {
return err
}
@@ -243,7 +271,7 @@ func (r *Repository) DeleteChannelModel(channelID string, id string, modelsJSON
return ErrChannelModelInUse
}
if err := tx.Model(&model.Task{}).
Where("channel_model_id = ? AND status IN ?", id, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}).
Where("channel_model_id IN ? AND status IN ?", ids, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}).
Count(&activeReferences).Error; err != nil {
return err
}
@@ -251,15 +279,15 @@ func (r *Repository) DeleteChannelModel(channelID string, id string, modelsJSON
return ErrChannelModelInUse
}
result := tx.Model(&model.ChannelModel{}).
Where("id = ? AND channel_id = ?", id, channelID).
Where("id IN ? AND channel_id = ?", ids, channelID).
Updates(map[string]any{"enabled": false, "price_version": gorm.Expr("price_version + 1"), "updated_at": now})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
if result.RowsAffected != int64(len(ids)) {
return gorm.ErrRecordNotFound
}
if err := tx.Where("id = ? AND channel_id = ?", id, channelID).Delete(&model.ChannelModel{}).Error; err != nil {
if err := tx.Where("id IN ? AND channel_id = ?", ids, channelID).Delete(&model.ChannelModel{}).Error; err != nil {
return err
}
channelResult := tx.Model(&model.ModelChannel{}).
@@ -271,8 +299,10 @@ func (r *Repository) DeleteChannelModel(channelID string, id string, modelsJSON
if channelResult.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
deleted = result.RowsAffected
return nil
})
return deleted, err
}
func (r *Repository) CreateMissingChannelModels(items []model.ChannelModel) (int64, error) {
+105 -1
View File
@@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"infinite-canvas/backend/internal/model"
@@ -275,6 +276,109 @@ func TestSaveAdminChannelModelRejectsActiveDuplicateKey(t *testing.T) {
}
}
func TestDeleteAdminChannelModelsDeletesSelectionAtomically(t *testing.T) {
svc, db := newChannelModelTestService(t)
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin}
channel := model.ModelChannel{ID: "channel-1", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true, Name: "Test", BaseURL: "https://example.com/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `["model-a","model-b","model-c"]`}
items := []model.ChannelModel{
{ID: "model-a", ChannelID: channel.ID, ModelKey: "model-a", DisplayName: "Model A", Enabled: true, PriceVersion: 1},
{ID: "model-b", ChannelID: channel.ID, ModelKey: "model-b", DisplayName: "Model B", Enabled: true, PriceVersion: 1},
{ID: "model-c", ChannelID: channel.ID, ModelKey: "model-c", DisplayName: "Model C", Enabled: true, PriceVersion: 1},
}
if err := db.Create(&channel).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&items).Error; err != nil {
t.Fatal(err)
}
deleted, err := svc.DeleteAdminChannelModels(admin, channel.ID, []string{" model-a ", "model-b", "model-a"})
if err != nil {
t.Fatal(err)
}
if deleted != 2 {
t.Fatalf("deleted = %d, want 2", deleted)
}
var storedChannel model.ModelChannel
if err := db.First(&storedChannel, "id = ?", channel.ID).Error; err != nil {
t.Fatal(err)
}
if storedChannel.ModelsJSON != `["model-c"]` {
t.Fatalf("ModelsJSON = %s, want model-c only", storedChannel.ModelsJSON)
}
var active []model.ChannelModel
if err := db.Where("channel_id = ?", channel.ID).Find(&active).Error; err != nil {
t.Fatal(err)
}
if len(active) != 1 || active[0].ID != "model-c" {
t.Fatalf("active models = %#v, want model-c", active)
}
var removed []model.ChannelModel
if err := db.Unscoped().Where("channel_id = ? AND id IN ?", channel.ID, []string{"model-a", "model-b"}).Find(&removed).Error; err != nil {
t.Fatal(err)
}
if len(removed) != 2 {
t.Fatalf("removed models = %#v, want two", removed)
}
for _, item := range removed {
if item.Enabled || item.PriceVersion != 2 || !item.DeletedAt.Valid {
t.Fatalf("removed model state = %#v", item)
}
}
}
func TestDeleteAdminChannelModelsRejectsWholeSelectionWhenOneModelIsInUse(t *testing.T) {
svc, db := newChannelModelTestService(t)
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin}
channel := model.ModelChannel{ID: "channel-1", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true, Name: "Test", BaseURL: "https://example.com/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `["model-a","model-b"]`}
items := []model.ChannelModel{
{ID: "model-a", ChannelID: channel.ID, ModelKey: "model-a", DisplayName: "Model A", Enabled: true, PriceVersion: 1},
{ID: "model-b", ChannelID: channel.ID, ModelKey: "model-b", DisplayName: "Model B", Enabled: true, PriceVersion: 1},
}
if err := db.Create(&channel).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&items).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Task{ID: "task-running", ChannelModelID: "model-b", Status: model.TaskStatusRunning}).Error; err != nil {
t.Fatal(err)
}
deleted, err := svc.DeleteAdminChannelModels(admin, channel.ID, []string{"model-a", "model-b"})
var authErr *AuthError
if !errors.As(err, &authErr) || authErr.Message != "所选渠道模型中有模型仍被前台模型供应线路或进行中任务使用,本次未删除任何模型" {
t.Fatalf("DeleteAdminChannelModels() deleted = %d, error = %#v", deleted, err)
}
var active int64
if err := db.Model(&model.ChannelModel{}).Where("channel_id = ?", channel.ID).Count(&active).Error; err != nil {
t.Fatal(err)
}
if active != 2 {
t.Fatalf("active models = %d, want 2 after atomic rejection", active)
}
var storedChannel model.ModelChannel
if err := db.First(&storedChannel, "id = ?", channel.ID).Error; err != nil {
t.Fatal(err)
}
if storedChannel.ModelsJSON != channel.ModelsJSON {
t.Fatalf("ModelsJSON changed after rejected batch: %s", storedChannel.ModelsJSON)
}
}
func TestNormalizeAdminChannelModelDeleteIDsRequiresBoundedSelection(t *testing.T) {
if _, err := normalizeAdminChannelModelDeleteIDs([]string{"", " "}); err == nil {
t.Fatal("empty selection should be rejected")
}
values := make([]string, 101)
for index := range values {
values[index] = "model-" + strconv.Itoa(index)
}
if _, err := normalizeAdminChannelModelDeleteIDs(values); err == nil {
t.Fatal("selection above 100 models should be rejected")
}
}
func TestResolveProviderConfigMapsSKUToProviderModel(t *testing.T) {
svc, db := newChannelModelTestService(t)
svc.dataDir = t.TempDir()
@@ -344,7 +448,7 @@ func newChannelModelTestService(t *testing.T) (*Service, *gorm.DB) {
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.IDSequence{}); err != nil {
if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.LogicalModel{}, &model.LogicalModelRevision{}, &model.LogicalModelRoute{}, &model.Task{}, &model.IDSequence{}); err != nil {
t.Fatal(err)
}
return &Service{repo: repository.New(db)}, db
+58 -17
View File
@@ -35,6 +35,8 @@ type ChannelModelRequest struct {
PriceTiers []ChannelModelPriceTierRequest `json:"priceTiers"`
}
const maxAdminChannelModelBatchDeleteCount = 100
// ChannelModelPriceTierRequest 是系统渠道内某个规格的上游 SKU 与结算价格。
// Resolution="*"、VideoSeconds=0 分别表示任意分辨率和任意时长。
type ChannelModelPriceTierRequest struct {
@@ -826,47 +828,86 @@ func normalizeChannelModelContractWithRegistry(registry *protocol.Registry, chan
}
func (s *Service) DeleteAdminChannelModel(actor *model.User, channelID string, id string) error {
_, err := s.DeleteAdminChannelModels(actor, channelID, []string{id})
return err
}
// DeleteAdminChannelModels validates the complete selection before asking the
// repository to remove it atomically. This deliberately rejects partial success:
// administrators can safely correct an in-use model and retry the same selection.
func (s *Service) DeleteAdminChannelModels(actor *model.User, channelID string, ids []string) (int64, error) {
if err := s.RequireAdmin(actor); err != nil {
return err
return 0, err
}
if _, err := s.repo.AdminSystemChannel(channelID); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return BadAuthRequest("系统渠道不存在或已删除")
return 0, BadAuthRequest("系统渠道不存在或已删除")
}
return err
return 0, err
}
if _, err := s.repo.ChannelModelByID(channelID, id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return BadAuthRequest("渠道模型不存在或已删除")
}
return err
}
items, err := s.repo.ChannelModels(channelID, false)
modelIDs, err := normalizeAdminChannelModelDeleteIDs(ids)
if err != nil {
return err
return 0, err
}
items, err := s.repo.ChannelModels(channelID, true)
if err != nil {
return 0, err
}
selected := make(map[string]bool, len(modelIDs))
for _, id := range modelIDs {
selected[id] = true
}
found := 0
for _, item := range items {
if selected[item.ID] {
found++
}
}
if found != len(modelIDs) {
return 0, BadAuthRequest("所选渠道模型中存在已删除或不属于当前渠道的记录,请刷新后重试")
}
names := make([]string, 0, len(items))
for _, item := range items {
if item.ID != id {
if item.Enabled && !selected[item.ID] {
names = append(names, item.ModelKey)
}
}
encoded, err := json.Marshal(names)
if err != nil {
return err
return 0, err
}
// 删除模型与渠道的兼容模型清单必须同事务提交,避免接口报错但列表已部分变化。
err = s.repo.DeleteChannelModel(channelID, id, string(encoded), time.Now())
deleted, err := s.repo.DeleteChannelModels(channelID, modelIDs, string(encoded), time.Now())
if errors.Is(err, repository.ErrChannelModelInUse) {
return BadAuthRequest("渠道模型仍被前台模型供应线路或进行中任务使用,请先移除线路并等待任务结束")
return 0, BadAuthRequest("所选渠道模型中有模型仍被前台模型供应线路或进行中任务使用,本次未删除任何模型")
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return BadAuthRequest("渠道模型不存在或已删除")
return 0, BadAuthRequest("所选渠道模型中存在已删除或不属于当前渠道的记录,请刷新后重试")
}
if err == nil {
s.invalidateRouteCatalog()
}
return err
return deleted, err
}
func normalizeAdminChannelModelDeleteIDs(values []string) ([]string, error) {
result := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range values {
id := strings.TrimSpace(value)
if id == "" || seen[id] {
continue
}
seen[id] = true
result = append(result, id)
}
if len(result) == 0 {
return nil, BadAuthRequest("请至少选择一个要删除的渠道模型")
}
if len(result) > maxAdminChannelModelBatchDeleteCount {
return nil, BadAuthRequest("单次最多删除 100 个渠道模型")
}
return result, nil
}
func (s *Service) syncInitialChannelModels(channel *model.ModelChannel, names []string) error {