Files
go-admin/common/actions/generic.go
T
zhangwenjian 024dd047ae feat✨: add generic CRUD actions
Index, View, ViewAs, Create, Update and Delete take the model and the
request as type parameters. They serve the same routes as IndexAction
and its siblings and answer them the same way; what they drop is what
the caller had to get right.

The older actions serve every request from instances passed at
registration, so each model and request type needs a Generate that
returns a copy, and a list route takes a func() interface{} whose
element type nothing checks. Here every request declares its own
values, and a model paired with the wrong request does not compile.
ViewAs answers a detail route with a type other than the model, as
ViewAction's f did.

Beyond that they differ in three places, all where the older ones were
wrong: a request with no database is answered with 500 instead of an
empty 200; the key is matched against the primary-key column as a
value, not handed to Where, which reads a string as SQL; and there is
no GenerateM whose error could be dropped.

A test runs the old and new actions on the same types through one
request script and requires every response to match.
2026-09-27 20:46:01 +08:00

295 lines
8.8 KiB
Go

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