feat(admin): 模型目录 - 增加前台模型删除功能
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Record<string, boolean>>({});
|
||||
const [editingModel, setEditingModel] = useState<AdminLogicalModel | null | undefined>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingModelId, setDeletingModelId] = useState<string>();
|
||||
const [simulatingModel, setSimulatingModel] = useState<AdminLogicalModel>();
|
||||
const [simulationIntent, setSimulationIntent] = useState<ModelRequestIntent>();
|
||||
const [simulationResult, setSimulationResult] = useState<RouteSimulationResult>();
|
||||
@@ -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) => (
|
||||
<AdminRowActions
|
||||
primary={{ label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openModel(item) }}
|
||||
visibleActionCount={1}
|
||||
actions={[
|
||||
{ key: "simulate", label: "模拟供应线路匹配", icon: <FlaskConical className="size-3.5" />, onClick: () => openSimulation(item) },
|
||||
{ key: "toggle", label: item.enabled ? "停用" : "启用", onClick: () => void toggleModel(item) },
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除模型",
|
||||
icon: <Trash2 className="size-3.5" />,
|
||||
danger: true,
|
||||
disabled: deletingModelId === item.id,
|
||||
confirm: {
|
||||
title: `删除前台模型“${item.name}”?`,
|
||||
description: "删除后模型将从目录中移除,不能在页面恢复;历史任务和版本记录会保留。排队中或进行中的任务仍在使用时无法删除。",
|
||||
okText: "确认删除",
|
||||
},
|
||||
onClick: () => removeModel(item),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -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<RouteSimulationResult>(apiClient.post(`/admin/logical-models/${encodeURIComponent(id)}/simulate`, intent));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user