diff --git a/.claude/skills/new-business-module/SKILL.md b/.claude/skills/new-business-module/SKILL.md index 1cc0b66c..c0a93e13 100644 --- a/.claude/skills/new-business-module/SKILL.md +++ b/.claude/skills/new-business-module/SKILL.md @@ -35,15 +35,17 @@ description: Scaffold a new single-table CRUD business module end to end — mig ### 3. 生成 model / dto / router 三个文件(Actions 模式) -不要手写 Api 与 Service。使用 `common/actions` 的通用 Action,一个模块只需 -model、dto、router 三个文件,完整写法照抄 `app/demo/` 的结构。 +不要手写 Api 与 Service。使用 `common/actions` 的泛型 Action +(`actions.Index[Model, Search]()` 等),一个模块只需 model、dto、router +三个文件,完整写法照抄 `app/demo/` 的结构。 -**关键正确性要求**(这三条是实际出问题最多的地方): +**关键正确性要求**: -- Model 实现 `models.ActiveRecord`(`Generate` / `GetId` / `TableName`), - `TableName()` 必须显式声明——GORM 配置了 `SingularTable`,不会自动推导 -- **`Generate()` 必须返回副本,不要就地返回**——Action 在并发请求间复用实例, - 就地返回会导致请求之间串数据;这个问题单人测试时几乎不出现,上线后才暴露 +- `TableName()` 必须显式声明——GORM 配置了 `SingularTable`,不会自动推导 +- 增改 DTO 写 `ToModel() (*Model, error)`;模型与 DTO 配错会编译不过 +- **不要写 `Generate()`**,也不要用旧的 `IndexAction` 等五个(已 Deprecated): + 旧写法在并发请求间复用实例,`Generate()` 一旦就地返回就会串数据, + 单人测试时几乎不出现、上线后才暴露;泛型 Action 每个请求新建自己的值 - 完成后确认 `cmd/api/` 中已用 `_` 导入新包,否则路由不会被注册 ### 4. 写菜单、接口与权限种子数据 @@ -82,7 +84,7 @@ model、dto、router 三个文件,完整写法照抄 `app/demo/` 的结构。 | 检查项 | 出错后果 | | --- | --- | -| `Generate()` 是否返回副本 | 并发请求之间串数据 | +| 路由是否用泛型 Action 而非旧的 `IndexAction` 等 | 旧写法要靠 `Generate()` 返回副本,漏了就并发串数据 | | 是否使用 `e.Orm` 而非全局 DB | 多租户下拿到错误的数据库连接 | | `TableName()` 是否显式声明 | GORM 不会自动推导 | | 迁移文件是否放在 `version/` | 放进 `version-local/` 会被忽略,别人拉代码看不到 | diff --git a/AGENTS.md b/AGENTS.md index b3e221be..01f544a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,34 +20,34 @@ Router → Api → Service → Model ## 优先使用通用 Action -单表 CRUD **不要手写 Api 与 Service**。`common/actions` 提供的五个 -Action 已覆盖参数绑定、数据权限过滤、操作人注入、分页与错误响应: +单表 CRUD **不要手写 Api 与 Service**。`common/actions` 提供的泛型 Action +已覆盖参数绑定、数据权限过滤、操作人注入、分页与错误响应: ```go r := v1.Group("/demo-product").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()) { - m := &models.DemoProduct{} - r.GET("", actions.PermissionAction(), actions.IndexAction(m, new(dto.DemoProductSearch), func() interface{} { - list := make([]models.DemoProduct, 0); return &list - })) - r.GET("/:id", actions.PermissionAction(), actions.ViewAction(new(dto.DemoProductById), func() interface{} { - return &models.DemoProduct{} - })) - r.POST("", actions.CreateAction(new(dto.DemoProductControl))) - r.PUT("/:id", actions.PermissionAction(), actions.UpdateAction(new(dto.DemoProductControl))) - r.DELETE("", actions.PermissionAction(), actions.DeleteAction(new(dto.DemoProductById))) + r.GET("", actions.PermissionAction(), actions.Index[models.DemoProduct, dto.DemoProductSearch]()) + r.GET("/:id", actions.PermissionAction(), actions.View[models.DemoProduct, dto.DemoProductById]()) + r.POST("", actions.Create[models.DemoProduct, dto.DemoProductControl]()) + r.PUT("/:id", actions.PermissionAction(), actions.Update[models.DemoProduct, dto.DemoProductControl]()) + r.DELETE("", actions.PermissionAction(), actions.Delete[models.DemoProduct, dto.DemoProductById]()) } ``` 这样一个模块只需 **model + dto + router** 三个文件,完整示例见 `app/demo/`。 -使用通用 Action 的前提: +类型参数就是约束,写错了编译不过: -- Model 实现 `models.ActiveRecord`(`Generate` / `GetId` / `TableName`) -- 列表 DTO 实现 `dto.Index`,增改删 DTO 实现 `dto.Control` -- **所有 `Generate()` 必须返回副本** —— Action 在并发请求间复用实例, - 就地返回会串数据(`app/demo` 的测试锁定了这一点) +- Model 需有 `TableName` / `GetId`,并内嵌 `models.ControlBy`(提供 `SetCreateBy` / `SetUpdateBy`) +- 列表 DTO:`Bind`、`GetNeedSearch`,内嵌 `dto.Pagination` +- 增改 DTO:`Bind` 与 `ToModel() (*Model, error)`——配错模型编译不过 - 详情/删除 DTO 内嵌 `dto.ObjectById` 即可继承 `Bind` 与 `GetId`,无需重写 +- 详情要返回与模型不同的结构时用 `ViewAs[Model, ById, Response]()`(见 `app/jobs`) +- **不需要 `Generate()`**:每个请求都新建自己的值,不存在跨请求共享的实例 + +旧的 `IndexAction` 等五个仍可用,已标记 Deprecated。它们在并发请求间复用 +注册时传入的实例,所以依赖「所有 `Generate()` 必须返回副本」这条约定—— +仍在用旧写法的模块要继续遵守它。 仅当业务超出单表 CRUD(跨表事务、外部调用、复杂校验)时才自行编写 Api 与 Service,写法见下。 diff --git a/app/demo/models/demo_product.go b/app/demo/models/demo_product.go index fb616730..61bb852f 100644 --- a/app/demo/models/demo_product.go +++ b/app/demo/models/demo_product.go @@ -25,13 +25,6 @@ func (DemoProduct) TableName() string { return "demo_product" } -// Generate 返回副本,供通用 Action 使用。 -// 必须返回新实例:Action 在并发请求间复用同一个模型指针,就地返回会串数据。 -func (e *DemoProduct) Generate() models.ActiveRecord { - o := *e - return &o -} - func (e *DemoProduct) GetId() interface{} { return e.Id } diff --git a/app/demo/router/demo_product.go b/app/demo/router/demo_product.go index 4d721543..00c0840d 100644 --- a/app/demo/router/demo_product.go +++ b/app/demo/router/demo_product.go @@ -29,21 +29,12 @@ func registerDemoProductRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi Use(authMiddleware.MiddlewareFunc()). // JWT 认证 Use(middleware.AuthCheckRole()) // Casbin 鉴权 { - m := &models.DemoProduct{} - // actions.PermissionAction() 注入数据权限上下文, // 列表与详情缺少它会绕过 DataScope 过滤 - r.GET("", actions.PermissionAction(), actions.IndexAction(m, new(dto.DemoProductSearch), func() interface{} { - list := make([]models.DemoProduct, 0) - return &list - })) - - r.GET("/:id", actions.PermissionAction(), actions.ViewAction(new(dto.DemoProductById), func() interface{} { - return &models.DemoProduct{} - })) - - r.POST("", actions.CreateAction(new(dto.DemoProductControl))) - r.PUT("/:id", actions.PermissionAction(), actions.UpdateAction(new(dto.DemoProductControl))) - r.DELETE("", actions.PermissionAction(), actions.DeleteAction(new(dto.DemoProductById))) + r.GET("", actions.PermissionAction(), actions.Index[models.DemoProduct, dto.DemoProductSearch]()) + r.GET("/:id", actions.PermissionAction(), actions.View[models.DemoProduct, dto.DemoProductById]()) + r.POST("", actions.Create[models.DemoProduct, dto.DemoProductControl]()) + r.PUT("/:id", actions.PermissionAction(), actions.Update[models.DemoProduct, dto.DemoProductControl]()) + r.DELETE("", actions.PermissionAction(), actions.Delete[models.DemoProduct, dto.DemoProductById]()) } } diff --git a/app/demo/service/dto/demo_product.go b/app/demo/service/dto/demo_product.go index 7516794b..7109b43d 100644 --- a/app/demo/service/dto/demo_product.go +++ b/app/demo/service/dto/demo_product.go @@ -36,15 +36,10 @@ func (m *DemoProductSearch) Bind(ctx *gin.Context) error { return ctx.ShouldBind(m) } -func (m *DemoProductSearch) Generate() dto.Index { - o := *m - return &o -} - -// DemoProductControl 新增与修改共用的入参 +// DemoProductControl is the request body of both create and update. // -// 通用 Action(Create / Update)通过 GenerateM 拿到落库对象, -// 因此这里不直接暴露 Model,字段校验用 validate tag 声明。 +// actions.Create / actions.Update get the row to write from ToModel, so the +// model itself is not exposed; field rules are declared with validate tags. type DemoProductControl struct { Id int `json:"id" comment:"主键"` Name string `json:"name" comment:"名称" validate:"required"` @@ -58,16 +53,9 @@ func (s *DemoProductControl) Bind(ctx *gin.Context) error { return ctx.ShouldBind(s) } -func (s *DemoProductControl) Generate() dto.Control { - o := *s - return &o -} - -func (s *DemoProductControl) GetId() interface{} { return s.Id } - -// GenerateM 组装落库对象。CreateBy / UpdateBy 由通用 Action 在此之后注入, -// 此处不要手动赋值。 -func (s *DemoProductControl) GenerateM() (common.ActiveRecord, error) { +// ToModel builds the row to write. The actions set CreateBy / UpdateBy +// afterwards, so do not set them here. +func (s *DemoProductControl) ToModel() (*models.DemoProduct, error) { return &models.DemoProduct{ Model: common.Model{Id: s.Id}, Name: s.Name, @@ -85,12 +73,3 @@ type DemoProductById struct { // Bind 与 GetId 由内嵌的 dto.ObjectById 提供:它已处理好 uri 绑定、 // DELETE 时的批量 ids 合并与参数校验,无需在此重复实现。 - -func (s *DemoProductById) Generate() dto.Control { - o := *s - return &o -} - -func (s *DemoProductById) GenerateM() (common.ActiveRecord, error) { - return &models.DemoProduct{}, nil -} diff --git a/app/demo/service/dto/demo_product_test.go b/app/demo/service/dto/demo_product_test.go index 32b1a7f3..ce1d19b0 100644 --- a/app/demo/service/dto/demo_product_test.go +++ b/app/demo/service/dto/demo_product_test.go @@ -4,83 +4,21 @@ import ( "testing" "go-admin/app/demo/models" - "go-admin/common/dto" - common "go-admin/common/models" ) -// 通用 Action 依赖 DTO 与 Model 实现一组接口。这些约束在编译期无法完全覆盖 -// (接口是在路由注册处才被要求的),因此用测试锁定,避免改动后在运行时才暴露。 - -func TestImplementsIndexInterface(t *testing.T) { - var _ dto.Index = (*DemoProductSearch)(nil) -} - -func TestImplementsControlInterface(t *testing.T) { - var _ dto.Control = (*DemoProductControl)(nil) - var _ dto.Control = (*DemoProductById)(nil) -} - -func TestModelImplementsActiveRecord(t *testing.T) { - var _ common.ActiveRecord = (*models.DemoProduct)(nil) -} - -// Generate 必须返回副本:通用 Action 在并发请求间复用同一个实例, -// 就地返回会导致请求之间串数据。 -func TestGenerateReturnsCopy(t *testing.T) { - src := &DemoProductControl{Id: 1, Name: "原始"} - got := src.Generate().(*DemoProductControl) - - if got == src { - t.Fatal("Generate 返回了同一指针,应返回副本") - } - got.Name = "被修改" - if src.Name != "原始" { - t.Errorf("修改副本影响了原对象:src.Name = %q", src.Name) - } -} - -func TestSearchGenerateReturnsCopy(t *testing.T) { - src := &DemoProductSearch{Name: "原始"} - got := src.Generate().(*DemoProductSearch) - - if got == src { - t.Fatal("Generate 返回了同一指针,应返回副本") - } - got.Name = "被修改" - if src.Name != "原始" { - t.Errorf("修改副本影响了原对象:src.Name = %q", src.Name) - } -} - -func TestModelGenerateReturnsCopy(t *testing.T) { - src := &models.DemoProduct{Name: "原始"} - got := src.Generate().(*models.DemoProduct) - - if got == src { - t.Fatal("Generate 返回了同一指针,应返回副本") - } - got.Name = "被修改" - if src.Name != "原始" { - t.Errorf("修改副本影响了原对象:src.Name = %q", src.Name) - } -} - -// GenerateM 组装落库对象,主键需正确传递,否则更新会退化成插入。 -func TestGenerateMCarriesId(t *testing.T) { +// ToModel builds the row to write; the key has to reach it, or an update +// matches no row. +func TestToModelCarriesId(t *testing.T) { c := &DemoProductControl{Id: 42, Name: "示例", Code: "P-42", Price: 9.9} - m, err := c.GenerateM() + p, err := c.ToModel() if err != nil { - t.Fatalf("GenerateM 返回错误: %v", err) - } - p, ok := m.(*models.DemoProduct) - if !ok { - t.Fatalf("GenerateM 返回类型错误: %T", m) + t.Fatalf("ToModel: %v", err) } if p.Id != 42 { - t.Errorf("主键未传递: got %d, want 42", p.Id) + t.Errorf("key not carried: got %d, want 42", p.Id) } if p.Name != "示例" || p.Code != "P-42" || p.Price != 9.9 { - t.Errorf("字段映射有误: %+v", p) + t.Errorf("fields mapped wrongly: %+v", p) } } diff --git a/app/jobs/models/sys_job.go b/app/jobs/models/sys_job.go index a8cc6bf9..9107465a 100644 --- a/app/jobs/models/sys_job.go +++ b/app/jobs/models/sys_job.go @@ -27,11 +27,6 @@ func (*SysJob) TableName() string { return "sys_job" } -func (e *SysJob) Generate() models.ActiveRecord { - o := *e - return &o -} - func (e *SysJob) GetId() interface{} { return e.JobId } diff --git a/app/jobs/router/sys_job.go b/app/jobs/router/sys_job.go index 090be794..3c6d44d5 100644 --- a/app/jobs/router/sys_job.go +++ b/app/jobs/router/sys_job.go @@ -19,17 +19,13 @@ func registerSysJobRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew r := v1.Group("/sysjob").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()) { - sysJob := &models2.SysJob{} - r.GET("", actions.PermissionAction(), actions.IndexAction(sysJob, new(dto2.SysJobSearch), func() interface{} { - list := make([]models2.SysJob, 0) - return &list - })) - r.GET("/:id", actions.PermissionAction(), actions.ViewAction(new(dto2.SysJobById), func() interface{} { - return &dto2.SysJobItem{} - })) - r.POST("", actions.CreateAction(new(dto2.SysJobControl))) - r.PUT("", actions.PermissionAction(), actions.UpdateAction(new(dto2.SysJobControl))) - r.DELETE("", actions.PermissionAction(), actions.DeleteAction(new(dto2.SysJobById))) + r.GET("", actions.PermissionAction(), actions.Index[models2.SysJob, dto2.SysJobSearch]()) + // The detail answers SysJobItem, whose entryId the edit form reads; + // the model itself marshals it as entry_id. + r.GET("/:id", actions.PermissionAction(), actions.ViewAs[models2.SysJob, dto2.SysJobById, dto2.SysJobItem]()) + r.POST("", actions.Create[models2.SysJob, dto2.SysJobControl]()) + r.PUT("", actions.PermissionAction(), actions.Update[models2.SysJob, dto2.SysJobControl]()) + r.DELETE("", actions.PermissionAction(), actions.Delete[models2.SysJob, dto2.SysJobById]()) } sysJob := apis.SysJob{} diff --git a/app/jobs/service/dto/sys_job.go b/app/jobs/service/dto/sys_job.go index f342f927..1ab9310f 100644 --- a/app/jobs/service/dto/sys_job.go +++ b/app/jobs/service/dto/sys_job.go @@ -6,7 +6,6 @@ import ( "go-admin/app/jobs/models" "go-admin/common/dto" - common "go-admin/common/models" ) type SysJobSearch struct { @@ -32,11 +31,6 @@ func (m *SysJobSearch) Bind(ctx *gin.Context) error { return err } -func (m *SysJobSearch) Generate() dto.Index { - o := *m - return &o -} - type SysJobControl struct { JobId int `json:"jobId"` JobName string `json:"jobName" validate:"required"` // 名称 @@ -55,12 +49,7 @@ func (s *SysJobControl) Bind(ctx *gin.Context) error { return ctx.ShouldBind(s) } -func (s *SysJobControl) Generate() dto.Control { - cp := *s - return &cp -} - -func (s *SysJobControl) GenerateM() (common.ActiveRecord, error) { +func (s *SysJobControl) ToModel() (*models.SysJob, error) { return &models.SysJob{ JobId: s.JobId, JobName: s.JobName, @@ -76,23 +65,10 @@ func (s *SysJobControl) GenerateM() (common.ActiveRecord, error) { }, nil } -func (s *SysJobControl) GetId() interface{} { - return s.JobId -} - type SysJobById struct { dto.ObjectById } -func (s *SysJobById) Generate() dto.Control { - cp := *s - return &cp -} - -func (s *SysJobById) GenerateM() (common.ActiveRecord, error) { - return &models.SysJob{}, nil -} - type SysJobItem struct { JobId int `json:"jobId"` JobName string `json:"jobName" validate:"required"` // 名称 diff --git a/common/actions/create.go b/common/actions/create.go index f667fc97..abaf57d5 100644 --- a/common/actions/create.go +++ b/common/actions/create.go @@ -14,6 +14,10 @@ import ( ) // CreateAction 通用新增动作 +// +// Deprecated: Use Create[Model, Control](). It builds its values per request, so the +// Generate() copies this action depends on are not needed, and a model +// paired with the wrong request does not compile. See generic.go. func CreateAction(control dto.Control) gin.HandlerFunc { return func(c *gin.Context) { log := api.GetRequestLogger(c) @@ -37,7 +41,7 @@ func CreateAction(control dto.Control) gin.HandlerFunc { return } object.SetCreateBy(user.GetUserId(c)) - err = db.WithContext(c).Create(object).Error + err = db.WithContext(c.Request.Context()).Create(object).Error if err != nil { log.Errorf("Create error: %s", err) response.Error(c, 500, err, "创建失败") diff --git a/common/actions/delete.go b/common/actions/delete.go index b8e992b6..444fb690 100644 --- a/common/actions/delete.go +++ b/common/actions/delete.go @@ -14,6 +14,10 @@ import ( ) // DeleteAction 通用删除动作 +// +// Deprecated: Use Delete[Model, ById](). It builds its values per request, so the +// Generate() copies this action depends on are not needed, and a model +// paired with the wrong request does not compile. See generic.go. func DeleteAction(control dto.Control) gin.HandlerFunc { return func(c *gin.Context) { db, err := pkg.GetOrm(c) @@ -43,7 +47,7 @@ func DeleteAction(control dto.Control) gin.HandlerFunc { //数据权限检查 p := GetPermissionFromContext(c) - db = db.WithContext(c).Scopes( + db = db.WithContext(c.Request.Context()).Scopes( Permission(object.TableName(), p), ).Where(req.GetId()).Delete(object) if err = db.Error; err != nil { diff --git a/common/actions/generic.go b/common/actions/generic.go new file mode 100644 index 00000000..10c62ae7 --- /dev/null +++ b/common/actions/generic.go @@ -0,0 +1,294 @@ +package actions + +import ( + "errors" + "net/http" + "reflect" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/v2/jwtauth/user" + "github.com/go-admin-team/go-admin-core/v2/response" + "github.com/go-admin-team/go-admin-core/v2/sdk/api" + "github.com/go-admin-team/go-admin-core/v2/sdk/pkg" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + + "go-admin/common/dto" +) + +// The generic CRUD actions. They serve the same five kinds of route as +// IndexAction and its siblings, and answer them the same way; what changes is +// what the caller has to get right. +// +// The older actions take instances at registration and serve every request +// from them, so each model and DTO has to implement a Generate that returns a +// copy - return the receiver and concurrent requests share one struct - and a +// list route takes a func() interface{} whose element type nothing checks. +// Here each request declares its own values of the types it was given, so +// neither convention exists, and a model paired with the wrong DTO does not +// compile. +// +// Three things differ from the older actions, all on paths they got wrong: +// +// - A request whose database connection cannot be found is answered with +// 500. The older actions logged it and wrote nothing, which reaches the +// client as an empty 200. +// - The key is matched against the model's primary-key column as a value. +// The older actions passed GetId() to Where, which GORM reads as a SQL +// condition when it is a string. +// - There is no GenerateM, so no error of its to drop. +// +// The type constraints are unexported: callers never name them, and an +// exported constraint would be a promise that could not be taken back. + +// record is a model the actions can create, read, change and delete. +type record[T any] interface { + *T + schema.Tabler + SetCreateBy(int) + SetUpdateBy(int) + GetId() any +} + +// searchReq is a list request: it binds itself, reports its page, and hands +// back the struct whose search tags MakeCondition turns into WHERE clauses. +type searchReq[S any] interface { + *S + Bind(*gin.Context) error + GetPageIndex() int + GetPageSize() int + GetNeedSearch() any +} + +// idReq is a request naming rows by key: one from the URI, or several from a +// DELETE body, as dto.ObjectById binds them. +type idReq[I any] interface { + *I + Bind(*gin.Context) error + GetId() any +} + +// controlReq is a create or update request: it binds itself and builds the +// model row it describes. +type controlReq[T, C any] interface { + *C + Bind(*gin.Context) error + ToModel() (*T, error) +} + +// Index lists T, filtered by S and by the caller's data permission. +func Index[T, S any, PT record[T], PS searchReq[S]]() gin.HandlerFunc { + return func(c *gin.Context) { + log := api.GetRequestLogger(c) + db, ok := genericOrm(c) + if !ok { + return + } + msgID := pkg.GenerateMsgIDFromContext(c) + + var req S + if err := PS(&req).Bind(c); err != nil { + response.Error(c, http.StatusUnprocessableEntity, err, "参数验证失败") + return + } + var object T + list := make([]T, 0) + var count int64 + p := GetPermissionFromContext(c) + err := db.WithContext(c.Request.Context()).Model(&object). + Scopes( + dto.MakeCondition(PS(&req).GetNeedSearch()), + dto.Paginate(PS(&req).GetPageSize(), PS(&req).GetPageIndex()), + Permission(PT(&object).TableName(), p), + ). + Find(&list).Limit(-1).Offset(-1). + Count(&count).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + log.Errorf("MsgID[%s] Index error: %s", msgID, err) + response.Error(c, 500, err, "查询失败") + return + } + response.PageOK(c, &list, int(count), PS(&req).GetPageIndex(), PS(&req).GetPageSize(), "查询成功") + c.Next() + } +} + +// View answers one T, named by I's key, if the caller's data permission +// reaches it. +func View[T, I any, PT record[T], PI idReq[I]]() gin.HandlerFunc { + return ViewAs[T, I, T, PT, PI]() +} + +// ViewAs is View answering with R instead of T: the row is looked up as T and +// scanned into R, for a detail route that shows a different shape than the +// model stores. +func ViewAs[T, I, R any, PT record[T], PI idReq[I]]() gin.HandlerFunc { + return func(c *gin.Context) { + log := api.GetRequestLogger(c) + db, ok := genericOrm(c) + if !ok { + return + } + msgID := pkg.GenerateMsgIDFromContext(c) + + var req I + if err := PI(&req).Bind(c); err != nil { + response.Error(c, http.StatusUnprocessableEntity, err, "参数验证失败") + return + } + var object T + var rsp R + p := GetPermissionFromContext(c) + err := db.Model(&object).WithContext(c.Request.Context()).Scopes( + Permission(PT(&object).TableName(), p), + ).Where(keyIs(PI(&req).GetId())).First(&rsp).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + response.Error(c, http.StatusNotFound, nil, "查看对象不存在或无权查看") + return + } + if err != nil { + log.Errorf("MsgID[%s] View error: %s", msgID, err) + response.Error(c, 500, err, "查看失败") + return + } + response.OK(c, &rsp, "查询成功") + c.Next() + } +} + +// Create inserts the T that C builds, recording the caller as its creator. +func Create[T, C any, PT record[T], PC controlReq[T, C]]() gin.HandlerFunc { + return func(c *gin.Context) { + log := api.GetRequestLogger(c) + db, ok := genericOrm(c) + if !ok { + return + } + + var req C + if err := PC(&req).Bind(c); err != nil { + response.Error(c, http.StatusUnprocessableEntity, err, err.Error()) + return + } + object, err := PC(&req).ToModel() + if err != nil { + response.Error(c, 500, err, "模型生成失败") + return + } + PT(object).SetCreateBy(user.GetUserId(c)) + if err = db.WithContext(c.Request.Context()).Create(object).Error; err != nil { + log.Errorf("Create error: %s", err) + response.Error(c, 500, err, "创建失败") + return + } + response.OK(c, PT(object).GetId(), "创建成功") + c.Next() + } +} + +// Update writes the T that C builds over the row with its key, if the +// caller's data permission reaches that row. +func Update[T, C any, PT record[T], PC controlReq[T, C]]() gin.HandlerFunc { + return func(c *gin.Context) { + log := api.GetRequestLogger(c) + db, ok := genericOrm(c) + if !ok { + return + } + msgID := pkg.GenerateMsgIDFromContext(c) + + var req C + if err := PC(&req).Bind(c); err != nil { + response.Error(c, http.StatusUnprocessableEntity, err, "参数验证失败") + return + } + object, err := PC(&req).ToModel() + if err != nil { + response.Error(c, 500, err, "模型生成失败") + return + } + PT(object).SetUpdateBy(user.GetUserId(c)) + + p := GetPermissionFromContext(c) + result := db.WithContext(c.Request.Context()).Scopes( + Permission(PT(object).TableName(), p), + ).Where(keyIs(PT(object).GetId())).Updates(object) + if err = result.Error; err != nil { + log.Errorf("MsgID[%s] Update error: %s", msgID, err) + response.Error(c, 500, err, "更新失败") + return + } + if result.RowsAffected == 0 { + response.Error(c, http.StatusForbidden, nil, "无权更新该数据") + return + } + response.OK(c, PT(object).GetId(), "更新成功") + c.Next() + } +} + +// Delete removes the rows I names, where the caller's data permission +// reaches them. +func Delete[T, I any, PT record[T], PI idReq[I]]() gin.HandlerFunc { + return func(c *gin.Context) { + log := api.GetRequestLogger(c) + db, ok := genericOrm(c) + if !ok { + return + } + msgID := pkg.GenerateMsgIDFromContext(c) + + var req I + if err := PI(&req).Bind(c); err != nil { + log.Errorf("MsgID[%s] Bind error: %s", msgID, err) + response.Error(c, http.StatusUnprocessableEntity, err, "参数验证失败") + return + } + var object T + PT(&object).SetUpdateBy(user.GetUserId(c)) + + p := GetPermissionFromContext(c) + result := db.WithContext(c.Request.Context()).Scopes( + Permission(PT(&object).TableName(), p), + ).Where(keyIs(PI(&req).GetId())).Delete(&object) + if err := result.Error; err != nil { + log.Errorf("MsgID[%s] Delete error: %s", msgID, err) + response.Error(c, 500, err, "删除失败") + return + } + if result.RowsAffected == 0 { + response.Error(c, http.StatusForbidden, nil, "无权删除该数据") + return + } + // The key of the empty model, as DeleteAction answers: the rows are + // named by the request, not by a model. + response.OK(c, PT(&object).GetId(), "删除成功") + c.Next() + } +} + +// genericOrm reads the request's database, answering 500 when there is none. +func genericOrm(c *gin.Context) (*gorm.DB, bool) { + db, err := pkg.GetOrm(c) + if err != nil { + api.GetRequestLogger(c).Error(err) + response.Error(c, 500, err, "数据库连接获取失败") + return nil, false + } + return db, true +} + +// keyIs matches the primary-key column against id as a value, or against +// each element when id is a slice or array. +func keyIs(id any) clause.Expression { + v := reflect.ValueOf(id) + if v.Kind() == reflect.Slice || v.Kind() == reflect.Array { + values := make([]any, v.Len()) + for i := range values { + values[i] = v.Index(i).Interface() + } + return clause.IN{Column: clause.PrimaryColumn, Values: values} + } + return clause.Eq{Column: clause.PrimaryColumn, Value: id} +} diff --git a/common/actions/generic_compile_test.go b/common/actions/generic_compile_test.go new file mode 100644 index 00000000..d9838bc6 --- /dev/null +++ b/common/actions/generic_compile_test.go @@ -0,0 +1,36 @@ +package actions_test + +import ( + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// A model paired with a request that builds a different model does not +// compile - the thing the older actions left to a type assertion at run +// time. Checked by building a package that does it, next to a control that +// is the same package with the pair corrected. +func TestGenericActionsRefuseAMismatchedPair(t *testing.T) { + root, err := filepath.Abs("../..") + if err != nil { + t.Fatal(err) + } + vet := func(pkg string) (string, error) { + cmd := exec.Command("go", "vet", "./common/actions/testdata/"+pkg) + cmd.Dir = root + out, err := cmd.CombinedOutput() + return string(out), err + } + + if out, err := vet("matched"); err != nil { + t.Fatalf("the control does not build, so the mismatch below proves nothing:\n%s", out) + } + out, err := vet("mismatch") + if err == nil { + t.Fatal("a model paired with another model's request compiled") + } + if !strings.Contains(out, "does not satisfy") || !strings.Contains(out, "ToModel") { + t.Fatalf("the build failed, but not on the pairing:\n%s", out) + } +} diff --git a/common/actions/generic_test.go b/common/actions/generic_test.go new file mode 100644 index 00000000..208c33f6 --- /dev/null +++ b/common/actions/generic_test.go @@ -0,0 +1,257 @@ +package actions_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "sync" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/go-admin-team/go-admin-core/v2/jwtauth" + "github.com/go-admin-team/go-admin-core/v2/sdk/config" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" + + "go-admin/common/actions" + "go-admin/common/dto" + "go-admin/common/models" +) + +// probeSearch is probeIndexReq with a search field, so a list request can +// be told apart from another in its response. +type probeSearch struct { + dto.Pagination `search:"-"` + Name string `form:"name" search:"type:exact;column:name;table:action_probe_row"` +} + +func (p *probeSearch) Generate() dto.Index { o := *p; return &o } +func (p *probeSearch) Bind(c *gin.Context) error { return c.ShouldBind(p) } +func (p *probeSearch) GetNeedSearch() interface{} { return *p } + +// probeById names rows the way every ById DTO here does. +type probeById struct { + dto.ObjectById +} + +func (s *probeById) Generate() dto.Control { o := *s; return &o } +func (s *probeById) GenerateM() (models.ActiveRecord, error) { return &probeRow{}, nil } + +// probeControl carries both the old actions' methods and ToModel, so one +// request type serves both generations of action. +type probeControl struct { + Id int `json:"id"` + Name string `json:"name"` +} + +func (s *probeControl) Bind(c *gin.Context) error { return c.ShouldBindJSON(s) } +func (s *probeControl) Generate() dto.Control { o := *s; return &o } +func (s *probeControl) GetId() interface{} { return s.Id } +func (s *probeControl) GenerateM() (models.ActiveRecord, error) { + return &probeRow{Model: models.Model{Id: s.Id}, Name: s.Name}, nil +} +func (s *probeControl) ToModel() (*probeRow, error) { + return &probeRow{Model: models.Model{Id: s.Id}, Name: s.Name}, nil +} + +// probeKey names a row by a string key, which the old actions handed to GORM +// as a SQL condition. +type probeKey struct { + Key string `uri:"id"` +} + +func (s *probeKey) Bind(c *gin.Context) error { return c.ShouldBindUri(s) } +func (s *probeKey) GetId() interface{} { return s.Key } + +func probeDB(t *testing.T, name string, l gormlogger.Interface) *gorm.DB { + t.Helper() + if l == nil { + l = gormlogger.Default.LogMode(gormlogger.Silent) + } + db, err := gorm.Open(sqlite.Open("file:"+name+"?mode=memory&cache=shared"), &gorm.Config{Logger: l}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&probeRow{}); err != nil { + t.Fatal(err) + } + return db +} + +// probeEngine serves the five routes from db, as caller 7. +func probeEngine(db *gorm.DB, register func(r gin.IRoutes)) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + if db != nil { + c.Set("db", db) + } + c.Set(jwtauth.JwtPayloadKey, jwtauth.MapClaims{"identity": float64(7)}) + c.Next() + }) + register(r) + return r +} + +func oldRoutes(r gin.IRoutes) { + r.GET("/x", actions.IndexAction(&probeRow{}, &probeSearch{}, func() interface{} { l := make([]probeRow, 0); return &l })) + r.GET("/x/:id", actions.ViewAction(&probeById{}, func() interface{} { return &probeRow{} })) + r.POST("/x", actions.CreateAction(&probeControl{})) + r.PUT("/x", actions.UpdateAction(&probeControl{})) + r.DELETE("/x", actions.DeleteAction(&probeById{})) +} + +func newRoutes(r gin.IRoutes) { + r.GET("/x", actions.Index[probeRow, probeSearch]()) + r.GET("/x/:id", actions.View[probeRow, probeById]()) + r.POST("/x", actions.Create[probeRow, probeControl]()) + r.PUT("/x", actions.Update[probeRow, probeControl]()) + r.DELETE("/x", actions.Delete[probeRow, probeById]()) +} + +var requestIDField = regexp.MustCompile(`"requestId":"[^"]*"`) + +func serve(r *gin.Engine, method, path, body string) (int, string) { + w := httptest.NewRecorder() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + return w.Code, requestIDField.ReplaceAllString(w.Body.String(), `"requestId":""`) +} + +// The generic actions answer every request exactly as the actions they +// replace, on the same types and the same data, until the old ones go. +func TestGenericActionsAnswerAsTheOldOnesDo(t *testing.T) { + script := []struct{ method, path, body string }{ + {"POST", "/x", `{"name":"a"}`}, + {"POST", "/x", `{"name":"b"}`}, + {"POST", "/x", `not json`}, + {"GET", "/x?pageIndex=1&pageSize=10", ""}, + {"GET", "/x?pageIndex=1&pageSize=1&name=b", ""}, + {"GET", "/x?pageIndex=2&pageSize=1", ""}, + {"GET", "/x/1", ""}, + {"GET", "/x/99", ""}, + {"GET", "/x/abc", ""}, + {"PUT", "/x", `{"id":1,"name":"a2"}`}, + {"PUT", "/x", `{"id":99,"name":"ghost"}`}, + {"PUT", "/x", `{"name":"no-id"}`}, + {"GET", "/x/1", ""}, + {"DELETE", "/x", `{"ids":[2]}`}, + {"DELETE", "/x", `{"ids":[99]}`}, + {"GET", "/x?pageIndex=1&pageSize=10", ""}, + } + old := probeEngine(probeDB(t, t.Name()+"-old", nil), oldRoutes) + gen := probeEngine(probeDB(t, t.Name()+"-new", nil), newRoutes) + for i, s := range script { + oc, ob := serve(old, s.method, s.path, s.body) + nc, nb := serve(gen, s.method, s.path, s.body) + if oc != nc || ob != nb { + t.Errorf("step %d, %s %s %s:\nold %d %s\nnew %d %s", i+1, s.method, s.path, s.body, oc, ob, nc, nb) + } + } +} + +// Every action that reads or changes existing rows applies the caller's +// data permission: Index, View, Update and Delete. +func TestGenericActionsApplyDataPermission(t *testing.T) { + previous := config.ApplicationConfig.EnableDP + config.ApplicationConfig.EnableDP = true + t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous }) + + for _, req := range []struct{ method, path, body string }{ + {"GET", "/x?pageIndex=1&pageSize=10", ""}, + {"GET", "/x/1", ""}, + {"PUT", "/x", `{"id":1,"name":"a"}`}, + {"DELETE", "/x", `{"ids":[1]}`}, + } { + cl := &capturingLogger{Interface: gormlogger.Default.LogMode(gormlogger.Silent)} + db := probeDB(t, strings.NewReplacer("/", "_", "?", "_").Replace(t.Name()+req.method+req.path), cl) + r := probeEngine(db, func(r gin.IRoutes) { + self := func(c *gin.Context) { + c.Set(actions.PermissionKey, &actions.DataPermission{DataScope: actions.DataScopeSelf, UserId: 7}) + } + r.GET("/x", self, actions.Index[probeRow, probeSearch]()) + r.GET("/x/:id", self, actions.View[probeRow, probeById]()) + r.PUT("/x", self, actions.Update[probeRow, probeControl]()) + r.DELETE("/x", self, actions.Delete[probeRow, probeById]()) + }) + serve(r, req.method, req.path, req.body) + if !strings.Contains(cl.all(), "action_probe_row.create_by = ") { + t.Errorf("%s %s ran without the data-permission scope:\n%s", req.method, req.path, cl.all()) + } + } +} + +// A string key is compared as a value. Handed to Where on its own, "1=1" +// is read as a SQL condition and matches every row. +func TestGenericViewMatchesAStringKeyAsAValue(t *testing.T) { + db := probeDB(t, t.Name(), nil) + if err := db.Create(&probeRow{Name: "a"}).Error; err != nil { + t.Fatal(err) + } + r := probeEngine(db, func(r gin.IRoutes) { r.GET("/x/:id", actions.View[probeRow, probeKey]()) }) + + _, body := serve(r, "GET", "/x/1=1", "") + var res struct{ Code int } + _ = json.Unmarshal([]byte(body), &res) + if res.Code != http.StatusNotFound { + t.Errorf("GET /x/1=1 answered %s; want the row not found", body) + } +} + +// Without a database in the request the old actions wrote nothing, which a +// client reads as an empty 200. +func TestGenericActionsAnswerAMissingDatabase(t *testing.T) { + r := probeEngine(nil, newRoutes) + for _, req := range []struct{ method, path, body string }{ + {"GET", "/x?pageIndex=1&pageSize=10", ""}, + {"GET", "/x/1", ""}, + {"POST", "/x", `{"name":"a"}`}, + {"PUT", "/x", `{"id":1}`}, + {"DELETE", "/x", `{"ids":[1]}`}, + } { + _, body := serve(r, req.method, req.path, req.body) + if !strings.Contains(body, `"code":500`) || !strings.Contains(body, "数据库连接获取失败") { + t.Errorf("%s %s answered %q; want a 500 naming the connection", req.method, req.path, body) + } + } +} + +// Concurrent requests to one route each see only what they asked for. The +// generic actions build every value per request, so there is nothing to +// share; this holds that shape. +func TestGenericIndexKeepsConcurrentRequestsApart(t *testing.T) { + db := probeDB(t, t.Name(), nil) + for i := 0; i < 20; i++ { + if err := db.Create(&probeRow{Name: fmt.Sprintf("n%d", i)}).Error; err != nil { + t.Fatal(err) + } + } + r := probeEngine(db, newRoutes) + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + name := fmt.Sprintf("n%d", i) + wg.Go(func() { + for j := 0; j < 10; j++ { + _, body := serve(r, "GET", "/x?pageIndex=1&pageSize=10&name="+name, "") + var res struct { + Data struct{ List []probeRow } + } + if err := json.Unmarshal([]byte(body), &res); err != nil { + t.Errorf("decoding %q: %v", body, err) + return + } + if len(res.Data.List) != 1 || res.Data.List[0].Name != name { + t.Errorf("asked for %s, got %+v", name, res.Data.List) + return + } + } + }) + } + wg.Wait() +} diff --git a/common/actions/index.go b/common/actions/index.go index 17758c65..fa7a5c66 100644 --- a/common/actions/index.go +++ b/common/actions/index.go @@ -15,6 +15,10 @@ import ( ) // IndexAction 通用查询动作 +// +// Deprecated: Use Index[Model, Search](). It builds its values per request, so the +// Generate() copies this action depends on are not needed, and a model +// paired with the wrong request does not compile. See generic.go. func IndexAction(m models.ActiveRecord, d dto.Index, f func() interface{}) gin.HandlerFunc { return func(c *gin.Context) { db, err := pkg.GetOrm(c) @@ -39,7 +43,7 @@ func IndexAction(m models.ActiveRecord, d dto.Index, f func() interface{}) gin.H //数据权限检查 p := GetPermissionFromContext(c) - err = db.WithContext(c).Model(object). + err = db.WithContext(c.Request.Context()).Model(object). Scopes( dto.MakeCondition(req.GetNeedSearch()), dto.Paginate(req.GetPageSize(), req.GetPageIndex()), diff --git a/common/actions/testdata/matched/matched.go b/common/actions/testdata/matched/matched.go new file mode 100644 index 00000000..64d80265 --- /dev/null +++ b/common/actions/testdata/matched/matched.go @@ -0,0 +1,11 @@ +// Package matched is mismatch with the pair corrected: the control that +// shows mismatch fails for the pairing, not for how it is built. +package matched + +import ( + "go-admin/app/demo/models" + "go-admin/app/demo/service/dto" + "go-admin/common/actions" +) + +var _ = actions.Create[models.DemoProduct, dto.DemoProductControl] diff --git a/common/actions/testdata/mismatch/mismatch.go b/common/actions/testdata/mismatch/mismatch.go new file mode 100644 index 00000000..99eac73d --- /dev/null +++ b/common/actions/testdata/mismatch/mismatch.go @@ -0,0 +1,12 @@ +// Package mismatch pairs a model with another model's request, which must +// not compile. TestGenericActionsRefuseAMismatchedPair builds it and expects +// the build to fail; testdata keeps it out of ./... . +package mismatch + +import ( + "go-admin/app/demo/models" + jobdto "go-admin/app/jobs/service/dto" + "go-admin/common/actions" +) + +var _ = actions.Create[models.DemoProduct, jobdto.SysJobControl] diff --git a/common/actions/update.go b/common/actions/update.go index 2c0afde6..6e849813 100644 --- a/common/actions/update.go +++ b/common/actions/update.go @@ -14,6 +14,10 @@ import ( ) // UpdateAction 通用更新动作 +// +// Deprecated: Use Update[Model, Control](). It builds its values per request, so the +// Generate() copies this action depends on are not needed, and a model +// paired with the wrong request does not compile. See generic.go. func UpdateAction(control dto.Control) gin.HandlerFunc { return func(c *gin.Context) { db, err := pkg.GetOrm(c) @@ -41,7 +45,7 @@ func UpdateAction(control dto.Control) gin.HandlerFunc { //数据权限检查 p := GetPermissionFromContext(c) - db = db.WithContext(c).Scopes( + db = db.WithContext(c.Request.Context()).Scopes( Permission(object.TableName(), p), ).Where(req.GetId()).Updates(object) if err = db.Error; err != nil { diff --git a/common/actions/view.go b/common/actions/view.go index 03d26821..60480f8c 100644 --- a/common/actions/view.go +++ b/common/actions/view.go @@ -15,6 +15,11 @@ import ( ) // ViewAction 通用详情动作 +// +// Deprecated: Use View[Model, ById](), or ViewAs[Model, ById, Response]() +// where f returned another type. It builds its values per request, so the +// Generate() copies this action depends on are not needed, and a model +// paired with the wrong request does not compile. See generic.go. func ViewAction(control dto.Control, f func() interface{}) gin.HandlerFunc { return func(c *gin.Context) { db, err := pkg.GetOrm(c) @@ -48,7 +53,7 @@ func ViewAction(control dto.Control, f func() interface{}) gin.HandlerFunc { //数据权限检查 p := GetPermissionFromContext(c) - err = db.Model(object).WithContext(c).Scopes( + err = db.Model(object).WithContext(c.Request.Context()).Scopes( Permission(object.TableName(), p), ).Where(req.GetId()).First(rsp).Error diff --git a/common/middleware/db.go b/common/middleware/db.go index 905eb04e..06d2bcc7 100644 --- a/common/middleware/db.go +++ b/common/middleware/db.go @@ -6,6 +6,6 @@ import ( ) func WithContextDb(c *gin.Context) { - c.Set("db", sdk.Runtime.GetDbByTenant(c.Request.Host).WithContext(c)) + c.Set("db", sdk.Runtime.GetDbByTenant(c.Request.Host).WithContext(c.Request.Context())) c.Next() } diff --git a/common/middleware/db_test.go b/common/middleware/db_test.go new file mode 100644 index 00000000..c150fb76 --- /dev/null +++ b/common/middleware/db_test.go @@ -0,0 +1,60 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/go-admin-team/go-admin-core/v2/sdk" + "gorm.io/gorm" +) + +// database/sql watches the context of a query that returns rows inside a +// transaction from a goroutine of its own, which can still be reading it +// after the handler has returned. GORM wraps every write in a transaction, +// and an INSERT on SQLite or PostgreSQL returns the new key as a row. gin +// reuses its Context for the next request as soon as the handler returns, so +// a query given the gin.Context races with that reuse; given the request's +// own context, it does not. Sequential requests are enough for the reuse, +// and -race, which make test runs with, reports it. +type probeRow struct { + Id int `gorm:"primaryKey;autoIncrement"` +} + +func (probeRow) TableName() string { return "with_context_db_probe" } + +func TestWithContextDbDoesNotHandQueriesThePooledContext(t *testing.T) { + const host = "with-context-db.test" + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&probeRow{}); err != nil { + t.Fatal(err) + } + previous := sdk.Runtime.GetDbByTenant(host) + sdk.Runtime.SetDbByTenant(host, db) + t.Cleanup(func() { sdk.Runtime.SetDbByTenant(host, previous) }) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(WithContextDb) + r.GET("/", func(c *gin.Context) { + if err := c.MustGet("db").(*gorm.DB).Create(&probeRow{}).Error; err != nil { + c.Status(http.StatusInternalServerError) + return + } + c.Status(http.StatusOK) + }) + for i := 0; i < 20; i++ { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = host + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("request %d answered %d", i, w.Code) + } + } +} diff --git a/docs/contract.md b/docs/contract.md index 599e7f7f..d0a88070 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -356,11 +356,11 @@ if res.RowsAffected == 0 { return ErrAlreadyPaid } // 别人先改了 | `api.Api` | core `sdk/api` | 一条链式糖:`MakeContext` / `Bind` / `MakeOrm` / `OK` / `PageOK` / `Error` | | `service.Service` | core `sdk/service` | 一个装 `Orm` / `Log` / `Cache` / `Error` 的结构体加一个 `AddError` | | `MakeCondition` / `search` tag | core `sdk/contract/dto` | 把 DTO 上的 `search:"type:exact;column:name;table:xx"` 翻成 WHERE | -| 通用 CRUD Action | go-admin `common/actions` | `IndexAction` 等五个。**留在 go-admin,没有下沉** | +| 通用 CRUD Action | go-admin `common/actions` | 泛型的 `Index` / `View` / `ViewAs` / `Create` / `Update` / `Delete`(旧的 `IndexAction` 等五个已标 Deprecated)。**留在 go-admin,没有下沉** | 最后一行是有意的:CRUD Action 是最需要演进的一类东西(分页参数、批量操作、 软删语义、字段级权限),而 core 的每一个导出都是永久承诺——放进去容易, -拿出来不可能。想用就把那 294 行抄走,抄走的那份还能按你自己的需要改。 +拿出来不可能。想用就把 `common/actions` 抄走,抄走的那份还能按你自己的需要改。 主仓唯一的真实业务模块 `app/admin` **一个 CRUD Action 都没用**,全是手写 Service。 `MakeCondition` 返回的是 `func(db *gorm.DB) *gorm.DB` 闭包,方言从闭包里那个