mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-24 11:08:09 +00:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60db31e0e8 | ||
|
|
e3e5d3e550 | ||
|
|
f427a42b4f | ||
|
|
1169cb3e87 | ||
|
|
ec10917272 | ||
|
|
b8af16baf0 | ||
|
|
92987cdee3 | ||
|
|
df98ffb5c5 | ||
|
|
acc9378283 | ||
|
|
b4b5bc5b3a | ||
|
|
27ad988fd7 | ||
|
|
effc3a3e69 | ||
|
|
08f789737f | ||
|
|
f57bf5d61d | ||
|
|
143dbf19a2 | ||
|
|
f6bd306d6d | ||
|
|
3beb00143a | ||
|
|
7a52a50964 | ||
|
|
630e13686c | ||
|
|
05661e2f3e | ||
|
|
3625ce851b | ||
|
|
8b312bed1d | ||
|
|
30bcb57f41 | ||
|
|
5bb211afcd | ||
|
|
d102c4b7c1 | ||
|
|
545453c93e | ||
|
|
c3d2a5952b | ||
|
|
d65b21baf6 | ||
|
|
0f31feae6f | ||
|
|
0494a27d6c | ||
|
|
e2b6ddb290 | ||
|
|
28eba92077 | ||
|
|
aa7a92664d | ||
|
|
68ecc5f9a8 | ||
|
|
8341044251 | ||
|
|
8b03400ecc | ||
|
|
8115c3a737 | ||
|
|
c01307202d | ||
|
|
5ecb1e6e4c | ||
|
|
9c299805c1 | ||
|
|
2a900c9876 | ||
|
|
0008b943a3 | ||
|
|
50c74b1f96 | ||
|
|
ae1eef6d4f | ||
|
|
1cd39b80b3 |
@@ -61,7 +61,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: 1.26.5
|
||||
go-version: 1.27.1
|
||||
|
||||
- name: Tidy
|
||||
run: go mod tidy
|
||||
|
||||
@@ -68,6 +68,28 @@ jobs:
|
||||
--health-retries 20
|
||||
--health-start-period 20s
|
||||
|
||||
# The dialect most installations actually run, and until the
|
||||
# scheduler lease (#915) the only one with no service here. The lease
|
||||
# reads the database's clock, and the first implementation read it as
|
||||
# a timestamp: over go-admin's own `parseTime=True&loc=Local` DSN,
|
||||
# MySQL's UTC_TIMESTAMP comes back relabelled as local time, so on any
|
||||
# host that is not UTC every lease was one zone offset out - and every
|
||||
# assertion that compared the lease only against itself still passed.
|
||||
# The three dialects that were here could not see it.
|
||||
mysql:
|
||||
image: mysql:8
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: GoAdmin_Test1
|
||||
MYSQL_DATABASE: goadmin_test
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -pGoAdmin_Test1"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
--health-start-period 20s
|
||||
|
||||
env:
|
||||
GO_ADMIN_TEST_REDIS_ADDR: 127.0.0.1:6379
|
||||
# The soft-delete conversion drops an index, and gorm's PostgreSQL driver
|
||||
@@ -77,13 +99,18 @@ jobs:
|
||||
# See go-admin#919.
|
||||
GO_ADMIN_TEST_POSTGRES_DSN: "host=127.0.0.1 port=5432 user=postgres password=postgres dbname=goadmin_test sslmode=disable"
|
||||
GO_ADMIN_TEST_SQLSERVER_DSN: "sqlserver://sa:GoAdmin_Test1@127.0.0.1:1433?database=goadmin_test"
|
||||
# loc=Local on purpose: it is what config/settings.yml ships and what
|
||||
# made the timezone defect above reachable. A DSN here that quietly
|
||||
# differed from the one installations use would test a configuration
|
||||
# nobody runs.
|
||||
GO_ADMIN_TEST_MYSQL_DSN: "root:GoAdmin_Test1@tcp(127.0.0.1:3306)/goadmin_test?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go 1.26
|
||||
- name: Set up Go 1.27
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: 1.26.5
|
||||
go-version: 1.27.1
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
@@ -100,6 +127,12 @@ jobs:
|
||||
- name: Get dependencies
|
||||
run: go mod tidy
|
||||
|
||||
# Before the tests rather than beside checksilent at the end: a formatting
|
||||
# miss is a one-command fix, and finding out about it after five minutes of
|
||||
# tests and an end-to-end install is five minutes nobody gets back.
|
||||
- name: Formatting
|
||||
run: make fmt-check
|
||||
|
||||
# go build does not compile _test.go, so building alone never ran a single
|
||||
# test. This is the only workflow that fires on every push and pull request,
|
||||
# which makes it the one place a test gate belongs.
|
||||
|
||||
@@ -20,7 +20,7 @@ Router → Api → Service → Model
|
||||
|
||||
## 优先使用通用 Action
|
||||
|
||||
单表 CRUD **不要手写 Handler 与 Service**。`common/actions` 提供的五个
|
||||
单表 CRUD **不要手写 Api 与 Service**。`common/actions` 提供的五个
|
||||
Action 已覆盖参数绑定、数据权限过滤、操作人注入、分页与错误响应:
|
||||
|
||||
```go
|
||||
@@ -49,7 +49,7 @@ r := v1.Group("/demo-product").Use(authMiddleware.MiddlewareFunc()).Use(middlewa
|
||||
就地返回会串数据(`app/demo` 的测试锁定了这一点)
|
||||
- 详情/删除 DTO 内嵌 `dto.ObjectById` 即可继承 `Bind` 与 `GetId`,无需重写
|
||||
|
||||
仅当业务超出单表 CRUD(跨表事务、外部调用、复杂校验)时才自行编写 Handler
|
||||
仅当业务超出单表 CRUD(跨表事务、外部调用、复杂校验)时才自行编写 Api
|
||||
与 Service,写法见下。
|
||||
|
||||
## Api 层(仅在通用 Action 不适用时)
|
||||
@@ -167,7 +167,7 @@ sys_menu / sys_menu_api_rule / casbin_rule 四张表如何配齐,用的是幂
|
||||
|
||||
## Swagger
|
||||
|
||||
Handler 必须带完整注解,`go generate` 会据此生成文档:
|
||||
Api 必须带完整注解,`go generate` 会据此生成文档:
|
||||
|
||||
```go
|
||||
// @Summary 岗位列表
|
||||
|
||||
@@ -80,6 +80,23 @@ else
|
||||
go run ./tools/checksilent
|
||||
endif
|
||||
|
||||
# gofmt as a gate, not a rewrite. CI cannot commit, and a target that quietly
|
||||
# reformats hides what it touched, so this reports and fails instead. `gofmt -l`
|
||||
# prints the files it would rewrite and nothing at all when there are none, so
|
||||
# that list is both the failure message and the instructions for fixing it.
|
||||
#
|
||||
# The tree reached zero unformatted files once; without something holding it
|
||||
# there it drifts back, which is how the previous batch grew to 26 files -
|
||||
# mostly a missing newline at the end of the file, which no reviewer notices.
|
||||
.PHONY: fmt-check
|
||||
fmt-check:
|
||||
@unformatted=$$(gofmt -l .); \
|
||||
if [ -n "$$unformatted" ]; then \
|
||||
echo "gofmt would rewrite these files. Run 'gofmt -w .' and commit the result:"; \
|
||||
echo "$$unformatted"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
#.PHONY: docker
|
||||
#docker:
|
||||
# docker build . -t go-admin:latest
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ antd 体验(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admi
|
||||
|
||||
### 环境要求
|
||||
|
||||
go 1.26.5
|
||||
go 1.27.1
|
||||
|
||||
node版本: v22+(推荐 v24 LTS)
|
||||
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ antd デモ(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admi
|
||||
|
||||
### 動作要件
|
||||
|
||||
go 1.26.5
|
||||
go 1.27.1
|
||||
|
||||
node バージョン: v22 以上(v24 LTS 推奨)
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ At the same time, a series of tutorials including videos and documents are provi
|
||||
|
||||
### Environmental requirements
|
||||
|
||||
go 1.26.5
|
||||
go 1.27.1
|
||||
|
||||
nodejs: v22+ (v24 LTS recommended)
|
||||
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ antd 體驗(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admi
|
||||
|
||||
### 環境需求
|
||||
|
||||
go 1.26.5
|
||||
go 1.27.1
|
||||
|
||||
node 版本: v22+(建議 v24 LTS)
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ package apis
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"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"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service"
|
||||
@@ -145,4 +145,4 @@ func (e SysApi) DeleteSysApi(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ package apis
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"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"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
@@ -216,5 +216,5 @@ func (e SysDictData) GetAll(c *gin.Context) {
|
||||
l = append(l, d)
|
||||
}
|
||||
|
||||
e.OK(l,"查询成功")
|
||||
e.OK(l, "查询成功")
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"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"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
@@ -31,7 +31,7 @@ type SysDictType struct {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) GetPage(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeGetPageReq{}
|
||||
req := dto.SysDictTypeGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
@@ -62,7 +62,7 @@ func (e SysDictType) GetPage(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) Get(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeGetReq{}
|
||||
req := dto.SysDictTypeGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
@@ -82,7 +82,7 @@ func (e SysDictType) Get(c *gin.Context) {
|
||||
e.OK(object, "查询成功")
|
||||
}
|
||||
|
||||
//Insert 字典类型创建
|
||||
// Insert 字典类型创建
|
||||
// @Summary 添加字典类型
|
||||
// @Description 获取JSON
|
||||
// @Tags 字典类型
|
||||
@@ -94,7 +94,7 @@ func (e SysDictType) Get(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) Insert(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeInsertReq{}
|
||||
req := dto.SysDictTypeInsertReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
@@ -109,7 +109,7 @@ func (e SysDictType) Insert(c *gin.Context) {
|
||||
err = s.Insert(&req)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err,fmt.Sprintf(" 创建字典类型失败,详情:%s", err.Error()))
|
||||
e.Error(500, err, fmt.Sprintf(" 创建字典类型失败,详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "创建成功")
|
||||
@@ -127,7 +127,7 @@ func (e SysDictType) Insert(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) Update(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeUpdateReq{}
|
||||
req := dto.SysDictTypeUpdateReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
@@ -157,7 +157,7 @@ func (e SysDictType) Update(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) Delete(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeDeleteReq{}
|
||||
req := dto.SysDictTypeDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
@@ -189,7 +189,7 @@ func (e SysDictType) Delete(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) GetAll(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeGetPageReq{}
|
||||
req := dto.SysDictTypeGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
@@ -207,4 +207,4 @@ func (e SysDictType) GetAll(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
e.OK(list, "查询成功")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ type SysLoginLog struct {
|
||||
// @Security Bearer
|
||||
func (e SysLoginLog) GetPage(c *gin.Context) {
|
||||
s := service.SysLoginLog{}
|
||||
req :=dto.SysLoginLogGetPageReq{}
|
||||
req := dto.SysLoginLogGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
@@ -60,7 +60,7 @@ func (e SysLoginLog) GetPage(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysLoginLog) Get(c *gin.Context) {
|
||||
s := service.SysLoginLog{}
|
||||
req :=dto.SysLoginLogGetReq{}
|
||||
req := dto.SysLoginLogGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req).
|
||||
@@ -90,7 +90,7 @@ func (e SysLoginLog) Get(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysLoginLog) Delete(c *gin.Context) {
|
||||
s := service.SysLoginLog{}
|
||||
req :=dto.SysLoginLogDeleteReq{}
|
||||
req := dto.SysLoginLogDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
@@ -107,4 +107,4 @@ func (e SysLoginLog) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func (e SysOperaLog) GetPage(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysOperaLog) Get(c *gin.Context) {
|
||||
s := new(service.SysOperaLog)
|
||||
req :=dto.SysOperaLogGetReq{}
|
||||
req := dto.SysOperaLogGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
@@ -96,7 +96,7 @@ func (e SysOperaLog) Get(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysOperaLog) Delete(c *gin.Context) {
|
||||
s := new(service.SysOperaLog)
|
||||
req :=dto.SysOperaLogDeleteReq{}
|
||||
req := dto.SysOperaLogDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
@@ -111,7 +111,7 @@ func (e SysOperaLog) Delete(c *gin.Context) {
|
||||
err = s.Remove(&req)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500,err, fmt.Sprintf("删除失败!错误详情:%s", err.Error()))
|
||||
e.Error(500, err, fmt.Sprintf("删除失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
|
||||
@@ -2,12 +2,12 @@ package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"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"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service"
|
||||
@@ -31,7 +31,7 @@ type SysPost struct {
|
||||
// @Security Bearer
|
||||
func (e SysPost) GetPage(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostPageReq{}
|
||||
req := dto.SysPostPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
@@ -65,7 +65,7 @@ func (e SysPost) GetPage(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysPost) Get(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostGetReq{}
|
||||
req := dto.SysPostGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
@@ -99,7 +99,7 @@ func (e SysPost) Get(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysPost) Insert(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostInsertReq{}
|
||||
req := dto.SysPostInsertReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
@@ -131,7 +131,7 @@ func (e SysPost) Insert(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysPost) Update(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostUpdateReq{}
|
||||
req := dto.SysPostUpdateReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
@@ -163,7 +163,7 @@ func (e SysPost) Update(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysPost) Delete(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostDeleteReq{}
|
||||
req := dto.SysPostDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
@@ -181,4 +181,4 @@ func (e SysPost) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,4 @@ func registerSysDeptRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
r1.GET("/deptTree", api.Get2Tree)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,4 @@ func registerSysLoginLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
|
||||
r.GET("/:id", api.Get)
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,4 +30,4 @@ func registerSysMenuRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
//r1.GET("/menuids", api.GetMenuIDS)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,4 +20,4 @@ func registerSysOperaLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
|
||||
r.GET("/:id", api.Get)
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,4 @@ func registerSyPostRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
|
||||
r.PUT("/:id", api.Update)
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,4 +36,4 @@ func registerSysUserRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
{
|
||||
v1auth.GET("/getinfo", api.GetInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,15 @@ import (
|
||||
|
||||
// SysDeptGetPageReq 列表或者搜索使用结构体
|
||||
type SysDeptGetPageReq struct {
|
||||
DeptId int `form:"deptId" search:"type:exact;column:dept_id;table:sys_dept" comment:"id"` //id
|
||||
ParentId int `form:"parentId" search:"type:exact;column:parent_id;table:sys_dept" comment:"上级部门"` //上级部门
|
||||
DeptPath string `form:"deptPath" search:"type:exact;column:dept_path;table:sys_dept" comment:""` //路径
|
||||
DeptName string `form:"deptName" search:"type:exact;column:dept_name;table:sys_dept" comment:"部门名称"` //部门名称
|
||||
Sort int `form:"sort" search:"type:exact;column:sort;table:sys_dept" comment:"排序"` //排序
|
||||
Leader string `form:"leader" search:"type:exact;column:leader;table:sys_dept" comment:"负责人"` //负责人
|
||||
Phone string `form:"phone" search:"type:exact;column:phone;table:sys_dept" comment:"手机"` //手机
|
||||
Email string `form:"email" search:"type:exact;column:email;table:sys_dept" comment:"邮箱"` //邮箱
|
||||
Status string `form:"status" search:"type:exact;column:status;table:sys_dept" comment:"状态"` //状态
|
||||
DeptId int `form:"deptId" search:"type:exact;column:dept_id;table:sys_dept" comment:"id"` //id
|
||||
ParentId int `form:"parentId" search:"type:exact;column:parent_id;table:sys_dept" comment:"上级部门"` //上级部门
|
||||
DeptPath string `form:"deptPath" search:"type:exact;column:dept_path;table:sys_dept" comment:""` //路径
|
||||
DeptName string `form:"deptName" search:"type:exact;column:dept_name;table:sys_dept" comment:"部门名称"` //部门名称
|
||||
Sort int `form:"sort" search:"type:exact;column:sort;table:sys_dept" comment:"排序"` //排序
|
||||
Leader string `form:"leader" search:"type:exact;column:leader;table:sys_dept" comment:"负责人"` //负责人
|
||||
Phone string `form:"phone" search:"type:exact;column:phone;table:sys_dept" comment:"手机"` //手机
|
||||
Email string `form:"email" search:"type:exact;column:email;table:sys_dept" comment:"邮箱"` //邮箱
|
||||
Status string `form:"status" search:"type:exact;column:status;table:sys_dept" comment:"状态"` //状态
|
||||
}
|
||||
|
||||
func (m *SysDeptGetPageReq) GetNeedSearch() interface{} {
|
||||
|
||||
@@ -54,4 +54,4 @@ type SysLoginLogDeleteReq struct {
|
||||
|
||||
func (s *SysLoginLogDeleteReq) GetId() interface{} {
|
||||
return s.Ids
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +258,7 @@ func (e *SysDept) SetDeptLabel() (m []dto.DeptLabel, err error) {
|
||||
list := make([]models.SysDept, 0)
|
||||
err = e.Orm.Find(&list).Error
|
||||
if err != nil {
|
||||
log.Error("find dept list error, %s", err.Error())
|
||||
log.Errorf("find dept list error, %s", err.Error())
|
||||
return
|
||||
}
|
||||
m = make([]dto.DeptLabel, 0)
|
||||
|
||||
@@ -107,4 +107,4 @@ type SysRoleMenu struct {
|
||||
// return nil, err
|
||||
// }
|
||||
// return r, nil
|
||||
//}
|
||||
//}
|
||||
|
||||
+35
-16
@@ -97,13 +97,20 @@ LOOP:
|
||||
}
|
||||
|
||||
// Setup 初始化
|
||||
// Setup gives every tenant a scheduler and a supervisor to decide whether
|
||||
// this instance is the one that fills it.
|
||||
//
|
||||
// One owner id for the whole process, not one per tenant: the thing holding
|
||||
// the leases is this process, and a log line naming it should name the same
|
||||
// thing in every database it appears in.
|
||||
func Setup(dbs map[string]*gorm.DB) {
|
||||
|
||||
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore Starting...")
|
||||
|
||||
owner := newOwnerID()
|
||||
for k, db := range dbs {
|
||||
sdk.Runtime.SetCrontabByTenant(k, cronjob.NewWithSeconds())
|
||||
setup(k, db)
|
||||
newSupervisor(k, db, owner).start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +156,7 @@ func setup(key string, db *gorm.DB) {
|
||||
startCrontab(crontab)
|
||||
}
|
||||
|
||||
// startCrontab starts c and arranges for it to be stopped on the way out.
|
||||
// startCrontab starts c.
|
||||
//
|
||||
// The stop used to be `defer crontab.Stop()` followed by `select {}`. The
|
||||
// select never returned, so the defer never ran and the scheduler was never
|
||||
@@ -158,24 +165,36 @@ func setup(key string, db *gorm.DB) {
|
||||
// got a scheduler at all. cron.Start is itself `go c.run()`, so the select was
|
||||
// blocking for nothing.
|
||||
//
|
||||
// cron.Stop returns a context that closes once the jobs already running have
|
||||
// finished. That is the wait the shutdown budget exists to bound: giving up on
|
||||
// it leaves those jobs running until the process exits, which is better than
|
||||
// holding the whole shutdown open for one job that will not end.
|
||||
// Stopping is no longer arranged here. A scheduler now stops for two
|
||||
// different reasons - the process is going down, or this instance lost the
|
||||
// lease (#915) - and only the supervisor knows which. Registering a shutdown
|
||||
// callback per start, when a start happens every time the lease is taken,
|
||||
// would also add one callback per leadership change for the life of the
|
||||
// process: SetShutdown appends.
|
||||
func startCrontab(c *cron.Cron) {
|
||||
c.Start()
|
||||
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore start success.")
|
||||
}
|
||||
|
||||
// 关闭任务
|
||||
sdk.Runtime.SetShutdown(func(ctx context.Context) {
|
||||
stopped := c.Stop()
|
||||
select {
|
||||
case <-stopped.Done():
|
||||
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore stopped.")
|
||||
case <-ctx.Done():
|
||||
fmt.Println(time.Now().Format(timeFormat), " [WARN] JobCore stop gave up waiting for running jobs")
|
||||
}
|
||||
})
|
||||
// stopCrontab stops one tenant's scheduler and waits for the jobs already
|
||||
// running to finish, bounded by ctx.
|
||||
//
|
||||
// cron.Stop returns a context that closes once those jobs have finished.
|
||||
// That is the wait the shutdown budget exists to bound: giving up on it
|
||||
// leaves them running until the process exits, which is better than holding
|
||||
// the whole shutdown open for one job that will not end.
|
||||
func stopCrontab(ctx context.Context, key string) {
|
||||
c := sdk.Runtime.GetCrontabByTenant(key)
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
stopped := c.Stop()
|
||||
select {
|
||||
case <-stopped.Done():
|
||||
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore stopped.")
|
||||
case <-ctx.Done():
|
||||
fmt.Println(time.Now().Format(timeFormat), " [WARN] JobCore stop gave up waiting for running jobs")
|
||||
}
|
||||
}
|
||||
|
||||
// AddJob 添加任务 AddJob(invokeTarget string, jobId int, jobName string, cronExpression string)
|
||||
|
||||
@@ -6,26 +6,45 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
glebarez "github.com/glebarez/sqlite"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/cronjob"
|
||||
"gorm.io/gorm"
|
||||
|
||||
models2 "go-admin/app/jobs/models"
|
||||
)
|
||||
|
||||
// The scheduler had never been stopped. `defer crontab.Stop()` sat directly
|
||||
// above a `select {}` that never returned, so the deferred call was
|
||||
// unreachable for the life of the process.
|
||||
//
|
||||
// It now goes through the supervisor, which is what production does and what
|
||||
// owns the shutdown callback since the lease landed (#915): a scheduler stops
|
||||
// either because the process is going down or because this instance lost the
|
||||
// lease, and only the supervisor can tell those apart.
|
||||
//
|
||||
// There is one test rather than several because BeforeExit closes to further
|
||||
// registration once it has run: a second RunShutdown in this binary would find
|
||||
// an empty registry and pass while proving nothing.
|
||||
func TestTheSchedulerIsStoppedOnTheWayOut(t *testing.T) {
|
||||
// registration once it has run: a second RunShutdown in this binary would
|
||||
// find an empty registry and pass while proving nothing. The lease-release
|
||||
// assertion is folded in here for the same reason.
|
||||
func TestTheSchedulerIsStoppedAndTheLeaseHandedBackOnTheWayOut(t *testing.T) {
|
||||
const tenant = "*"
|
||||
|
||||
db := leaseDB(t)
|
||||
var ticks atomic.Int64
|
||||
|
||||
c := cronjob.NewWithSeconds()
|
||||
if _, err := c.AddFunc("* * * * * *", func() { ticks.Add(1) }); err != nil {
|
||||
t.Fatalf("AddFunc: %v", err)
|
||||
}
|
||||
sdk.Runtime.SetCrontabByTenant(tenant, c)
|
||||
|
||||
startCrontab(c)
|
||||
s := newSupervisor(tenant, db, "instance-under-test")
|
||||
s.start()
|
||||
|
||||
if !s.holdsLease() {
|
||||
t.Fatal("the supervisor did not take a free lease, so this test would prove nothing about giving it back")
|
||||
}
|
||||
|
||||
// It has to be running before stopping it can mean anything.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
@@ -49,4 +68,33 @@ func TestTheSchedulerIsStoppedOnTheWayOut(t *testing.T) {
|
||||
if n := ticks.Load() - at; n > 0 {
|
||||
t.Errorf("the job fired %d more times after shutdown: the scheduler is still running", n)
|
||||
}
|
||||
|
||||
// And the lease is free, so a successor takes it immediately instead of
|
||||
// waiting out a TTL held by a process that has exited.
|
||||
var row models2.SysJobLease
|
||||
if err := db.Where("name = ?", models2.SchedulerLeaseName).First(&row).Error; err != nil {
|
||||
t.Fatalf("reading the lease row: %v", err)
|
||||
}
|
||||
if row.Owner != "" {
|
||||
t.Errorf("the lease is still owned by %q after shutdown; a successor would wait out the TTL", row.Owner)
|
||||
}
|
||||
}
|
||||
|
||||
// leaseDB is a database with the two tables jobs.setup touches and one free
|
||||
// lease row, which is the shape migration 1786700009000 leaves behind.
|
||||
func leaseDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(glebarez.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("opening sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models2.SysJob{}, &models2.SysJobLease{}); err != nil {
|
||||
t.Fatalf("migrating: %v", err)
|
||||
}
|
||||
row := models2.SysJobLease{Name: models2.SchedulerLeaseName}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seeding the lease row: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
models2 "go-admin/app/jobs/models"
|
||||
)
|
||||
|
||||
// nowExprMs is the dialect's expression for the current time as
|
||||
// milliseconds since the Unix epoch.
|
||||
//
|
||||
// The lease compares one instance's idea of "expired" against another
|
||||
// instance's idea of "still mine", so both have to come from the same clock.
|
||||
// Two processes whose wall clocks differ by more than the lease TTL would
|
||||
// otherwise both hold it and both schedule - the exact situation the lease
|
||||
// exists to prevent, and it would look like it was working, because each
|
||||
// instance's own arithmetic is self-consistent.
|
||||
//
|
||||
// Milliseconds rather than a timestamp, because a timestamp does not survive
|
||||
// the trip through a driver unchanged. MySQL's UTC_TIMESTAMP read over
|
||||
// go-admin's own `parseTime=True&loc=Local` DSN arrives labelled as local
|
||||
// time: on a UTC+8 host every lease is eight hours out, and a test that only
|
||||
// checked the lease logic against itself passes anyway. An epoch integer has
|
||||
// no timezone for a driver to apply.
|
||||
func nowExprMs(dialect string) (string, error) {
|
||||
switch dialect {
|
||||
case "mysql":
|
||||
// UNIX_TIMESTAMP reads its argument in the session timezone and
|
||||
// NOW(3) is in the session timezone, so the two cancel and the
|
||||
// result is the absolute epoch regardless of what that zone is.
|
||||
return "CAST(ROUND(UNIX_TIMESTAMP(NOW(3)) * 1000) AS SIGNED)", nil
|
||||
case "postgres":
|
||||
return "CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)", nil
|
||||
case "sqlite":
|
||||
// julianday is the portable millisecond clock here: strftime('%s')
|
||||
// truncates to the second, and unixepoch('now','subsec') needs
|
||||
// SQLite 3.42.
|
||||
return "CAST((julianday('now') - 2440587.5) * 86400000.0 AS INTEGER)", nil
|
||||
case "sqlserver":
|
||||
return "DATEDIFF_BIG(millisecond, '1970-01-01T00:00:00', SYSUTCDATETIME())", nil
|
||||
}
|
||||
return "", fmt.Errorf("no epoch-milliseconds expression for dialect %q", dialect)
|
||||
}
|
||||
|
||||
// dbNowMs reads the clock from the database rather than from this process.
|
||||
//
|
||||
// The read and the UPDATE that uses it are two statements, so the value is
|
||||
// already slightly stale by the time it is compared - and that is the safe
|
||||
// direction in both places it is used:
|
||||
//
|
||||
// - as the expiry cutoff, a stale-old now makes this instance *less*
|
||||
// likely to decide another instance's lease has expired;
|
||||
// - as the basis for a new expiry, it makes this instance's own lease
|
||||
// expire sooner, so it renews sooner.
|
||||
//
|
||||
// Neither error makes two instances hold the lease at once.
|
||||
//
|
||||
// Zero is rejected rather than returned. It is what a failed conversion
|
||||
// looks like, it is before every expiry there will ever be, and an
|
||||
// implementation that passed it on would read every lease as expired, hand
|
||||
// it to every instance, and restore the defect this lease fixes with a lease
|
||||
// table sitting on top of it.
|
||||
func dbNowMs(db *gorm.DB) (int64, error) {
|
||||
expr, err := nowExprMs(db.Dialector.Name())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var ms int64
|
||||
if err := db.Raw("SELECT " + expr).Row().Scan(&ms); err != nil {
|
||||
return 0, fmt.Errorf("reading the database clock: %w", err)
|
||||
}
|
||||
if ms <= 0 {
|
||||
return 0, fmt.Errorf("the database clock read as %d from %q", ms, expr)
|
||||
}
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
// newOwnerID identifies this process in the lease row.
|
||||
//
|
||||
// Hostname and pid make a log line answer "which one is it" without a lookup;
|
||||
// the random suffix is what actually makes it unique, because a container
|
||||
// restarted under the same name can come back with the same hostname and the
|
||||
// same pid 1.
|
||||
func newOwnerID() string {
|
||||
host, err := os.Hostname()
|
||||
if err != nil || host == "" {
|
||||
host = "unknown"
|
||||
}
|
||||
return fmt.Sprintf("%s-%d-%s", host, os.Getpid(), uuid.New().String()[:8])
|
||||
}
|
||||
|
||||
// lease is one instance's claim on scheduling one database's jobs.
|
||||
type lease struct {
|
||||
db *gorm.DB
|
||||
owner string
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// acquire takes the lease or renews one this instance already holds, and
|
||||
// reports whether this instance holds it when it returns.
|
||||
//
|
||||
// Renewal is tried first and is scoped to this owner, so it cannot take a
|
||||
// lease another instance has meanwhile claimed. Only if that matches nothing
|
||||
// does it try to take an expired one. Both are single UPDATE statements
|
||||
// decided by RowsAffected: the database, not this process, arbitrates
|
||||
// between two instances running this at the same moment.
|
||||
//
|
||||
// There is no insert path. The migration seeds the row, so a missing row is
|
||||
// a broken installation rather than a state to recover from - and it is
|
||||
// reported as one, instead of being papered over by an insert that two
|
||||
// instances would race to win.
|
||||
func (l *lease) acquire() (bool, error) {
|
||||
nowMs, err := dbNowMs(l.db)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
expiresMs := nowMs + l.ttl.Milliseconds()
|
||||
|
||||
renewed := l.db.Model(&models2.SysJobLease{}).
|
||||
Where("name = ? AND owner = ?", models2.SchedulerLeaseName, l.owner).
|
||||
Update("expires_at_ms", expiresMs)
|
||||
if renewed.Error != nil {
|
||||
return false, fmt.Errorf("renewing the scheduler lease: %w", renewed.Error)
|
||||
}
|
||||
if renewed.RowsAffected > 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
taken := l.db.Model(&models2.SysJobLease{}).
|
||||
Where("name = ? AND expires_at_ms <= ?", models2.SchedulerLeaseName, nowMs).
|
||||
Updates(map[string]any{
|
||||
"owner": l.owner,
|
||||
"acquired_at_ms": nowMs,
|
||||
"expires_at_ms": expiresMs,
|
||||
})
|
||||
if taken.Error != nil {
|
||||
return false, fmt.Errorf("taking the scheduler lease: %w", taken.Error)
|
||||
}
|
||||
if taken.RowsAffected > 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Neither statement matched. Either another instance holds an
|
||||
// unexpired lease - the ordinary case, and not an error - or the row
|
||||
// the migration seeds is gone, which is, and which would otherwise
|
||||
// present as jobs silently never running anywhere.
|
||||
var rows int64
|
||||
if err := l.db.Model(&models2.SysJobLease{}).
|
||||
Where("name = ?", models2.SchedulerLeaseName).
|
||||
Count(&rows).Error; err != nil {
|
||||
return false, fmt.Errorf("checking for the scheduler lease row: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return false, fmt.Errorf("the %q lease row is missing from %s; run the migrations",
|
||||
models2.SchedulerLeaseName, (&models2.SysJobLease{}).TableName())
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// release hands the lease back so a successor can take it now instead of
|
||||
// waiting out the TTL. It is scoped to this owner: an instance that already
|
||||
// lost the lease must not clear the row its successor is holding.
|
||||
func (l *lease) release() error {
|
||||
res := l.db.Model(&models2.SysJobLease{}).
|
||||
Where("name = ? AND owner = ?", models2.SchedulerLeaseName, l.owner).
|
||||
Updates(map[string]any{"owner": "", "expires_at_ms": 0})
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("releasing the scheduler lease: %w", res.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
glebarez "github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlserver"
|
||||
"gorm.io/gorm"
|
||||
|
||||
models2 "go-admin/app/jobs/models"
|
||||
)
|
||||
|
||||
// The lease is the one thing in this package whose correctness is a property
|
||||
// of the database rather than of this process, so these run against every
|
||||
// dialect that can be reached. SQLite always; the others when their DSN is
|
||||
// set, and they must be set in CI - a suite that quietly skipped them would
|
||||
// report success for a lease that cannot be taken at all on the dialect most
|
||||
// installations actually run.
|
||||
const (
|
||||
mysqlDSNEnv = "GO_ADMIN_TEST_MYSQL_DSN"
|
||||
postgresDSNEnv = "GO_ADMIN_TEST_POSTGRES_DSN"
|
||||
sqlserverDSNEnv = "GO_ADMIN_TEST_SQLSERVER_DSN"
|
||||
)
|
||||
|
||||
type dialectDB struct {
|
||||
name string
|
||||
open func(string) gorm.Dialector
|
||||
env string
|
||||
}
|
||||
|
||||
var optionalDialects = []dialectDB{
|
||||
{"mysql", func(dsn string) gorm.Dialector { return mysql.Open(dsn) }, mysqlDSNEnv},
|
||||
{"postgres", func(dsn string) gorm.Dialector { return postgres.Open(dsn) }, postgresDSNEnv},
|
||||
{"sqlserver", func(dsn string) gorm.Dialector { return sqlserver.Open(dsn) }, sqlserverDSNEnv},
|
||||
}
|
||||
|
||||
// eachDialect runs body against SQLite and against every optional dialect
|
||||
// whose DSN is set.
|
||||
func eachDialect(t *testing.T, body func(t *testing.T, db *gorm.DB)) {
|
||||
t.Helper()
|
||||
|
||||
t.Run("sqlite", func(t *testing.T) {
|
||||
db, err := gorm.Open(glebarez.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("opening sqlite: %v", err)
|
||||
}
|
||||
body(t, seedLeaseTable(t, db))
|
||||
})
|
||||
|
||||
for _, d := range optionalDialects {
|
||||
t.Run(d.name, func(t *testing.T) {
|
||||
dsn := os.Getenv(d.env)
|
||||
if dsn == "" {
|
||||
if os.Getenv("CI") != "" {
|
||||
t.Fatalf("%s is not set while CI is: the lease must not go untested on %s", d.env, d.name)
|
||||
}
|
||||
t.Skipf("%s is not set; skipping %s", d.env, d.name)
|
||||
}
|
||||
db, err := gorm.Open(d.open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("connecting to %s: %v", d.env, err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("sql.DB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
body(t, seedLeaseTable(t, db))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// seedLeaseTable builds the shape 1786700009000 leaves behind: the table,
|
||||
// and exactly one free row.
|
||||
func seedLeaseTable(t *testing.T, db *gorm.DB) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
if err := db.Migrator().DropTable(&models2.SysJobLease{}); err != nil {
|
||||
t.Fatalf("dropping sys_job_lease: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models2.SysJobLease{}); err != nil {
|
||||
t.Fatalf("creating sys_job_lease: %v", err)
|
||||
}
|
||||
row := models2.SysJobLease{Name: models2.SchedulerLeaseName, AcquiredAtMs: 0, ExpiresAtMs: 0}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seeding the lease row: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestTheDatabaseClockIsReadableAndIsNotTheZeroTime(t *testing.T) {
|
||||
eachDialect(t, func(t *testing.T, db *gorm.DB) {
|
||||
nowMs, err := dbNowMs(db)
|
||||
if err != nil {
|
||||
t.Fatalf("dbNowMs: %v", err)
|
||||
}
|
||||
if nowMs <= 0 {
|
||||
t.Fatal("the database clock read as zero, which would read every lease as expired")
|
||||
}
|
||||
// Not an assertion about either clock's accuracy - a container's
|
||||
// clock and this one can drift. An hour is far wider than drift
|
||||
// and far narrower than a timezone offset, which is the mistake
|
||||
// this catches: reading MySQL's UTC_TIMESTAMP over a loc=Local
|
||||
// DSN lands exactly one zone offset away and is invisible to
|
||||
// every assertion that only compares the lease against itself.
|
||||
drift := time.Duration(time.Now().UnixMilli()-nowMs) * time.Millisecond
|
||||
if drift > time.Hour || drift < -time.Hour {
|
||||
t.Errorf("the database clock is %v away from this process's; a timezone mistake looks exactly like this", drift)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOnlyOneOfTwoInstancesTakesTheLease(t *testing.T) {
|
||||
eachDialect(t, func(t *testing.T, db *gorm.DB) {
|
||||
a := &lease{db: db, owner: "instance-a", ttl: time.Minute}
|
||||
b := &lease{db: db, owner: "instance-b", ttl: time.Minute}
|
||||
|
||||
heldA, err := a.acquire()
|
||||
if err != nil {
|
||||
t.Fatalf("a.acquire: %v", err)
|
||||
}
|
||||
if !heldA {
|
||||
t.Fatal("the first instance did not take a free lease")
|
||||
}
|
||||
|
||||
heldB, err := b.acquire()
|
||||
if err != nil {
|
||||
t.Fatalf("b.acquire: %v", err)
|
||||
}
|
||||
if heldB {
|
||||
t.Error("the second instance took a lease the first one holds: both would schedule")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTheHolderRenewsAndTheOtherStillCannotTakeIt(t *testing.T) {
|
||||
eachDialect(t, func(t *testing.T, db *gorm.DB) {
|
||||
a := &lease{db: db, owner: "instance-a", ttl: time.Minute}
|
||||
b := &lease{db: db, owner: "instance-b", ttl: time.Minute}
|
||||
|
||||
if held, err := a.acquire(); err != nil || !held {
|
||||
t.Fatalf("a.acquire: held=%v err=%v", held, err)
|
||||
}
|
||||
before := readLease(t, db)
|
||||
|
||||
if held, err := a.acquire(); err != nil || !held {
|
||||
t.Fatalf("a renewing: held=%v err=%v", held, err)
|
||||
}
|
||||
after := readLease(t, db)
|
||||
|
||||
if after.ExpiresAtMs < before.ExpiresAtMs {
|
||||
t.Errorf("renewal moved the expiry backwards: %d then %d", before.ExpiresAtMs, after.ExpiresAtMs)
|
||||
}
|
||||
if after.AcquiredAtMs != before.AcquiredAtMs {
|
||||
t.Errorf("renewal moved acquired_at_ms (%d then %d); it must say when the lease was taken, not when it was last renewed",
|
||||
before.AcquiredAtMs, after.AcquiredAtMs)
|
||||
}
|
||||
if held, err := b.acquire(); err != nil || held {
|
||||
t.Errorf("the other instance took a renewed lease: held=%v err=%v", held, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnExpiredLeaseIsTakenOver(t *testing.T) {
|
||||
eachDialect(t, func(t *testing.T, db *gorm.DB) {
|
||||
a := &lease{db: db, owner: "instance-a", ttl: time.Minute}
|
||||
b := &lease{db: db, owner: "instance-b", ttl: time.Minute}
|
||||
|
||||
if held, err := a.acquire(); err != nil || !held {
|
||||
t.Fatalf("a.acquire: held=%v err=%v", held, err)
|
||||
}
|
||||
|
||||
// What a dead leader leaves behind: its row, unrenewed, past its
|
||||
// expiry. Forced rather than waited out, so the test does not
|
||||
// trade a second of sleep for the same assertion.
|
||||
expire(t, db)
|
||||
|
||||
if held, err := b.acquire(); err != nil || !held {
|
||||
t.Fatalf("the successor did not take an expired lease: held=%v err=%v", held, err)
|
||||
}
|
||||
if got := readLease(t, db).Owner; got != "instance-b" {
|
||||
t.Errorf("owner is %q after takeover, want instance-b", got)
|
||||
}
|
||||
|
||||
// And the instance that lost it must not get it back by renewing:
|
||||
// renewal is scoped to the owner column it no longer matches.
|
||||
if held, err := a.acquire(); err != nil || held {
|
||||
t.Errorf("the dead leader renewed a lease it had lost: held=%v err=%v", held, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReleaseHandsTheLeaseOnWithoutWaitingOutTheTTL(t *testing.T) {
|
||||
eachDialect(t, func(t *testing.T, db *gorm.DB) {
|
||||
a := &lease{db: db, owner: "instance-a", ttl: time.Hour}
|
||||
b := &lease{db: db, owner: "instance-b", ttl: time.Minute}
|
||||
|
||||
if held, err := a.acquire(); err != nil || !held {
|
||||
t.Fatalf("a.acquire: held=%v err=%v", held, err)
|
||||
}
|
||||
if held, err := b.acquire(); err != nil || held {
|
||||
t.Fatalf("precondition: b must not hold it yet (held=%v err=%v)", held, err)
|
||||
}
|
||||
|
||||
if err := a.release(); err != nil {
|
||||
t.Fatalf("a.release: %v", err)
|
||||
}
|
||||
if held, err := b.acquire(); err != nil || !held {
|
||||
t.Errorf("a released a lease with an hour left and the successor still could not take it: held=%v err=%v", held, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReleasingALeaseSomebodyElseHoldsDoesNothing(t *testing.T) {
|
||||
eachDialect(t, func(t *testing.T, db *gorm.DB) {
|
||||
a := &lease{db: db, owner: "instance-a", ttl: time.Minute}
|
||||
stale := &lease{db: db, owner: "instance-gone", ttl: time.Minute}
|
||||
|
||||
if held, err := a.acquire(); err != nil || !held {
|
||||
t.Fatalf("a.acquire: held=%v err=%v", held, err)
|
||||
}
|
||||
if err := stale.release(); err != nil {
|
||||
t.Fatalf("stale.release: %v", err)
|
||||
}
|
||||
if got := readLease(t, db).Owner; got != "instance-a" {
|
||||
t.Errorf("owner is %q; an instance that already lost the lease cleared its successor's row", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAMissingLeaseRowIsReportedRatherThanSilentlyNeverScheduling(t *testing.T) {
|
||||
eachDialect(t, func(t *testing.T, db *gorm.DB) {
|
||||
if err := db.Where("name = ?", models2.SchedulerLeaseName).
|
||||
Delete(&models2.SysJobLease{}).Error; err != nil {
|
||||
t.Fatalf("deleting the lease row: %v", err)
|
||||
}
|
||||
a := &lease{db: db, owner: "instance-a", ttl: time.Minute}
|
||||
held, err := a.acquire()
|
||||
if held {
|
||||
t.Fatal("acquire reported the lease held with no row to hold")
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("a missing lease row was reported as an ordinary 'someone else holds it': jobs would never run anywhere and nothing would say why")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func readLease(t *testing.T, db *gorm.DB) models2.SysJobLease {
|
||||
t.Helper()
|
||||
var row models2.SysJobLease
|
||||
if err := db.Where("name = ?", models2.SchedulerLeaseName).First(&row).Error; err != nil {
|
||||
t.Fatalf("reading the lease row: %v", err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func expire(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Model(&models2.SysJobLease{}).
|
||||
Where("name = ?", models2.SchedulerLeaseName).
|
||||
Update("expires_at_ms", 0).Error; err != nil {
|
||||
t.Fatalf("expiring the lease: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package models
|
||||
|
||||
// SchedulerLeaseName is the name of the one lease row per database.
|
||||
//
|
||||
// One row, not one per tenant: a tenant is a separate database with its own
|
||||
// sys_job table and its own scheduler, so the row that decides who schedules
|
||||
// it lives in that database alongside the jobs it governs.
|
||||
const SchedulerLeaseName = "scheduler"
|
||||
|
||||
// SysJobLease is the scheduler's single-writer lease over one database.
|
||||
//
|
||||
// app/jobs registers every enabled job into an in-process cron.Cron and keeps
|
||||
// each job's scheduler handle in sys_job.entry_id. The scheduler is per
|
||||
// process and entry_id is one shared column, so a second instance pointed at
|
||||
// the same database does not divide the work - it overwrites it, and nothing
|
||||
// logs that it did (issue #915). Only the holder of this lease calls
|
||||
// jobs.Setup, which keeps the scheduler single-writer while the HTTP side
|
||||
// still scales.
|
||||
//
|
||||
// It deliberately embeds neither models.ModelTime nor models.ControlBy. A
|
||||
// lease is machine state, not a record a person creates, edits or
|
||||
// soft-deletes: there is no author to attribute it to, and a deleted-but-
|
||||
// present lease row would be a row that both does and does not hold the
|
||||
// scheduler.
|
||||
type SysJobLease struct {
|
||||
// Name is the lease being held. The migration seeds exactly one row,
|
||||
// SchedulerLeaseName, and the runtime only ever updates it - there is
|
||||
// no insert path, so two instances starting at once cannot race to
|
||||
// create the row they are both trying to claim.
|
||||
Name string `json:"name" gorm:"type:varchar(64);primaryKey"`
|
||||
|
||||
// Owner identifies the process that holds the lease. Empty means the
|
||||
// lease is free, which is what the migration seeds.
|
||||
Owner string `json:"owner" gorm:"type:varchar(191);not null"`
|
||||
|
||||
// AcquiredAtMs is when the current owner took the lease, not when it
|
||||
// last renewed: a leader that has held it for an hour and one that took
|
||||
// over a second ago are different situations, and only this column
|
||||
// tells them apart. Renewal moves ExpiresAtMs and leaves this alone.
|
||||
AcquiredAtMs int64 `json:"acquiredAtMs" gorm:"column:acquired_at_ms;not null"`
|
||||
|
||||
// ExpiresAtMs is when another instance may take the lease.
|
||||
//
|
||||
// Milliseconds since the Unix epoch, in a BIGINT, rather than a
|
||||
// timestamp column. A timestamp crossing the driver boundary carries
|
||||
// timezone semantics that the driver applies on the way through: with
|
||||
// go-admin's own `parseTime=True&loc=Local` DSN, MySQL's UTC_TIMESTAMP
|
||||
// comes back labelled as local time, and a lease written in Asia/
|
||||
// Shanghai is then eight hours out - in whichever direction makes every
|
||||
// other instance's lease look expired. An integer has no timezone for
|
||||
// anything to apply, and the comparison that decides who schedules
|
||||
// becomes integer arithmetic that no DSN setting can reinterpret.
|
||||
ExpiresAtMs int64 `json:"expiresAtMs" gorm:"column:expires_at_ms;not null"`
|
||||
}
|
||||
|
||||
func (*SysJobLease) TableName() string {
|
||||
return "sys_job_lease"
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// The frame the leaked goroutines park in. Remove starts it, and it is the
|
||||
// only goroutine in this package that sends on a channel the caller may have
|
||||
// walked away from.
|
||||
const removeSenderFrame = "go-admin/app/jobs.Remove.func1"
|
||||
|
||||
// Remove hands the caller a channel it is free to abandon: RemoveJob stops
|
||||
// waiting after a second and returns a timeout error. The send therefore has
|
||||
// to complete with nobody receiving, or every stop that times out parks a
|
||||
// goroutine on it for the life of the process.
|
||||
//
|
||||
// The order matters. Counting parked goroutines straight after calling Remove
|
||||
// would pass while proving nothing, because the goroutine may not have reached
|
||||
// the send yet. So the entries are waited out first: an empty scheduler means
|
||||
// every goroutine is at or past its send, and only then is a survivor a leak.
|
||||
func TestRemoveLetsItsGoroutineFinishWithNobodyReceiving(t *testing.T) {
|
||||
const jobs = 20
|
||||
|
||||
c := cron.New()
|
||||
ids := make([]cron.EntryID, 0, jobs)
|
||||
for i := 0; i < jobs; i++ {
|
||||
id, err := c.AddFunc("@every 1h", func() {})
|
||||
if err != nil {
|
||||
t.Fatalf("AddFunc: %v", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
// The returned channel is dropped on purpose: this is what a caller
|
||||
// that has already timed out leaves behind.
|
||||
_ = Remove(c, int(id))
|
||||
}
|
||||
|
||||
if err := waitFor(3*time.Second, func() bool { return len(c.Entries()) == 0 }); err != nil {
|
||||
t.Fatalf("the scheduler still holds %d entries, so the goroutines never reached their send "+
|
||||
"and this test cannot show anything", len(c.Entries()))
|
||||
}
|
||||
|
||||
if err := waitFor(3*time.Second, func() bool { return parkedInRemove() == 0 }); err != nil {
|
||||
t.Errorf("%d of %d goroutines are still parked sending on an abandoned channel:\n%s",
|
||||
parkedInRemove(), jobs, oneParkedStack())
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(d time.Duration, done func() bool) error {
|
||||
deadline := time.Now().Add(d)
|
||||
for {
|
||||
if done() {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return errTimeout
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
var errTimeout = timeoutError{}
|
||||
|
||||
type timeoutError struct{}
|
||||
|
||||
func (timeoutError) Error() string { return "timed out" }
|
||||
|
||||
func parkedInRemove() int {
|
||||
return strings.Count(goroutineDump(), removeSenderFrame)
|
||||
}
|
||||
|
||||
func oneParkedStack() string {
|
||||
for _, block := range strings.Split(goroutineDump(), "\n\n") {
|
||||
if strings.Contains(block, removeSenderFrame) {
|
||||
return block
|
||||
}
|
||||
}
|
||||
return "(none)"
|
||||
}
|
||||
|
||||
func goroutineDump() string {
|
||||
buf := make([]byte, 1<<20)
|
||||
for {
|
||||
n := runtime.Stack(buf, true)
|
||||
if n < len(buf) {
|
||||
return string(buf[:n])
|
||||
}
|
||||
buf = make([]byte, 2*len(buf))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/cronjob"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// leaseTTL is how long a lease stays valid without being renewed, and
|
||||
// leaseHeartbeat is how often the holder renews it.
|
||||
//
|
||||
// The gap between them is the point: at a third of the TTL, two consecutive
|
||||
// renewals can fail - a restarting database, a paused container - and the
|
||||
// third still lands before anything else may take the lease. Making them
|
||||
// equal would hand the scheduler to another instance on the first missed
|
||||
// beat.
|
||||
//
|
||||
// The TTL is also the longest the jobs can be stopped everywhere: an
|
||||
// instance killed without running its shutdown leaves its lease behind, and
|
||||
// the successor waits this long before taking it.
|
||||
const (
|
||||
leaseTTL = 30 * time.Second
|
||||
leaseHeartbeat = 10 * time.Second
|
||||
)
|
||||
|
||||
// supervisor keeps one tenant's scheduler in step with one lease.
|
||||
//
|
||||
// It exists because holding the lease is not a decision made once at
|
||||
// startup. An instance that never gets the lease has to keep asking, or the
|
||||
// death of the current holder would stop the jobs until somebody restarted a
|
||||
// process by hand; and an instance that holds it has to stop scheduling the
|
||||
// moment it can no longer prove it still does, or a network partition turns
|
||||
// into the two-schedulers-at-once defect (#915) that the lease exists to
|
||||
// prevent.
|
||||
type supervisor struct {
|
||||
key string
|
||||
db *gorm.DB
|
||||
lease *lease
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
// lastRenew is when this instance last proved it holds the lease. It
|
||||
// is compared only against this process's own later readings, never
|
||||
// against another instance's, so the monotonic clock is the right one
|
||||
// here - the reason the lease itself reads the database's clock does
|
||||
// not apply to measuring how long ago something happened locally.
|
||||
lastRenew time.Time
|
||||
|
||||
stop chan struct{}
|
||||
stopOnce sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newSupervisor(key string, db *gorm.DB, owner string) *supervisor {
|
||||
return &supervisor{
|
||||
key: key,
|
||||
db: db,
|
||||
lease: &lease{db: db, owner: owner, ttl: leaseTTL},
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// start takes the lease if it is free, schedules this tenant's jobs if it
|
||||
// got it, and then keeps both facts true for the life of the process.
|
||||
//
|
||||
// The first attempt is synchronous so that a single-instance deployment -
|
||||
// which is nearly all of them - has its jobs registered by the time Setup
|
||||
// returns, exactly as it did before there was a lease.
|
||||
func (s *supervisor) start() {
|
||||
s.tick()
|
||||
|
||||
go s.heartbeat()
|
||||
|
||||
sdk.Runtime.SetShutdown(func(ctx context.Context) {
|
||||
s.shutdown(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *supervisor) heartbeat() {
|
||||
defer close(s.done)
|
||||
|
||||
t := time.NewTicker(leaseHeartbeat)
|
||||
defer t.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-t.C:
|
||||
s.tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tick asks for the lease and makes the scheduler match the answer.
|
||||
func (s *supervisor) tick() {
|
||||
held, err := s.lease.acquire()
|
||||
if err != nil {
|
||||
// Not knowing is not the same as having lost it. The lease is
|
||||
// still ours until it expires, so the scheduler keeps running
|
||||
// and this instance keeps trying - a database that is briefly
|
||||
// unreachable must not stop the jobs, and must not hand them to
|
||||
// anyone else either, because nobody else can reach it to take
|
||||
// the lease.
|
||||
log.Errorf("[Job] scheduler lease for %s: %v", s.key, err)
|
||||
if s.heldFor() > leaseTTL {
|
||||
log.Errorf("[Job] scheduler lease for %s has not been renewed in %v; stopping the scheduler before anything else takes it",
|
||||
s.key, leaseTTL)
|
||||
s.stopScheduling()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !held {
|
||||
s.stopScheduling()
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.lastRenew = time.Now()
|
||||
already := s.running
|
||||
s.mu.Unlock()
|
||||
|
||||
if !already {
|
||||
s.startScheduling()
|
||||
}
|
||||
}
|
||||
|
||||
// heldFor reports how long it has been since this instance last proved it
|
||||
// holds the lease. A zero lastRenew means it never has, which is not a lease
|
||||
// that has gone stale.
|
||||
func (s *supervisor) heldFor() time.Duration {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.lastRenew.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return time.Since(s.lastRenew)
|
||||
}
|
||||
|
||||
func (s *supervisor) startScheduling() {
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.running = true
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Infof("[Job] holding the scheduler lease for %s; registering its jobs", s.key)
|
||||
setup(s.key, s.db)
|
||||
}
|
||||
|
||||
// stopScheduling stops this tenant's scheduler and puts a fresh one in its
|
||||
// place.
|
||||
//
|
||||
// Fresh, rather than reusing the stopped one, because taking the lease back
|
||||
// runs setup again and setup adds every enabled job to whatever scheduler is
|
||||
// registered. Reusing it would leave the previous registration in place and
|
||||
// fire every job twice - the symptom this whole change is here to remove,
|
||||
// reintroduced one layer down.
|
||||
func (s *supervisor) stopScheduling() {
|
||||
s.mu.Lock()
|
||||
if !s.running {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.running = false
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Infof("[Job] no longer holding the scheduler lease for %s; stopping its jobs", s.key)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), leaseHeartbeat)
|
||||
defer cancel()
|
||||
stopCrontab(ctx, s.key)
|
||||
sdk.Runtime.SetCrontabByTenant(s.key, cronjob.NewWithSeconds())
|
||||
}
|
||||
|
||||
// shutdown stops the heartbeat, stops the scheduler and hands the lease back
|
||||
// so a successor can take it now rather than waiting out the TTL.
|
||||
func (s *supervisor) shutdown(ctx context.Context) {
|
||||
s.stopOnce.Do(func() { close(s.stop) })
|
||||
select {
|
||||
case <-s.done:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
wasRunning := s.running
|
||||
s.running = false
|
||||
s.mu.Unlock()
|
||||
|
||||
if wasRunning {
|
||||
stopCrontab(ctx, s.key)
|
||||
if err := s.lease.release(); err != nil {
|
||||
log.Errorf("[Job] releasing the scheduler lease for %s: %v", s.key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// holdsLease reports whether this instance is currently scheduling. It exists
|
||||
// for the tests: everything else acts on the answer inside tick.
|
||||
func (s *supervisor) holdsLease() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.running
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/cronjob"
|
||||
|
||||
models2 "go-admin/app/jobs/models"
|
||||
)
|
||||
|
||||
// An instance that starts while another one holds the lease must not
|
||||
// register the jobs. This is the whole point: every instance registering the
|
||||
// whole enabled list into its own scheduler is what made one job fire once
|
||||
// per instance (#915).
|
||||
func TestASecondInstanceDoesNotScheduleWhileTheFirstHoldsTheLease(t *testing.T) {
|
||||
const tenant = "second-instance"
|
||||
db := leaseDB(t)
|
||||
sdk.Runtime.SetCrontabByTenant(tenant, cronjob.NewWithSeconds())
|
||||
|
||||
first := newSupervisor(tenant, db, "instance-a")
|
||||
first.tick()
|
||||
if !first.holdsLease() {
|
||||
t.Fatal("the first instance did not take a free lease")
|
||||
}
|
||||
|
||||
second := newSupervisor(tenant, db, "instance-b")
|
||||
second.tick()
|
||||
if second.holdsLease() {
|
||||
t.Error("a second instance scheduled while the first holds the lease: the job would fire twice per tick")
|
||||
}
|
||||
}
|
||||
|
||||
// Losing the lease has to stop the scheduler, not merely stop it from being
|
||||
// taken again. A holder that keeps scheduling after its lease has gone to
|
||||
// somebody else is two schedulers at once - the defect the lease exists to
|
||||
// prevent, reached from the other direction.
|
||||
func TestTheSupervisorStopsSchedulingWhenItLosesTheLease(t *testing.T) {
|
||||
const tenant = "loses-lease"
|
||||
db := leaseDB(t)
|
||||
sdk.Runtime.SetCrontabByTenant(tenant, cronjob.NewWithSeconds())
|
||||
|
||||
holder := newSupervisor(tenant, db, "instance-a")
|
||||
holder.tick()
|
||||
if !holder.holdsLease() {
|
||||
t.Fatal("the supervisor did not take a free lease, so losing it cannot be observed")
|
||||
}
|
||||
|
||||
// What a partition looks like from the database's side: the lease
|
||||
// lapsed and somebody else took it while this instance was away.
|
||||
expire(t, db)
|
||||
successor := &lease{db: db, owner: "instance-b", ttl: time.Minute}
|
||||
if held, err := successor.acquire(); err != nil || !held {
|
||||
t.Fatalf("the successor could not take the expired lease: held=%v err=%v", held, err)
|
||||
}
|
||||
|
||||
holder.tick()
|
||||
|
||||
if holder.holdsLease() {
|
||||
t.Error("the supervisor kept scheduling after the lease went to another instance")
|
||||
}
|
||||
if got := readLease(t, db).Owner; got != "instance-b" {
|
||||
t.Errorf("owner is %q; the instance that lost the lease wrote over its successor", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A database that cannot be reached is not the same as a lease that has been
|
||||
// lost. Stopping on the first failed renewal would stop the jobs every time
|
||||
// the database blinked - and hand them to nobody, because no other instance
|
||||
// can reach it to take the lease either.
|
||||
func TestABrieflyUnreachableDatabaseDoesNotStopTheScheduler(t *testing.T) {
|
||||
const tenant = "db-blip"
|
||||
db := leaseDB(t)
|
||||
sdk.Runtime.SetCrontabByTenant(tenant, cronjob.NewWithSeconds())
|
||||
|
||||
s := newSupervisor(tenant, db, "instance-a")
|
||||
s.tick()
|
||||
if !s.holdsLease() {
|
||||
t.Fatal("the supervisor did not take a free lease")
|
||||
}
|
||||
|
||||
// The table going missing is how an unreachable database presents to
|
||||
// acquire: every statement against it returns an error.
|
||||
if err := db.Migrator().DropTable(&models2.SysJobLease{}); err != nil {
|
||||
t.Fatalf("dropping the lease table: %v", err)
|
||||
}
|
||||
|
||||
s.tick()
|
||||
|
||||
if !s.holdsLease() {
|
||||
t.Error("one failed renewal stopped the scheduler; the lease had not expired yet and nobody else could have taken it")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
)
|
||||
|
||||
// columnLengthPattern pulls the first parenthesized integer out of a MySQL
|
||||
// COLUMN_TYPE string - the "(255)" in "varchar(255)", the "(10" in
|
||||
// "decimal(10,2)". Works regardless of trailing modifiers such as
|
||||
// "unsigned" or a charset clause, since it only looks for the first digits
|
||||
// after the first '('.
|
||||
var columnLengthPattern = regexp.MustCompile(`\((\d+)`)
|
||||
|
||||
// InferColumnWidth backs R2's fallback path: when a column's colWidth is
|
||||
// left at its 0 sentinel (unconfigured), this reads sys_columns.column_type
|
||||
// - MySQL's information_schema.COLUMNS.COLUMN_TYPE, which carries length,
|
||||
// e.g. "varchar(255)", "int(11)", "decimal(10,2)", "tinyint(1)" - and
|
||||
// returns a px width sized to fit inside go-admin-ui's ~580px text-column
|
||||
// budget for a 1280px viewport (its AGENTS.md "列宽" section).
|
||||
//
|
||||
// The judgment has to be columnType, not goType: sys_tables.go:323-338
|
||||
// gives every non-primary-key int/tinyint/bigint/decimal column goType
|
||||
// "string" (a bare substring match on "int" that also catches "tinyint"/
|
||||
// "bigint", intentional at import time but useless for telling a boolean
|
||||
// flag from a bigint), so goType alone cannot distinguish a switch column
|
||||
// from a price column from a name column. This is the same judgment call
|
||||
// API契约.md §1.1 made, reversing the PRD's original "GoType" reading of R2.
|
||||
// GoType is not consulted anywhere in this function, including for
|
||||
// datetime/timestamp columns - those are matched on columnType too.
|
||||
//
|
||||
// Exported and pure (string in, int out) so QA can pin an exact input/output
|
||||
// table against it directly (测试用例.md §2.5's own recommendation), rather
|
||||
// than only being able to assert "the rendered page happens not to overflow".
|
||||
func InferColumnWidth(columnType string) int {
|
||||
ct := strings.ToLower(strings.TrimSpace(columnType))
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(ct, "tinyint(1)"):
|
||||
// MySQL's own shape for a boolean/status flag - a tag or a switch,
|
||||
// not text, so it wants less room than a general numeric column.
|
||||
return 70
|
||||
|
||||
case strings.Contains(ct, "datetime"), strings.Contains(ct, "timestamp"),
|
||||
strings.Contains(ct, "date"), strings.Contains(ct, "time"):
|
||||
return 110
|
||||
|
||||
case strings.HasPrefix(ct, "tinyint"), strings.HasPrefix(ct, "smallint"),
|
||||
strings.HasPrefix(ct, "mediumint"), strings.HasPrefix(ct, "int"),
|
||||
strings.HasPrefix(ct, "bigint"), strings.HasPrefix(ct, "decimal"),
|
||||
strings.HasPrefix(ct, "float"), strings.HasPrefix(ct, "double"):
|
||||
// API契约.md §1.1: "decimal/bigint/int 类给数字型窄宽度" groups these
|
||||
// together rather than sizing each individually - none of them need
|
||||
// more than a handful of digits' worth of width.
|
||||
return 90
|
||||
|
||||
case strings.HasPrefix(ct, "varchar"), strings.HasPrefix(ct, "char"):
|
||||
return varcharWidth(columnLength(ct))
|
||||
|
||||
case strings.Contains(ct, "text"), strings.Contains(ct, "blob"):
|
||||
// longtext/mediumtext/text/blob: no declared length to size against,
|
||||
// and content here is free-form, so this errs wide rather than
|
||||
// guessing a number the actual content will not respect.
|
||||
return 260
|
||||
|
||||
default:
|
||||
// Unrecognized column_type (an enum, a json column, a driver this
|
||||
// codebase does not special-case, ...). Matches the flat fallback
|
||||
// vue.go.template already used for every non-datetime column before
|
||||
// this function existed, so a type this does not recognize is no
|
||||
// worse off than the old blanket default.
|
||||
return 120
|
||||
}
|
||||
}
|
||||
|
||||
// varcharWidth tiers a char/varchar column by its declared length. The
|
||||
// tiers are deliberately coarse - R2 only asks for "common tables land in
|
||||
// the 580px budget", not pixel-perfect sizing per character.
|
||||
func varcharWidth(n int) int {
|
||||
switch {
|
||||
case n <= 0:
|
||||
// Length did not parse (unexpected shape) - mid tier, not the
|
||||
// narrowest, since an un-lengthed varchar is unlikely to be a
|
||||
// short code column.
|
||||
return 150
|
||||
case n <= 10:
|
||||
return 90
|
||||
case n <= 20:
|
||||
return 110
|
||||
case n <= 50:
|
||||
return 150
|
||||
case n <= 100:
|
||||
return 200
|
||||
default:
|
||||
return 240
|
||||
}
|
||||
}
|
||||
|
||||
// columnLength extracts the first parenthesized integer, or 0 if the type
|
||||
// string does not have one (already-lowercased input expected).
|
||||
func columnLength(columnType string) int {
|
||||
m := columnLengthPattern.FindStringSubmatch(columnType)
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// applyInferredColumnWidths fills in InferColumnWidth's result for every
|
||||
// column still at the 0 "unconfigured" sentinel, in place, before the
|
||||
// template that reads .ColWidth runs. A column the user (or F6's config
|
||||
// page) already gave an explicit width is left untouched.
|
||||
func applyInferredColumnWidths(columns []tools.SysColumns) {
|
||||
for i := range columns {
|
||||
if columns[i].ColWidth == 0 {
|
||||
columns[i].ColWidth = InferColumnWidth(columns[i].ColumnType)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
)
|
||||
|
||||
// Input/output pins for InferColumnWidth, per 测试用例.md §2.5's own
|
||||
// recommendation ("QA 才能在阶段 4 补一张精确的输入→输出对照表断言, 而不是只测
|
||||
// 结果凑巧没溢出这种弱结论") - this is that table, kept next to the function
|
||||
// it pins rather than only living in a later QA-owned suite.
|
||||
func TestInferColumnWidth(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
columnType string
|
||||
want int
|
||||
}{
|
||||
{"boolean/status flag", "tinyint(1)", 70},
|
||||
{"boolean flag, case-insensitive", "TINYINT(1)", 70},
|
||||
{"datetime", "datetime", 110},
|
||||
{"timestamp", "timestamp", 110},
|
||||
{"date only", "date", 110},
|
||||
{"time only", "time", 110},
|
||||
|
||||
{"plain tinyint (not the (1) boolean shape)", "tinyint(4)", 90},
|
||||
{"smallint", "smallint(6)", 90},
|
||||
{"mediumint", "mediumint(9)", 90},
|
||||
{"int", "int(11)", 90},
|
||||
{"bigint", "bigint(20)", 90},
|
||||
{"decimal", "decimal(10,2)", 90},
|
||||
{"float", "float", 90},
|
||||
{"double", "double", 90},
|
||||
|
||||
{"varchar short code", "varchar(8)", 90},
|
||||
{"varchar at the 10 boundary", "varchar(10)", 90},
|
||||
{"varchar just past the 10 boundary", "varchar(11)", 110},
|
||||
{"varchar at the 20 boundary", "varchar(20)", 110},
|
||||
{"varchar mid length", "varchar(32)", 150},
|
||||
{"varchar at the 50 boundary", "varchar(50)", 150},
|
||||
{"varchar just past the 50 boundary", "varchar(51)", 200},
|
||||
{"varchar(255), the common default", "varchar(255)", 240},
|
||||
{"char, fixed-width", "char(2)", 90},
|
||||
{"varchar with no parsed length", "varchar", 150},
|
||||
|
||||
{"text, no length to size against", "text", 260},
|
||||
{"longtext", "longtext", 260},
|
||||
{"mediumtext", "mediumtext", 260},
|
||||
{"blob", "blob", 260},
|
||||
|
||||
{"unrecognized type falls back to the old flat default", "json", 120},
|
||||
{"empty column_type falls back to the old flat default", "", 120},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := InferColumnWidth(tc.columnType); got != tc.want {
|
||||
t.Errorf("InferColumnWidth(%q) = %d, want %d", tc.columnType, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyInferredColumnWidths(t *testing.T) {
|
||||
columns := []tools.SysColumns{
|
||||
{JsonField: "name", ColumnType: "varchar(64)", ColWidth: 0},
|
||||
{JsonField: "price", ColumnType: "decimal(10,2)", ColWidth: 300}, // already configured
|
||||
}
|
||||
|
||||
applyInferredColumnWidths(columns)
|
||||
|
||||
if columns[0].ColWidth == 0 {
|
||||
t.Error("unconfigured column: want an inferred non-zero width, still 0")
|
||||
}
|
||||
if want := InferColumnWidth("varchar(64)"); columns[0].ColWidth != want {
|
||||
t.Errorf("unconfigured column: want %d (InferColumnWidth's own answer), got %d", want, columns[0].ColWidth)
|
||||
}
|
||||
if columns[1].ColWidth != 300 {
|
||||
t.Errorf("already-configured column: want the user's 300 left untouched, got %d", columns[1].ColWidth)
|
||||
}
|
||||
}
|
||||
+104
-10
@@ -22,6 +22,29 @@ type Gen struct {
|
||||
api.Api
|
||||
}
|
||||
|
||||
// genLangFuncs backs the lang-zh/lang-en templates (PRD 010 F3/F9). The
|
||||
// generated files are TypeScript, and go-admin-ui's eslint config requires
|
||||
// single-quoted strings with no trailing comma (@stylistic/quotes,
|
||||
// @stylistic/comma-dangle: never) - text/template's builtin `printf "%q"`
|
||||
// only produces Go/JSON-style double-quoted output, so this supplies a
|
||||
// single-quote equivalent instead of leaning on the builtin.
|
||||
var genLangFuncs = template.FuncMap{
|
||||
"singleQuote": func(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `'`, `\'`, "\n", `\n`, "\r", `\r`)
|
||||
return "'" + r.Replace(s) + "'"
|
||||
},
|
||||
}
|
||||
|
||||
// parseGenTemplate is template.ParseFiles plus genLangFuncs, for the two
|
||||
// language-pack templates. template.New's name must match the file's base
|
||||
// name - ParseFiles reuses the template already registered under that name
|
||||
// instead of creating an unnamed second one, which is what makes Execute
|
||||
// find the parsed content afterwards.
|
||||
func parseGenTemplate(path string) (*template.Template, error) {
|
||||
base := path[strings.LastIndex(path, "/")+1:]
|
||||
return template.New(base).Funcs(genLangFuncs).ParseFiles(path)
|
||||
}
|
||||
|
||||
func (e Gen) Preview(c *gin.Context) {
|
||||
e.Context = c
|
||||
log := e.GetLogger()
|
||||
@@ -45,10 +68,10 @@ func (e Gen) Preview(c *gin.Context) {
|
||||
e.Error(500, err, fmt.Sprintf("api模版读取失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
t3, err := template.ParseFiles("template/v4/js.go.template")
|
||||
t3, err := template.ParseFiles("template/v4/ts.go.template")
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("js模版读取失败!错误详情:%s", err.Error()))
|
||||
e.Error(500, err, fmt.Sprintf("ts模版读取失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
t4, err := template.ParseFiles("template/v4/vue.go.template")
|
||||
@@ -75,6 +98,22 @@ func (e Gen) Preview(c *gin.Context) {
|
||||
e.Error(500, err, fmt.Sprintf("service模版读取失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
// t8/t9 back F3/F9 (PRD 010): one language pack per locale, nested under
|
||||
// gen/{PackageName}/{BusinessName}.ts by NOActionsGen below so go-admin-ui's
|
||||
// gen-namespace.ts glob (`./*/*.ts` under each locale's gen/) picks them up.
|
||||
// See docs-prd/010-代码生成器前端模板迁移Vue3/API契约.md §2.3.
|
||||
t8, err := parseGenTemplate("template/v4/lang-zh.go.template")
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("zh语言包模版读取失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
t9, err := parseGenTemplate("template/v4/lang-en.go.template")
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("en语言包模版读取失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
db, err := pkg.GetOrm(c)
|
||||
if err != nil {
|
||||
@@ -83,7 +122,18 @@ func (e Gen) Preview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
tab, _ := table.Get(db,false)
|
||||
tab, _ := table.Get(db, false)
|
||||
// MLTBName (table_name with underscores turned to dashes) is a gorm:"-"
|
||||
// field - table.Get never fills it in, so every template that reads it
|
||||
// (the .vue/.ts import paths, e.g. "@/api/{PackageName}/{MLTBName}")
|
||||
// silently rendered it empty here. NOActionsGen has set this since it
|
||||
// existed (see below); Preview never did, which is why the two paths
|
||||
// are not interchangeable stand-ins for each other and should not be
|
||||
// assumed to be.
|
||||
tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1)
|
||||
// R2: infer a width for any column the config page left at colWidth's 0
|
||||
// sentinel, before vue.go.template reads .ColWidth - see column_width.go.
|
||||
applyInferredColumnWidths(tab.Columns)
|
||||
var b1 bytes.Buffer
|
||||
err = t1.Execute(&b1, tab)
|
||||
var b2 bytes.Buffer
|
||||
@@ -98,15 +148,21 @@ func (e Gen) Preview(c *gin.Context) {
|
||||
err = t6.Execute(&b6, tab)
|
||||
var b7 bytes.Buffer
|
||||
err = t7.Execute(&b7, tab)
|
||||
var b8 bytes.Buffer
|
||||
err = t8.Execute(&b8, tab)
|
||||
var b9 bytes.Buffer
|
||||
err = t9.Execute(&b9, tab)
|
||||
|
||||
mp := make(map[string]interface{})
|
||||
mp["template/model.go.template"] = b1.String()
|
||||
mp["template/api.go.template"] = b2.String()
|
||||
mp["template/js.go.template"] = b3.String()
|
||||
mp["template/api.ts.template"] = b3.String()
|
||||
mp["template/vue.go.template"] = b4.String()
|
||||
mp["template/router.go.template"] = b5.String()
|
||||
mp["template/dto.go.template"] = b6.String()
|
||||
mp["template/service.go.template"] = b7.String()
|
||||
mp["template/lang-zh.go.template"] = b8.String()
|
||||
mp["template/lang-en.go.template"] = b9.String()
|
||||
e.OK(mp, "")
|
||||
}
|
||||
|
||||
@@ -129,7 +185,7 @@ func (e Gen) GenCode(c *gin.Context) {
|
||||
}
|
||||
|
||||
table.TableId = id
|
||||
tab, _ := table.Get(db,false)
|
||||
tab, _ := table.Get(db, false)
|
||||
|
||||
e.NOActionsGen(c, tab)
|
||||
|
||||
@@ -155,7 +211,7 @@ func (e Gen) GenApiToFile(c *gin.Context) {
|
||||
}
|
||||
|
||||
table.TableId = id
|
||||
tab, _ := table.Get(db,false)
|
||||
tab, _ := table.Get(db, false)
|
||||
e.genApiToFile(c, tab)
|
||||
|
||||
e.OK("", "Code generated successfully!")
|
||||
@@ -165,6 +221,8 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
|
||||
e.Context = c
|
||||
log := e.GetLogger()
|
||||
tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1)
|
||||
// R2: see the matching call and comment in Preview above.
|
||||
applyInferredColumnWidths(tab.Columns)
|
||||
|
||||
basePath := "template/v4/"
|
||||
routerFile := basePath + "no_actions/router_check_role.go.template"
|
||||
@@ -191,10 +249,10 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
|
||||
e.Error(500, err, fmt.Sprintf("路由模版失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
t4, err := template.ParseFiles(basePath + "js.go.template")
|
||||
t4, err := template.ParseFiles(basePath + "ts.go.template")
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("js模版解析失败!错误详情:%s", err.Error()))
|
||||
e.Error(500, err, fmt.Sprintf("ts模版解析失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
t5, err := template.ParseFiles(basePath + "vue.go.template")
|
||||
@@ -215,6 +273,19 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
|
||||
e.Error(500, err, fmt.Sprintf("service模版失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
// t8/t9 back F3/F9 (PRD 010): see the matching comment in Preview above.
|
||||
t8, err := parseGenTemplate(basePath + "lang-zh.go.template")
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("zh语言包模版解析失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
t9, err := parseGenTemplate(basePath + "lang-en.go.template")
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("en语言包模版解析失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
_ = pkg.PathCreate("./app/" + tab.PackageName + "/apis/")
|
||||
_ = pkg.PathCreate("./app/" + tab.PackageName + "/models/")
|
||||
@@ -227,6 +298,23 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
|
||||
e.Error(500, err, fmt.Sprintf("views目录创建失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
// gen/{PackageName}/ nests under each locale so go-admin-ui's
|
||||
// gen-namespace.ts (`./*/*.ts` glob, one level under gen/) picks the file
|
||||
// up - a flat gen/{BusinessName}.ts would let two tables in different
|
||||
// packages silently overwrite each other's translations, since
|
||||
// BusinessName only has a pattern check, no uniqueness check.
|
||||
err = pkg.PathCreate(config.GenConfig.FrontPath + "/lang/zh-CN/gen/" + tab.PackageName + "/")
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("zh语言包目录创建失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
err = pkg.PathCreate(config.GenConfig.FrontPath + "/lang/en-US/gen/" + tab.PackageName + "/")
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("en语言包目录创建失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
var b1 bytes.Buffer
|
||||
err = t1.Execute(&b1, tab)
|
||||
@@ -242,13 +330,19 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
|
||||
err = t6.Execute(&b6, tab)
|
||||
var b7 bytes.Buffer
|
||||
err = t7.Execute(&b7, tab)
|
||||
var b8 bytes.Buffer
|
||||
err = t8.Execute(&b8, tab)
|
||||
var b9 bytes.Buffer
|
||||
err = t9.Execute(&b9, tab)
|
||||
pkg.FileCreate(b1, "./app/"+tab.PackageName+"/models/"+tab.TBName+".go")
|
||||
pkg.FileCreate(b2, "./app/"+tab.PackageName+"/apis/"+tab.TBName+".go")
|
||||
pkg.FileCreate(b3, "./app/"+tab.PackageName+"/router/"+tab.TBName+".go")
|
||||
pkg.FileCreate(b4, config.GenConfig.FrontPath+"/api/"+tab.PackageName+"/"+tab.MLTBName+".js")
|
||||
pkg.FileCreate(b4, config.GenConfig.FrontPath+"/api/"+tab.PackageName+"/"+tab.MLTBName+".ts")
|
||||
pkg.FileCreate(b5, config.GenConfig.FrontPath+"/views/"+tab.PackageName+"/"+tab.MLTBName+"/index.vue")
|
||||
pkg.FileCreate(b6, "./app/"+tab.PackageName+"/service/dto/"+tab.TBName+".go")
|
||||
pkg.FileCreate(b7, "./app/"+tab.PackageName+"/service/"+tab.TBName+".go")
|
||||
pkg.FileCreate(b8, config.GenConfig.FrontPath+"/lang/zh-CN/gen/"+tab.PackageName+"/"+tab.BusinessName+".ts")
|
||||
pkg.FileCreate(b9, config.GenConfig.FrontPath+"/lang/en-US/gen/"+tab.PackageName+"/"+tab.BusinessName+".ts")
|
||||
|
||||
}
|
||||
|
||||
@@ -302,7 +396,7 @@ func (e Gen) GenMenuAndApi(c *gin.Context) {
|
||||
}
|
||||
|
||||
table.TableId = id
|
||||
tab, _ := table.Get(e.Orm,true)
|
||||
tab, _ := table.Get(e.Orm, true)
|
||||
tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1)
|
||||
|
||||
Mmenu := dto.SysMenuInsertReq{}
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "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"
|
||||
_ "github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
@@ -80,7 +80,7 @@ func (e SysTable) Get(c *gin.Context) {
|
||||
|
||||
var data tools.SysTables
|
||||
data.TableId, _ = pkg.StringToInt(c.Param("tableId"))
|
||||
result, err := data.Get(db,true)
|
||||
result, err := data.Get(db, true)
|
||||
if err != nil {
|
||||
log.Errorf("Get error, %s", err.Error())
|
||||
e.Error(500, err, "")
|
||||
@@ -107,7 +107,7 @@ func (e SysTable) GetSysTablesInfo(c *gin.Context) {
|
||||
if c.Request.FormValue("tableName") != "" {
|
||||
data.TBName = c.Request.FormValue("tableName")
|
||||
}
|
||||
result, err := data.Get(db,true)
|
||||
result, err := data.Get(db, true)
|
||||
if err != nil {
|
||||
log.Errorf("Get error, %s", err.Error())
|
||||
e.Error(500, err, "抱歉未找到相关信息")
|
||||
@@ -368,6 +368,21 @@ func (e SysTable) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// PRD 010 F10: this bind-and-save path has no field-level validation of
|
||||
// its own (API契约.md §1.2/§2.1, D6) - see sys_tables_validate.go for
|
||||
// what each check guards and why colWidth is sanitized in place rather
|
||||
// than rejected.
|
||||
if err = validateAndSanitizeColumns(data.Columns); err != nil {
|
||||
log.Errorf("validate columns error, %s", err.Error())
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
if err = validateBusinessNameUnique(db, data.PackageName, data.BusinessName, data.TableId); err != nil {
|
||||
log.Errorf("validate businessName error, %s", err.Error())
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
data.UpdateBy = 0
|
||||
result, err := data.Update(db)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
)
|
||||
|
||||
// jsonFieldPattern accepts any legal JS/TS identifier that starts with a
|
||||
// lowercase letter - not businessName's rule.
|
||||
//
|
||||
// This used to be businessName's own pattern (^[a-z][A-Za-z]+$, requiring at
|
||||
// least two letters and no digits), copied over on the theory that jsonField
|
||||
// "should tighten to the same identifier shape". That theory does not hold:
|
||||
// businessName is typed by a person on genInfoForm.vue, so a strict pattern
|
||||
// is a reasonable guardrail on human input. jsonField is computed by the
|
||||
// importer from the column name (sys_tables.go's namelist/JsonField loop) -
|
||||
// nobody types it, so the same pattern only rejects names the importer
|
||||
// legitimately produces. A one-letter column ("x") or a column ending in a
|
||||
// digit ("address2", "a1") both import to a single camelCase word with no
|
||||
// separators to re-capitalize, and both used to fail this check - meaning a
|
||||
// table that merely contained such a column could never save any config
|
||||
// again, unrelated columns included, since this check runs over every
|
||||
// column on every Update.
|
||||
//
|
||||
// What still has to be rejected is a jsonField that cannot be a raw object
|
||||
// key at all: empty, containing whitespace/punctuation, or leading with a
|
||||
// digit (`2faEnabled: 1` is not valid JS - identifiers cannot start with a
|
||||
// digit, and this is what lands as the property name in gen.go's generated
|
||||
// interface / lang file, both unquoted). Hence still anchoring on a
|
||||
// lowercase letter first, but no longer requiring a second character or
|
||||
// forbidding digits after it.
|
||||
var jsonFieldPattern = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`)
|
||||
|
||||
// colWidthMin/colWidthMax are API契约.md §2.1's suggested range for colWidth.
|
||||
const (
|
||||
colWidthMin = 40
|
||||
colWidthMax = 800
|
||||
)
|
||||
|
||||
// expressionMarkers flags the "meant to be evaluated" shapes API契约.md §2.1
|
||||
// says defaultValue must not carry: it is spliced into the generated
|
||||
// defaultModel() as a literal and never evaluated, so anything that looks
|
||||
// like a function call or a block is rejected outright rather than
|
||||
// generating code that silently does nothing.
|
||||
var expressionMarkers = []string{"(", ")", "{", "}", "`", ";", "=>"}
|
||||
|
||||
// validateAndSanitizeColumns enforces PRD 010 F10 on the columns carried by
|
||||
// a table update (sys_tables.go:357's Update handler, the one bind-and-save
|
||||
// path with no field-level validation at all - see API契约.md §1.2/§2.1,
|
||||
// decision D6).
|
||||
//
|
||||
// jsonField and defaultValue problems reject the request outright: letting
|
||||
// either through would corrupt the generated i18n file silently (a
|
||||
// duplicate or malformed jsonField becomes a duplicate or invalid key in
|
||||
// gen/{PackageName}/{BusinessName}.ts, see the lang-zh/lang-en templates).
|
||||
// An out-of-range colWidth does not reject - §2.1 says it "falls back to
|
||||
// the inferred value", so this resets it to the 0 sentinel in place and lets
|
||||
// R2's inference take over, the same as if the field had never been set.
|
||||
func validateAndSanitizeColumns(columns []tools.SysColumns) error {
|
||||
seen := make(map[string]bool, len(columns))
|
||||
for i := range columns {
|
||||
col := &columns[i]
|
||||
|
||||
if !jsonFieldPattern.MatchString(col.JsonField) {
|
||||
return fmt.Errorf("jsonField 格式不合法:%q,须以小写字母开头且只能包含英文字母", col.JsonField)
|
||||
}
|
||||
if seen[col.JsonField] {
|
||||
return fmt.Errorf("jsonField 在同一张表内重复:%q", col.JsonField)
|
||||
}
|
||||
seen[col.JsonField] = true
|
||||
|
||||
if col.ColWidth != 0 && (col.ColWidth < colWidthMin || col.ColWidth > colWidthMax) {
|
||||
col.ColWidth = 0
|
||||
}
|
||||
|
||||
for _, marker := range expressionMarkers {
|
||||
if strings.Contains(col.DefaultValue, marker) {
|
||||
return fmt.Errorf("defaultValue 不允许包含表达式或函数调用内容:%q", col.DefaultValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateBusinessNameUnique enforces PRD 010 F10's other half: two tables
|
||||
// sharing (packageName, businessName) write the same generated language
|
||||
// pack path, gen/{PackageName}/{BusinessName}.ts (see gen.go's
|
||||
// NOActionsGen), so the second one silently overwrites the first's
|
||||
// translations. tableID excludes the row being saved, so a table updating
|
||||
// its own unchanged name does not trip the check on itself.
|
||||
//
|
||||
// G10's other concern - colliding with the built-in admin/* i18n namespace -
|
||||
// does not apply here anymore: D9 moved generated keys to their own gen/
|
||||
// namespace, so this only has to guard generated tables against each other.
|
||||
func validateBusinessNameUnique(db *gorm.DB, packageName, businessName string, tableID int) error {
|
||||
var count int64
|
||||
err := db.Table("sys_tables").
|
||||
Where("package_name = ? AND business_name = ? AND table_id != ?", packageName, businessName, tableID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("packageName=%q 下 businessName=%q 已被其它表使用", packageName, businessName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
)
|
||||
|
||||
func TestValidateAndSanitizeColumns_JsonFieldFormat(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
jsonField string
|
||||
wantErr bool
|
||||
}{
|
||||
{"lower camelCase", "userName", false},
|
||||
{"two-letter lowercase", "id", false},
|
||||
// The importer's own output (sys_tables.go's namelist/JsonField
|
||||
// loop), not made up: a single-letter column ("x"), and a column
|
||||
// whose last name segment ends in a digit ("address2", "a1") both
|
||||
// produce a jsonField with no separator left to re-capitalize.
|
||||
// These three used to be rejected - the whole point of this fix.
|
||||
{"single letter, real importer output for a column named x", "x", false},
|
||||
{"letters then a trailing digit, real importer output for address2", "address2", false},
|
||||
{"two letters then a digit, real importer output for a1", "a1", false},
|
||||
{"leading underscore rejected", "_id", true},
|
||||
{"leading digit rejected (not a legal identifier start)", "1name", true},
|
||||
{"snake_case rejected (importer never emits an underscore)", "user_name", true},
|
||||
{"dot rejected, would break the gen/{pkg}/{biz}.ts key path", "user.name", true},
|
||||
{"empty rejected", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateAndSanitizeColumns([]tools.SysColumns{{JsonField: tc.jsonField}})
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("jsonField %q: want error, got nil", tc.jsonField)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("jsonField %q: want no error, got %v", tc.jsonField, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAndSanitizeColumns_JsonFieldUniqueWithinTable(t *testing.T) {
|
||||
err := validateAndSanitizeColumns([]tools.SysColumns{
|
||||
{JsonField: "name"},
|
||||
{JsonField: "name"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("want error for a jsonField repeated in the same table, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAndSanitizeColumns_ColWidthOutOfRangeIsSanitizedNotRejected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
width int
|
||||
want int
|
||||
}{
|
||||
{"zero (unconfigured) is left alone", 0, 0},
|
||||
{"in range is left alone", 150, 150},
|
||||
{"lower bound is left alone", colWidthMin, colWidthMin},
|
||||
{"upper bound is left alone", colWidthMax, colWidthMax},
|
||||
{"too small falls back to the sentinel", colWidthMin - 1, 0},
|
||||
{"too large falls back to the sentinel", colWidthMax + 1, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cols := []tools.SysColumns{{JsonField: "name", ColWidth: tc.width}}
|
||||
if err := validateAndSanitizeColumns(cols); err != nil {
|
||||
t.Fatalf("colWidth %d: want no error (out-of-range sanitizes, it does not reject), got %v", tc.width, err)
|
||||
}
|
||||
if cols[0].ColWidth != tc.want {
|
||||
t.Errorf("colWidth %d: want sanitized to %d, got %d", tc.width, tc.want, cols[0].ColWidth)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAndSanitizeColumns_DefaultValueExpressionRejected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
defaultValue string
|
||||
wantErr bool
|
||||
}{
|
||||
{"plain literal", "0", false},
|
||||
{"plain string literal", "active", false},
|
||||
{"empty (unconfigured)", "", false},
|
||||
{"function call rejected", "Date.now()", true},
|
||||
{"template literal rejected", "`x`", true},
|
||||
{"arrow function rejected", "() => 1", true},
|
||||
{"statement separator rejected", "1; drop", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateAndSanitizeColumns([]tools.SysColumns{{JsonField: "name", DefaultValue: tc.defaultValue}})
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("defaultValue %q: want error, got nil", tc.defaultValue)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("defaultValue %q: want no error, got %v", tc.defaultValue, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newBusinessNameTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(new(tools.SysTables)); err != nil {
|
||||
t.Fatalf("migrate sys_tables: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestValidateBusinessNameUnique(t *testing.T) {
|
||||
db := newBusinessNameTestDB(t)
|
||||
|
||||
existing := tools.SysTables{TBName: "sys_widget", PackageName: "biz", BusinessName: "widget"}
|
||||
if err := db.Table("sys_tables").Create(&existing).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
t.Run("same package, same businessName, different table: rejected", func(t *testing.T) {
|
||||
other := tools.SysTables{TBName: "sys_widget_copy", PackageName: "biz", BusinessName: "widget"}
|
||||
if err := db.Table("sys_tables").Create(&other).Error; err != nil {
|
||||
t.Fatalf("seed second row: %v", err)
|
||||
}
|
||||
// Unscoped: a plain Delete only soft-deletes (SysTables carries
|
||||
// common.ModelTime), which would leave this row's businessName
|
||||
// looking taken for the next subtest - production's own delete path
|
||||
// (SysTables.BatchDelete) hard-deletes for the same reason.
|
||||
defer db.Table("sys_tables").Unscoped().Delete(&other)
|
||||
|
||||
if err := validateBusinessNameUnique(db, "biz", "widget", other.TableId); err == nil {
|
||||
t.Error("want error for a businessName already used by another table in the same package, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different package, same businessName: allowed", func(t *testing.T) {
|
||||
if err := validateBusinessNameUnique(db, "other-pkg", "widget", 0); err != nil {
|
||||
t.Errorf("want no error across different packages, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a table checking against its own current name: allowed", func(t *testing.T) {
|
||||
if err := validateBusinessNameUnique(db, "biz", "widget", existing.TableId); err != nil {
|
||||
t.Errorf("want no error when the only match is the row being saved itself, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -45,6 +45,19 @@ type SysColumns struct {
|
||||
CreateBy int `gorm:"column:create_by;size:20;" json:"createBy"`
|
||||
UpdateBy int `gorm:"column:update_By;size:20;" json:"updateBy"`
|
||||
|
||||
// ColWidth and DefaultValue back PRD 010 F1/F2 (代码生成器前端模板迁移 Vue 3).
|
||||
// Both use a sentinel default (0 / "") rather than NULL - see
|
||||
// docs-prd/010-代码生成器前端模板迁移Vue3/数据库变更.md §1.1: a non-pointer
|
||||
// int/string field can never read NULL back out, and NULL would give
|
||||
// "unconfigured" two representations instead of one. Callers test
|
||||
// ColWidth == 0 / DefaultValue == "" to detect "not configured".
|
||||
//
|
||||
// ColWidth deliberately has no gorm size tag: this codebase's "size:N"
|
||||
// convention on numeric fields maps to a narrow SQL integer type (see
|
||||
// column_width_test.go), and col_width needs to hold values up to 800.
|
||||
ColWidth int `gorm:"column:col_width;not null;default:0;comment:table column width in px, 0 = not configured" json:"colWidth"`
|
||||
DefaultValue string `gorm:"column:default_value;size:255;not null;default:'';comment:form field default value, empty = not configured" json:"defaultValue"`
|
||||
|
||||
common.ModelTime
|
||||
}
|
||||
|
||||
@@ -97,5 +110,23 @@ func (e *SysColumns) Update(tx *gorm.DB) (update SysColumns, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Updates(&e) above skips zero-value fields (GORM's struct-form Updates
|
||||
// always does), but ColWidth/DefaultValue's own "unconfigured" sentinel
|
||||
// is 0/"" (see the field comments on SysColumns) - so clearing either one
|
||||
// back to its sentinel is indistinguishable, to a struct-form Updates,
|
||||
// from "the caller didn't touch this field" and silently does not get
|
||||
// written. A map-form Updates does not skip zero values, so it is used
|
||||
// here for just these two columns rather than widening this to
|
||||
// Select("*") (which would also start writing every other zero-valued
|
||||
// field on this struct - Sort, the Pk/Required/... bools - and that is a
|
||||
// pre-existing gap in this method affecting fields outside PRD 010's
|
||||
// scope, not fixed here).
|
||||
if err = tx.Table("sys_columns").Model(&update).Updates(map[string]interface{}{
|
||||
"col_width": e.ColWidth,
|
||||
"default_value": e.DefaultValue,
|
||||
}).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GORM's Updates(struct) skips zero-value fields, and PRD 010 F1/F2 chose 0 /
|
||||
// "" as the sentinel for "unconfigured" (docs-prd/010-代码生成器前端模板迁移Vue3/
|
||||
// 数据库变更.md §1.1). Put those together and Update can set ColWidth/
|
||||
// DefaultValue but never clear them back to the sentinel: the struct-form
|
||||
// Updates call silently drops the very values this feature needs to write.
|
||||
func TestSysColumnsUpdateClearsSentinelFields(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(new(SysColumns)); err != nil {
|
||||
t.Fatalf("migrate sys_columns: %v", err)
|
||||
}
|
||||
|
||||
col := SysColumns{TableId: 1, ColumnName: "status", ColWidth: 150, DefaultValue: "active"}
|
||||
if _, err := col.Create(db); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// Reset back to the sentinel - the UI action for "go back to inferred
|
||||
// width / no default", not merely "never configured".
|
||||
update := SysColumns{ColumnId: col.ColumnId, ColWidth: 0, DefaultValue: ""}
|
||||
if _, err := update.Update(db); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
|
||||
var got SysColumns
|
||||
if err := db.Table("sys_columns").First(&got, col.ColumnId).Error; err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if got.ColWidth != 0 {
|
||||
t.Errorf("colWidth: want 0 (cleared), got %d - Update() did not write the sentinel back", got.ColWidth)
|
||||
}
|
||||
if got.DefaultValue != "" {
|
||||
t.Errorf("defaultValue: want \"\" (cleared), got %q - Update() did not write the sentinel back", got.DefaultValue)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -5,4 +5,4 @@ import "go-admin/app/demo/router"
|
||||
func init() {
|
||||
//注册路由 fixme 其他应用的路由,在本目录新建文件放在init方法
|
||||
AppRouters = append(AppRouters, router.InitRouter)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ func init() {
|
||||
rootCmd.AddCommand(app.StartCmd)
|
||||
}
|
||||
|
||||
//Execute : apply commands
|
||||
// Execute : apply commands
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(-1)
|
||||
|
||||
@@ -13,4 +13,4 @@ type SysApi struct {
|
||||
|
||||
func (SysApi) TableName() string {
|
||||
return "sys_api"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
package models
|
||||
|
||||
type SysMenu struct {
|
||||
MenuId int `json:"menuId" gorm:"primaryKey;autoIncrement"`
|
||||
MenuName string `json:"menuName" gorm:"size:128;"`
|
||||
Title string `json:"title" gorm:"size:128;"`
|
||||
Icon string `json:"icon" gorm:"size:128;"`
|
||||
Path string `json:"path" gorm:"size:128;"`
|
||||
Paths string `json:"paths" gorm:"size:128;"`
|
||||
MenuType string `json:"menuType" gorm:"size:1;"`
|
||||
Action string `json:"action" gorm:"size:16;"`
|
||||
Permission string `json:"permission" gorm:"size:255;"`
|
||||
ParentId int `json:"parentId" gorm:"size:11;"`
|
||||
NoCache bool `json:"noCache" gorm:"size:8;"`
|
||||
Breadcrumb string `json:"breadcrumb" gorm:"size:255;"`
|
||||
Component string `json:"component" gorm:"size:255;"`
|
||||
Sort int `json:"sort" gorm:"size:4;"`
|
||||
Visible string `json:"visible" gorm:"size:1;"`
|
||||
IsFrame string `json:"isFrame" gorm:"size:1;DEFAULT:0;"`
|
||||
SysApi []SysApi `json:"sysApi" gorm:"many2many:sys_menu_api_rule"`
|
||||
MenuId int `json:"menuId" gorm:"primaryKey;autoIncrement"`
|
||||
MenuName string `json:"menuName" gorm:"size:128;"`
|
||||
Title string `json:"title" gorm:"size:128;"`
|
||||
Icon string `json:"icon" gorm:"size:128;"`
|
||||
Path string `json:"path" gorm:"size:128;"`
|
||||
Paths string `json:"paths" gorm:"size:128;"`
|
||||
MenuType string `json:"menuType" gorm:"size:1;"`
|
||||
Action string `json:"action" gorm:"size:16;"`
|
||||
Permission string `json:"permission" gorm:"size:255;"`
|
||||
ParentId int `json:"parentId" gorm:"size:11;"`
|
||||
NoCache bool `json:"noCache" gorm:"size:8;"`
|
||||
Breadcrumb string `json:"breadcrumb" gorm:"size:255;"`
|
||||
Component string `json:"component" gorm:"size:255;"`
|
||||
Sort int `json:"sort" gorm:"size:4;"`
|
||||
Visible string `json:"visible" gorm:"size:1;"`
|
||||
IsFrame string `json:"isFrame" gorm:"size:1;DEFAULT:0;"`
|
||||
SysApi []SysApi `json:"sysApi" gorm:"many2many:sys_menu_api_rule"`
|
||||
ControlBy
|
||||
ModelTime
|
||||
}
|
||||
|
||||
func (SysMenu) TableName() string {
|
||||
return "sys_menu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,4 @@ type SysPost struct {
|
||||
|
||||
func (SysPost) TableName() string {
|
||||
return "sys_post"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,4 @@ type SysRole struct {
|
||||
|
||||
func (SysRole) TableName() string {
|
||||
return "sys_role"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
jobmodels "go-admin/app/jobs/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Create sys_job_lease and seed the one row the scheduler competes for
|
||||
// (issue #915).
|
||||
//
|
||||
// The row is seeded here rather than created on demand at startup. Two
|
||||
// instances starting together would otherwise race to insert the very row
|
||||
// they are each trying to claim, and the loser would have to tell a
|
||||
// duplicate-key error apart from a real one in whichever driver it is
|
||||
// running against. Seeding it makes the runtime path two UPDATE statements
|
||||
// and nothing else.
|
||||
//
|
||||
// It is seeded free - no owner, and an expiry far enough in the past that
|
||||
// the first instance to ask takes it - so that installing this migration
|
||||
// does not leave the scheduler waiting out a TTL that nobody is holding.
|
||||
//
|
||||
// Ordered after 1786700003000 (the soft-delete conversion), so importing
|
||||
// cmd/migrate/migration/models is banned here - see
|
||||
// schema_coverage_test.go's TestPostConversionMigrationsAvoidFrozenSeedModels.
|
||||
// sys_job_lease is AutoMigrate'd from its runtime model under
|
||||
// app/jobs/models directly, and it is absent from 1786700003000's frozen
|
||||
// softDeleteTables list because it embeds no common.ModelTime: a lease that
|
||||
// could be soft-deleted would be a row that both does and does not hold the
|
||||
// scheduler.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700009000JobSchedulerLease)
|
||||
}
|
||||
|
||||
func _1786700009000JobSchedulerLease(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Migrator().AutoMigrate(new(jobmodels.SysJobLease)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Seeded free: no owner, and an expiry of 0 - before every clock
|
||||
// reading there will ever be - so the first instance to ask takes
|
||||
// it rather than waiting out a TTL nobody is holding.
|
||||
lease := jobmodels.SysJobLease{
|
||||
Name: jobmodels.SchedulerLeaseName,
|
||||
Owner: "",
|
||||
AcquiredAtMs: 0,
|
||||
ExpiresAtMs: 0,
|
||||
}
|
||||
if err := tx.Create(&lease).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
jobmodels "go-admin/app/jobs/models"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
func openJobLeaseDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// The runtime has no insert path - two instances starting together would
|
||||
// race to create the row they are both trying to claim - so the row has to
|
||||
// exist when the migration finishes or nothing ever schedules anything.
|
||||
func TestTheSchedulerLeaseMigrationLeavesExactlyOneFreeRow(t *testing.T) {
|
||||
db := openJobLeaseDB(t)
|
||||
|
||||
if err := _1786700009000JobSchedulerLease(db, "1786700009000"); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
if !db.Migrator().HasTable(&jobmodels.SysJobLease{}) {
|
||||
t.Fatal("sys_job_lease was not created")
|
||||
}
|
||||
|
||||
var rows []jobmodels.SysJobLease
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
t.Fatalf("reading sys_job_lease: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("sys_job_lease holds %d rows, want exactly 1", len(rows))
|
||||
}
|
||||
|
||||
row := rows[0]
|
||||
if row.Name != jobmodels.SchedulerLeaseName {
|
||||
t.Errorf("the seeded row is named %q, want %q; acquire looks the row up by this name and would find nothing",
|
||||
row.Name, jobmodels.SchedulerLeaseName)
|
||||
}
|
||||
if row.Owner != "" {
|
||||
t.Errorf("the seeded lease is owned by %q; a fresh install would wait out a TTL held by nobody", row.Owner)
|
||||
}
|
||||
// Zero, not "now": the take is `expires_at_ms <= now`, so a seeded
|
||||
// expiry in the future is a scheduler that does not start until it
|
||||
// passes.
|
||||
if row.ExpiresAtMs != 0 {
|
||||
t.Errorf("the seeded lease expires at %d, want 0", row.ExpiresAtMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSchedulerLeaseMigrationRecordsItsVersion(t *testing.T) {
|
||||
db := openJobLeaseDB(t)
|
||||
|
||||
if err := _1786700009000JobSchedulerLease(db, "1786700009000"); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
var got common.Migration
|
||||
if err := db.Where("version = ?", "1786700009000").First(&got).Error; err != nil {
|
||||
t.Fatalf("the migration did not record its version, so it would run again on every start: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Add sys_columns.col_width and sys_columns.default_value for PRD 010 F1/F2
|
||||
// (代码生成器前端模板迁移 Vue 3).
|
||||
//
|
||||
// col_width backs R2's column-width inference fallback and default_value
|
||||
// backs R1/A6's "unconfigured rows still generate a usable page" guarantee -
|
||||
// see docs-prd/010-代码生成器前端模板迁移Vue3/数据库变更.md §1.1 for why both
|
||||
// defaults are sentinels (0 / "") rather than NULL: a non-pointer Go int/
|
||||
// string field can never read NULL back out, and NULL would give
|
||||
// "unconfigured" two representations instead of one.
|
||||
//
|
||||
// Ordered after 1786700003000, so this reads tools.SysColumns (the runtime
|
||||
// model sys_columns's Update/GetPage/GetSysTablesInfo actually query through)
|
||||
// rather than cmd/migrate/migration/models, matching every migration in this
|
||||
// directory since sys_columns was converted - see
|
||||
// 1786700004000_generator_tables_marker.go and schema_coverage_test.go's
|
||||
// TestPostConversionMigrationsAvoidFrozenSeedModels.
|
||||
//
|
||||
// Hard prerequisite: tools.SysColumns must already declare ColWidth and
|
||||
// DefaultValue (with the gorm tags in the doc above) by the time this file
|
||||
// is compiled - AddColumn reads the column definition off the struct's own
|
||||
// tag, not off anything in this file. Landing this migration without that
|
||||
// model change first makes HasColumn/AddColumn silently do nothing (the
|
||||
// field lookup fails and AddColumn returns an error naming the missing
|
||||
// field), which fails loudly rather than silently - see the "no such field"
|
||||
// error - so this is caught at migrate time, not left for a report later.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700010000GenColumnLayoutFields)
|
||||
}
|
||||
|
||||
func _1786700010000GenColumnLayoutFields(db *gorm.DB, version string) error {
|
||||
m := db.Migrator()
|
||||
if !m.HasColumn(&tools.SysColumns{}, "ColWidth") {
|
||||
if err := m.AddColumn(&tools.SysColumns{}, "ColWidth"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !m.HasColumn(&tools.SysColumns{}, "DefaultValue") {
|
||||
if err := m.AddColumn(&tools.SysColumns{}, "DefaultValue"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
}
|
||||
@@ -45,8 +45,8 @@ func (e *QiNiuKODO) getToken() (string, error) {
|
||||
return putPolicy.UploadToken(mac), nil
|
||||
}
|
||||
|
||||
//Setup 装载
|
||||
//endpoint sss
|
||||
// Setup 装载
|
||||
// endpoint sss
|
||||
func (e *QiNiuKODO) Setup(endpoint, accessKeyID, accessKeySecret, BucketName string, options ...ClientOption) error {
|
||||
|
||||
mac := qbox.NewMac(accessKeyID, accessKeySecret)
|
||||
|
||||
@@ -10,8 +10,8 @@ type ALiYunOSS struct {
|
||||
BucketName string
|
||||
}
|
||||
|
||||
//Setup 装载
|
||||
//endpoint sss
|
||||
// Setup 装载
|
||||
// endpoint sss
|
||||
func (e *ALiYunOSS) Setup(endpoint, accessKeyID, accessKeySecret, BucketName string, options ...ClientOption) error {
|
||||
client, err := oss.New(endpoint, accessKeyID, accessKeySecret)
|
||||
if err != nil {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// NoCache is a middleware function that appends headers
|
||||
// to prevent the client from caching the HTTP response.
|
||||
func NoCache(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate, value")
|
||||
c.Header("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate")
|
||||
c.Header("Expires", "Thu, 01 Jan 1970 00:00:00 GMT")
|
||||
c.Header("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
|
||||
c.Next()
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestNoCache(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
NoCache(c)
|
||||
|
||||
if got := w.Header().Get("Cache-Control"); got != "no-cache, no-store, max-age=0, must-revalidate" {
|
||||
t.Errorf("Cache-Control = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("Expires"); got != "Thu, 01 Jan 1970 00:00:00 GMT" {
|
||||
t.Errorf("Expires = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("Last-Modified"); got == "" {
|
||||
t.Error("Last-Modified should not be empty")
|
||||
} else if _, err := time.Parse(http.TimeFormat, got); err != nil {
|
||||
t.Errorf("Last-Modified = %q is not a valid HTTP time: %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptions(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("OPTIONS request", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodOptions, "/", nil)
|
||||
|
||||
Options(c)
|
||||
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
|
||||
t.Errorf("Access-Control-Allow-Origin = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("Access-Control-Allow-Methods"); got != "GET,POST,PUT,PATCH,DELETE,OPTIONS" {
|
||||
t.Errorf("Access-Control-Allow-Methods = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("Access-Control-Allow-Headers"); got != "authorization, origin, content-type, accept" {
|
||||
t.Errorf("Access-Control-Allow-Headers = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("Allow"); got != "HEAD,GET,POST,PUT,PATCH,DELETE,OPTIONS" {
|
||||
t.Errorf("Allow = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("Content-Type"); got != "application/json" {
|
||||
t.Errorf("Content-Type = %q", got)
|
||||
}
|
||||
if !c.IsAborted() {
|
||||
t.Error("expected the request to be aborted")
|
||||
}
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-OPTIONS request", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
Options(c)
|
||||
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Access-Control-Allow-Origin = %q, want empty", got)
|
||||
}
|
||||
if c.IsAborted() {
|
||||
t.Error("expected the request not to be aborted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSecure(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("without TLS", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
Secure(c)
|
||||
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
|
||||
t.Errorf("Access-Control-Allow-Origin = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Errorf("X-Content-Type-Options = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("X-XSS-Protection"); got != "1; mode=block" {
|
||||
t.Errorf("X-XSS-Protection = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("Strict-Transport-Security"); got != "" {
|
||||
t.Errorf("Strict-Transport-Security = %q, want empty without TLS", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with TLS", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Request.TLS = &tls.ConnectionState{}
|
||||
|
||||
Secure(c)
|
||||
|
||||
if got := w.Header().Get("Strict-Transport-Security"); got != "max-age=31536000" {
|
||||
t.Errorf("Strict-Transport-Security = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -38,6 +38,6 @@ var CasbinExclude = []UrlInfo{
|
||||
{Url: "/", Method: "GET"},
|
||||
{Url: "/api/v1/server-monitor", Method: "GET"},
|
||||
{Url: "/api/v1/public/uploadFile", Method: "POST"},
|
||||
{Url: "/api/v1/user/pwd/set", Method: "PUT"},
|
||||
{Url: "/api/v1/user/pwd/set", Method: "PUT"},
|
||||
{Url: "/api/v1/sys-user", Method: "PUT"},
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
module github.com/go-admin-team/example-app-order
|
||||
|
||||
go 1.25.13
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.8.0
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.10.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.8.0 h1:ZTw5Z/UT1/7OltbGPEaEVerRk4z3koB6O8nDbb84tPM=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.8.0/go.mod h1:LG/XvEfOplbuadKrPTPm0Nu5pN06aQUNZZC3ao4B4gs=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.10.0 h1:MM1wl9s2iW3M4GFsT8SyaUWaj2Q8m1NSuundXc6DWzI=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.10.0/go.mod h1:Q0FfO+8pfPNkPqk9fcRpe2sSOHfir82cjZO8Nol/8SI=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module go-admin
|
||||
|
||||
go 1.26.5
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/alibaba/sentinel-golang v1.0.4
|
||||
@@ -11,7 +11,7 @@ require (
|
||||
github.com/casbin/casbin/v3 v3.8.1
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.8.0
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible
|
||||
github.com/mssola/user_agent v0.6.0
|
||||
|
||||
@@ -145,8 +145,8 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.8.0 h1:ZTw5Z/UT1/7OltbGPEaEVerRk4z3koB6O8nDbb84tPM=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.8.0/go.mod h1:LG/XvEfOplbuadKrPTPm0Nu5pN06aQUNZZC3ao4B4gs=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.10.0 h1:MM1wl9s2iW3M4GFsT8SyaUWaj2Q8m1NSuundXc6DWzI=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.10.0/go.mod h1:Q0FfO+8pfPNkPqk9fcRpe2sSOHfir82cjZO8Nol/8SI=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
|
||||
|
||||
+10
-12
@@ -23,20 +23,18 @@ metadata:
|
||||
version: v1
|
||||
spec:
|
||||
# One replica, and the drain window below buys nothing at one replica: there
|
||||
# is nowhere to send the traffic this pod stops taking. Raising it needs two
|
||||
# changes that are not this number:
|
||||
# is nowhere to send the traffic this pod stops taking.
|
||||
#
|
||||
# The volume below is shared by every replica, and the log path in
|
||||
# settings.yml lives on it, so a second pod would append to the same
|
||||
# rotating file.
|
||||
# The scheduler no longer stands in the way of raising this. Every pod takes
|
||||
# a lease row in its own database (sys_job_lease) and only the holder
|
||||
# registers the jobs, so one enabled job fires once however many pods there
|
||||
# are; a pod that loses the lease stops scheduling, and one that exits hands
|
||||
# it back so a successor starts without waiting out the lease. See #915.
|
||||
#
|
||||
# The job scheduler is per process while its handle on a job is one shared
|
||||
# column. Startup runs `UPDATE sys_job SET entry_id = 0 WHERE entry_id > 0`
|
||||
# across the whole table (app/jobs/jobbase.go), so a second pod erases the
|
||||
# first pod's ids and writes its own, and every pod registers the whole
|
||||
# enabled list in its own scheduler. Neither symptom logs anything: an
|
||||
# enabled job fires once per pod, and stopping one from the UI removes an
|
||||
# entry from the wrong process and still answers 200. See #915.
|
||||
# What still does stand in the way: the volume below is shared by every
|
||||
# replica, and the log path in settings.yml lives on it, so a second pod
|
||||
# appends to the same rotating file. Give each replica its own log
|
||||
# destination before raising this.
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 查询{{.ClassName}}列表
|
||||
export function list{{.ClassName}}(query) {
|
||||
return request({
|
||||
url: '/api/v1/{{.ModuleName}}',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 查询{{.ClassName}}详细
|
||||
export function get{{.ClassName}} ({{.PkJsonField}}) {
|
||||
return request({
|
||||
url: '/api/v1/{{.ModuleName}}/' + {{.PkJsonField}},
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// 新增{{.ClassName}}
|
||||
export function add{{.ClassName}}(data) {
|
||||
return request({
|
||||
url: '/api/v1/{{.ModuleName}}',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 修改{{.ClassName}}
|
||||
export function update{{.ClassName}}(data) {
|
||||
return request({
|
||||
url: '/api/v1/{{.ModuleName}}/'+data.{{.PkJsonField}},
|
||||
method: 'put',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 删除{{.ClassName}}
|
||||
export function del{{.ClassName}}(data) {
|
||||
return request({
|
||||
url: '/api/v1/{{.ModuleName}}',
|
||||
method: 'delete',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
{{- range $i, $col := .Columns}}
|
||||
{{- if $i}},{{end}}
|
||||
{{$col.JsonField}}: {{if $col.ColumnComment}}{{singleQuote $col.ColumnComment}}{{else}}{{singleQuote $col.JsonField}}{{end}}
|
||||
{{- end}}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
{{- range $i, $col := .Columns}}
|
||||
{{- if $i}},{{end}}
|
||||
{{$col.JsonField}}: {{if $col.ColumnComment}}{{singleQuote $col.ColumnComment}}{{else}}{{singleQuote $col.JsonField}}{{end}}
|
||||
{{- end}}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
{{- /*
|
||||
$pkType: the primary key's TS type for get{ClassName}'s parameter.
|
||||
Defaults to "number" - true for every column but string primary keys
|
||||
(natural keys), which do exist (sys_tables.go:323-338 gives a primary
|
||||
key column GoType "string" whenever its ColumnType is not int-shaped).
|
||||
Matches vue.go.template's own $pkType derivation exactly (F4) - useForm
|
||||
there is typed on the same column, and a mismatch between the two is a
|
||||
TS compile error at the call site, not a runtime bug.
|
||||
*/ -}}
|
||||
{{- $pkType := "number" -}}
|
||||
{{- $hasQuery := false -}}
|
||||
{{- range .Columns -}}
|
||||
{{- if and .Pk (eq .GoType "string") }}{{$pkType = "string"}}{{end -}}
|
||||
{{- if eq .IsQuery "1" }}{{$hasQuery = true}}{{end -}}
|
||||
{{- end -}}
|
||||
import request from '@/utils/request'
|
||||
import type { ApiResponse, PageQuery, PageResult, Id } from '@/types/api'
|
||||
|
||||
export interface {{.ClassName}} {
|
||||
{{- range .Columns}}
|
||||
{{.JsonField}}?: {{if eq .GoType "int" -}}
|
||||
number
|
||||
{{- else if eq .GoType "int64" -}}
|
||||
number
|
||||
{{- else if eq .GoType "float32" -}}
|
||||
number
|
||||
{{- else if eq .GoType "float64" -}}
|
||||
number
|
||||
{{- else -}}
|
||||
string
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
|
||||
{{if $hasQuery -}}
|
||||
export interface {{.ClassName}}Query {
|
||||
{{- range .Columns}}
|
||||
{{- if eq .IsQuery "1"}}
|
||||
{{.JsonField}}?: {{if eq .GoType "int" -}}
|
||||
number
|
||||
{{- else if eq .GoType "int64" -}}
|
||||
number
|
||||
{{- else if eq .GoType "float32" -}}
|
||||
number
|
||||
{{- else if eq .GoType "float64" -}}
|
||||
number
|
||||
{{- else -}}
|
||||
string
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- else -}}
|
||||
{{- /*
|
||||
No column is marked IsQuery - a plain display table with no search form
|
||||
is a normal shape, not an edge case, so this still has to produce a type
|
||||
useTable<Row, Query>/list{ClassName}(query: Query & PageQuery) can use.
|
||||
`export interface {ClassName}Query {}` is what naturally falls out of the
|
||||
range above finding nothing to iterate, but an empty interface trips
|
||||
@typescript-eslint/no-empty-object-type and fails pnpm lint.
|
||||
|
||||
Record<string, never> (this file's first attempt, and the type
|
||||
useTable.ts's own `TQuery extends object = Record<string, never>` default
|
||||
uses) looks like the obvious match but is wrong here: it is a mapped type
|
||||
over *every* string key, each mapped to never, so intersecting it with
|
||||
PageQuery does not leave PageQuery alone - `pageIndex` becomes
|
||||
`never & number`, i.e. never, and no value can be passed for it at all.
|
||||
useTable.ts itself never hits this because its one internal use of
|
||||
`TQuery & PageQuery` goes through an `as` cast rather than a structural
|
||||
check (composables/useTable.ts ~line 160); code that builds the object
|
||||
literal directly - such as a foreign-key column's
|
||||
`list{FkClass}({ pageIndex: 1, pageSize: 100 })` call in vue.go.template -
|
||||
is not casting anything and hits the real error, only when the referenced
|
||||
table happens to have no query columns of its own (a plain lookup/dict
|
||||
table used as a dropdown source, not a rare shape).
|
||||
|
||||
Record<never, never> is the type with the same intent - "no query
|
||||
columns" - but the mapped-type domain is `never`, so it has no keys at
|
||||
all rather than "every key, mapped to never": it behaves as the empty
|
||||
object type `{}` under intersection, leaving PageQuery's own pageIndex/
|
||||
pageSize untouched, and confirmed separately not to trip
|
||||
no-empty-object-type either (it is a generic instantiation, not a
|
||||
literal `{}` type annotation).
|
||||
*/ -}}
|
||||
export type {{.ClassName}}Query = Record<never, never>
|
||||
{{- end}}
|
||||
|
||||
export function list{{.ClassName}}(query: {{.ClassName}}Query & PageQuery) {
|
||||
return request<ApiResponse<PageResult<{{.ClassName}}>>>({
|
||||
url: '/api/v1/{{.ModuleName}}',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
export function get{{.ClassName}}({{.PkJsonField}}: {{$pkType}}) {
|
||||
return request<ApiResponse<{{.ClassName}}>>({
|
||||
url: '/api/v1/{{.ModuleName}}/' + {{.PkJsonField}},
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function add{{.ClassName}}(data: {{.ClassName}}) {
|
||||
return request<ApiResponse<{{.ClassName}}>>({
|
||||
url: '/api/v1/{{.ModuleName}}',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function update{{.ClassName}}(data: {{.ClassName}}) {
|
||||
return request<ApiResponse<{{.ClassName}}>>({
|
||||
url: '/api/v1/{{.ModuleName}}/' + data.{{.PkJsonField}},
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function del{{.ClassName}}(ids: Id[]) {
|
||||
return request<ApiResponse<null>>({
|
||||
url: '/api/v1/{{.ModuleName}}',
|
||||
method: 'delete',
|
||||
data: { ids: ids.map(Number) }
|
||||
})
|
||||
}
|
||||
+377
-467
@@ -1,479 +1,389 @@
|
||||
{{$tableComment:=.TableComment}}
|
||||
{{- /*
|
||||
Vue 3 + Element Plus + TypeScript list page (PRD 010, F4).
|
||||
|
||||
Shape matches go-admin-ui/src/views/demo/product/index.vue, the reference
|
||||
page AGENTS.md names: PageContainer + ProTable + useTable/useForm/useRemove,
|
||||
<script setup lang="ts">. The old template produced slot-scope/.sync/.native
|
||||
syntax that Vue 3 removed outright (PRD 010 G1) -- this is not a patch on
|
||||
that file, it is a different template for a different framework version.
|
||||
|
||||
Every label goes through $t('gen.{PackageName}.{BusinessName}.{JsonField}'),
|
||||
never a literal ColumnComment -- see src/lang/{locale}/gen/index.ts (F9) for
|
||||
how that namespace is loaded. This is also why the file must not contain a
|
||||
literal CJK character anywhere, comments included: D10's acceptance check is
|
||||
a bare regex scan of the rendered output with no exception for "but this one
|
||||
is a comment", so a Chinese aside here would fail the same test a stray
|
||||
placeholder="{{"{{"}}.ColumnComment{{"}}"}}" would.
|
||||
|
||||
HtmlType has seven stored values (PRD 010 G8) and only four render here on
|
||||
purpose: checkbox and datetime became selectable in the F7 front-end change
|
||||
(editTable.vue), so they get a branch; file stays disabled there, but a row
|
||||
imported or edited before that change can still carry "file" or any other
|
||||
value this template does not know -- the final branch below renders those,
|
||||
and anything else future work introduces, as a plain input rather than
|
||||
emitting nothing (PRD 010 phase-3 constraint #1: a silently empty field is
|
||||
worse than a plain one).
|
||||
*/ -}}
|
||||
{{- $package := .PackageName -}}
|
||||
{{- $business := .BusinessName -}}
|
||||
{{- /*
|
||||
Whether any column needs a given import, computed once by walking .Columns
|
||||
rather than at each usage site -- text/template has no way to ask "did the
|
||||
loop below already import this", so the alternative is repeating the same
|
||||
import line once per matching column. "$var = value" (not ":=") reassigns an
|
||||
outer-scope variable from inside a range -- a text/template feature since
|
||||
Go 1.11, needed here because a range body cannot otherwise leave a mark on
|
||||
anything outside itself.
|
||||
|
||||
Each condition below must match, term for term, the condition guarding the
|
||||
markup or script that actually consumes the import -- not just "this column
|
||||
has a DictType/FkTableName", which is necessary but not sufficient. A column
|
||||
can carry dictionary or foreign-key metadata that no rendered branch reads:
|
||||
FkTableName/DictType lose to each other by priority (FK wins search, list
|
||||
and the form's select branch; the form's radio branch never looks at FK at
|
||||
all), and a column can carry either one while being neither queryable nor
|
||||
listed nor an insertable select/radio -- created_at/updated_at are exactly
|
||||
this: sys_tables.go assigns HtmlType "datetime" to any timestamp/datetime
|
||||
column on import whether or not it ever reaches IsList, because GetList's
|
||||
audit-column exclusion is a separate, later step. Get a term here wrong in
|
||||
either direction and either an import goes unused (no-unused-vars) or a real
|
||||
usage silently loses its import (a ReferenceError this template cannot see
|
||||
coming, since Vue components are the last stage that runs).
|
||||
*/ -}}
|
||||
{{- $hasDict := false -}}
|
||||
{{- $hasDictList := false -}}
|
||||
{{- $hasFk := false -}}
|
||||
{{- $hasDatetime := false -}}
|
||||
{{- $hasRules := false -}}
|
||||
{{- $hasQuery := false -}}
|
||||
{{- $pkType := "number" -}}
|
||||
{{- range .Columns -}}
|
||||
{{- $dictUsed := and (ne .DictType "") (or (and (eq .IsQuery "1") (eq .FkTableName "")) (and (eq .IsList "1") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "select") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "radio"))) -}}
|
||||
{{- $fkUsed := and (ne .FkTableName "") (or (eq .IsQuery "1") (eq .IsList "1") (and (eq .IsInsert "1") (eq .HtmlType "select"))) -}}
|
||||
{{- if $dictUsed }}{{$hasDict = true}}{{end -}}
|
||||
{{- if and (eq .IsList "1") (eq .FkTableName "") (ne .DictType "") }}{{$hasDictList = true}}{{end -}}
|
||||
{{- if $fkUsed }}{{$hasFk = true}}{{end -}}
|
||||
{{- if and (eq .IsList "1") (eq .FkTableName "") (eq .DictType "") (eq .HtmlType "datetime") }}{{$hasDatetime = true}}{{end -}}
|
||||
{{- if eq .IsQuery "1" }}{{$hasQuery = true}}{{end -}}
|
||||
{{- if and (eq .IsInsert "1") (eq .IsRequired "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy") }}{{$hasRules = true}}{{end -}}
|
||||
{{- if and .Pk (eq .GoType "string") }}{{$pkType = "string"}}{{end -}}
|
||||
{{- end -}}
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<el-form ref="queryForm" :model="queryParams" :inline="true" label-width="68px">
|
||||
{{range .Columns}}
|
||||
{{- $x := .IsQuery -}}
|
||||
{{- if (eq $x "1") -}}
|
||||
<el-form-item label="{{.ColumnComment}}" prop="{{.JsonField}}">
|
||||
{{- if ne .FkTableName "" -}}
|
||||
<el-select v-model="queryParams.{{.JsonField}}"
|
||||
placeholder="请选择" clearable size="small" {{if eq .IsEdit "false" -}} :disabled="isEdit" {{- end }}>
|
||||
<el-option
|
||||
v-for="dict in {{.JsonField}}Options"
|
||||
:key="dict.key"
|
||||
:label="dict.value"
|
||||
:value="dict.key"
|
||||
/>
|
||||
</el-select>
|
||||
{{- else -}}
|
||||
{{if eq .DictType "" -}}
|
||||
<el-input v-model="queryParams.{{.JsonField}}" placeholder="请输入{{.ColumnComment}}" clearable
|
||||
size="small" @keyup.enter.native="handleQuery"/>
|
||||
{{- else -}}
|
||||
<el-select v-model="queryParams.{{.JsonField}}"
|
||||
placeholder="{{$tableComment}}{{.ColumnComment}}" clearable size="small">
|
||||
<el-option
|
||||
v-for="dict in {{.JsonField}}Options"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
</el-form-item>
|
||||
{{end}}
|
||||
{{- end }}
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<PageContainer>
|
||||
<ProTable :table="table" selection row-key="{{.PkJsonField}}">
|
||||
{{- if $hasQuery}}
|
||||
<template #search>
|
||||
{{- range .Columns}}
|
||||
{{- if eq .IsQuery "1"}}
|
||||
{{- $key := printf "gen.%s.%s.%s" $package $business .JsonField}}
|
||||
<el-form-item :label="$t('{{$key}}')">
|
||||
{{- if ne .FkTableName ""}}
|
||||
<el-select v-model="table.query.{{.JsonField}}" clearable :placeholder="$t('common.selectPlaceholder')">
|
||||
<el-option
|
||||
v-for="item in {{.JsonField}}FkOptions"
|
||||
:key="item.{{.FkLabelId}}"
|
||||
:label="item.{{.FkLabelName}}"
|
||||
:value="item.{{.FkLabelId}}"
|
||||
/>
|
||||
</el-select>
|
||||
{{- else if ne .DictType ""}}
|
||||
<el-select v-model="table.query.{{.JsonField}}" clearable :placeholder="$t('common.selectPlaceholder')">
|
||||
<el-option
|
||||
v-for="dict in {{.JsonField}}DictOptions"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
{{- else if eq .HtmlType "datetime"}}
|
||||
<el-date-picker
|
||||
v-model="table.query.{{.JsonField}}"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD[T]HH:mm:ssZ"
|
||||
clearable
|
||||
/>
|
||||
{{- else}}
|
||||
<el-input v-model="table.query.{{.JsonField}}" clearable />
|
||||
{{- end}}
|
||||
</el-form-item>
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
</template>
|
||||
{{- end}}
|
||||
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:add']"
|
||||
type="primary"
|
||||
icon="el-icon-plus"
|
||||
size="mini"
|
||||
@click="handleAdd"
|
||||
>新增
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:edit']"
|
||||
type="success"
|
||||
icon="el-icon-edit"
|
||||
size="mini"
|
||||
:disabled="single"
|
||||
@click="handleUpdate"
|
||||
>修改
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']"
|
||||
type="danger"
|
||||
icon="el-icon-delete"
|
||||
size="mini"
|
||||
:disabled="multiple"
|
||||
@click="handleDelete"
|
||||
>删除
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<template #toolbar>
|
||||
<el-button v-permisaction="['{{.PackageName}}:{{.BusinessName}}:add']" type="primary" @click="form.openCreate()">
|
||||
{{ "{{" }} $t('common.add') {{ "}}" }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']"
|
||||
type="danger"
|
||||
plain
|
||||
:disabled="table.multiple"
|
||||
@click="remove(table.selectedIds)"
|
||||
>
|
||||
{{ "{{" }} $t('common.delete') {{ "}}" }}
|
||||
</el-button>
|
||||
</template>
|
||||
{{- range .Columns}}
|
||||
{{- if eq .IsList "1"}}
|
||||
{{- $key := printf "gen.%s.%s.%s" $package $business .JsonField}}
|
||||
{{- if ne .FkTableName ""}}
|
||||
|
||||
<el-table v-loading="loading" :data="{{.BusinessName}}List" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" align="center"/>
|
||||
{{- range .Columns -}}
|
||||
{{- $x := .IsList -}}
|
||||
{{- if (eq $x "1") }}
|
||||
{{- if ne .FkTableName "" -}}
|
||||
<el-table-column label="{{.ColumnComment}}" align="center" prop="{{.JsonField}}" :formatter="{{.JsonField}}Format" width="100">
|
||||
<template slot-scope="scope">
|
||||
{{ "{{" }} {{.JsonField}}Format(scope.row) {{"}}"}}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('{{$key}}')" min-width="{{if .ColWidth}}{{.ColWidth}}{{else}}120{{end}}" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ "{{" }} {{.JsonField}}Label(row.{{.JsonField}}) {{ "}}" }}</template>
|
||||
</el-table-column>
|
||||
{{- else if ne .DictType ""}}
|
||||
|
||||
{{- else -}}
|
||||
{{- if ne .DictType "" -}}
|
||||
<el-table-column label="{{.ColumnComment}}" align="center" prop="{{.JsonField}}"
|
||||
:formatter="{{.JsonField}}Format" width="100">
|
||||
<template slot-scope="scope">
|
||||
{{ "{{" }} {{.JsonField}}Format(scope.row) {{"}}"}}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('{{$key}}')" min-width="{{if .ColWidth}}{{.ColWidth}}{{else}}120{{end}}">
|
||||
<template #default="{ row }">{{ "{{" }} dictLabel({{.JsonField}}DictOptions, row.{{.JsonField}}) {{ "}}" }}</template>
|
||||
</el-table-column>
|
||||
{{- else if eq .HtmlType "datetime"}}
|
||||
|
||||
{{- end -}}
|
||||
{{- if eq .DictType "" -}}
|
||||
{{- if eq .HtmlType "datetime" -}}
|
||||
<el-table-column label="{{.ColumnComment}}" align="center" prop="{{.JsonField}}"
|
||||
:show-overflow-tooltip="true">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ "{{" }} parseTime(scope.row.{{.JsonField}}) {{"}}"}}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
{{- else -}}
|
||||
<el-table-column label="{{.ColumnComment}}" align="center" prop="{{.JsonField}}"
|
||||
:show-overflow-tooltip="true"/>
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
slot="reference"
|
||||
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:edit']"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-edit"
|
||||
@click="handleUpdate(scope.row)"
|
||||
>修改
|
||||
</el-button>
|
||||
<el-popconfirm
|
||||
class="delete-popconfirm"
|
||||
title="确认要删除吗?"
|
||||
confirm-button-text="删除"
|
||||
@confirm="handleDelete(scope.row)"
|
||||
>
|
||||
<el-button
|
||||
slot="reference"
|
||||
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-delete"
|
||||
>删除
|
||||
</el-button>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-table-column :label="$t('{{$key}}')" min-width="{{if .ColWidth}}{{.ColWidth}}{{else}}110{{end}}">
|
||||
<template #default="{ row }"><DateCell :value="row.{{.JsonField}}" /></template>
|
||||
</el-table-column>
|
||||
{{- else}}
|
||||
|
||||
<pagination
|
||||
v-show="total>0"
|
||||
:total="total"
|
||||
:page.sync="queryParams.pageIndex"
|
||||
:limit.sync="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
<el-table-column
|
||||
:label="$t('{{$key}}')"
|
||||
prop="{{.JsonField}}"
|
||||
min-width="{{if .ColWidth}}{{.ColWidth}}{{else}}120{{end}}"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
<!-- 添加或修改对话框 -->
|
||||
<el-dialog :title="title" :visible.sync="open" width="500px">
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
|
||||
{{ range .Columns }}
|
||||
{{- $x := .IsInsert -}}
|
||||
{{- if (eq $x "1") -}}
|
||||
{{- if (.Pk) }}
|
||||
{{- else if eq .GoField "CreatedAt" -}}
|
||||
{{- else if eq .GoField "UpdatedAt" -}}
|
||||
{{- else if eq .GoField "DeletedAt" -}}
|
||||
{{- else if eq .GoField "UpdateBy" -}}
|
||||
{{- else if eq .GoField "CreateBy" -}}
|
||||
{{- else }}
|
||||
<el-form-item label="{{.ColumnComment}}" prop="{{.JsonField}}">
|
||||
{{ if eq "input" .HtmlType -}}
|
||||
<el-input v-model{{if eq .GoType "int64" -}}.number{{- end}}="form.{{.JsonField}}" placeholder="{{.ColumnComment}}"
|
||||
{{if eq .IsEdit "false" -}}:disabled="isEdit" {{- end}}/>
|
||||
{{- else if eq "select" .HtmlType -}}
|
||||
{{- if ne .FkTableName "" -}}
|
||||
<el-select v-model="form.{{.JsonField}}"
|
||||
placeholder="请选择" {{if eq .IsEdit "false" -}} :disabled="isEdit" {{- end }}>
|
||||
<el-option
|
||||
v-for="dict in {{.JsonField}}Options"
|
||||
:key="dict.key"
|
||||
:label="dict.value"
|
||||
:value="dict.key"
|
||||
/>
|
||||
</el-select>
|
||||
{{- else -}}
|
||||
<el-select v-model="form.{{.JsonField}}"
|
||||
placeholder="请选择" {{if eq .IsEdit "false" -}} :disabled="isEdit" {{- end }}>
|
||||
<el-option
|
||||
v-for="dict in {{.JsonField}}Options"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
{{- end -}}
|
||||
{{- else if eq "radio" .HtmlType -}}
|
||||
<el-radio-group v-model="form.{{.JsonField}}">
|
||||
<el-radio
|
||||
v-for="dict in {{.JsonField}}Options"
|
||||
:key="dict.value"
|
||||
:label="dict.value"
|
||||
>{{"{{"}} dict.label {{"}}"}}</el-radio>
|
||||
</el-radio-group>
|
||||
{{- else if eq "file" .HtmlType -}}
|
||||
<el-input
|
||||
v-model="form.{{.JsonField}}"
|
||||
placeholder="图片"
|
||||
/>
|
||||
<el-button type="primary" @click="fileShow{{.GoField}}">选择文件</el-button>
|
||||
{{- else if eq "datetime" .HtmlType -}}
|
||||
<el-date-picker
|
||||
v-model="form.{{.JsonField}}"
|
||||
type="datetime"
|
||||
placeholder="选择日期">
|
||||
</el-date-picker>
|
||||
{{- else if eq "textarea" .HtmlType -}}
|
||||
<el-input
|
||||
v-model="form.{{.JsonField}}"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入内容">
|
||||
</el-input>
|
||||
{{- end }}
|
||||
</el-form-item>
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
<template #actions="{ row }">
|
||||
<el-button v-permisaction="['{{.PackageName}}:{{.BusinessName}}:edit']" link type="primary" @click="form.openEdit(row)">
|
||||
{{ "{{" }} $t('common.edit') {{ "}}" }}
|
||||
</el-button>
|
||||
<el-button v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']" link type="danger" @click="remove(row.{{.PkJsonField}})">
|
||||
{{ "{{" }} $t('common.delete') {{ "}}" }}
|
||||
</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
|
||||
<el-dialog
|
||||
v-model="form.visible"
|
||||
:title="form.title"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
@closed="form.reset"
|
||||
>
|
||||
<el-form
|
||||
:ref="form.bindFormRef"
|
||||
v-loading="form.loading"
|
||||
:model="form.model"
|
||||
{{- if $hasRules}}
|
||||
:rules="form.rules"
|
||||
{{- end}}
|
||||
label-width="100px"
|
||||
>
|
||||
{{- range .Columns}}
|
||||
{{- if and (eq .IsInsert "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy")}}
|
||||
{{- $key := printf "gen.%s.%s.%s" $package $business .JsonField}}
|
||||
<el-form-item :label="$t('{{$key}}')" prop="{{.JsonField}}">
|
||||
{{- if eq .HtmlType "select"}}
|
||||
{{- if ne .FkTableName ""}}
|
||||
<el-select v-model="form.model.{{.JsonField}}" :placeholder="$t('common.selectPlaceholder')">
|
||||
<el-option
|
||||
v-for="item in {{.JsonField}}FkOptions"
|
||||
:key="item.{{.FkLabelId}}"
|
||||
:label="item.{{.FkLabelName}}"
|
||||
:value="item.{{.FkLabelId}}"
|
||||
/>
|
||||
</el-select>
|
||||
{{- else if ne .DictType ""}}
|
||||
<el-select v-model="form.model.{{.JsonField}}" :placeholder="$t('common.selectPlaceholder')">
|
||||
<el-option
|
||||
v-for="dict in {{.JsonField}}DictOptions"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
{{- else}}
|
||||
<el-input v-model="form.model.{{.JsonField}}" />
|
||||
{{- end}}
|
||||
{{- else if eq .HtmlType "radio"}}
|
||||
{{- if ne .DictType ""}}
|
||||
<el-radio-group v-model="form.model.{{.JsonField}}">
|
||||
<el-radio v-for="dict in {{.JsonField}}DictOptions" :key="dict.value" :value="dict.value">
|
||||
{{ "{{" }} dict.label {{ "}}" }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
{{- else}}
|
||||
<el-input v-model="form.model.{{.JsonField}}" />
|
||||
{{- end}}
|
||||
{{- else if eq .HtmlType "checkbox"}}
|
||||
<el-checkbox v-model="form.model.{{.JsonField}}" true-value="1" false-value="0" />
|
||||
{{- else if eq .HtmlType "datetime"}}
|
||||
<el-date-picker
|
||||
v-model="form.model.{{.JsonField}}"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD[T]HH:mm:ssZ"
|
||||
/>
|
||||
{{- else if eq .HtmlType "textarea"}}
|
||||
<el-input v-model="form.model.{{.JsonField}}" type="textarea" :rows="2" />
|
||||
{{- else}}
|
||||
<el-input v-model="form.model.{{.JsonField}}" />
|
||||
{{- end}}
|
||||
</el-form-item>
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="form.close">{{ "{{" }} $t('common.dialogCancel') {{ "}}" }}</el-button>
|
||||
<el-button type="primary" :loading="form.submitting" @click="form.submit">
|
||||
{{ "{{" }} $t('common.dialogConfirm') {{ "}}" }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {add{{.ClassName}}, del{{.ClassName}}, get{{.ClassName}}, list{{.ClassName}}, update{{.ClassName}}} from '@/api/{{ .PackageName}}/{{ .MLTBName}}'
|
||||
{{ $package:=.PackageName }}
|
||||
{{range .Columns}}
|
||||
{{- if ne .FkTableName "" -}}
|
||||
import {list{{.FkTableNameClass}} } from '@/api/{{ $package }}/{{ .FkTableNamePackage}}'
|
||||
{{ end -}}
|
||||
{{- end -}}
|
||||
<script setup lang="ts">
|
||||
{{- if $hasRules}}
|
||||
import { computed } from 'vue'
|
||||
{{- end}}
|
||||
{{- if $hasFk}}
|
||||
import { ref, onMounted } from 'vue'
|
||||
{{- end}}
|
||||
{{- if $hasRules}}
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { FormRules } from 'element-plus'
|
||||
{{- end}}
|
||||
import PageContainer from '@/components/PageContainer/index.vue'
|
||||
import ProTable from '@/components/ProTable/index.vue'
|
||||
{{- if $hasDatetime}}
|
||||
import DateCell from '@/components/DateCell/index.vue'
|
||||
{{- end}}
|
||||
{{- if $hasDict}}
|
||||
{{- if $hasDictList}}
|
||||
import { useTable, useForm, useRemove, useDict, dictLabel } from '@/composables'
|
||||
{{- else}}
|
||||
import { useTable, useForm, useRemove, useDict } from '@/composables'
|
||||
{{- end}}
|
||||
{{- else}}
|
||||
import { useTable, useForm, useRemove } from '@/composables'
|
||||
{{- end}}
|
||||
import {
|
||||
add{{.ClassName}}, del{{.ClassName}}, get{{.ClassName}}, list{{.ClassName}}, update{{.ClassName}}
|
||||
} from '@/api/{{.PackageName}}/{{.MLTBName}}'
|
||||
import type { {{.ClassName}}, {{.ClassName}}Query } from '@/api/{{.PackageName}}/{{.MLTBName}}'
|
||||
{{- /*
|
||||
Two columns pointing at the same foreign table must not import it twice --
|
||||
"one FK-configured column" was never the same thing as "one distinct target
|
||||
table", and gen.go has no concept of a table's FK targets being unique.
|
||||
text/template has no set to check membership in, so the dedup is a nested
|
||||
range: a column only imports its target if no earlier, equally-used column
|
||||
already claimed the same FkTableNameClass. $fkUsed is repeated here (it also
|
||||
guards the const declarations above) because a column with FkTableName set
|
||||
but reaching none of them -- unqueried, unlisted, not an insert select --
|
||||
has nothing that would use the import either.
|
||||
*/ -}}
|
||||
{{- range $i, $col := .Columns}}
|
||||
{{- $fkUsed := and (ne $col.FkTableName "") (or (eq $col.IsQuery "1") (eq $col.IsList "1") (and (eq $col.IsInsert "1") (eq $col.HtmlType "select"))) -}}
|
||||
{{- if $fkUsed}}
|
||||
{{- $alreadyImported := false -}}
|
||||
{{- range $j, $prior := $.Columns}}
|
||||
{{- if lt $j $i}}
|
||||
{{- $priorUsed := and (ne $prior.FkTableName "") (or (eq $prior.IsQuery "1") (eq $prior.IsList "1") (and (eq $prior.IsInsert "1") (eq $prior.HtmlType "select"))) -}}
|
||||
{{- if and $priorUsed (eq $prior.FkTableNameClass $col.FkTableNameClass) }}{{$alreadyImported = true}}{{end -}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- if not $alreadyImported}}
|
||||
import { list{{$col.FkTableNameClass}} } from '@/api/{{$package}}/{{$col.FkTableNamePackage}}'
|
||||
import type { {{$col.FkTableNameClass}} } from '@/api/{{$package}}/{{$col.FkTableNamePackage}}'
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- /*
|
||||
Manage suffix, not just ClassName: this must match gen.go's
|
||||
Cmenu.MenuName = tab.ClassName + "Manage" byte for byte (PRD 010 R4), or
|
||||
keep-alive's include list -- built from menu_name -- never matches this
|
||||
component's name and the page never caches. The old template wrote
|
||||
name: '{ClassName}' with no suffix; the mismatch went unnoticed because
|
||||
stores/permission.ts's loadView() rewrites the rendered component's name to
|
||||
menu_name at runtime regardless of what defineOptions said (PRD 010 G7).
|
||||
That fallback stays in place after this change -- it is not this template's
|
||||
to remove -- but the value declared here should be right regardless of it.
|
||||
*/}}
|
||||
|
||||
export default {
|
||||
name: '{{.ClassName}}',
|
||||
components: {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// 遮罩层
|
||||
loading: true,
|
||||
// 选中数组
|
||||
ids: [],
|
||||
// 非单个禁用
|
||||
single: true,
|
||||
// 非多个禁用
|
||||
multiple: true,
|
||||
// 总条数
|
||||
total: 0,
|
||||
// 弹出层标题
|
||||
title: '',
|
||||
// 是否显示弹出层
|
||||
open: false,
|
||||
isEdit: false,
|
||||
// 类型数据字典
|
||||
typeOptions: [],
|
||||
{{.BusinessName}}List: [],
|
||||
{{range .Columns}}
|
||||
{{- if ne .DictType "" -}}
|
||||
{{.JsonField}}Options: [],
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
// 关系表类型
|
||||
{{range .Columns}}
|
||||
{{- if ne .FkTableName "" -}}
|
||||
{{.JsonField}}Options :[],
|
||||
{{ end -}}
|
||||
{{- end }}
|
||||
// 查询参数
|
||||
queryParams: {
|
||||
pageIndex: 1,
|
||||
pageSize: 10,
|
||||
{{ range .Columns }}
|
||||
{{- if (.IsQuery) -}}
|
||||
{{.JsonField}}:undefined,
|
||||
{{ end -}}
|
||||
{{- end }}
|
||||
},
|
||||
// 表单参数
|
||||
form: {
|
||||
},
|
||||
// 表单校验
|
||||
rules: {
|
||||
{{- range .Columns -}}
|
||||
{{- $x := .IsQuery -}}
|
||||
{{- if (eq $x "1") -}}
|
||||
{{.JsonField}}: [ {required: true, message: '{{.ColumnComment}}不能为空', trigger: 'blur'} ],
|
||||
{{ end }}
|
||||
{{- end -}}
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getList()
|
||||
{{range .Columns}}
|
||||
{{- if ne .DictType "" -}}
|
||||
this.getDicts('{{.DictType}}').then(response => {
|
||||
this.{{.JsonField}}Options = response.data
|
||||
})
|
||||
{{ end -}}
|
||||
{{- if ne .FkTableName "" -}}
|
||||
this.get{{.FkTableNameClass}}Items()
|
||||
{{ end -}}
|
||||
{{- end -}}
|
||||
},
|
||||
methods: {
|
||||
/** 查询参数列表 */
|
||||
getList() {
|
||||
this.loading = true
|
||||
list{{.ClassName}}(this.addDateRange(this.queryParams, this.dateRange)).then(response => {
|
||||
this.{{.BusinessName}}List = response.data.list
|
||||
this.total = response.data.count
|
||||
this.loading = false
|
||||
}
|
||||
)
|
||||
},
|
||||
// 取消按钮
|
||||
cancel() {
|
||||
this.open = false
|
||||
this.reset()
|
||||
},
|
||||
// 表单重置
|
||||
reset() {
|
||||
this.form = {
|
||||
{{ range .Columns}}
|
||||
{{- $x := .IsInsert -}}
|
||||
{{- if (eq $x "1") -}}
|
||||
{{- if eq .GoField "CreatedAt" -}}
|
||||
{{- else if eq .GoField "UpdatedAt" -}}
|
||||
{{- else if eq .GoField "DeletedAt" -}}
|
||||
{{- else if eq .GoField "UpdateBy" -}}
|
||||
{{- else if eq .GoField "CreateBy" -}}
|
||||
{{- else }}
|
||||
{{.JsonField}}: undefined,
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
}
|
||||
this.resetForm('form')
|
||||
},
|
||||
getImgList: function() {
|
||||
this.form[this.fileIndex] = this.$refs['fileChoose'].resultList[0].fullUrl
|
||||
},
|
||||
fileClose: function() {
|
||||
this.fileOpen = false
|
||||
},
|
||||
{{range .Columns}}
|
||||
{{- if ne .DictType "" -}}
|
||||
{{.JsonField}}Format(row) {
|
||||
return this.selectDictLabel(this.{{.JsonField}}Options, row.{{.JsonField}})
|
||||
},
|
||||
{{ end -}}
|
||||
{{- if ne .FkTableName "" -}}
|
||||
{{.JsonField}}Format(row) {
|
||||
return this.selectItemsLabel(this.{{.JsonField}}Options, row.{{.JsonField}})
|
||||
},
|
||||
{{ end -}}
|
||||
{{- end -}}
|
||||
// 关系
|
||||
{{range .Columns}}
|
||||
{{- if ne .FkTableName "" -}}
|
||||
get{{.FkTableNameClass}}Items() {
|
||||
this.getItems(list{{.FkTableNameClass}}, undefined).then(res => {
|
||||
this.{{.JsonField}}Options = this.setItems(res, '{{.FkLabelId}}', '{{.FkLabelName}}')
|
||||
})
|
||||
},
|
||||
{{ end -}}
|
||||
{{- end -}}
|
||||
// 文件
|
||||
{{range .Columns}}
|
||||
{{- if eq .HtmlType "file" -}}
|
||||
fileShow{{.GoField}}: function() {
|
||||
this.fileOpen = true
|
||||
this.fileIndex = '{{.JsonField}}'
|
||||
},
|
||||
{{ end -}}
|
||||
{{- end -}}
|
||||
/** 搜索按钮操作 */
|
||||
handleQuery() {
|
||||
this.queryParams.pageIndex = 1
|
||||
this.getList()
|
||||
},
|
||||
/** 重置按钮操作 */
|
||||
resetQuery() {
|
||||
this.dateRange = []
|
||||
this.resetForm('queryForm')
|
||||
this.handleQuery()
|
||||
},
|
||||
/** 新增按钮操作 */
|
||||
handleAdd() {
|
||||
this.reset()
|
||||
this.open = true
|
||||
this.title = '添加{{.TableComment}}'
|
||||
this.isEdit = false
|
||||
},
|
||||
// 多选框选中数据
|
||||
handleSelectionChange(selection) {
|
||||
this.ids = selection.map(item => item.{{.PkJsonField}})
|
||||
this.single = selection.length !== 1
|
||||
this.multiple = !selection.length
|
||||
},
|
||||
/** 修改按钮操作 */
|
||||
handleUpdate(row) {
|
||||
this.reset()
|
||||
const {{.PkJsonField}} =
|
||||
row.{{.PkJsonField}} || this.ids
|
||||
get{{.ClassName}}({{.PkJsonField}}).then(response => {
|
||||
this.form = response.data
|
||||
this.open = true
|
||||
this.title = '修改{{.TableComment}}'
|
||||
this.isEdit = true
|
||||
})
|
||||
},
|
||||
/** 提交按钮 */
|
||||
submitForm: function () {
|
||||
this.$refs['form'].validate(valid => {
|
||||
if (valid) {
|
||||
if (this.form.{{.PkJsonField}} !== undefined) {
|
||||
update{{.ClassName}}(this.form).then(response => {
|
||||
if (response.code === 200) {
|
||||
this.msgSuccess(response.msg)
|
||||
this.open = false
|
||||
this.getList()
|
||||
} else {
|
||||
this.msgError(response.msg)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
add{{.ClassName}}(this.form).then(response => {
|
||||
if (response.code === 200) {
|
||||
this.msgSuccess(response.msg)
|
||||
this.open = false
|
||||
this.getList()
|
||||
} else {
|
||||
this.msgError(response.msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
/** 删除按钮操作 */
|
||||
handleDelete(row) {
|
||||
var Ids = (row.{{.PkJsonField}} && [row.{{.PkJsonField}}]) || this.ids
|
||||
defineOptions({ name: '{{.ClassName}}Manage' })
|
||||
{{- range .Columns}}
|
||||
{{- $dictUsed := and (ne .DictType "") (or (and (eq .IsQuery "1") (eq .FkTableName "")) (and (eq .IsList "1") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "select") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "radio"))) -}}
|
||||
{{- $fkUsed := and (ne .FkTableName "") (or (eq .IsQuery "1") (eq .IsList "1") (and (eq .IsInsert "1") (eq .HtmlType "select"))) -}}
|
||||
{{- if $dictUsed}}
|
||||
|
||||
this.$confirm('是否确认删除编号为"' + Ids + '"的数据项?', '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(function () {
|
||||
return del{{.ClassName}}( { 'ids': Ids })
|
||||
}).then((response) => {
|
||||
if (response.code === 200) {
|
||||
this.msgSuccess(response.msg)
|
||||
this.open = false
|
||||
this.getList()
|
||||
} else {
|
||||
this.msgError(response.msg)
|
||||
}
|
||||
}).catch(function () {
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const { {{.DictType}}: {{.JsonField}}DictOptions } = useDict('{{.DictType}}')
|
||||
{{- end}}
|
||||
{{- if $fkUsed}}
|
||||
|
||||
const {{.JsonField}}FkOptions = ref<{{.FkTableNameClass}}[]>([])
|
||||
onMounted(async() => {
|
||||
const res = await list{{.FkTableNameClass}}({ pageIndex: 1, pageSize: 100 })
|
||||
{{.JsonField}}FkOptions.value = res.data?.list ?? []
|
||||
})
|
||||
{{- if eq .IsList "1"}}
|
||||
const {{.JsonField}}Label = (value: unknown) =>
|
||||
{{.JsonField}}FkOptions.value.find(item => item.{{.FkLabelId}} === value)?.{{.FkLabelName}} ?? value
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- /*
|
||||
Every object literal below is built on one line, joined with ", " through a
|
||||
$first flag rather than one field per line with a trailing comma after each:
|
||||
comma-dangle is "never" (no comma before the closing brace) and comma-style
|
||||
is "last" (a comma may not open a line), and text/template has no arithmetic
|
||||
to compute "is this the last matching column" up front -- knowing that would
|
||||
be what a one-field-per-line, trailing-comma-free rendering needs instead.
|
||||
*/}}
|
||||
|
||||
const table = useTable<{{.ClassName}}, {{.ClassName}}Query>({
|
||||
api: list{{.ClassName}},
|
||||
idKey: '{{.PkJsonField}}'
|
||||
{{- if $hasQuery}},
|
||||
defaultQuery: () => ({{"{"}} {{$qFirst := true}}{{range .Columns}}{{if eq .IsQuery "1"}}{{if $qFirst}}{{$qFirst = false}}{{else}}, {{end}}{{.JsonField}}: undefined{{end}}{{end}} {{"}"}})
|
||||
{{- end}}
|
||||
})
|
||||
{{- if $hasRules}}
|
||||
|
||||
const { t } = useI18n()
|
||||
{{- /*
|
||||
Built from the same field-label key rather than a dedicated
|
||||
gen.{pkg}.{biz}.rules.{field} key: R3 derives one key per field from
|
||||
PackageName+BusinessName+JsonField, and a second, validation-only key per
|
||||
required field would double the language pack's surface for a message that
|
||||
reads fine as the field name alone in the space Element Plus renders it --
|
||||
directly under the labelled field it failed to validate.
|
||||
*/}}
|
||||
|
||||
const rules = computed<FormRules>(() => ({ {{$rFirst := true}}
|
||||
{{- range .Columns}}
|
||||
{{- if and (eq .IsInsert "1") (eq .IsRequired "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy")}}
|
||||
{{- $key := printf "gen.%s.%s.%s" $package $business .JsonField}}
|
||||
{{- if $rFirst}}{{$rFirst = false}}{{else}},
|
||||
{{end}}{{.JsonField}}: [{ required: true, message: t('{{$key}}'), trigger: '{{if or (eq .HtmlType "select") (eq .HtmlType "radio") (eq .HtmlType "datetime") (eq .HtmlType "checkbox")}}change{{else}}blur{{end}}' }]
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}))
|
||||
{{- end}}
|
||||
|
||||
const form = useForm<{{.ClassName}}, {{$pkType}}>({
|
||||
defaultModel: () => ({{"{"}} {{.PkJsonField}}: undefined{{range .Columns}}{{if and (eq .IsInsert "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy")}}, {{.JsonField}}: {{if eq .DefaultValue ""}}undefined{{else if eq .GoType "int"}}{{.DefaultValue}}{{else}}'{{js .DefaultValue}}'{{end}}{{end}}{{end}} {{"}"}}),
|
||||
idKey: '{{.PkJsonField}}',
|
||||
{{- if $hasRules}}
|
||||
rules,
|
||||
{{- end}}
|
||||
api: { get: get{{.ClassName}}, add: add{{.ClassName}}, update: update{{.ClassName}} },
|
||||
onSuccess: () => table.getList()
|
||||
})
|
||||
|
||||
const { remove } = useRemove({
|
||||
api: del{{.ClassName}},
|
||||
onSuccess: () => table.getList()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module go-admin-e2e-apporder
|
||||
|
||||
go 1.26.5
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/glebarez/go-sqlite v1.22.0
|
||||
@@ -40,7 +40,7 @@ require (
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/gin-gonic/gin v1.12.0 // indirect
|
||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.8.0 // indirect
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.10.0 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-openapi/jsonpointer v1.0.0 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.0 // indirect
|
||||
|
||||
@@ -145,8 +145,8 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.8.0 h1:ZTw5Z/UT1/7OltbGPEaEVerRk4z3koB6O8nDbb84tPM=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.8.0/go.mod h1:LG/XvEfOplbuadKrPTPm0Nu5pN06aQUNZZC3ao4B4gs=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.10.0 h1:MM1wl9s2iW3M4GFsT8SyaUWaj2Q8m1NSuundXc6DWzI=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.10.0/go.mod h1:Q0FfO+8pfPNkPqk9fcRpe2sSOHfir82cjZO8Nol/8SI=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
|
||||
|
||||
Reference in New Issue
Block a user