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:
zhangwenjian
2026-08-14 16:46:09 +08:00
parent e8c2e0a966
commit d1f5fe5681
6 changed files with 355 additions and 0 deletions
+37
View File
@@ -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
}
+49
View File
@@ -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)))
}
}
+74
View File
@@ -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)
}
}
+96
View File
@@ -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 新增与修改共用的入参
//
// 通用 ActionCreate / 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
}
+91
View File
@@ -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")
}
}
+8
View File
@@ -0,0 +1,8 @@
package api
import "go-admin/app/demo/router"
func init() {
//注册路由 fixme 其他应用的路由,在本目录新建文件放在init方法
AppRouters = append(AppRouters, router.InitRouter)
}