feat(admin): 模型目录 - 增加前台模型删除功能

This commit is contained in:
ddCat
2026-08-20 12:09:05 +08:00
6 changed files with 170 additions and 1 deletions

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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)

View File

@@ -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),
},
]}
/>
),

View File

@@ -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));
}