mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-21 18:20:50 +00:00
feat✨: 新增 app/demo 标准 CRUD 参照模块
作为编码约定的可执行参照物:文档会滞后,而这个模块过时会导致构建或测试 失败,因此以它为准。 目录骨架与自动注册文件由项目自带的脚手架生成: go run main.go app -n demo 它同时产出 cmd/api/demo.go,其中的 init() 将路由追加进 AppRouters, 无需在任何中心文件手工登记。 模块本身演示了单表 CRUD 的推荐写法——直接使用 common/actions 提供的五个 通用 Action,因此只有 model、dto、router 三个业务文件,没有 apis 与 service。手写 Handler 的场景仅在业务超出单表 CRUD 时才需要。 DTO 中详情/删除入参内嵌 dto.ObjectById 以复用其 Bind 与 GetId,不重复 实现 uri 绑定与批量 ids 合并逻辑。 补充 8 项测试锁定通用 Action 的接口约束,其中最关键的是 Generate() 必须 返回副本——Action 在并发请求间复用实例,就地返回会串数据。反向验证:将 Generate 改为就地返回,测试立即失败。
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go-admin/common/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DemoProduct 示例模型
|
||||||
|
//
|
||||||
|
// 内嵌 ControlBy 与 ModelTime 后,创建人/更新人与时间戳由框架自动维护;
|
||||||
|
// 数据权限(actions.Permission)正是按 create_by 过滤,缺少 ControlBy 会使其失效。
|
||||||
|
type DemoProduct struct {
|
||||||
|
models.Model
|
||||||
|
|
||||||
|
Name string `json:"name" gorm:"size:128;comment:名称"`
|
||||||
|
Code string `json:"code" gorm:"size:64;comment:编码"`
|
||||||
|
Price float64 `json:"price" gorm:"comment:单价"`
|
||||||
|
Status string `json:"status" gorm:"size:4;comment:状态"`
|
||||||
|
Remark string `json:"remark" gorm:"size:255;comment:备注"`
|
||||||
|
|
||||||
|
models.ControlBy
|
||||||
|
models.ModelTime
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||||
|
|
||||||
|
"go-admin/app/demo/models"
|
||||||
|
"go-admin/app/demo/service/dto"
|
||||||
|
"go-admin/common/actions"
|
||||||
|
"go-admin/common/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 路由通过 init 自注册,无需在任何中心文件登记。
|
||||||
|
// 新建应用时用 `go run main.go app -n <名称>` 生成骨架,
|
||||||
|
// 它会同时产出 cmd/api/<名称>.go 完成注册。
|
||||||
|
func init() {
|
||||||
|
routerCheckRole = append(routerCheckRole, registerDemoProductRouter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerDemoProductRouter 标准 CRUD 的推荐写法。
|
||||||
|
//
|
||||||
|
// 五个通用 Action 覆盖了增删改查的全部样板逻辑——参数绑定、数据权限过滤、
|
||||||
|
// 操作人注入、分页、错误响应,因此本模块没有 apis 与 service 文件。
|
||||||
|
//
|
||||||
|
// 仅当业务逻辑超出单表 CRUD(如跨表事务、外部调用、复杂校验)时,才需要
|
||||||
|
// 自行编写 Handler 与 Service,写法参照 app/admin/apis/sys_post.go。
|
||||||
|
func registerDemoProductRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||||
|
r := v1.Group("/demo-product").
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
_ "github.com/gin-gonic/gin"
|
||||||
|
log "github.com/go-admin-team/go-admin-core/logger"
|
||||||
|
"github.com/go-admin-team/go-admin-core/sdk"
|
||||||
|
// "github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||||
|
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||||
|
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||||
|
common "go-admin/common/middleware"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
routerNoCheckRole = make([]func(*gin.RouterGroup), 0)
|
||||||
|
routerCheckRole = make([]func(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware), 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitRouter 路由初始化
|
||||||
|
func InitRouter() {
|
||||||
|
var r *gin.Engine
|
||||||
|
h := sdk.Runtime.GetEngine()
|
||||||
|
if h == nil {
|
||||||
|
h = gin.New()
|
||||||
|
sdk.Runtime.SetEngine(h)
|
||||||
|
}
|
||||||
|
switch h.(type) {
|
||||||
|
case *gin.Engine:
|
||||||
|
r = h.(*gin.Engine)
|
||||||
|
default:
|
||||||
|
log.Fatal("not support other engine")
|
||||||
|
os.Exit(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the jwt middleware
|
||||||
|
authMiddleware, err := common.AuthInit()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("JWT Init Error, %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册业务路由
|
||||||
|
InitBusinessRouter(r, authMiddleware)
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitBusinessRouter(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) *gin.Engine {
|
||||||
|
|
||||||
|
// 无需认证的路由
|
||||||
|
noCheckRoleRouter(r)
|
||||||
|
// 需要认证的路由
|
||||||
|
checkRoleRouter(r, authMiddleware)
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// noCheckRoleRouter 无需认证的路由
|
||||||
|
func noCheckRoleRouter(r *gin.Engine) {
|
||||||
|
// 可根据业务需求来设置接口版本
|
||||||
|
v := r.Group("/api/v1")
|
||||||
|
|
||||||
|
for _, f := range routerNoCheckRole {
|
||||||
|
f(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRoleRouter 需要认证的路由
|
||||||
|
func checkRoleRouter(r *gin.Engine, authMiddleware *jwtauth.GinJWTMiddleware) {
|
||||||
|
// 可根据业务需求来设置接口版本
|
||||||
|
v := r.Group("/api/v1")
|
||||||
|
|
||||||
|
for _, f := range routerCheckRole {
|
||||||
|
f(v, authMiddleware)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"go-admin/app/demo/models"
|
||||||
|
"go-admin/common/dto"
|
||||||
|
common "go-admin/common/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DemoProductSearch 列表查询条件
|
||||||
|
//
|
||||||
|
// search tag 决定 MakeCondition 拼出的 WHERE:
|
||||||
|
//
|
||||||
|
// exact 精确匹配 / icontains 忽略大小写模糊 / gte 大于等于 …
|
||||||
|
//
|
||||||
|
// 未打 search tag 的字段不参与查询,可避免无意间开放过滤维度。
|
||||||
|
type DemoProductSearch struct {
|
||||||
|
dto.Pagination `search:"-"`
|
||||||
|
|
||||||
|
Name string `form:"name" search:"type:icontains;column:name;table:demo_product"`
|
||||||
|
Code string `form:"code" search:"type:exact;column:code;table:demo_product"`
|
||||||
|
Status string `form:"status" search:"type:exact;column:status;table:demo_product"`
|
||||||
|
|
||||||
|
DemoProductOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
// DemoProductOrder 排序字段单独成组,避免与查询字段混在一起
|
||||||
|
type DemoProductOrder struct {
|
||||||
|
CreatedAtOrder string `form:"createdAtOrder" search:"type:order;column:created_at;table:demo_product"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DemoProductSearch) GetNeedSearch() interface{} { return *m }
|
||||||
|
|
||||||
|
func (m *DemoProductSearch) Bind(ctx *gin.Context) error {
|
||||||
|
return ctx.ShouldBind(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DemoProductSearch) Generate() dto.Index {
|
||||||
|
o := *m
|
||||||
|
return &o
|
||||||
|
}
|
||||||
|
|
||||||
|
// DemoProductControl 新增与修改共用的入参
|
||||||
|
//
|
||||||
|
// 通用 Action(Create / Update)通过 GenerateM 拿到落库对象,
|
||||||
|
// 因此这里不直接暴露 Model,字段校验用 validate tag 声明。
|
||||||
|
type DemoProductControl struct {
|
||||||
|
Id int `json:"id" comment:"主键"`
|
||||||
|
Name string `json:"name" comment:"名称" validate:"required"`
|
||||||
|
Code string `json:"code" comment:"编码" validate:"required"`
|
||||||
|
Price float64 `json:"price" comment:"单价" validate:"gte=0"`
|
||||||
|
Status string `json:"status" comment:"状态"`
|
||||||
|
Remark string `json:"remark" comment:"备注"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return &models.DemoProduct{
|
||||||
|
Model: common.Model{Id: s.Id},
|
||||||
|
Name: s.Name,
|
||||||
|
Code: s.Code,
|
||||||
|
Price: s.Price,
|
||||||
|
Status: s.Status,
|
||||||
|
Remark: s.Remark,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DemoProductById 详情与删除共用,支持单个 id 与批量 ids
|
||||||
|
type DemoProductById struct {
|
||||||
|
dto.ObjectById
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
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) {
|
||||||
|
c := &DemoProductControl{Id: 42, Name: "示例", Code: "P-42", Price: 9.9}
|
||||||
|
m, err := c.GenerateM()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateM 返回错误: %v", err)
|
||||||
|
}
|
||||||
|
p, ok := m.(*models.DemoProduct)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("GenerateM 返回类型错误: %T", m)
|
||||||
|
}
|
||||||
|
if p.Id != 42 {
|
||||||
|
t.Errorf("主键未传递: got %d, want 42", p.Id)
|
||||||
|
}
|
||||||
|
if p.Name != "示例" || p.Code != "P-42" || p.Price != 9.9 {
|
||||||
|
t.Errorf("字段映射有误: %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableName(t *testing.T) {
|
||||||
|
if got := (models.DemoProduct{}).TableName(); got != "demo_product" {
|
||||||
|
t.Errorf("TableName() = %q, want %q", got, "demo_product")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "go-admin/app/demo/router"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
//注册路由 fixme 其他应用的路由,在本目录新建文件放在init方法
|
||||||
|
AppRouters = append(AppRouters, router.InitRouter)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user