mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-25 11:31:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5864058a81 | ||
|
|
45035a16e4 | ||
|
|
f4d0108d49 | ||
|
|
b81611ba72 | ||
|
|
bb34108831 | ||
|
|
b7fd92f39b | ||
|
|
63b800a3ba | ||
|
|
ed9450a2d5 | ||
|
|
1d551a10ab | ||
|
|
4d7c9e5a12 | ||
|
|
d1f5fe5681 | ||
|
|
e8c2e0a966 | ||
|
|
cef0a19a9c | ||
|
|
c0e81363dc | ||
|
|
df2e4a2b48 | ||
|
|
9088ebc2e1 | ||
|
|
9f2dec3036 | ||
|
|
ea049f9b06 | ||
|
|
1f1349a685 | ||
|
|
dcef2df38e |
@@ -29,7 +29,12 @@ jobs:
|
||||
- name: Build
|
||||
run: env CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -tags "sqlite3,json1" --ldflags "-extldflags -static" -o main .
|
||||
|
||||
# 以下推镜像与重启步骤仅在 master 收到 push 时执行。
|
||||
# pull_request 事件同样会触发本工作流,若不加限制,任何指向 master 的
|
||||
# PR 一经创建就会把 PR 分支的镜像推上仓库,并直接重启线上 API 服务,
|
||||
# 且发生在合并之前。构建与编译校验不受影响,PR 仍会执行。
|
||||
- name: Build the Docker image and push
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
run: |
|
||||
docker login --username=${{ secrets.DOCKER_USERNAME }} registry.ap-northeast-1.aliyuncs.com --password=${{ secrets.DOCKER_PASSWORD }}
|
||||
echo "************ docker login end"
|
||||
@@ -43,6 +48,7 @@ jobs:
|
||||
echo "************ docker push end"
|
||||
|
||||
- name: Restart server # 第五步,重启服务
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
env:
|
||||
GITHUB_SHA_X: ${GITHUB_SHA}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
.idea
|
||||
.vscode
|
||||
*/.DS_Store
|
||||
.DS_Store
|
||||
static/uploadfile
|
||||
main.exe
|
||||
*.exe
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
# AGENTS.md — go-admin 后端
|
||||
|
||||
> 给 AI 编码工具与新贡献者的约定。**只写"不遵守就会出错"的规则**;技术栈版本以
|
||||
> `go.mod` 为准,命令以 `Makefile` 为准,此处不复述,避免与代码脱节。
|
||||
>
|
||||
> 标准 CRUD 模块的完整写法见 **`app/demo/`** —— 那是可编译、有测试、CI 会跑的参照物。
|
||||
> 本文与它冲突时,以 `app/demo/` 为准。
|
||||
|
||||
## 分层
|
||||
|
||||
```
|
||||
Router → Api → Service → Model
|
||||
路由注册 参数绑定 业务逻辑 GORM 结构体
|
||||
中间件链 调用 Service 操作数据库 TableName()
|
||||
```
|
||||
|
||||
对应目录:`app/{模块}/router|apis|service|models`,DTO 位于 `service/dto`。
|
||||
|
||||
**不可跨层**:Api 不直接操作 `Orm`,Service 不接触 `gin.Context`。
|
||||
|
||||
## 优先使用通用 Action
|
||||
|
||||
单表 CRUD **不要手写 Handler 与 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)))
|
||||
}
|
||||
```
|
||||
|
||||
这样一个模块只需 **model + dto + router** 三个文件,完整示例见 `app/demo/`。
|
||||
|
||||
使用通用 Action 的前提:
|
||||
|
||||
- Model 实现 `models.ActiveRecord`(`Generate` / `GetId` / `TableName`)
|
||||
- 列表 DTO 实现 `dto.Index`,增改删 DTO 实现 `dto.Control`
|
||||
- **所有 `Generate()` 必须返回副本** —— Action 在并发请求间复用实例,
|
||||
就地返回会串数据(`app/demo` 的测试锁定了这一点)
|
||||
- 详情/删除 DTO 内嵌 `dto.ObjectById` 即可继承 `Bind` 与 `GetId`,无需重写
|
||||
|
||||
仅当业务超出单表 CRUD(跨表事务、外部调用、复杂校验)时才自行编写 Handler
|
||||
与 Service,写法见下。
|
||||
|
||||
## Api 层(仅在通用 Action 不适用时)
|
||||
|
||||
结构体嵌入 `api.Api`,链式初始化后**必须检查 `Errors`**:
|
||||
|
||||
```go
|
||||
func (e SysPost) GetPage(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req := dto.SysPostPageReq{}
|
||||
err := e.MakeContext(c).MakeOrm().Bind(&req, binding.Form).MakeService(&s.Service).Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
// ... 调用 s.GetPage(...)
|
||||
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
|
||||
}
|
||||
```
|
||||
|
||||
响应一律走 `e.OK` / `e.PageOK` / `e.Error`,不要自行 `c.JSON`。
|
||||
|
||||
## Service 层(仅在通用 Action 不适用时)
|
||||
|
||||
结构体嵌入 `service.Service`(持有 `Orm` 与 `Log`)。查询通过 Scopes 组合:
|
||||
|
||||
```go
|
||||
err = e.Orm.Model(&data).Scopes(
|
||||
cDto.MakeCondition(c.GetNeedSearch()), // 由 search tag 生成 WHERE
|
||||
cDto.Paginate(c.GetPageSize(), c.GetPageIndex()),
|
||||
actions.Permission(data.TableName(), p), // 数据权限,列表/详情必须带
|
||||
).Find(list).Limit(-1).Offset(-1).Count(count).Error
|
||||
```
|
||||
|
||||
**遗漏 `actions.Permission` 会使数据权限配置静默失效** —— 这是最容易出的错。
|
||||
|
||||
错误一律 `return err` 向上传递,日志用 `e.Log.Errorf`,不使用 `panic`。
|
||||
|
||||
## DTO
|
||||
|
||||
搜索条件由 tag 声明,`MakeCondition` 据此拼 SQL:
|
||||
|
||||
```go
|
||||
type SysPostPageReq struct {
|
||||
dto.Pagination `search:"-"`
|
||||
PostName string `form:"postName" search:"type:contains;column:post_name;table:sys_post"`
|
||||
}
|
||||
func (m *SysPostPageReq) GetNeedSearch() interface{} { return *m }
|
||||
```
|
||||
|
||||
`type` 可选:`exact` `iexact` `contains` `gt` `gte` `lt` `lte` `order` `left`(联表)。
|
||||
|
||||
## Model
|
||||
|
||||
```go
|
||||
type SysPost struct {
|
||||
PostId int `gorm:"primaryKey;autoIncrement" json:"postId"`
|
||||
// ... 业务字段
|
||||
models.ControlBy // CreateBy / UpdateBy
|
||||
models.ModelTime // CreatedAt / UpdatedAt / DeletedAt
|
||||
}
|
||||
func (SysPost) TableName() string { return "sys_post" }
|
||||
```
|
||||
|
||||
`TableName()` 必须显式声明(GORM 配置了 `SingularTable`,不会自动推导复数)。
|
||||
|
||||
## 路由注册
|
||||
|
||||
通过 `init()` 自注册,不在中心文件手工添加:
|
||||
|
||||
```go
|
||||
func init() { routerCheckRole = append(routerCheckRole, registerSysPostRouter) }
|
||||
|
||||
func registerSysPostRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
api := apis.SysPost{}
|
||||
r := v1.Group("/post").
|
||||
Use(authMiddleware.MiddlewareFunc()).
|
||||
Use(middleware.AuthCheckRole()). // Casbin 鉴权
|
||||
Use(actions.PermissionAction()) // 注入数据权限
|
||||
{ r.GET("", api.GetPage); r.POST("", api.Insert); /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
新增路由文件后,需确认 `cmd/api/` 中已用 `_` 导入该包。
|
||||
|
||||
## 命名
|
||||
|
||||
| 对象 | 规则 | 示例 |
|
||||
|---|---|---|
|
||||
| 数据表 | `sys_` 前缀 + 下划线 | `sys_post` |
|
||||
| API 路径 | `/api/v1/` + kebab-case | `/api/v1/sys-user` |
|
||||
| DTO | `{Model}{Action}Req` | `SysPostPageReq` |
|
||||
| 权限标识 | `模块:资源:操作` | `admin:sysPost:add` |
|
||||
|
||||
权限标识需与前端 `v-permisaction` 一致,并写入 `sys_menu` 种子数据。
|
||||
|
||||
## Swagger
|
||||
|
||||
Handler 必须带完整注解,`go generate` 会据此生成文档:
|
||||
|
||||
```go
|
||||
// @Summary 岗位列表
|
||||
// @Tags 岗位
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /api/v1/post [get]
|
||||
// @Security Bearer
|
||||
```
|
||||
|
||||
## 本地运行
|
||||
|
||||
**配置 `driver: sqlite3` 时必须带构建标签**,否则启动即 panic:
|
||||
|
||||
```bash
|
||||
go run -tags sqlite3 . migrate -c config/settings.sqlite.yml
|
||||
go run -tags sqlite3 . server -c config/settings.sqlite.yml
|
||||
```
|
||||
|
||||
原因:`common/database/open.go` 带 `//go:build !sqlite3`,不加标签时编进的是
|
||||
不含 sqlite3 的版本,`opens["sqlite3"]` 为 nil,调用时在 nil 函数上崩溃。
|
||||
报错信息不会提到构建标签,容易误判成环境损坏。MySQL / PostgreSQL 无此问题。
|
||||
|
||||
对应 `Makefile` 的 `build-sqlite` 目标。
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
文件名前 13 位为时间戳版本号。**已执行过的迁移文件不可修改** ——
|
||||
`sys_migration` 表按版本号去重,改动不会重跑,只能新增一个迁移来修正。
|
||||
|
||||
放哪个目录取决于身份:
|
||||
|
||||
| 目录 | 用途 | 是否入库 |
|
||||
|---|---|---|
|
||||
| `version/` | 框架自带迁移,随仓库分发给所有使用者 | 是 |
|
||||
| `version-local/` | 使用者自己项目的迁移 | 否(已在 `.gitignore`) |
|
||||
|
||||
**向本仓库提交迁移必须放 `version/`** —— 放进 `version-local/` 会被忽略掉,
|
||||
`git status` 看不到,PR 里也不会出现。两个目录的包名分别是 `version` 与
|
||||
`version_local`(后者与目录名不一致,因为标识符不能含连字符)。
|
||||
|
||||
## 提交规范
|
||||
|
||||
格式 `type+emoji: 描述`:
|
||||
|
||||
`feat✨` `fix🐛` `style💄` `docs📝` `perf👌` `test✅` `refactor🎨` `chore🔧`
|
||||
|
||||
一个提交只做一件事。改动跨越多个语义时拆分提交,不要混在一起。
|
||||
|
||||
## 红线
|
||||
|
||||
- 不使用全局 DB 变量,一律用 `e.Orm`(来自请求上下文,多租户依赖它)
|
||||
- 不在 Service 中引用 `gin.Context`
|
||||
- 生产部署前确认 `mode: prod` 且已修改 `jwt.secret`(dev 模式下 token 几乎不过期)
|
||||
- 不提交 `config/settings.yml` 中的真实凭据
|
||||
@@ -21,7 +21,7 @@ run:
|
||||
# 进入到项目根目录 执行 make run 命令
|
||||
@docker-compose up -d
|
||||
|
||||
# 启动方式二 docker run 这里注意-v挂载的宿主机的地址改为部署时的实际决对路径
|
||||
# 启动方式二 docker run 这里注意-v挂载的宿主机的地址改为部署时的实际绝对路径
|
||||
#@docker run --name=go-admin -p 8000:8000 -v /home/code/go/src/go-admin/go-admin/config:/go-admin-api/config -v /home/code/go/src/go-admin/go-admin-api/static:/go-admin/static -v /home/code/go/src/go-admin/go-admin/temp:/go-admin-api/temp -d --restart=always go-admin:latest
|
||||
|
||||
@echo "go-admin service is running..."
|
||||
|
||||
+1
-4
@@ -19,10 +19,7 @@
|
||||
|
||||
## 🎬 在线体验
|
||||
|
||||
Element UI vue体验:[https://vue2.go-admin.dev](https://vue2.go-admin.dev/#/login)
|
||||
> ⚠️⚠️⚠️ 账号 / 密码: admin / 123456
|
||||
|
||||
Arco Design vue3 demo:[https://vue3.go-admin.dev](https://vue3.go-admin.dev/#/login)
|
||||
Element Plus vue3 体验:[https://vue.go-admin.pro](https://vue.go-admin.pro/#/login)
|
||||
> ⚠️⚠️⚠️ 账号 / 密码: admin / 123456
|
||||
|
||||
antd体验:[https://antd.go-admin.pro](https://antd.go-admin.pro/)
|
||||
|
||||
@@ -20,10 +20,7 @@ The front-end and back-end separation authority management system based on Gin +
|
||||
|
||||
## 🎬 Online Demo
|
||||
|
||||
Element UI vue demo:[https://vue2.go-admin.dev](https://vue2.go-admin.dev/#/login)
|
||||
> 账号 / 密码: admin / 123456
|
||||
|
||||
Arco Design vue3 demo:[https://vue3.go-admin.dev](https://vue3.go-admin.dev/#/login)
|
||||
Element Plus vue3 demo:[https://vue.go-admin.pro](https://vue.go-admin.pro/#/login)
|
||||
> 账号 / 密码: admin / 123456
|
||||
|
||||
antd demo:[https://antd.go-admin.pro](https://antd.go-admin.pro/)
|
||||
|
||||
@@ -11,10 +11,11 @@ const INDEX = `
|
||||
<meta charset="utf-8">
|
||||
<title>GO-ADMIN欢迎您</title>
|
||||
<style>
|
||||
body{
|
||||
margin:0;
|
||||
padding:0;
|
||||
overflow-y:hidden
|
||||
html,body{
|
||||
margin:0;
|
||||
padding:0;
|
||||
height:100%;
|
||||
overflow-y:hidden;
|
||||
}
|
||||
</style>
|
||||
<script src="https://libs.baidu.com/jquery/1.9.0/jquery.js"></script>
|
||||
@@ -28,7 +29,7 @@ $(function(){
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<iframe id="iframe" frameborder="0" src="https://www.go-admin.pro" style="width:100%;"></iframe>
|
||||
<iframe id="iframe" frameborder="0" src="https://www.go-admin.pro" style="width:100%;height:100%;"></iframe>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
@@ -69,8 +69,15 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
v1.POST("/login", authMiddleware.LoginHandler)
|
||||
// Refresh time can be longer than token timeout
|
||||
v1.GET("/refresh_token", authMiddleware.RefreshHandler)
|
||||
// GET /api/v1/refresh_token 已移除,原因见 issue #820:
|
||||
// 该接口用业务 token 即可换取新 token,而续期上限 MaxRefresh 依据的
|
||||
// orig_iat 在每次续期时被一并重置,上限永远无法到达 —— token 一旦泄
|
||||
// 露即等同于永久访问权。它此前还位于 CasbinExclude 中,任何角色的已
|
||||
// 登录用户都能调用,不受权限约束。
|
||||
//
|
||||
// 官方前端从未调用该接口(store 中的 refreshToken action 无人 dispatch),
|
||||
// 移除不影响正常使用。若确需无感续期,应在 go-admin-core 中区分
|
||||
// access token 与 refresh token 后重新实现,而非沿用此路由。
|
||||
}
|
||||
registerBaseRouter(v1, authMiddleware)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/demo/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// 示例模块的建表迁移。
|
||||
//
|
||||
// 迁移文件名前 13 位是版本号(Unix 毫秒时间戳),框架按文件名升序执行,
|
||||
// 已执行的版本记录在 sys_migration 表中,不会重复运行。
|
||||
//
|
||||
// 本文件放在 version/ 是因为它随框架一起分发;**业务项目自己的迁移应放
|
||||
// version-local/**,该目录已被 .gitignore 忽略,不会与上游冲突。
|
||||
//
|
||||
// 生成新的迁移骨架:go run main.go migrate -c config/settings.yml -g
|
||||
//
|
||||
// 注意:已执行过的迁移文件不可再修改——版本号已入表,改动不会重跑。
|
||||
// 需要调整时新建一个迁移。
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700000000DemoProduct)
|
||||
}
|
||||
|
||||
func _1786700000000DemoProduct(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
// 1. 建表
|
||||
if err := tx.Migrator().AutoMigrate(new(models.DemoProduct)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 初始化数据(可选)。此处留空,示例模块不写入业务数据。
|
||||
|
||||
// 3. 记录版本号——必须,否则该迁移每次启动都会重跑
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
"go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// 为 app/demo 模块写入菜单、接口与权限种子数据。
|
||||
//
|
||||
// 一个业务模块要在界面上可用,需要四类数据:
|
||||
//
|
||||
// 1. sys_api —— 后端路由的登记,Casbin 据此判定权限
|
||||
// 2. sys_menu —— 侧边栏菜单(目录 M / 菜单 C / 按钮 F)
|
||||
// 3. sys_menu_api_rule —— 菜单与接口的多对多关联,角色保存时据此生成策略
|
||||
// 4. casbin_rule —— 实际生效的权限策略
|
||||
//
|
||||
// 注意 casbin_rule 才是 adapter 实际使用的表;models.CasbinRule 对应的
|
||||
// sys_casbin_rule 是历史遗留,其 7 列 size:512 唯一索引在 MySQL 下会超出
|
||||
// 索引长度限制,不要将其纳入 AutoMigrate。
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700001000DemoMenu)
|
||||
}
|
||||
|
||||
const (
|
||||
demoMenuId = 9000 // 目录:示例模块
|
||||
demoProductId = 9001 // 菜单:商品管理
|
||||
demoApiBaseId = 9000 // sys_api 起始 id
|
||||
adminRoleKey = "admin"
|
||||
)
|
||||
|
||||
func _1786700001000DemoMenu(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
// ---- 1. 接口登记 ----
|
||||
apis := []models.SysApi{
|
||||
{Id: demoApiBaseId + 1, Handle: "go-admin/common/actions.IndexAction.func1", Title: "示例商品列表", Path: "/api/v1/demo-product", Type: "SYS", Action: "GET"},
|
||||
{Id: demoApiBaseId + 2, Handle: "go-admin/common/actions.ViewAction.func1", Title: "示例商品详情", Path: "/api/v1/demo-product/:id", Type: "SYS", Action: "GET"},
|
||||
{Id: demoApiBaseId + 3, Handle: "go-admin/common/actions.CreateAction.func1", Title: "示例商品新增", Path: "/api/v1/demo-product", Type: "SYS", Action: "POST"},
|
||||
{Id: demoApiBaseId + 4, Handle: "go-admin/common/actions.UpdateAction.func1", Title: "示例商品修改", Path: "/api/v1/demo-product/:id", Type: "SYS", Action: "PUT"},
|
||||
{Id: demoApiBaseId + 5, Handle: "go-admin/common/actions.DeleteAction.func1", Title: "示例商品删除", Path: "/api/v1/demo-product", Type: "SYS", Action: "DELETE"},
|
||||
}
|
||||
for i := range apis {
|
||||
if err := upsert(tx, &models.SysApi{}, "id = ?", apis[i].Id, &apis[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 2. 菜单 ----
|
||||
// 目录本身不对应页面,component 固定为 Layout
|
||||
dir := models.SysMenu{
|
||||
MenuId: demoMenuId, MenuName: "Demo", Title: "示例模块", Icon: "example",
|
||||
Path: "/demo", Paths: "/0/9000", MenuType: "M", ParentId: 0,
|
||||
Component: "Layout", Sort: 900, Visible: "0", IsFrame: "1",
|
||||
}
|
||||
if err := upsert(tx, &models.SysMenu{}, "menu_id = ?", dir.MenuId, &dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 菜单的 menu_name 需与前端组件 name 一致,否则 keep-alive 缓存无法命中
|
||||
page := models.SysMenu{
|
||||
MenuId: demoProductId, MenuName: "DemoProduct", Title: "商品管理", Icon: "documentation",
|
||||
Path: "/demo/product", Paths: "/0/9000/9001", MenuType: "C", ParentId: demoMenuId,
|
||||
Component: "/demo/product/index", Sort: 1, Visible: "0", IsFrame: "1",
|
||||
SysApi: apis,
|
||||
}
|
||||
if err := upsert(tx, &models.SysMenu{}, "menu_id = ?", page.MenuId, &page); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 按钮级权限,permission 需与前端 v-permisaction 中的标识一致
|
||||
buttons := []models.SysMenu{
|
||||
{MenuId: 9002, MenuName: "DemoProductAdd", Title: "新增", MenuType: "F", ParentId: demoProductId, Permission: "demo:product:add", Sort: 1, Visible: "0", IsFrame: "1"},
|
||||
{MenuId: 9003, MenuName: "DemoProductEdit", Title: "修改", MenuType: "F", ParentId: demoProductId, Permission: "demo:product:edit", Sort: 2, Visible: "0", IsFrame: "1"},
|
||||
{MenuId: 9004, MenuName: "DemoProductDel", Title: "删除", MenuType: "F", ParentId: demoProductId, Permission: "demo:product:delete", Sort: 3, Visible: "0", IsFrame: "1"},
|
||||
}
|
||||
for i := range buttons {
|
||||
buttons[i].Paths = "/0/9000/9001/" + strconv.Itoa(buttons[i].MenuId)
|
||||
if err := upsert(tx, &models.SysMenu{}, "menu_id = ?", buttons[i].MenuId, &buttons[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 3. 授权给 admin 角色 ----
|
||||
var role models.SysRole
|
||||
err := tx.Where("role_key = ?", adminRoleKey).First(&role).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 尚未初始化角色数据时跳过授权,不阻断迁移
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range []int{demoMenuId, demoProductId, 9002, 9003, 9004} {
|
||||
if err = tx.Exec(
|
||||
"INSERT INTO sys_role_menu (role_id, menu_id) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu WHERE role_id = ? AND menu_id = ?)",
|
||||
role.RoleId, id, role.RoleId, id,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 4. Casbin 策略 ----
|
||||
// admin 角色在中间件中直接放行,此处仍写入策略以便复制该角色配置时可继承
|
||||
for _, a := range apis {
|
||||
if err = tx.Exec(
|
||||
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) SELECT 'p', ?, ?, ?, '', '', '' WHERE NOT EXISTS (SELECT 1 FROM casbin_rule WHERE ptype='p' AND v0=? AND v1=? AND v2=?)",
|
||||
role.RoleKey, a.Path, a.Action, role.RoleKey, a.Path, a.Action,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// upsert 存在则更新、不存在则插入。迁移可能在已有数据的库上运行,
|
||||
// 直接 Create 会因主键冲突失败。
|
||||
func upsert(tx *gorm.DB, model interface{}, where string, id int, value interface{}) error {
|
||||
var count int64
|
||||
if err := tx.Model(model).Where(where, id).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return tx.Model(model).Where(where, id).Updates(value).Error
|
||||
}
|
||||
return tx.Create(value).Error
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
"go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// 清理 GET /api/v1/refresh_token 的残留权限数据。
|
||||
//
|
||||
// 该路由已随 issue #820 的修复移除:它允许用业务 token 换取新 token,而续期
|
||||
// 上限 MaxRefresh 依据的 orig_iat 每次续期都被重置,上限永远无法到达。
|
||||
//
|
||||
// 路由虽已删除(请求会返回 404),但已有部署的库中仍残留三类记录:接口登记、
|
||||
// 菜单与接口的绑定、Casbin 策略。留着会让「接口管理」列出一个不存在的端点,
|
||||
// 角色配置里也仍可勾选,造成误解。
|
||||
//
|
||||
// 按 path 匹配而非固定 id —— 使用者若执行过 `server -a` 重新注册接口,id 会
|
||||
// 与官方种子数据不同。
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700002000RemoveRefreshTokenApi)
|
||||
}
|
||||
|
||||
const refreshTokenPath = "/api/v1/refresh_token"
|
||||
|
||||
func _1786700002000RemoveRefreshTokenApi(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var apiIds []int
|
||||
if err := tx.Model(&models.SysApi{}).
|
||||
Where("path = ? AND action = ?", refreshTokenPath, "GET").
|
||||
Pluck("id", &apiIds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(apiIds) > 0 {
|
||||
// 先断开绑定,再删接口本身,避免遗留悬空外键
|
||||
// 连接表由 GORM many2many 生成,列名为 sys_api_id 而非 api_id
|
||||
if err := tx.Exec("DELETE FROM sys_menu_api_rule WHERE sys_api_id IN ?", apiIds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("id IN ?", apiIds).Delete(&models.SysApi{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// casbin_rule 才是 adapter 实际使用的表,策略按路径存储,与 sys_api 的
|
||||
// id 无关,因此即使上面没匹配到接口也要清理
|
||||
if err := tx.Exec(
|
||||
"DELETE FROM casbin_rule WHERE ptype = 'p' AND v1 = ?", refreshTokenPath,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
+7
-11
@@ -13,21 +13,17 @@ type GeneralDelDto struct {
|
||||
|
||||
func (g GeneralDelDto) GetIds() []int {
|
||||
ids := make([]int, 0)
|
||||
if g.Id != 0 {
|
||||
// Id 此前在 else 分支里被重复追加:仅传 Id 时会得到 [5 5],
|
||||
// 同一条记录被执行两次删除
|
||||
if g.Id > 0 {
|
||||
ids = append(ids, g.Id)
|
||||
}
|
||||
if len(g.Ids) > 0 {
|
||||
for _, id := range g.Ids {
|
||||
if id > 0 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if g.Id > 0 {
|
||||
ids = append(ids, g.Id)
|
||||
for _, id := range g.Ids {
|
||||
if id > 0 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) <= 0 {
|
||||
if len(ids) == 0 {
|
||||
//方式全部删除
|
||||
ids = append(ids, 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// GetIds 曾在 else 分支中重复追加 Id:仅传 Id 时返回 [5 5],
|
||||
// 导致删除接口对同一条记录执行两次。
|
||||
func TestGeneralDelDtoGetIds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
dto GeneralDelDto
|
||||
want []int
|
||||
}{
|
||||
{"仅 Id", GeneralDelDto{Id: 5}, []int{5}},
|
||||
{"仅 Ids", GeneralDelDto{Ids: []int{1, 2}}, []int{1, 2}},
|
||||
{"Id 与 Ids 并存", GeneralDelDto{Id: 5, Ids: []int{1, 2}}, []int{5, 1, 2}},
|
||||
{"Ids 含非正数被过滤", GeneralDelDto{Ids: []int{0, -1, 3}}, []int{3}},
|
||||
{"Id 为 0 视为未传", GeneralDelDto{Id: 0, Ids: []int{7}}, []int{7}},
|
||||
{"全部为空时回退到 0(全量删除约定)", GeneralDelDto{}, []int{0}},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := c.dto.GetIds()
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("GetIds() = %v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package global
|
||||
|
||||
const (
|
||||
// Version go-admin version info
|
||||
Version = "2.3.0"
|
||||
Version = "2.4.0"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -24,7 +24,6 @@ var CasbinExclude = []UrlInfo{
|
||||
{Url: "/api/v1/menuids", Method: "GET"},
|
||||
{Url: "/api/v1/roleMenuTreeselect/:roleId", Method: "GET"},
|
||||
{Url: "/api/v1/roleDeptTreeselect/:roleId", Method: "GET"},
|
||||
{Url: "/api/v1/refresh_token", Method: "GET"},
|
||||
{Url: "/api/v1/configKey/:configKey", Method: "GET"},
|
||||
{Url: "/api/v1/app-config", Method: "GET"},
|
||||
{Url: "/api/v1/user/profile", Method: "GET"},
|
||||
|
||||
@@ -37,7 +37,6 @@ INSERT INTO sys_api (id, handle, title, path, type, "action", created_at, update
|
||||
(45, 'go-admin/app/admin/apis.SysMenu.GetMenuTreeSelect-fm', '菜单权限列表【角色配菜单使用】', '/api/v1/roleMenuTreeselect/:roleId', 'SYS', 'GET', '2021-05-13 19:59:02.762', '2021-06-17 11:48:40.732', NULL, 0, 0),
|
||||
(46, 'go-admin/app/admin/apis.SysDept.GetDeptTreeRoleSelect-fm', '角色部门结构树【自定义数据权限】', '/api/v1/roleDeptTreeselect/:roleId', 'SYS', 'GET', '2021-05-13 19:59:02.809', '2021-06-17 11:48:40.732', NULL, 0, 0),
|
||||
(47, 'go-admin/app/admin/apis.SysRole.Get-fm', '角色通过id获取', '/api/v1/role/:id', 'BUS', 'GET', '2021-05-13 19:59:02.850', '2021-06-17 11:48:40.732', NULL, 0, 0),
|
||||
(48, 'github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth.(*GinJWTMiddleware).RefreshHandler-fm', '刷新token', '/api/v1/refresh_token', 'SYS', 'GET', '2021-05-13 19:59:02.892', '2021-06-13 20:53:49.278', NULL, 0, 0),
|
||||
(53, 'go-admin/app/admin/apis.SysConfig.GetPage-fm', '参数列表', '/api/v1/config', 'BUS', 'GET', '2021-05-13 19:59:03.116', '2021-06-17 11:48:40.732', NULL, 0, 0),
|
||||
(54, 'go-admin/app/admin/apis.SysConfig.Get-fm', '参数通过id获取', '/api/v1/config/:id', 'BUS', 'GET', '2021-05-13 19:59:03.157', '2021-06-17 11:48:40.732', NULL, 0, 0),
|
||||
(55, 'go-admin/app/admin/apis.SysConfig.GetSysConfigByKEYForService-fm', '参数通过键名搜索【基础默认配置】', '/api/v1/configKey/:configKey', 'SYS', 'GET', '2021-05-13 19:59:03.198', '2021-06-13 20:53:49.745', NULL, 0, 0),
|
||||
|
||||
@@ -35,7 +35,6 @@ INSERT INTO sys_api VALUES (44, 'go-admin/app/admin/apis.SysRole.GetPage-fm', '
|
||||
INSERT INTO sys_api VALUES (45, 'go-admin/app/admin/apis.SysMenu.GetMenuTreeSelect-fm', '菜单权限列表【角色配菜单使用】', '/api/v1/roleMenuTreeselect/:roleId', 'SYS', 'GET', '2021-05-13 19:59:02.762', '2021-06-17 11:48:40.732', NULL, 0, 0);
|
||||
INSERT INTO sys_api VALUES (46, 'go-admin/app/admin/apis.SysDept.GetDeptTreeRoleSelect-fm', '角色部门结构树【自定义数据权限】', '/api/v1/roleDeptTreeselect/:roleId', 'SYS', 'GET', '2021-05-13 19:59:02.809', '2021-06-17 11:48:40.732', NULL, 0, 0);
|
||||
INSERT INTO sys_api VALUES (47, 'go-admin/app/admin/apis.SysRole.Get-fm', '角色通过id获取', '/api/v1/role/:id', 'BUS', 'GET', '2021-05-13 19:59:02.850', '2021-06-17 11:48:40.732', NULL, 0, 0);
|
||||
INSERT INTO sys_api VALUES (48, 'github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth.(*GinJWTMiddleware).RefreshHandler-fm', '刷新token', '/api/v1/refresh_token', 'SYS', 'GET', '2021-05-13 19:59:02.892', '2021-06-13 20:53:49.278', NULL, 0, 0);
|
||||
INSERT INTO sys_api VALUES (53, 'go-admin/app/admin/apis.SysConfig.GetPage-fm', '参数列表', '/api/v1/config', 'BUS', 'GET', '2021-05-13 19:59:03.116', '2021-06-17 11:48:40.732', NULL, 0, 0);
|
||||
INSERT INTO sys_api VALUES (54, 'go-admin/app/admin/apis.SysConfig.Get-fm', '参数通过id获取', '/api/v1/config/:id', 'BUS', 'GET', '2021-05-13 19:59:03.157', '2021-06-17 11:48:40.732', NULL, 0, 0);
|
||||
INSERT INTO sys_api VALUES (55, 'go-admin/app/admin/apis.SysConfig.GetSysConfigByKEYForService-fm', '参数通过键名搜索【基础默认配置】', '/api/v1/configKey/:configKey', 'SYS', 'GET', '2021-05-13 19:59:03.198', '2021-06-13 20:53:49.745', NULL, 0, 0);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# 架构说明
|
||||
|
||||
> 本文记录**为什么这样设计**与不易从代码直接读出的语义。
|
||||
> 编码规范见根目录 `AGENTS.md`,标准写法见 `app/demo/`。
|
||||
|
||||
## 数据权限(DataScope)
|
||||
|
||||
`PermissionAction()` 中间件从数据库取出当前用户的 DataScope 存入 `gin.Context`;
|
||||
Service 在查询时通过 `actions.Permission(tableName, p)` 这个 GORM Scope 追加 WHERE。
|
||||
|
||||
实现见 `common/actions/permission.go:62-79`,五档语义:
|
||||
|
||||
| 值 | 含义 | 过滤方式 |
|
||||
|---|---|---|
|
||||
| `1` 或其他 | 全部数据 | 不追加条件 |
|
||||
| `2` | 本角色关联部门的数据 | `create_by` 属于 `sys_role_dept` 关联部门下的用户 |
|
||||
| `3` | 本部门数据 | `create_by` 属于同部门用户 |
|
||||
| `4` | 本部门及子部门 | 按 `sys_dept.dept_path` 前缀匹配 |
|
||||
| `5` | 仅本人 | `create_by = 当前用户` |
|
||||
|
||||
**过滤依据是 `create_by` 字段**,因此参与数据权限的表必须内嵌 `models.ControlBy`。
|
||||
|
||||
总开关:`config/settings.yml` 的 `application.enabledp`。关闭时 `Permission` 直接返回原
|
||||
查询,这也意味着**关闭开关后所有数据权限配置立即失效**,排查问题时先确认此项。
|
||||
|
||||
## 定时任务
|
||||
|
||||
两类任务,配置在 `sys_job` 表:
|
||||
|
||||
- **HTTP 任务** —— 按 Cron 表达式请求指定 URL
|
||||
- **函数任务** —— 调用注册在 `app/jobs` 中的 Go 函数
|
||||
|
||||
自定义函数任务需实现 `JobExec` 接口(`app/jobs/type.go:10`):
|
||||
|
||||
```go
|
||||
type JobExec interface {
|
||||
Exec(arg interface{}) error
|
||||
}
|
||||
```
|
||||
|
||||
并注册进 `app/jobs/examples.go` 的 `jobList` 映射,键名与 `sys_job` 表中配置的调用目标
|
||||
对应。
|
||||
|
||||
任务内需要数据库连接时,通过 `sdk.Runtime.GetDbByKey("*")` 获取,不要反向 import
|
||||
`app/admin/service`。
|
||||
|
||||
## 配置扩展
|
||||
|
||||
业务自定义配置写在 `config/extend.go` 中的结构体,对应 `settings.yml` 的 `extend:`
|
||||
节点,代码中通过 `config.ExtConfig.Xxx` 访问。
|
||||
|
||||
配置值支持环境变量占位:在 yml 中写 `${ENV_NAME}`,由 `go-admin-core` 的
|
||||
`config/reader/preprocessor.go` 在加载时替换。敏感信息可借此避免写入文件。
|
||||
|
||||
## 多数据源
|
||||
|
||||
`WithContextDb` 中间件按请求解析出对应的数据库连接放入上下文,Service 通过 `e.Orm`
|
||||
取用。**不要使用全局 DB 变量** —— 那会绕过多租户隔离。
|
||||
|
||||
多库配置见 `settings.yml` 的 `databases` 与 `registers` 节点,后者用于 dbresolver
|
||||
读写分离。
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
迁移文件放 `cmd/migrate/migration/version-local/`,文件名前 13 位为 Unix 毫秒时间戳,
|
||||
框架按文件名升序执行,已执行版本记录在 `sys_migration` 表。
|
||||
|
||||
```bash
|
||||
go run main.go migrate -c config/settings.yml -g # 生成骨架
|
||||
```
|
||||
|
||||
**已执行过的迁移文件不可修改** —— 版本号已入表,改动不会重跑。需要调整时新建迁移。
|
||||
|
||||
## 构建注意
|
||||
|
||||
- 默认构建禁用 CGO;使用 SQLite 需 `make build-sqlite`(带 `-tags sqlite3`)
|
||||
- `mode: prod` 时不注册 Swagger 路由
|
||||
- dev 模式下 JWT 超时被设为极大值,生产部署前务必确认 `mode` 与 `jwt.secret`
|
||||
|
||||
## 参考
|
||||
|
||||
- 编码规范:根目录 `AGENTS.md`
|
||||
- 标准 CRUD 模块:`app/demo/`
|
||||
- API 文档:`go generate` 生成到 `docs/admin/`,dev 模式下访问
|
||||
`/swagger/admin/index.html`
|
||||
Reference in New Issue
Block a user