diff --git a/backend/internal/handler/logical_models.go b/backend/internal/handler/logical_models.go index e7a177d08..6a5c26454 100644 --- a/backend/internal/handler/logical_models.go +++ b/backend/internal/handler/logical_models.go @@ -58,6 +58,18 @@ func RegisterLogicalModelRoutes(r *gin.RouterGroup, svc *service.Service) { r.PATCH("/admin/logical-models/:id", func(c *gin.Context) { saveAdminLogicalModel(c, svc, c.Param("id")) }) + r.DELETE("/admin/logical-models/:id", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.DeleteAdminLogicalModel(actor, c.Param("id")); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"ok": true}) + }) r.POST("/admin/logical-models/:id/simulate", func(c *gin.Context) { actor, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/repository/logical_models.go b/backend/internal/repository/logical_models.go index e8e7bc797..96b81c94d 100644 --- a/backend/internal/repository/logical_models.go +++ b/backend/internal/repository/logical_models.go @@ -16,6 +16,8 @@ type LogicalModelGraph struct { ChannelModels []model.ChannelModel } +var ErrLogicalModelInUse = errors.New("logical model is in use") + func (r *Repository) LogicalModels(includeDisabled bool) ([]model.LogicalModel, error) { var items []model.LogicalModel query := r.db.Order("sort_order asc, created_at asc") @@ -401,6 +403,41 @@ func (r *Repository) SaveLogicalModelBundle(item *model.LogicalModel, revision * }) } +func (r *Repository) DeleteLogicalModel(id string, now time.Time) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // 先停用并锁定目录主体,避免删除期间仍被新的数据库查询选中。 + result := tx.Model(&model.LogicalModel{}). + Where("id = ?", id). + Updates(map[string]any{"enabled": false, "updated_at": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + + var activeTasks int64 + if err := tx.Model(&model.Task{}). + Where("logical_model_id = ? AND status IN ?", id, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). + Count(&activeTasks).Error; err != nil { + return err + } + if activeTasks > 0 { + return ErrLogicalModelInUse + } + + // revision 与 route 是不可变的任务快照;删除目录主体时保留它们供历史记录追溯。 + deleted := tx.Where("id = ?", id).Delete(&model.LogicalModel{}) + if deleted.Error != nil { + return deleted.Error + } + if deleted.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return nil + }) +} + func (r *Repository) DeleteLogicalModelRoute(id string) error { return r.db.Model(&model.LogicalModelRoute{}).Where("id = ?", id).Updates(map[string]any{"enabled": false, "weight": 0, "updated_at": time.Now()}).Error } diff --git a/backend/internal/repository/logical_models_test.go b/backend/internal/repository/logical_models_test.go index b064f5823..8b8c1f968 100644 --- a/backend/internal/repository/logical_models_test.go +++ b/backend/internal/repository/logical_models_test.go @@ -1,6 +1,7 @@ package repository import ( + "errors" "testing" "time" @@ -48,3 +49,62 @@ func TestSaveLogicalModelBundleAllocatesMonotonicRevisionSequence(t *testing.T) t.Fatalf("updated model state = %#v", updated) } } + +func TestDeleteLogicalModelPreservesPublishedHistory(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:logical-model-delete-history?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.LogicalModel{}, &model.LogicalModelRevision{}, &model.LogicalModelRoute{}, &model.Task{}); err != nil { + t.Fatal(err) + } + repo := New(db) + + item := &model.LogicalModel{ID: "LMODEL_DELETE", Code: "delete-demo", Name: "Delete Demo", Capability: "text", Enabled: true, PricePolicy: "unified", BillingMode: "fixed_request", CreatedAt: time.Now(), UpdatedAt: time.Now()} + revision := &model.LogicalModelRevision{ID: "REVISION_DELETE", LogicalModelID: item.ID, CapabilitySpecJSON: `{"version":1,"capability":"text"}`, DefaultOptionsJSON: `{}`, CreatedAt: time.Now()} + routes := []model.LogicalModelRoute{{ID: "ROUTE_DELETE", ChannelModelID: "CMODEL_DELETE", Enabled: true, Weight: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}} + if err := repo.SaveLogicalModelBundle(item, revision, routes, true); err != nil { + t.Fatal(err) + } + + if err := repo.DeleteLogicalModel(item.ID, time.Now()); err != nil { + t.Fatal(err) + } + if err := db.First(&model.LogicalModel{}, "id = ?", item.ID).Error; !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("logical model lookup error = %v, want record not found", err) + } + for label, target := range map[string]any{"revision": &model.LogicalModelRevision{}, "route": &model.LogicalModelRoute{}} { + if err := db.First(target).Error; err != nil { + t.Fatalf("%s history was not preserved: %v", label, err) + } + } +} + +func TestDeleteLogicalModelRejectsActiveTask(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:logical-model-delete-active-task?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.LogicalModel{}, &model.Task{}); err != nil { + t.Fatal(err) + } + repo := New(db) + item := model.LogicalModel{ID: "LMODEL_ACTIVE", Code: "active-demo", Name: "Active Demo", Enabled: true, CreatedAt: time.Now(), UpdatedAt: time.Now()} + if err := db.Create(&item).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Task{ID: "TASK_ACTIVE", LogicalModelID: item.ID, Status: model.TaskStatusRunning, CreatedAt: time.Now(), UpdatedAt: time.Now()}).Error; err != nil { + t.Fatal(err) + } + + if err := repo.DeleteLogicalModel(item.ID, time.Now()); !errors.Is(err, ErrLogicalModelInUse) { + t.Fatalf("DeleteLogicalModel() error = %v, want ErrLogicalModelInUse", err) + } + var preserved model.LogicalModel + if err := db.First(&preserved, "id = ?", item.ID).Error; err != nil { + t.Fatalf("active logical model was removed: %v", err) + } + if !preserved.Enabled { + t.Fatal("active logical model disable was not rolled back") + } +} diff --git a/backend/internal/service/logical_models.go b/backend/internal/service/logical_models.go index 39ccc9270..af42060b4 100644 --- a/backend/internal/service/logical_models.go +++ b/backend/internal/service/logical_models.go @@ -408,6 +408,31 @@ func (s *Service) SaveAdminLogicalModel(actor *model.User, id string, req Logica return s.buildAdminLogicalModel(*item, graph, systemChannelByID) } +func (s *Service) DeleteAdminLogicalModel(actor *model.User, id string) error { + if err := s.RequireAdmin(actor); err != nil { + return err + } + item, err := s.repo.LogicalModel(strings.TrimSpace(id)) + if logicalModelNotFound(err) { + return BadAuthRequest("前台模型不存在或已删除") + } + if err != nil { + return err + } + if err := s.repo.DeleteLogicalModel(item.ID, time.Now()); err != nil { + if errors.Is(err, repository.ErrLogicalModelInUse) { + return BadAuthRequest("前台模型仍被排队中或进行中任务使用,请等待任务结束后再删除") + } + if logicalModelNotFound(err) { + return BadAuthRequest("前台模型不存在或已删除") + } + return err + } + s.invalidateRouteCatalog() + _ = s.appendAdminAudit(actor, "logical_model.delete", "logical_model", item.ID, "删除前台模型目录主体", map[string]any{"code": item.Code, "name": item.Name}) + return nil +} + func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalModelRequest) (*model.LogicalModel, *model.LogicalModelRevision, []model.LogicalModelRoute, bool, error) { code := strings.ToLower(strings.TrimSpace(req.Code)) name := strings.TrimSpace(req.Name) diff --git a/web/src/pages/admin/logical-models/logical-models-page.tsx b/web/src/pages/admin/logical-models/logical-models-page.tsx index 6e144fded..afac0e355 100644 --- a/web/src/pages/admin/logical-models/logical-models-page.tsx +++ b/web/src/pages/admin/logical-models/logical-models-page.tsx @@ -1,7 +1,7 @@ import { Alert, App, Button, Drawer, Form, Input, InputNumber, Modal, Segmented, Select, Switch, Table, Tag } from "antd"; import type { FormInstance } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { FlaskConical, GitBranch, Layers3, Pencil, Plus, Search } from "lucide-react"; +import { FlaskConical, GitBranch, Layers3, Pencil, Plus, Search, Trash2 } from "lucide-react"; import { useDeferredValue, useEffect, useMemo, useState, type ReactNode } from "react"; import { PaginationBar } from "@/components/layout/workspace-page"; @@ -14,6 +14,7 @@ import { listAdminChannels } from "@/services/api/auth"; import { listAdminChannelModels, type ChannelModel } from "@/services/api/wallet"; import { createAdminLogicalModel, + deleteAdminLogicalModel, listAdminLogicalModels, simulateAdminLogicalModel, updateAdminLogicalModel, @@ -71,6 +72,7 @@ export default function LogicalModelsPage() { const [channelEnabled, setChannelEnabled] = useState>({}); const [editingModel, setEditingModel] = useState(); const [saving, setSaving] = useState(false); + const [deletingModelId, setDeletingModelId] = useState(); const [simulatingModel, setSimulatingModel] = useState(); const [simulationIntent, setSimulationIntent] = useState(); const [simulationResult, setSimulationResult] = useState(); @@ -180,6 +182,21 @@ export default function LogicalModelsPage() { } }; + const removeModel = async (item: AdminLogicalModel) => { + setDeletingModelId(item.id); + try { + await deleteAdminLogicalModel(item.id); + setModels((current) => current.filter((model) => model.id !== item.id)); + if (paginatedModels.length === 1 && page > 1) setPage(page - 1); + message.success("前台模型已删除"); + } catch (error) { + message.error(error instanceof Error ? error.message : "删除前台模型失败"); + throw error; + } finally { + setDeletingModelId(undefined); + } + }; + const openSimulation = (item: AdminLogicalModel) => { setSimulationIntent({ capability: item.capability, @@ -238,9 +255,23 @@ export default function LogicalModelsPage() { render: (_, item) => ( , onClick: () => openModel(item) }} + visibleActionCount={1} actions={[ { key: "simulate", label: "模拟供应线路匹配", icon: , onClick: () => openSimulation(item) }, { key: "toggle", label: item.enabled ? "停用" : "启用", onClick: () => void toggleModel(item) }, + { + key: "delete", + label: "删除模型", + icon: , + danger: true, + disabled: deletingModelId === item.id, + confirm: { + title: `删除前台模型“${item.name}”?`, + description: "删除后模型将从目录中移除,不能在页面恢复;历史任务和版本记录会保留。排队中或进行中的任务仍在使用时无法删除。", + okText: "确认删除", + }, + onClick: () => removeModel(item), + }, ]} /> ), diff --git a/web/src/services/api/logical-models.ts b/web/src/services/api/logical-models.ts index 9c2813c33..ece0396c3 100644 --- a/web/src/services/api/logical-models.ts +++ b/web/src/services/api/logical-models.ts @@ -103,6 +103,10 @@ export function updateAdminLogicalModel(id: string, input: LogicalModelMutation) return request<{ model: AdminLogicalModel }>(apiClient.patch(`/admin/logical-models/${encodeURIComponent(id)}`, input)); } +export function deleteAdminLogicalModel(id: string) { + return request<{ ok: boolean }>(apiClient.delete(`/admin/logical-models/${encodeURIComponent(id)}`)); +} + export function simulateAdminLogicalModel(id: string, intent: ModelRequestIntent) { return request(apiClient.post(`/admin/logical-models/${encodeURIComponent(id)}/simulate`, intent)); }