mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-24 19:17:43 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76c9d1211e | ||
|
|
f5273f5a58 | ||
|
|
54ffaac9c5 | ||
|
|
484de2e698 | ||
|
|
d72ff76aad | ||
|
|
4a523bed92 | ||
|
|
55dc33b865 | ||
|
|
3d13f5856a | ||
|
|
aa2976ba17 | ||
|
|
8e141ff8a0 | ||
|
|
2628ab8e3e | ||
|
|
1b7dcd843c | ||
|
|
f0d91fb763 | ||
|
|
7238c6a26d | ||
|
|
0964cf98e2 | ||
|
|
04c6a081ae | ||
|
|
fcbd9ae02e | ||
|
|
d34d30a197 | ||
|
|
ecfea845c2 | ||
|
|
e05ff7c809 | ||
|
|
28a9626661 | ||
|
|
96b2cb3acf | ||
|
|
87fe6b7d9b | ||
|
|
722de8ea65 | ||
|
|
8ffde94433 | ||
|
|
08eef12bca | ||
|
|
ab0e8e6056 | ||
|
|
2cef52d906 | ||
|
|
95077b116b | ||
|
|
d4cf11d313 | ||
|
|
f201792d8f | ||
|
|
d16f5e7180 | ||
|
|
f8f697af2b | ||
|
|
eb8da38a46 | ||
|
|
f19568c69a | ||
|
|
91bf25e5fe | ||
|
|
85666f160f | ||
|
|
b2baf48dc6 | ||
|
|
c906e1d503 | ||
|
|
3b93ac19f8 | ||
|
|
9914373d45 | ||
|
|
4911730012 | ||
|
|
88bab51056 | ||
|
|
0fd4f68b6c | ||
|
|
c66cb5c6a8 | ||
|
|
b16ec0af77 | ||
|
|
82ea8539eb | ||
|
|
d17d5c1206 | ||
|
|
041d22d0d2 | ||
|
|
5864058a81 | ||
|
|
45035a16e4 | ||
|
|
f4d0108d49 | ||
|
|
b81611ba72 | ||
|
|
bb34108831 | ||
|
|
b7fd92f39b | ||
|
|
63b800a3ba | ||
|
|
ed9450a2d5 | ||
|
|
1d551a10ab | ||
|
|
4d7c9e5a12 | ||
|
|
d1f5fe5681 | ||
|
|
e8c2e0a966 | ||
|
|
cef0a19a9c | ||
|
|
c0e81363dc | ||
|
|
df2e4a2b48 | ||
|
|
9088ebc2e1 | ||
|
|
9f2dec3036 | ||
|
|
ea049f9b06 | ||
|
|
1f1349a685 | ||
|
|
dcef2df38e |
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: new-business-module
|
||||
description: Scaffold a new single-table CRUD business module end to end — migration, Actions-mode model/dto/router, and the sys_menu/sys_api/casbin seed data that makes it show up in the UI with working permissions. Use when the user wants to add a new business table/module to go-admin, not for cross-table or non-CRUD business logic.
|
||||
---
|
||||
|
||||
# 新增业务模块
|
||||
|
||||
给一张新的业务表配齐"能跑、能看见、能授权"的完整闭环:迁移 → 后端代码 → 菜单与权限种子数据。
|
||||
只适用于单表增删改查;跨表事务、外部调用、复杂校验等超出这个范围(见下方"何时不适用")。
|
||||
|
||||
开始前先读 `AGENTS.md`(分层边界、通用 Action 使用前提、命名规则)和 `app/demo/` 下的全部文件——
|
||||
这是可编译、有测试、CI 会跑的参照物,本文与它冲突时以它为准。
|
||||
|
||||
## 何时不适用
|
||||
|
||||
业务超出单表 CRUD(跨表事务、外部服务调用、复杂校验)时,不要用这个 skill 硬套——
|
||||
改成手写 Handler + Service,参照 `app/admin/apis/sys_post.go` 及其 Service,遵守
|
||||
`AGENTS.md` 的分层约束(Api 不碰 Orm,Service 不碰 `gin.Context`,一律用 `e.Orm`)。
|
||||
|
||||
## 步骤
|
||||
|
||||
### 1. 确认表结构
|
||||
|
||||
表结构需符合命名规范:`sys_`/业务前缀 + 下划线(如 `tb_article`)。核对字段是否已有
|
||||
`created_at`/`updated_at`/`deleted_at` 这类约定字段。
|
||||
|
||||
### 2. 写数据库迁移
|
||||
|
||||
放在 `cmd/migrate/migration/version/` 目录(**不是** `version-local/` —— 后者在
|
||||
`.gitignore` 中,提交时会被忽略,`git status` 也看不到)。
|
||||
|
||||
- 文件名前 13 位是时间戳版本号
|
||||
- 已执行过的迁移文件不可修改;需要修正时新增一个迁移
|
||||
- 包名为 `version`
|
||||
|
||||
### 3. 生成 model / dto / router 三个文件(Actions 模式)
|
||||
|
||||
不要手写 Api 与 Service。使用 `common/actions` 的通用 Action,一个模块只需
|
||||
model、dto、router 三个文件,完整写法照抄 `app/demo/` 的结构。
|
||||
|
||||
**关键正确性要求**(这三条是实际出问题最多的地方):
|
||||
|
||||
- Model 实现 `models.ActiveRecord`(`Generate` / `GetId` / `TableName`),
|
||||
`TableName()` 必须显式声明——GORM 配置了 `SingularTable`,不会自动推导
|
||||
- **`Generate()` 必须返回副本,不要就地返回**——Action 在并发请求间复用实例,
|
||||
就地返回会导致请求之间串数据;这个问题单人测试时几乎不出现,上线后才暴露
|
||||
- 完成后确认 `cmd/api/` 中已用 `_` 导入新包,否则路由不会被注册
|
||||
|
||||
### 4. 写菜单、接口与权限种子数据
|
||||
|
||||
这一步最容易被漏掉——代码能编译、接口能测通,但界面上看不到菜单、点了按钮说
|
||||
没权限,往往就是漏了这一步。**完整参照 `cmd/migrate/migration/version/1786700001000_demo_menu.go`**
|
||||
——那是可运行、幂等(用 `upsert`,重复跑不会报错)的真实例子,逐字照抄结构,只换 ID 和业务字段。
|
||||
|
||||
一个模块要在界面上可用,需要四类数据,缺一样都不行:
|
||||
|
||||
| 表 | 作用 |
|
||||
|---|---|
|
||||
| `sys_api` | 后端路由登记,Casbin 据此判定权限 |
|
||||
| `sys_menu` | 侧边栏菜单(目录用 `M`、菜单用 `C`、按钮用 `F`) |
|
||||
| `sys_menu_api_rule` | 菜单与接口的多对多关联,角色保存时据此生成策略 |
|
||||
| `casbin_rule` | 实际生效的权限策略(**不是** `sys_casbin_rule`,那张表的唯一索引在 MySQL 下会超长,不要迁移它) |
|
||||
|
||||
必须核对的两处一致性——**错了不会报错,只会在界面上表现为"看不到/点不动"**:
|
||||
|
||||
- `sys_menu.menu_name` 必须与前端组件的 `defineOptions({ name: 'XxxManage' })` 一致,
|
||||
否则 `keep-alive` 缓存静默失效
|
||||
- 按钮级 `sys_menu.permission`(格式 `模块:资源:操作`)必须与前端
|
||||
`v-permisaction="['模块:资源:操作']"` 完全一致,否则按钮权限判断静默失效
|
||||
|
||||
### 5. 收尾检查
|
||||
|
||||
| 检查项 | 出错后果 |
|
||||
| --- | --- |
|
||||
| `Generate()` 是否返回副本 | 并发请求之间串数据 |
|
||||
| 是否使用 `e.Orm` 而非全局 DB | 多租户下拿到错误的数据库连接 |
|
||||
| `TableName()` 是否显式声明 | GORM 不会自动推导 |
|
||||
| 迁移文件是否放在 `version/` | 放进 `version-local/` 会被忽略,别人拉代码看不到 |
|
||||
| `sys_menu.menu_name` 是否与前端组件 `name` 一致 | keep-alive 缓存静默失效 |
|
||||
| `sys_menu.permission` 是否与前端 `v-permisaction` 一致 | 按钮权限静默失效 |
|
||||
|
||||
跑一遍 `go run -tags sqlite3 . migrate -c config/settings.sqlite.yml` 验证迁移可执行,
|
||||
再 `go run -tags sqlite3 . server -c config/settings.sqlite.yml` 启动服务,用 admin
|
||||
账号登录确认新菜单和按钮权限都出现了。
|
||||
|
||||
如果前端页面还没生成,下一步用 go-admin-ui 仓库里的 `new-list-page` skill——两边靠
|
||||
`sys_menu.permission` / `v-permisaction` 这个字符串对齐。
|
||||
|
||||
> 不要把 `config/settings.yml` 的真实内容贴给 AI 工具——`database.source` 含数据库
|
||||
> 账号密码,`jwt.secret` 泄露后可被用来伪造任意用户的 token。
|
||||
@@ -6,6 +6,14 @@ on:
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
# One deploy at a time. Two merges seconds apart raced here: both runs did
|
||||
# docker rm -f then docker run, the second removed the container the first had
|
||||
# just created, and the first's docker run then failed on a name conflict -
|
||||
# leaving the demo on the older image with a red deploy.
|
||||
concurrency:
|
||||
group: deploy-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
IMAGE_NAME: registry.ap-northeast-1.aliyuncs.com/go-admin/go-admin-api # 镜像名称
|
||||
TAG: ${{ github.sha }}
|
||||
@@ -29,7 +37,12 @@ jobs:
|
||||
- name: Build
|
||||
run: env CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -tags "sqlite3,json1" --ldflags "-extldflags -static" -o main .
|
||||
|
||||
# 以下推镜像与重启步骤仅在 master 收到 push 时执行。
|
||||
# pull_request 事件同样会触发本工作流,若不加限制,任何指向 master 的
|
||||
# PR 一经创建就会把 PR 分支的镜像推上仓库,并直接重启线上 API 服务,
|
||||
# 且发生在合并之前。构建与编译校验不受影响,PR 仍会执行。
|
||||
- name: Build the Docker image and push
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
run: |
|
||||
docker login --username=${{ secrets.DOCKER_USERNAME }} registry.ap-northeast-1.aliyuncs.com --password=${{ secrets.DOCKER_PASSWORD }}
|
||||
echo "************ docker login end"
|
||||
@@ -43,6 +56,7 @@ jobs:
|
||||
echo "************ docker push end"
|
||||
|
||||
- name: Restart server # 第五步,重启服务
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
env:
|
||||
GITHUB_SHA_X: ${GITHUB_SHA}
|
||||
@@ -51,7 +65,65 @@ jobs:
|
||||
username: ${{ secrets.SSH_USERNAME }}
|
||||
key: ${{ secrets.DEPLOY_KEY }}
|
||||
# 重启的脚本,根据自身情况做相应改动,一般要做的是migrate数据库以及重启服务器
|
||||
#
|
||||
# 配置从宿主机挂载,不使用镜像里的那份:演示站连的是托管数据库,
|
||||
# 而 config/settings.demo.yml 会随仓库公开、也会打进镜像,凭据不能写在那里。
|
||||
# 镜像里那份保持 sqlite,供 clone 仓库的人开箱即用。
|
||||
#
|
||||
# 路径本身走 secret:它不是凭据,但本仓库公开,没有理由把服务器的
|
||||
# 目录结构一并公布。DEMO_CONFIG_PATH 指向宿主机上那份配置。
|
||||
#
|
||||
# 顺序是有意的:迁移先跑,跑不过就保持现有版本不动;
|
||||
# 旧容器改名保留而不是删除,新容器不健康时能原样恢复。
|
||||
# 健康检查两条都要过——HTTP 活着不代表数据库通了。
|
||||
script: |
|
||||
sudo docker rm -f go-admin-api
|
||||
set -u
|
||||
CFG="${{ secrets.DEMO_CONFIG_PATH }}"
|
||||
IMG="${{ env.IMAGE_NAME_TAG }}"
|
||||
NAME=go-admin-api
|
||||
PREV="$NAME-prev"
|
||||
|
||||
test -f "$CFG" || { echo "宿主机配置缺失,中止部署"; exit 1; }
|
||||
|
||||
sudo docker login --username=${{ secrets.DOCKER_USERNAME }} registry.ap-northeast-1.aliyuncs.com --password=${{ secrets.DOCKER_PASSWORD }}
|
||||
sudo docker run -d -p 8000:8000 --name go-admin-api ${{ env.IMAGE_NAME_TAG }}
|
||||
sudo docker pull "$IMG" || { echo "拉取镜像失败,中止部署"; exit 1; }
|
||||
|
||||
# 迁移用新镜像跑。失败时线上仍是旧版本配旧 schema,是自洽的;
|
||||
# 硬切过去才会得到代码与表对不上的服务。
|
||||
if ! sudo docker run --rm -v "$CFG":/config/settings.yml:ro "$IMG" \
|
||||
/main migrate -c /config/settings.yml; then
|
||||
echo "迁移失败,保持现有版本"; exit 1
|
||||
fi
|
||||
|
||||
if sudo docker ps -a --format '{{.Names}}' | grep -qx "$NAME"; then
|
||||
sudo docker rm -f "$PREV" >/dev/null 2>&1 || true
|
||||
sudo docker rename "$NAME" "$PREV"
|
||||
sudo docker stop "$PREV" >/dev/null
|
||||
fi
|
||||
|
||||
sudo docker run -d -p 8000:8000 \
|
||||
-v "$CFG":/config/settings.yml:ro \
|
||||
--name "$NAME" "$IMG"
|
||||
|
||||
ok=0
|
||||
for i in $(seq 1 20); do
|
||||
sleep 3
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -m 5 http://127.0.0.1:8000/api/v1/captcha 2>/dev/null || true)
|
||||
if [ "$code" = "200" ] && sudo docker logs "$NAME" 2>&1 | grep -q 'connect success'; then
|
||||
ok=1; echo "健康检查通过(第 $i 次探测)"; break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ok" = "1" ]; then
|
||||
sudo docker rm -f "$PREV" >/dev/null 2>&1 || true
|
||||
else
|
||||
echo "健康检查失败,回滚到上一版本"
|
||||
sudo docker logs --tail 40 "$NAME" 2>&1 || true
|
||||
sudo docker rm -f "$NAME" >/dev/null 2>&1 || true
|
||||
if sudo docker ps -a --format '{{.Names}}' | grep -qx "$PREV"; then
|
||||
sudo docker rename "$PREV" "$NAME"
|
||||
sudo docker start "$NAME" >/dev/null
|
||||
echo "已恢复"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
name: 'GitHub Actions Mirror'
|
||||
|
||||
on: [push, delete]
|
||||
|
||||
jobs:
|
||||
mirror_to_gitee:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: 'Mirror to gitee'
|
||||
uses: pixta-dev/repository-mirroring-action@674e65a7d483ca28dafaacba0d07351bdcc8bd75 # v1
|
||||
with:
|
||||
target_repo_url:
|
||||
git@gitee.com:go-admin-team/go-admin.git
|
||||
ssh_private_key:
|
||||
${{ secrets.GITEE_KEY }}
|
||||
mirror_to_gitlab:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: 'Mirror to gitlab'
|
||||
uses: pixta-dev/repository-mirroring-action@674e65a7d483ca28dafaacba0d07351bdcc8bd75 # v1
|
||||
with:
|
||||
target_repo_url:
|
||||
git@gitlab.com:go-admin-team/go-admin.git
|
||||
ssh_private_key:
|
||||
${{ secrets.GITLAB_KEY }}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
.idea
|
||||
.vscode
|
||||
*/.DS_Store
|
||||
.DS_Store
|
||||
static/uploadfile
|
||||
main.exe
|
||||
*.exe
|
||||
@@ -21,5 +21,11 @@ cmd/migrate/migration/version-local/*
|
||||
config/settings.deva.yml
|
||||
go-admin-server
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
# Everything under .claude is private by default. Skills meant for people using
|
||||
# go-admin are re-included one directory at a time, so a personal one dropped in
|
||||
# here is never committed by accident.
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
.claude/skills/*
|
||||
!.claude/skills/new-business-module/
|
||||
config/settings.local.dev.yml
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# AGENTS.md — go-admin 后端
|
||||
|
||||
> 给 AI 编码工具与新贡献者的约定。**只写"不遵守就会出错"的规则**;技术栈版本以
|
||||
> `go.mod` 为准,命令以 `Makefile` 为准,此处不复述,避免与代码脱节。
|
||||
>
|
||||
> 标准 CRUD 模块的完整写法见 **`app/demo/`** —— 那是可编译、有测试、CI 会跑的参照物。
|
||||
> 本文与它冲突时,以 `app/demo/` 为准。
|
||||
|
||||
## 分层
|
||||
|
||||
```
|
||||
Router → Api → Service → Model
|
||||
路由注册 参数绑定 业务逻辑 GORM 结构体
|
||||
中间件链 调用 Service 操作数据库 TableName()
|
||||
```
|
||||
|
||||
对应目录:`app/{模块}/router|apis|service|models`,DTO 位于 `service/dto`。
|
||||
|
||||
**不可跨层**:Api 不直接操作 `Orm`,Service 不接触 `gin.Context`。
|
||||
|
||||
## 优先使用通用 Action
|
||||
|
||||
单表 CRUD **不要手写 Handler 与 Service**。`common/actions` 提供的五个
|
||||
Action 已覆盖参数绑定、数据权限过滤、操作人注入、分页与错误响应:
|
||||
|
||||
```go
|
||||
r := v1.Group("/demo-product").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
{
|
||||
m := &models.DemoProduct{}
|
||||
r.GET("", actions.PermissionAction(), actions.IndexAction(m, new(dto.DemoProductSearch), func() interface{} {
|
||||
list := make([]models.DemoProduct, 0); return &list
|
||||
}))
|
||||
r.GET("/:id", actions.PermissionAction(), actions.ViewAction(new(dto.DemoProductById), func() interface{} {
|
||||
return &models.DemoProduct{}
|
||||
}))
|
||||
r.POST("", actions.CreateAction(new(dto.DemoProductControl)))
|
||||
r.PUT("/:id", actions.PermissionAction(), actions.UpdateAction(new(dto.DemoProductControl)))
|
||||
r.DELETE("", actions.PermissionAction(), actions.DeleteAction(new(dto.DemoProductById)))
|
||||
}
|
||||
```
|
||||
|
||||
这样一个模块只需 **model + dto + router** 三个文件,完整示例见 `app/demo/`。
|
||||
|
||||
使用通用 Action 的前提:
|
||||
|
||||
- Model 实现 `models.ActiveRecord`(`Generate` / `GetId` / `TableName`)
|
||||
- 列表 DTO 实现 `dto.Index`,增改删 DTO 实现 `dto.Control`
|
||||
- **所有 `Generate()` 必须返回副本** —— Action 在并发请求间复用实例,
|
||||
就地返回会串数据(`app/demo` 的测试锁定了这一点)
|
||||
- 详情/删除 DTO 内嵌 `dto.ObjectById` 即可继承 `Bind` 与 `GetId`,无需重写
|
||||
|
||||
仅当业务超出单表 CRUD(跨表事务、外部调用、复杂校验)时才自行编写 Handler
|
||||
与 Service,写法见下。
|
||||
|
||||
## Api 层(仅在通用 Action 不适用时)
|
||||
|
||||
结构体嵌入 `api.Api`,链式初始化后**必须检查 `Errors`**:
|
||||
|
||||
```go
|
||||
func (e SysPost) GetPage(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req := dto.SysPostPageReq{}
|
||||
err := e.MakeContext(c).MakeOrm().Bind(&req, binding.Form).MakeService(&s.Service).Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
// ... 调用 s.GetPage(...)
|
||||
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
|
||||
}
|
||||
```
|
||||
|
||||
响应一律走 `e.OK` / `e.PageOK` / `e.Error`,不要自行 `c.JSON`。
|
||||
|
||||
## Service 层(仅在通用 Action 不适用时)
|
||||
|
||||
结构体嵌入 `service.Service`(持有 `Orm` 与 `Log`)。查询通过 Scopes 组合:
|
||||
|
||||
```go
|
||||
err = e.Orm.Model(&data).Scopes(
|
||||
cDto.MakeCondition(c.GetNeedSearch()), // 由 search tag 生成 WHERE
|
||||
cDto.Paginate(c.GetPageSize(), c.GetPageIndex()),
|
||||
actions.Permission(data.TableName(), p), // 数据权限,列表/详情必须带
|
||||
).Find(list).Limit(-1).Offset(-1).Count(count).Error
|
||||
```
|
||||
|
||||
**遗漏 `actions.Permission` 会使数据权限配置静默失效** —— 这是最容易出的错。
|
||||
|
||||
错误一律 `return err` 向上传递,日志用 `e.Log.Errorf`,不使用 `panic`。
|
||||
|
||||
## DTO
|
||||
|
||||
搜索条件由 tag 声明,`MakeCondition` 据此拼 SQL:
|
||||
|
||||
```go
|
||||
type SysPostPageReq struct {
|
||||
dto.Pagination `search:"-"`
|
||||
PostName string `form:"postName" search:"type:contains;column:post_name;table:sys_post"`
|
||||
}
|
||||
func (m *SysPostPageReq) GetNeedSearch() interface{} { return *m }
|
||||
```
|
||||
|
||||
`type` 可选:`exact` `iexact` `contains` `gt` `gte` `lt` `lte` `order` `left`(联表)。
|
||||
|
||||
## Model
|
||||
|
||||
```go
|
||||
type SysPost struct {
|
||||
PostId int `gorm:"primaryKey;autoIncrement" json:"postId"`
|
||||
// ... 业务字段
|
||||
models.ControlBy // CreateBy / UpdateBy
|
||||
models.ModelTime // CreatedAt / UpdatedAt / DeletedAt
|
||||
}
|
||||
func (SysPost) TableName() string { return "sys_post" }
|
||||
```
|
||||
|
||||
`TableName()` 必须显式声明(GORM 配置了 `SingularTable`,不会自动推导复数)。
|
||||
|
||||
## 路由注册
|
||||
|
||||
通过 `init()` 自注册,不在中心文件手工添加:
|
||||
|
||||
```go
|
||||
func init() { routerCheckRole = append(routerCheckRole, registerSysPostRouter) }
|
||||
|
||||
func registerSysPostRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
api := apis.SysPost{}
|
||||
r := v1.Group("/post").
|
||||
Use(authMiddleware.MiddlewareFunc()).
|
||||
Use(middleware.AuthCheckRole()). // Casbin 鉴权
|
||||
Use(actions.PermissionAction()) // 注入数据权限
|
||||
{ r.GET("", api.GetPage); r.POST("", api.Insert); /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
新增路由文件后,需确认 `cmd/api/` 中已用 `_` 导入该包。
|
||||
|
||||
## 命名
|
||||
|
||||
| 对象 | 规则 | 示例 |
|
||||
|---|---|---|
|
||||
| 数据表 | `sys_` 前缀 + 下划线 | `sys_post` |
|
||||
| API 路径 | `/api/v1/` + kebab-case | `/api/v1/sys-user` |
|
||||
| DTO | `{Model}{Action}Req` | `SysPostPageReq` |
|
||||
| 权限标识 | `模块:资源:操作` | `admin:sysPost:add` |
|
||||
|
||||
权限标识需与前端 `v-permisaction` 一致,并写入 `sys_menu` 种子数据——完整可运行的
|
||||
参照见 `cmd/migrate/migration/version/1786700001000_demo_menu.go`(sys_api /
|
||||
sys_menu / sys_menu_api_rule / casbin_rule 四张表如何配齐,用的是幂等 upsert,
|
||||
可以直接照抄结构)。
|
||||
|
||||
## Swagger
|
||||
|
||||
Handler 必须带完整注解,`go generate` 会据此生成文档:
|
||||
|
||||
```go
|
||||
// @Summary 岗位列表
|
||||
// @Tags 岗位
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /api/v1/post [get]
|
||||
// @Security Bearer
|
||||
```
|
||||
|
||||
## 本地运行
|
||||
|
||||
**配置 `driver: sqlite3` 时必须带构建标签**,否则启动即 panic:
|
||||
|
||||
```bash
|
||||
go run -tags sqlite3 . migrate -c config/settings.sqlite.yml
|
||||
go run -tags sqlite3 . server -c config/settings.sqlite.yml
|
||||
```
|
||||
|
||||
原因:`common/database/open.go` 带 `//go:build !sqlite3`,不加标签时编进的是
|
||||
不含 sqlite3 的版本,`opens["sqlite3"]` 为 nil,调用时在 nil 函数上崩溃。
|
||||
报错信息不会提到构建标签,容易误判成环境损坏。MySQL / PostgreSQL 无此问题。
|
||||
|
||||
对应 `Makefile` 的 `build-sqlite` 目标。
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
文件名前 13 位为时间戳版本号。**已执行过的迁移文件不可修改** ——
|
||||
`sys_migration` 表按版本号去重,改动不会重跑,只能新增一个迁移来修正。
|
||||
|
||||
放哪个目录取决于身份:
|
||||
|
||||
| 目录 | 用途 | 是否入库 |
|
||||
|---|---|---|
|
||||
| `version/` | 框架自带迁移,随仓库分发给所有使用者 | 是 |
|
||||
| `version-local/` | 使用者自己项目的迁移 | 否(已在 `.gitignore`) |
|
||||
|
||||
**向本仓库提交迁移必须放 `version/`** —— 放进 `version-local/` 会被忽略掉,
|
||||
`git status` 看不到,PR 里也不会出现。两个目录的包名分别是 `version` 与
|
||||
`version_local`(后者与目录名不一致,因为标识符不能含连字符)。
|
||||
|
||||
## 提交规范
|
||||
|
||||
格式 `type+emoji: 描述`:
|
||||
|
||||
`feat✨` `fix🐛` `style💄` `docs📝` `perf👌` `test✅` `refactor🎨` `chore🔧`
|
||||
|
||||
一个提交只做一件事。改动跨越多个语义时拆分提交,不要混在一起。
|
||||
|
||||
## 红线
|
||||
|
||||
- 不使用全局 DB 变量,一律用 `e.Orm`(来自请求上下文,多租户依赖它)
|
||||
- 不在 Service 中引用 `gin.Context`
|
||||
- 生产部署前确认 `mode: prod` 且已修改 `jwt.secret`(dev 模式下 token 几乎不过期)
|
||||
- 不提交 `config/settings.yml` 中的真实凭据
|
||||
@@ -1,28 +0,0 @@
|
||||
FROM golang:alpine as builder
|
||||
|
||||
MAINTAINER lwnmengjing
|
||||
|
||||
ENV GOPROXY https://goproxy.cn/
|
||||
|
||||
WORKDIR /go/release
|
||||
#RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
RUN apk update && apk add tzdata
|
||||
|
||||
COPY go.mod ./go.mod
|
||||
RUN go mod tidy
|
||||
COPY . .
|
||||
RUN pwd && ls
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -a -installsuffix cgo -o go-admin .
|
||||
|
||||
FROM alpine
|
||||
|
||||
COPY --from=builder /go/release/go-admin /
|
||||
|
||||
COPY --from=builder /go/release/config/settings.yml /config/settings.yml
|
||||
|
||||
COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["/go-admin","server","-c", "/config/settings.yml"]
|
||||
@@ -21,7 +21,7 @@ run:
|
||||
# 进入到项目根目录 执行 make run 命令
|
||||
@docker-compose up -d
|
||||
|
||||
# 启动方式二 docker run 这里注意-v挂载的宿主机的地址改为部署时的实际决对路径
|
||||
# 启动方式二 docker run 这里注意-v挂载的宿主机的地址改为部署时的实际绝对路径
|
||||
#@docker run --name=go-admin -p 8000:8000 -v /home/code/go/src/go-admin/go-admin/config:/go-admin-api/config -v /home/code/go/src/go-admin/go-admin-api/static:/go-admin/static -v /home/code/go/src/go-admin/go-admin/temp:/go-admin-api/temp -d --restart=always go-admin:latest
|
||||
|
||||
@echo "go-admin service is running..."
|
||||
|
||||
+7
-10
@@ -19,13 +19,10 @@
|
||||
|
||||
## 🎬 在线体验
|
||||
|
||||
Element UI vue体验:[https://vue2.go-admin.dev](https://vue2.go-admin.dev/#/login)
|
||||
Element Plus vue3 体验:[https://vue.go-admin.pro](https://vue.go-admin.pro/#/login)
|
||||
> ⚠️⚠️⚠️ 账号 / 密码: admin / 123456
|
||||
|
||||
Arco Design vue3 demo:[https://vue3.go-admin.dev](https://vue3.go-admin.dev/#/login)
|
||||
> ⚠️⚠️⚠️ 账号 / 密码: admin / 123456
|
||||
|
||||
antd体验:[https://antd.go-admin.pro](https://antd.go-admin.pro/)
|
||||
antd 体验(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admin.pro/)
|
||||
> ⚠️⚠️⚠️ 账号 / 密码: admin / 123456
|
||||
|
||||
## ✨ 特性
|
||||
@@ -81,9 +78,9 @@ antd体验:[https://antd.go-admin.pro](https://antd.go-admin.pro/)
|
||||
|
||||
### 轻松实现go-admin写出第一个应用 - 文档教程
|
||||
|
||||
[步骤一 - 基础内容介绍](https://doc.zhangwj.com/guide/intro/tutorial01.html)
|
||||
[步骤一 - 基础内容介绍](https://doc.go-admin.dev/guide/intro/tutorial01.html)
|
||||
|
||||
[步骤二 - 实际应用 - 编写增删改查](https://doc.zhangwj.com/guide/intro/tutorial02.html)
|
||||
[步骤二 - 实际应用 - 编写增删改查](https://doc.go-admin.dev/guide/intro/tutorial02.html)
|
||||
|
||||
### 手把手教你从入门到放弃 - 视频教程
|
||||
|
||||
@@ -332,7 +329,7 @@ pnpm dev
|
||||
6. [spf13/viper](https://github.com/spf13/viper)
|
||||
7. [gorm](https://github.com/jinzhu/gorm)
|
||||
8. [gin-swagger](https://github.com/swaggo/gin-swagger)
|
||||
9. [jwt-go](https://github.com/dgrijalva/jwt-go)
|
||||
9. [golang-jwt](https://github.com/golang-jwt/jwt)
|
||||
10. [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
|
||||
11. [ruoyi-vue](https://gitee.com/y_project/RuoYi-Vue)
|
||||
12. [form-generator](https://github.com/JakHuang/form-generator)
|
||||
@@ -346,10 +343,10 @@ pnpm dev
|
||||
|
||||
## 🤝 链接
|
||||
|
||||
[Go开发者成长线路图](http://www.golangroadmap.com/)
|
||||
- [mss-boot-io](https://docs.mss-boot-io.top/)
|
||||
|
||||
## 🔑 License
|
||||
|
||||
[MIT](https://github.com/go-admin-team/go-admin/blob/master/LICENSE.md)
|
||||
|
||||
Copyright (c) 2024 wenjianzhang
|
||||
Copyright (c) 2026 wenjianzhang
|
||||
|
||||
@@ -20,14 +20,11 @@ The front-end and back-end separation authority management system based on Gin +
|
||||
|
||||
## 🎬 Online Demo
|
||||
|
||||
Element UI vue demo:[https://vue2.go-admin.dev](https://vue2.go-admin.dev/#/login)
|
||||
> 账号 / 密码: admin / 123456
|
||||
Element Plus vue3 demo:[https://vue.go-admin.pro](https://vue.go-admin.pro/#/login)
|
||||
> Account / Password: admin / 123456
|
||||
|
||||
Arco Design vue3 demo:[https://vue3.go-admin.dev](https://vue3.go-admin.dev/#/login)
|
||||
> 账号 / 密码: admin / 123456
|
||||
|
||||
antd demo:[https://antd.go-admin.pro](https://antd.go-admin.pro/)
|
||||
> 账号 / 密码: admin / 123456
|
||||
antd demo (go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admin.pro/)
|
||||
> Account / Password: admin / 123456
|
||||
>
|
||||
## ✨ Feature
|
||||
|
||||
@@ -79,9 +76,9 @@ At the same time, a series of tutorials including videos and documents are provi
|
||||
|
||||
### Easily implement go-admin to write the first application-documentation tutorial
|
||||
|
||||
[Step 1 - basic content introduction](https://doc.zhangwj.com/guide/intro/tutorial01.html)
|
||||
[Step 1 - basic content introduction](https://doc.go-admin.dev/guide/intro/tutorial01.html)
|
||||
|
||||
[Step 2 - Practical application - writing database operations](https://doc.zhangwj.com/guide/intro/tutorial02.html)
|
||||
[Step 2 - Practical application - writing database operations](https://doc.go-admin.dev/guide/intro/tutorial02.html)
|
||||
|
||||
### Teach you from getting started to giving up-video tutorial
|
||||
|
||||
@@ -323,7 +320,7 @@ The `go-admin` project has always been developed in the GoLand integrated develo
|
||||
2. [spf13/viper](https://github.com/spf13/viper)
|
||||
2. [gorm](https://github.com/jinzhu/gorm)
|
||||
2. [gin-swagger](https://github.com/swaggo/gin-swagger)
|
||||
2. [jwt-go](https://github.com/dgrijalva/jwt-go)
|
||||
2. [golang-jwt](https://github.com/golang-jwt/jwt)
|
||||
2. [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
|
||||
2. [ruoyi-vue](https://gitee.com/y_project/RuoYi-Vue)
|
||||
2. [form-generator](https://github.com/JakHuang/form-generator)
|
||||
@@ -335,11 +332,10 @@ The `go-admin` project has always been developed in the GoLand integrated develo
|
||||
<img class="no-margin" src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/pay.png" height="200px" >
|
||||
|
||||
## 🤝 Link
|
||||
- [Go developer growth roadmap](http://www.golangroadmap.com/)
|
||||
- [mss-boot-io](https://docs.mss-boot-io.top/)
|
||||
|
||||
## 🔑 License
|
||||
|
||||
[MIT](https://github.com/go-admin-team/go-admin/blob/master/LICENSE.md)
|
||||
|
||||
Copyright (c) 2022 wenjianzhang
|
||||
Copyright (c) 2026 wenjianzhang
|
||||
|
||||
@@ -2,8 +2,8 @@ package apis
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/captcha"
|
||||
"github.com/go-admin-team/go-admin-core/v2/captcha"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
)
|
||||
|
||||
type System struct {
|
||||
|
||||
@@ -11,10 +11,11 @@ const INDEX = `
|
||||
<meta charset="utf-8">
|
||||
<title>GO-ADMIN欢迎您</title>
|
||||
<style>
|
||||
body{
|
||||
margin:0;
|
||||
padding:0;
|
||||
overflow-y:hidden
|
||||
html,body{
|
||||
margin:0;
|
||||
padding:0;
|
||||
height:100%;
|
||||
overflow-y:hidden;
|
||||
}
|
||||
</style>
|
||||
<script src="https://libs.baidu.com/jquery/1.9.0/jquery.js"></script>
|
||||
@@ -28,7 +29,7 @@ $(function(){
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<iframe id="iframe" frameborder="0" src="https://www.go-admin.pro" style="width:100%;"></iframe>
|
||||
<iframe id="iframe" frameborder="0" src="https://www.go-admin.pro" style="width:100%;height:100%;"></iframe>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
@@ -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/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
"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"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service"
|
||||
|
||||
@@ -3,8 +3,8 @@ package apis
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service"
|
||||
|
||||
@@ -3,10 +3,10 @@ package apis
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
|
||||
@@ -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/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
"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"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
|
||||
@@ -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/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
"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"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
|
||||
@@ -3,7 +3,7 @@ package apis
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
|
||||
@@ -3,8 +3,8 @@ package apis
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service"
|
||||
"go-admin/app/admin/service/dto"
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
"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"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service"
|
||||
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
"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/service"
|
||||
"go-admin/app/admin/service/dto"
|
||||
@@ -122,7 +122,7 @@ func (e SysRole) Insert(c *gin.Context) {
|
||||
if req.Status == "" {
|
||||
req.Status = "2"
|
||||
}
|
||||
cb := sdk.Runtime.GetCasbinKey(c.Request.Host)
|
||||
cb := sdk.Runtime.GetCasbinByTenant(c.Request.Host)
|
||||
err = s.Insert(&req, cb)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
@@ -161,7 +161,7 @@ func (e SysRole) Update(c *gin.Context) {
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
cb := sdk.Runtime.GetCasbinKey(c.Request.Host)
|
||||
cb := sdk.Runtime.GetCasbinByTenant(c.Request.Host)
|
||||
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
|
||||
@@ -203,7 +203,7 @@ func (e SysRole) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
cb := sdk.Runtime.GetCasbinKey(c.Request.Host)
|
||||
cb := sdk.Runtime.GetCasbinByTenant(c.Request.Host)
|
||||
err = s.Remove(&req, cb)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
|
||||
@@ -2,11 +2,11 @@ package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"gorm.io/gorm"
|
||||
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
)
|
||||
|
||||
type DataPermission struct {
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/bitly/go-simplejson"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/runtime"
|
||||
"github.com/go-admin-team/go-admin-core/storage"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
"github.com/go-admin-team/go-admin-core/v2/storage"
|
||||
|
||||
"go-admin/common/models"
|
||||
)
|
||||
@@ -54,7 +54,7 @@ func SaveSysApi(message storage.Messager) (err error) {
|
||||
err = fmt.Errorf("json Unmarshal error, %s", err.Error())
|
||||
return err
|
||||
}
|
||||
dbList := sdk.Runtime.GetDb()
|
||||
dbList := sdk.Runtime.GetAllDb()
|
||||
for _, d := range dbList {
|
||||
for _, v := range l.List {
|
||||
if v.HttpMethod != "HEAD" ||
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/storage"
|
||||
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/storage"
|
||||
|
||||
"go-admin/common/models"
|
||||
)
|
||||
@@ -45,7 +45,7 @@ func (e *SysLoginLog) GetId() interface{} {
|
||||
// SaveLoginLog 从队列中获取登录日志
|
||||
func SaveLoginLog(message storage.Messager) (err error) {
|
||||
//准备db
|
||||
db := sdk.Runtime.GetDbByKey(message.GetPrefix())
|
||||
db := sdk.Runtime.GetDbByTenant(message.GetPrefix())
|
||||
if db == nil {
|
||||
err = errors.New("db not exist")
|
||||
log.Errorf("host[%s]'s %s", message.GetPrefix(), err.Error())
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/storage"
|
||||
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/storage"
|
||||
|
||||
"go-admin/common/models"
|
||||
)
|
||||
@@ -53,7 +53,7 @@ func (e *SysOperaLog) GetId() interface{} {
|
||||
// SaveOperaLog 从队列中获取操作日志
|
||||
func SaveOperaLog(message storage.Messager) (err error) {
|
||||
//准备db
|
||||
db := sdk.Runtime.GetDbByKey(message.GetPrefix())
|
||||
db := sdk.Runtime.GetDbByTenant(message.GetPrefix())
|
||||
if db == nil {
|
||||
err = errors.New("db not exist")
|
||||
log.Errorf("host[%s]'s %s", message.GetPrefix(), err.Error())
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
common "go-admin/common/middleware"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ package router
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -2,7 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
|
||||
"go-admin/app/admin/apis"
|
||||
"go-admin/common/middleware"
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"go-admin/common/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -2,7 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"go-admin/app/admin/apis"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"go-admin/app/admin/apis"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"go-admin/app/admin/apis"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"go-admin/app/admin/apis"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"go-admin/app/admin/apis"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"go-admin/app/admin/apis"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
|
||||
"go-admin/app/admin/apis"
|
||||
"go-admin/common/middleware"
|
||||
|
||||
@@ -4,11 +4,11 @@ import (
|
||||
"go-admin/app/admin/apis"
|
||||
"mime"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/ws"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/ws"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
|
||||
swaggerfiles "github.com/swaggo/files"
|
||||
@@ -69,8 +69,15 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
v1.POST("/login", authMiddleware.LoginHandler)
|
||||
// Refresh time can be longer than token timeout
|
||||
v1.GET("/refresh_token", authMiddleware.RefreshHandler)
|
||||
// GET /api/v1/refresh_token 已移除,原因见 issue #820:
|
||||
// 该接口用业务 token 即可换取新 token,而续期上限 MaxRefresh 依据的
|
||||
// orig_iat 在每次续期时被一并重置,上限永远无法到达 —— token 一旦泄
|
||||
// 露即等同于永久访问权。它此前还位于 CasbinExclude 中,任何角色的已
|
||||
// 登录用户都能调用,不受权限约束。
|
||||
//
|
||||
// 官方前端从未调用该接口(store 中的 refreshToken action 无人 dispatch),
|
||||
// 移除不影响正常使用。若确需无感续期,应在 go-admin-core 中区分
|
||||
// access token 与 refresh token 后重新实现,而非沿用此路由。
|
||||
}
|
||||
registerBaseRouter(v1, authMiddleware)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"go-admin/app/admin/apis"
|
||||
"go-admin/common/actions"
|
||||
"go-admin/common/middleware"
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/runtime"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service/dto"
|
||||
"go-admin/common/actions"
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"go-admin/app/admin/service/dto"
|
||||
cDto "go-admin/common/dto"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
)
|
||||
|
||||
type SysConfig struct {
|
||||
|
||||
@@ -4,13 +4,13 @@ import (
|
||||
"errors"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
|
||||
"go-admin/app/admin/service/dto"
|
||||
cDto "go-admin/common/dto"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
)
|
||||
|
||||
type SysDept struct {
|
||||
|
||||
@@ -3,7 +3,7 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
@@ -59,7 +59,12 @@ func (e *SysDictType) Insert(c *dto.SysDictTypeInsertReq) error {
|
||||
var data models.SysDictType
|
||||
c.Generate(&data)
|
||||
var count int64
|
||||
e.Orm.Model(&data).Where("dict_type = ?", data.DictType).Count(&count)
|
||||
// The error was dropped, so a query that failed left count at zero and the
|
||||
// insert went ahead as though the name were free.
|
||||
if err = e.Orm.Model(&data).Where("dict_type = ?", data.DictType).Count(&count).Error; err != nil {
|
||||
e.Log.Errorf("db error: %s", err)
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("当前字典类型[%s]已经存在!", data.DictType)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
cDto "go-admin/common/dto"
|
||||
cModels "go-admin/common/models"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
)
|
||||
|
||||
type SysMenu struct {
|
||||
@@ -395,7 +395,11 @@ func (e *SysMenu) getByRoleName(roleName string) ([]models.SysMenu, error) {
|
||||
data := make([]models.SysMenu, 0)
|
||||
|
||||
if roleName == "admin" {
|
||||
err = e.Orm.Where(" menu_type in ('M','C') and deleted_at is null").
|
||||
// The soft-delete condition is GORM's to add: it appends one for the
|
||||
// model's DeletedAt field on every query. Writing it by hand duplicates
|
||||
// that and hard-codes what "deleted" looks like — a column that stops
|
||||
// being nullable turns this clause into one that matches nothing.
|
||||
err = e.Orm.Where("menu_type in ('M','C')").
|
||||
Order("sort").
|
||||
Find(&data).
|
||||
Error
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
)
|
||||
|
||||
// The admin branch of getSysMenuByRoleName carried "deleted_at is null" in its
|
||||
// where clause. GORM adds that condition itself for a model with a DeletedAt
|
||||
// field, so the clause was a duplicate — and one written in terms of a column
|
||||
// being null, which stops being true the moment the column stops being
|
||||
// nullable. This pins the behaviour the clause was there for.
|
||||
func TestSoftDeletedMenusAreNotReturned(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SysMenu{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
live := models.SysMenu{MenuName: "live", MenuType: "M"}
|
||||
gone := models.SysMenu{MenuName: "gone", MenuType: "M"}
|
||||
if err := db.Create(&live).Error; err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := db.Create(&gone).Error; err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := db.Delete(&gone).Error; err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
|
||||
// Through getByRoleName rather than a copy of its query: a test that
|
||||
// reissues the statement passes whether or not the production line still
|
||||
// says what it is supposed to, which is what the first version of this
|
||||
// test did.
|
||||
e := &SysMenu{}
|
||||
e.Orm = db
|
||||
got, err := e.getByRoleName("admin")
|
||||
if err != nil {
|
||||
t.Fatalf("getByRoleName: %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d rows, want 1", len(got))
|
||||
}
|
||||
if got[0].MenuName != "live" {
|
||||
t.Errorf("got %q, want the row that was not deleted", got[0].MenuName)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"go-admin/app/admin/service/dto"
|
||||
cDto "go-admin/common/dto"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
@@ -3,12 +3,12 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/casbin/casbin/v2"
|
||||
"github.com/casbin/casbin/v3"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
)
|
||||
|
||||
// SysRoleMenu 即将弃用结构体
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service/dto"
|
||||
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/common/actions"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"go-admin/common/models"
|
||||
)
|
||||
|
||||
// DemoProduct 示例模型
|
||||
//
|
||||
// 内嵌 ControlBy 与 ModelTime 后,创建人/更新人与时间戳由框架自动维护;
|
||||
// 数据权限(actions.Permission)正是按 create_by 过滤,缺少 ControlBy 会使其失效。
|
||||
type DemoProduct struct {
|
||||
models.Model
|
||||
|
||||
Name string `json:"name" gorm:"size:128;comment:名称"`
|
||||
Code string `json:"code" gorm:"size:64;comment:编码"`
|
||||
Price float64 `json:"price" gorm:"comment:单价"`
|
||||
Status string `json:"status" gorm:"size:4;comment:状态"`
|
||||
Remark string `json:"remark" gorm:"size:255;comment:备注"`
|
||||
|
||||
models.ControlBy
|
||||
models.ModelTime
|
||||
}
|
||||
|
||||
func (DemoProduct) TableName() string {
|
||||
return "demo_product"
|
||||
}
|
||||
|
||||
// Generate 返回副本,供通用 Action 使用。
|
||||
// 必须返回新实例:Action 在并发请求间复用同一个模型指针,就地返回会串数据。
|
||||
func (e *DemoProduct) Generate() models.ActiveRecord {
|
||||
o := *e
|
||||
return &o
|
||||
}
|
||||
|
||||
func (e *DemoProduct) GetId() interface{} {
|
||||
return e.Id
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
|
||||
"go-admin/app/demo/models"
|
||||
"go-admin/app/demo/service/dto"
|
||||
"go-admin/common/actions"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
// 路由通过 init 自注册,无需在任何中心文件登记。
|
||||
// 新建应用时用 `go run main.go app -n <名称>` 生成骨架,
|
||||
// 它会同时产出 cmd/api/<名称>.go 完成注册。
|
||||
func init() {
|
||||
routerCheckRole = append(routerCheckRole, registerDemoProductRouter)
|
||||
}
|
||||
|
||||
// registerDemoProductRouter 标准 CRUD 的推荐写法。
|
||||
//
|
||||
// 五个通用 Action 覆盖了增删改查的全部样板逻辑——参数绑定、数据权限过滤、
|
||||
// 操作人注入、分页、错误响应,因此本模块没有 apis 与 service 文件。
|
||||
//
|
||||
// 仅当业务逻辑超出单表 CRUD(如跨表事务、外部调用、复杂校验)时,才需要
|
||||
// 自行编写 Handler 与 Service,写法参照 app/admin/apis/sys_post.go。
|
||||
func registerDemoProductRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
r := v1.Group("/demo-product").
|
||||
Use(authMiddleware.MiddlewareFunc()). // JWT 认证
|
||||
Use(middleware.AuthCheckRole()) // Casbin 鉴权
|
||||
{
|
||||
m := &models.DemoProduct{}
|
||||
|
||||
// actions.PermissionAction() 注入数据权限上下文,
|
||||
// 列表与详情缺少它会绕过 DataScope 过滤
|
||||
r.GET("", actions.PermissionAction(), actions.IndexAction(m, new(dto.DemoProductSearch), func() interface{} {
|
||||
list := make([]models.DemoProduct, 0)
|
||||
return &list
|
||||
}))
|
||||
|
||||
r.GET("/:id", actions.PermissionAction(), actions.ViewAction(new(dto.DemoProductById), func() interface{} {
|
||||
return &models.DemoProduct{}
|
||||
}))
|
||||
|
||||
r.POST("", actions.CreateAction(new(dto.DemoProductControl)))
|
||||
r.PUT("/:id", actions.PermissionAction(), actions.UpdateAction(new(dto.DemoProductControl)))
|
||||
r.DELETE("", actions.PermissionAction(), actions.DeleteAction(new(dto.DemoProductById)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "github.com/gin-gonic/gin"
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
// "github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
common "go-admin/common/middleware"
|
||||
"os"
|
||||
)
|
||||
|
||||
var (
|
||||
routerNoCheckRole = make([]func(*gin.RouterGroup), 0)
|
||||
routerCheckRole = make([]func(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware), 0)
|
||||
)
|
||||
|
||||
// InitRouter 路由初始化
|
||||
func InitRouter() {
|
||||
var r *gin.Engine
|
||||
h := sdk.Runtime.GetEngine()
|
||||
if h == nil {
|
||||
h = gin.New()
|
||||
sdk.Runtime.SetEngine(h)
|
||||
}
|
||||
switch h.(type) {
|
||||
case *gin.Engine:
|
||||
r = h.(*gin.Engine)
|
||||
default:
|
||||
log.Fatal("not support other engine")
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
// the jwt middleware
|
||||
authMiddleware, err := common.AuthInit()
|
||||
if err != nil {
|
||||
log.Fatalf("JWT Init Error, %s", err.Error())
|
||||
}
|
||||
|
||||
// 注册业务路由
|
||||
InitBusinessRouter(r, authMiddleware)
|
||||
}
|
||||
|
||||
func InitBusinessRouter(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) *gin.Engine {
|
||||
|
||||
// 无需认证的路由
|
||||
noCheckRoleRouter(r)
|
||||
// 需要认证的路由
|
||||
checkRoleRouter(r, authMiddleware)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// noCheckRoleRouter 无需认证的路由
|
||||
func noCheckRoleRouter(r *gin.Engine) {
|
||||
// 可根据业务需求来设置接口版本
|
||||
v := r.Group("/api/v1")
|
||||
|
||||
for _, f := range routerNoCheckRole {
|
||||
f(v)
|
||||
}
|
||||
}
|
||||
|
||||
// checkRoleRouter 需要认证的路由
|
||||
func checkRoleRouter(r *gin.Engine, authMiddleware *jwtauth.GinJWTMiddleware) {
|
||||
// 可根据业务需求来设置接口版本
|
||||
v := r.Group("/api/v1")
|
||||
|
||||
for _, f := range routerCheckRole {
|
||||
f(v, authMiddleware)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go-admin/app/demo/models"
|
||||
"go-admin/common/dto"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// DemoProductSearch 列表查询条件
|
||||
//
|
||||
// search tag 决定 MakeCondition 拼出的 WHERE:
|
||||
//
|
||||
// exact 精确匹配 / icontains 忽略大小写模糊 / gte 大于等于 …
|
||||
//
|
||||
// 未打 search tag 的字段不参与查询,可避免无意间开放过滤维度。
|
||||
type DemoProductSearch struct {
|
||||
dto.Pagination `search:"-"`
|
||||
|
||||
Name string `form:"name" search:"type:icontains;column:name;table:demo_product"`
|
||||
Code string `form:"code" search:"type:exact;column:code;table:demo_product"`
|
||||
Status string `form:"status" search:"type:exact;column:status;table:demo_product"`
|
||||
|
||||
DemoProductOrder
|
||||
}
|
||||
|
||||
// DemoProductOrder 排序字段单独成组,避免与查询字段混在一起
|
||||
type DemoProductOrder struct {
|
||||
CreatedAtOrder string `form:"createdAtOrder" search:"type:order;column:created_at;table:demo_product"`
|
||||
}
|
||||
|
||||
func (m *DemoProductSearch) GetNeedSearch() interface{} { return *m }
|
||||
|
||||
func (m *DemoProductSearch) Bind(ctx *gin.Context) error {
|
||||
return ctx.ShouldBind(m)
|
||||
}
|
||||
|
||||
func (m *DemoProductSearch) Generate() dto.Index {
|
||||
o := *m
|
||||
return &o
|
||||
}
|
||||
|
||||
// DemoProductControl 新增与修改共用的入参
|
||||
//
|
||||
// 通用 Action(Create / Update)通过 GenerateM 拿到落库对象,
|
||||
// 因此这里不直接暴露 Model,字段校验用 validate tag 声明。
|
||||
type DemoProductControl struct {
|
||||
Id int `json:"id" comment:"主键"`
|
||||
Name string `json:"name" comment:"名称" validate:"required"`
|
||||
Code string `json:"code" comment:"编码" validate:"required"`
|
||||
Price float64 `json:"price" comment:"单价" validate:"gte=0"`
|
||||
Status string `json:"status" comment:"状态"`
|
||||
Remark string `json:"remark" comment:"备注"`
|
||||
}
|
||||
|
||||
func (s *DemoProductControl) Bind(ctx *gin.Context) error {
|
||||
return ctx.ShouldBind(s)
|
||||
}
|
||||
|
||||
func (s *DemoProductControl) Generate() dto.Control {
|
||||
o := *s
|
||||
return &o
|
||||
}
|
||||
|
||||
func (s *DemoProductControl) GetId() interface{} { return s.Id }
|
||||
|
||||
// GenerateM 组装落库对象。CreateBy / UpdateBy 由通用 Action 在此之后注入,
|
||||
// 此处不要手动赋值。
|
||||
func (s *DemoProductControl) GenerateM() (common.ActiveRecord, error) {
|
||||
return &models.DemoProduct{
|
||||
Model: common.Model{Id: s.Id},
|
||||
Name: s.Name,
|
||||
Code: s.Code,
|
||||
Price: s.Price,
|
||||
Status: s.Status,
|
||||
Remark: s.Remark,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DemoProductById 详情与删除共用,支持单个 id 与批量 ids
|
||||
type DemoProductById struct {
|
||||
dto.ObjectById
|
||||
}
|
||||
|
||||
// Bind 与 GetId 由内嵌的 dto.ObjectById 提供:它已处理好 uri 绑定、
|
||||
// DELETE 时的批量 ids 合并与参数校验,无需在此重复实现。
|
||||
|
||||
func (s *DemoProductById) Generate() dto.Control {
|
||||
o := *s
|
||||
return &o
|
||||
}
|
||||
|
||||
func (s *DemoProductById) GenerateM() (common.ActiveRecord, error) {
|
||||
return &models.DemoProduct{}, nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/demo/models"
|
||||
"go-admin/common/dto"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// 通用 Action 依赖 DTO 与 Model 实现一组接口。这些约束在编译期无法完全覆盖
|
||||
// (接口是在路由注册处才被要求的),因此用测试锁定,避免改动后在运行时才暴露。
|
||||
|
||||
func TestImplementsIndexInterface(t *testing.T) {
|
||||
var _ dto.Index = (*DemoProductSearch)(nil)
|
||||
}
|
||||
|
||||
func TestImplementsControlInterface(t *testing.T) {
|
||||
var _ dto.Control = (*DemoProductControl)(nil)
|
||||
var _ dto.Control = (*DemoProductById)(nil)
|
||||
}
|
||||
|
||||
func TestModelImplementsActiveRecord(t *testing.T) {
|
||||
var _ common.ActiveRecord = (*models.DemoProduct)(nil)
|
||||
}
|
||||
|
||||
// Generate 必须返回副本:通用 Action 在并发请求间复用同一个实例,
|
||||
// 就地返回会导致请求之间串数据。
|
||||
func TestGenerateReturnsCopy(t *testing.T) {
|
||||
src := &DemoProductControl{Id: 1, Name: "原始"}
|
||||
got := src.Generate().(*DemoProductControl)
|
||||
|
||||
if got == src {
|
||||
t.Fatal("Generate 返回了同一指针,应返回副本")
|
||||
}
|
||||
got.Name = "被修改"
|
||||
if src.Name != "原始" {
|
||||
t.Errorf("修改副本影响了原对象:src.Name = %q", src.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchGenerateReturnsCopy(t *testing.T) {
|
||||
src := &DemoProductSearch{Name: "原始"}
|
||||
got := src.Generate().(*DemoProductSearch)
|
||||
|
||||
if got == src {
|
||||
t.Fatal("Generate 返回了同一指针,应返回副本")
|
||||
}
|
||||
got.Name = "被修改"
|
||||
if src.Name != "原始" {
|
||||
t.Errorf("修改副本影响了原对象:src.Name = %q", src.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelGenerateReturnsCopy(t *testing.T) {
|
||||
src := &models.DemoProduct{Name: "原始"}
|
||||
got := src.Generate().(*models.DemoProduct)
|
||||
|
||||
if got == src {
|
||||
t.Fatal("Generate 返回了同一指针,应返回副本")
|
||||
}
|
||||
got.Name = "被修改"
|
||||
if src.Name != "原始" {
|
||||
t.Errorf("修改副本影响了原对象:src.Name = %q", src.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateM 组装落库对象,主键需正确传递,否则更新会退化成插入。
|
||||
func TestGenerateMCarriesId(t *testing.T) {
|
||||
c := &DemoProductControl{Id: 42, Name: "示例", Code: "P-42", Price: 9.9}
|
||||
m, err := c.GenerateM()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateM 返回错误: %v", err)
|
||||
}
|
||||
p, ok := m.(*models.DemoProduct)
|
||||
if !ok {
|
||||
t.Fatalf("GenerateM 返回类型错误: %T", m)
|
||||
}
|
||||
if p.Id != 42 {
|
||||
t.Errorf("主键未传递: got %d, want 42", p.Id)
|
||||
}
|
||||
if p.Name != "示例" || p.Code != "P-42" || p.Price != 9.9 {
|
||||
t.Errorf("字段映射有误: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableName(t *testing.T) {
|
||||
if got := (models.DemoProduct{}).TableName(); got != "demo_product" {
|
||||
t.Errorf("TableName() = %q, want %q", got, "demo_product")
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
|
||||
"go-admin/app/jobs/service"
|
||||
"go-admin/common/dto"
|
||||
@@ -29,7 +29,7 @@ func (e SysJob) RemoveJobForService(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
s.Cron = sdk.Runtime.GetCrontabKey(c.Request.Host)
|
||||
s.Cron = sdk.Runtime.GetCrontabByTenant(c.Request.Host)
|
||||
err = s.RemoveJob(&v)
|
||||
if err != nil {
|
||||
e.Logger.Errorf("RemoveJob error, %s", err.Error())
|
||||
@@ -58,7 +58,7 @@ func (e SysJob) StartJobForService(c *gin.Context) {
|
||||
s := service.SysJob{}
|
||||
s.Orm = db
|
||||
s.Log = log
|
||||
s.Cron = sdk.Runtime.GetCrontabKey(c.Request.Host)
|
||||
s.Cron = sdk.Runtime.GetCrontabByTenant(c.Request.Host)
|
||||
err = s.StartJob(&v)
|
||||
if err != nil {
|
||||
log.Errorf("GetCrontabKey error, %s", err.Error())
|
||||
|
||||
+6
-6
@@ -2,16 +2,16 @@ package jobs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
models2 "go-admin/app/jobs/models"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/cronjob"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/cronjob"
|
||||
)
|
||||
|
||||
var timeFormat = "2006-01-02 15:04:05"
|
||||
@@ -101,13 +101,13 @@ func Setup(dbs map[string]*gorm.DB) {
|
||||
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore Starting...")
|
||||
|
||||
for k, db := range dbs {
|
||||
sdk.Runtime.SetCrontab(k, cronjob.NewWithSeconds())
|
||||
sdk.Runtime.SetCrontabByTenant(k, cronjob.NewWithSeconds())
|
||||
setup(k, db)
|
||||
}
|
||||
}
|
||||
|
||||
func setup(key string, db *gorm.DB) {
|
||||
crontab := sdk.Runtime.GetCrontabKey(key)
|
||||
crontab := sdk.Runtime.GetCrontabByTenant(key)
|
||||
sysJob := models2.SysJob{}
|
||||
jobList := make([]models2.SysJob, 0)
|
||||
err := sysJob.GetList(db, &jobList)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
//"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
//"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
common "go-admin/common/middleware"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -2,7 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"go-admin/app/jobs/apis"
|
||||
models2 "go-admin/app/jobs/models"
|
||||
dto2 "go-admin/app/jobs/service/dto"
|
||||
|
||||
@@ -2,7 +2,7 @@ package dto
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"go-admin/app/jobs/models"
|
||||
|
||||
"go-admin/common/dto"
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
"github.com/robfig/cron/v3"
|
||||
|
||||
"go-admin/app/jobs"
|
||||
|
||||
+30
-15
@@ -8,12 +8,13 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/utils"
|
||||
"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/sdk/pkg/utils"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"go-admin/common/file_store"
|
||||
"go-admin/config"
|
||||
)
|
||||
|
||||
type FileResponse struct {
|
||||
@@ -95,7 +96,7 @@ func (e File) baseImg(c *gin.Context, fileResponse FileResponse, urlPrefix strin
|
||||
source, _ := c.GetPostForm("source")
|
||||
|
||||
if err := thirdUpload(source, fileName, base64File); err != nil {
|
||||
e.Error(200, errors.New(""), "上传第三方失败")
|
||||
e.Error(200, err, "上传第三方失败")
|
||||
return fileResponse
|
||||
}
|
||||
|
||||
@@ -126,7 +127,7 @@ func (e File) multipleFile(c *gin.Context, urlPrefix string) []FileResponse {
|
||||
|
||||
fileType, _ := utils.GetType(multipartFileName)
|
||||
if err := thirdUpload(source, fileName, multipartFileName); err != nil {
|
||||
e.Error(500, errors.New(""), "上传第三方失败")
|
||||
e.Error(500, err, "上传第三方失败")
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -176,22 +177,36 @@ func (e File) buildFileResponse(filePath, urlPrefix, fileName, fileType string)
|
||||
}
|
||||
}
|
||||
|
||||
// thirdUpload copies the file that was already stored locally to the object
|
||||
// store the request asked for. source "1", and anything unrecognised, keeps the
|
||||
// local copy only.
|
||||
//
|
||||
// Both branches used to construct a zero-value ALiYunOSS and call UpLoad on it,
|
||||
// which panicked - and the qiniu branch constructed the aliyun client, so
|
||||
// source=3 never reached qiniu even in principle.
|
||||
func thirdUpload(source string, name string, path string) error {
|
||||
switch source {
|
||||
case "2":
|
||||
return ossUpload("img/"+name, path)
|
||||
return upload(file_store.AliYunOSS, config.ExtConfig.FileStore.AliYun, "img/"+name, path)
|
||||
case "3":
|
||||
return qiniuUpload("img/"+name, path)
|
||||
return upload(file_store.QiNiuKodo, config.ExtConfig.FileStore.QiNiu, "img/"+name, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ossUpload(name string, path string) error {
|
||||
oss := file_store.ALiYunOSS{}
|
||||
return oss.UpLoad(name, path)
|
||||
}
|
||||
|
||||
func qiniuUpload(name string, path string) error {
|
||||
oss := file_store.ALiYunOSS{}
|
||||
return oss.UpLoad(name, path)
|
||||
func upload(driver file_store.DriverType, store config.ObjectStore, name, path string) error {
|
||||
if !store.Configured() {
|
||||
return fmt.Errorf("file store %s is not configured; set it under extend.fileStore", driver)
|
||||
}
|
||||
oxs := file_store.OXS{
|
||||
Endpoint: store.Endpoint,
|
||||
AccessKeyID: store.AccessKeyID,
|
||||
AccessKeySecret: store.AccessKeySecret,
|
||||
BucketName: store.BucketName,
|
||||
}
|
||||
client, err := oxs.Setup(driver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.UpLoad(name, path)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
|
||||
@@ -2,8 +2,8 @@ package tools
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
_ "github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
)
|
||||
@@ -41,7 +41,7 @@ func (e Gen) GetDBColumnList(c *gin.Context) {
|
||||
}
|
||||
|
||||
data.TableName = c.Request.FormValue("tableName")
|
||||
pkg.Assert(data.TableName == "", "table name cannot be empty!", 500)
|
||||
pkg.Assert(data.TableName != "", "table name cannot be empty!", 500)
|
||||
result, count, err := data.GetPage(db, pageSize, pageIndex)
|
||||
if err != nil {
|
||||
log.Errorf("GetPage error, %s", err.Error())
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
const emptyTableNameMsg = "table name cannot be empty!"
|
||||
|
||||
// bodyOf covers both the success and the CustomError shape: both carry msg.
|
||||
type bodyOf struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
// newColumnListEngine wires the handler the way the router does, including the
|
||||
// middleware that turns pkg.Assert's panic into a response.
|
||||
func newColumnListEngine(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
// The query targets MySQL's information_schema; the driver setting only has
|
||||
// to select that branch, the statement itself is never expected to succeed.
|
||||
previous := config.DatabaseConfig.Driver
|
||||
config.DatabaseConfig.Driver = "mysql"
|
||||
t.Cleanup(func() { config.DatabaseConfig.Driver = previous })
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.CustomError)
|
||||
r.GET("/db/columns/page", func(c *gin.Context) {
|
||||
c.Set("db", db)
|
||||
c.Set(pkg.LoggerKey, logger.NewHelper(logger.DefaultLogger))
|
||||
Gen{}.GetDBColumnList(c)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func columnListMsg(t *testing.T, r *gin.Engine, query string) bodyOf {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/db/columns/page"+query, nil))
|
||||
|
||||
var body bodyOf
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode %q: %v", w.Body.String(), err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestGetDBColumnList_AcceptsATableName(t *testing.T) {
|
||||
body := columnListMsg(t, newColumnListEngine(t), "?tableName=sys_user")
|
||||
if body.Msg == emptyTableNameMsg {
|
||||
t.Fatalf("request carried a table name and was still rejected as empty: %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDBColumnList_RejectsAMissingTableName(t *testing.T) {
|
||||
body := columnListMsg(t, newColumnListEngine(t), "")
|
||||
if body.Msg != emptyTableNameMsg {
|
||||
t.Fatalf("missing table name should be rejected, got %+v", body)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@ package tools
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
_ "github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
)
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
)
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/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"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-admin/config"
|
||||
)
|
||||
|
||||
// Both branches used to construct a zero-value ALiYunOSS and call UpLoad on it,
|
||||
// which panicked; the qiniu branch built the aliyun client, so source=3 could
|
||||
// not have reached qiniu even with credentials. Unconfigured now reports which
|
||||
// store is missing.
|
||||
func TestThirdUploadReportsAnUnconfiguredStore(t *testing.T) {
|
||||
previous := config.ExtConfig.FileStore
|
||||
config.ExtConfig.FileStore = config.FileStore{}
|
||||
t.Cleanup(func() { config.ExtConfig.FileStore = previous })
|
||||
|
||||
for source, want := range map[string]string{"2": "AliYunOSS", "3": "QiNiuKodo"} {
|
||||
err := thirdUpload(source, "x.png", "/tmp/x.png")
|
||||
if err == nil {
|
||||
t.Errorf("source=%s: no error from an unconfigured store", source)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("source=%s: error names %q, want it to mention %s", source, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// source 1 and anything unrecognised keep the local copy and do nothing else.
|
||||
func TestThirdUploadIgnoresLocalAndUnknownSources(t *testing.T) {
|
||||
for _, source := range []string{"", "1", "9"} {
|
||||
if err := thirdUpload(source, "x.png", "/tmp/x.png"); err != nil {
|
||||
t.Errorf("source=%q returned %v, want nil", source, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ package tools
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -24,47 +24,38 @@ type DBColumns struct {
|
||||
}
|
||||
|
||||
func (e *DBColumns) GetPage(tx *gorm.DB, pageSize int, pageIndex int) ([]DBColumns, int, error) {
|
||||
pkg.Assert(config.DatabaseConfig.Driver == "mysql", "目前只支持mysql数据库", 500)
|
||||
|
||||
var doc []DBColumns
|
||||
var count int64
|
||||
table := new(gorm.DB)
|
||||
|
||||
if config.DatabaseConfig.Driver == "mysql" {
|
||||
table = tx.Table("information_schema.`COLUMNS`")
|
||||
table = table.Where("table_schema= ? ", config.GenConfig.DBName)
|
||||
|
||||
if e.TableName != "" {
|
||||
return nil, 0, errors.New("table name cannot be empty!")
|
||||
}
|
||||
|
||||
table = table.Where("TABLE_NAME = ?", e.TableName)
|
||||
if e.TableName == "" {
|
||||
return nil, 0, errors.New("table name cannot be empty!")
|
||||
}
|
||||
|
||||
table := tx.Table("information_schema.`COLUMNS`")
|
||||
table = table.Where("table_schema= ? ", config.GenConfig.DBName)
|
||||
table = table.Where("TABLE_NAME = ?", e.TableName)
|
||||
|
||||
if err := table.Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&doc).Offset(-1).Limit(-1).Count(&count).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
//table.Count(&count)
|
||||
return doc, int(count), nil
|
||||
|
||||
}
|
||||
|
||||
func (e *DBColumns) GetList(tx *gorm.DB) ([]DBColumns, error) {
|
||||
var doc []DBColumns
|
||||
table := new(gorm.DB)
|
||||
pkg.Assert(config.DatabaseConfig.Driver == "mysql", "目前只支持mysql数据库", 500)
|
||||
|
||||
var doc []DBColumns
|
||||
if e.TableName == "" {
|
||||
return nil, errors.New("table name cannot be empty!")
|
||||
}
|
||||
|
||||
if config.DatabaseConfig.Driver == "mysql" {
|
||||
table = tx.Table("information_schema.columns")
|
||||
table = table.Where("table_schema= ? ", config.GenConfig.DBName)
|
||||
|
||||
table = table.Where("TABLE_NAME = ?", e.TableName).Order("ORDINAL_POSITION asc")
|
||||
} else {
|
||||
pkg.Assert(true, "目前只支持mysql数据库", 500)
|
||||
}
|
||||
table := tx.Table("information_schema.columns")
|
||||
table = table.Where("table_schema= ? ", config.GenConfig.DBName)
|
||||
table = table.Where("TABLE_NAME = ?", e.TableName).Order("ORDINAL_POSITION asc")
|
||||
if err := table.Find(&doc).Error; err != nil {
|
||||
return doc, err
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ package tools
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
config2 "github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
config2 "github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
)
|
||||
|
||||
type DBTables struct {
|
||||
@@ -20,43 +20,49 @@ type DBTables struct {
|
||||
}
|
||||
|
||||
func (e *DBTables) GetPage(tx *gorm.DB, pageSize int, pageIndex int) ([]DBTables, int, error) {
|
||||
pkg.Assert(config2.DatabaseConfig.Driver == "mysql", "目前只支持mysql数据库", 500)
|
||||
|
||||
var doc []DBTables
|
||||
table := new(gorm.DB)
|
||||
var count int64
|
||||
|
||||
if config2.DatabaseConfig.Driver == "mysql" {
|
||||
table = tx.Table("information_schema.tables")
|
||||
table = table.Where("TABLE_NAME not in (select table_name from `" + config2.GenConfig.DBName + "`.sys_tables) ")
|
||||
table = table.Where("table_schema= ? ", config2.GenConfig.DBName)
|
||||
|
||||
if e.TableName != "" {
|
||||
table = table.Where("TABLE_NAME = ?", e.TableName)
|
||||
}
|
||||
if err := table.Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&doc).Offset(-1).Limit(-1).Count(&count).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
} else {
|
||||
pkg.Assert(true, "目前只支持mysql数据库", 500)
|
||||
// Tables already registered with the generator are not candidates. Read them
|
||||
// through the model on this connection: the subquery used to spell the
|
||||
// schema out by hand, so it only resolved when sys_tables happened to live
|
||||
// in the schema being generated from, and it counted soft-deleted rows.
|
||||
var generated []string
|
||||
if err := tx.Model(&SysTables{}).Pluck("table_name", &generated).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
//table.Count(&count)
|
||||
table := tx.Table("information_schema.tables")
|
||||
table = table.Where("table_schema= ? ", config2.GenConfig.DBName)
|
||||
if len(generated) > 0 {
|
||||
// NOT IN (NULL) is unknown for every row, so an empty list has to skip
|
||||
// the clause instead of rendering it.
|
||||
table = table.Where("TABLE_NAME not in (?)", generated)
|
||||
}
|
||||
|
||||
if e.TableName != "" {
|
||||
table = table.Where("TABLE_NAME = ?", e.TableName)
|
||||
}
|
||||
if err := table.Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&doc).Offset(-1).Limit(-1).Count(&count).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return doc, int(count), nil
|
||||
}
|
||||
|
||||
func (e *DBTables) Get(tx *gorm.DB) (DBTables, error) {
|
||||
pkg.Assert(config2.DatabaseConfig.Driver == "mysql", "目前只支持mysql数据库", 500)
|
||||
|
||||
var doc DBTables
|
||||
if config2.DatabaseConfig.Driver == "mysql" {
|
||||
table := tx.Table("information_schema.tables")
|
||||
table = table.Where("table_schema= ? ", config2.GenConfig.DBName)
|
||||
if e.TableName == "" {
|
||||
return doc, errors.New("table name cannot be empty!")
|
||||
}
|
||||
table = table.Where("TABLE_NAME = ?", e.TableName)
|
||||
if err := table.First(&doc).Error; err != nil {
|
||||
return doc, err
|
||||
}
|
||||
} else {
|
||||
pkg.Assert(true, "目前只支持mysql数据库", 500)
|
||||
if e.TableName == "" {
|
||||
return doc, errors.New("table name cannot be empty!")
|
||||
}
|
||||
table := tx.Table("information_schema.tables")
|
||||
table = table.Where("table_schema= ? ", config2.GenConfig.DBName)
|
||||
table = table.Where("TABLE_NAME = ?", e.TableName)
|
||||
if err := table.First(&doc).Error; err != nil {
|
||||
return doc, err
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
config2 "github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const generatorSchema = "go_admin_test"
|
||||
|
||||
// newCandidateDB stands in for MySQL: sqlite is given an attached database
|
||||
// called information_schema so the same query runs, and sys_tables lives on the
|
||||
// connection the way it does in production.
|
||||
func newCandidateDB(t *testing.T, tables ...string) *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.Exec(`ATTACH DATABASE ':memory:' AS information_schema`).Error; err != nil {
|
||||
t.Fatalf("attach information_schema: %v", err)
|
||||
}
|
||||
if err := db.Exec("CREATE TABLE information_schema.`tables` (" +
|
||||
"TABLE_NAME text, TABLE_SCHEMA text, `ENGINE` text, TABLE_ROWS text," +
|
||||
"TABLE_COLLATION text, CREATE_TIME text, UPDATE_TIME text, TABLE_COMMENT text)").Error; err != nil {
|
||||
t.Fatalf("create information_schema.tables: %v", err)
|
||||
}
|
||||
for _, name := range tables {
|
||||
if err := db.Exec("INSERT INTO information_schema.`tables` (TABLE_NAME, TABLE_SCHEMA) VALUES (?, ?)",
|
||||
name, generatorSchema).Error; err != nil {
|
||||
t.Fatalf("insert %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := db.AutoMigrate(new(SysTables)); err != nil {
|
||||
t.Fatalf("migrate sys_tables: %v", err)
|
||||
}
|
||||
|
||||
previousDriver := config2.DatabaseConfig.Driver
|
||||
previousName := config2.GenConfig.DBName
|
||||
config2.DatabaseConfig.Driver = "mysql"
|
||||
config2.GenConfig.DBName = generatorSchema
|
||||
t.Cleanup(func() {
|
||||
config2.DatabaseConfig.Driver = previousDriver
|
||||
config2.GenConfig.DBName = previousName
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
func candidateNames(t *testing.T, db *gorm.DB) []string {
|
||||
t.Helper()
|
||||
found, _, err := new(DBTables).GetPage(db, 100, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPage: %v", err)
|
||||
}
|
||||
names := make([]string, 0, len(found))
|
||||
for _, row := range found {
|
||||
names = append(names, row.TableName)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func contains(names []string, want string) bool {
|
||||
for _, name := range names {
|
||||
if name == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// An empty sys_tables must not filter everything out - that is what a bare
|
||||
// NOT IN (empty set) does, and a fresh install is exactly the case where the
|
||||
// list matters most.
|
||||
func TestGetPageListsEveryTableWhenNoneAreRegistered(t *testing.T) {
|
||||
db := newCandidateDB(t, "sys_user", "sys_role")
|
||||
|
||||
names := candidateNames(t, db)
|
||||
if len(names) != 2 {
|
||||
t.Fatalf("want both tables offered on a fresh install, got %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPageSkipsAlreadyRegisteredTables(t *testing.T) {
|
||||
db := newCandidateDB(t, "sys_user", "sys_role")
|
||||
if err := db.Create(&SysTables{TBName: "sys_user"}).Error; err != nil {
|
||||
t.Fatalf("register sys_user: %v", err)
|
||||
}
|
||||
|
||||
names := candidateNames(t, db)
|
||||
if contains(names, "sys_user") {
|
||||
t.Errorf("sys_user is already registered and was offered again: %v", names)
|
||||
}
|
||||
if !contains(names, "sys_role") {
|
||||
t.Errorf("sys_role is not registered and was withheld: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting the generator entry has to hand the table back, which the raw
|
||||
// subquery never did: it read the row whether or not it was soft-deleted.
|
||||
func TestGetPageOffersTablesWhoseEntryWasDeleted(t *testing.T) {
|
||||
db := newCandidateDB(t, "sys_user")
|
||||
registered := SysTables{TBName: "sys_user"}
|
||||
if err := db.Create(®istered).Error; err != nil {
|
||||
t.Fatalf("register sys_user: %v", err)
|
||||
}
|
||||
if err := db.Delete(®istered).Error; err != nil {
|
||||
t.Fatalf("delete the entry: %v", err)
|
||||
}
|
||||
|
||||
if names := candidateNames(t, db); !contains(names, "sys_user") {
|
||||
t.Errorf("the generator entry is deleted, sys_user should be a candidate again: %v", names)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The generator reads MySQL's information_schema and nothing else. Every entry
|
||||
// point says so, but the guard used to be written pkg.Assert(true, ...), which
|
||||
// never fires: pkg.Assert panics when its condition is false. A non-mysql
|
||||
// deployment therefore fell through to a query built on a zero-value *gorm.DB.
|
||||
//
|
||||
// tx is nil on purpose - the assertion has to come before anything touches it.
|
||||
func TestCodegenModelsRefuseNonMySQLDrivers(t *testing.T) {
|
||||
previous := config.DatabaseConfig.Driver
|
||||
config.DatabaseConfig.Driver = "postgres"
|
||||
t.Cleanup(func() { config.DatabaseConfig.Driver = previous })
|
||||
|
||||
cases := map[string]func(*gorm.DB){
|
||||
"DBTables.GetPage": func(tx *gorm.DB) {
|
||||
_, _, _ = new(DBTables).GetPage(tx, 10, 1)
|
||||
},
|
||||
"DBTables.Get": func(tx *gorm.DB) {
|
||||
_, _ = (&DBTables{TableName: "sys_user"}).Get(tx)
|
||||
},
|
||||
"DBColumns.GetPage": func(tx *gorm.DB) {
|
||||
_, _, _ = (&DBColumns{TableName: "sys_user"}).GetPage(tx, 10, 1)
|
||||
},
|
||||
"DBColumns.GetList": func(tx *gorm.DB) {
|
||||
_, _ = (&DBColumns{TableName: "sys_user"}).GetList(tx)
|
||||
},
|
||||
}
|
||||
|
||||
for name, call := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
defer func() {
|
||||
raised := recover()
|
||||
if raised == nil {
|
||||
t.Fatal("driver is not mysql and the call went through anyway")
|
||||
}
|
||||
msg, ok := raised.(string)
|
||||
if !ok || !strings.Contains(msg, "目前只支持mysql数据库") {
|
||||
t.Fatalf("want the mysql-only assertion, got %v", raised)
|
||||
}
|
||||
}()
|
||||
call(nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"go-admin/app/other/apis"
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"go-admin/app/admin/apis"
|
||||
"go-admin/app/other/apis/tools"
|
||||
)
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
common "go-admin/common/middleware"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/tools/transfer"
|
||||
"github.com/go-admin-team/go-admin-core/v2/tools/transfer"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -2,7 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"go-admin/app/other/apis"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package api
|
||||
|
||||
import "go-admin/app/demo/router"
|
||||
|
||||
func init() {
|
||||
//注册路由 fixme 其他应用的路由,在本目录新建文件放在init方法
|
||||
AppRouters = append(AppRouters, router.InitRouter)
|
||||
}
|
||||
+9
-9
@@ -9,12 +9,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/config/source/file"
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/v2/config/source/file"
|
||||
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/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -66,7 +66,7 @@ func setup() {
|
||||
storage.Setup,
|
||||
)
|
||||
//注册监听函数
|
||||
queue := sdk.Runtime.GetMemoryQueue("")
|
||||
queue := sdk.Runtime.GetQueuePrefix("")
|
||||
queue.Register(global.LoginLog, models.SaveLoginLog)
|
||||
queue.Register(global.OperateLog, models.SaveOperaLog)
|
||||
queue.Register(global.ApiCheck, models.SaveSysApi)
|
||||
@@ -95,13 +95,13 @@ func run() error {
|
||||
|
||||
go func() {
|
||||
jobs.InitJob()
|
||||
jobs.Setup(sdk.Runtime.GetDb())
|
||||
jobs.Setup(sdk.Runtime.GetAllDb())
|
||||
|
||||
}()
|
||||
|
||||
if apiCheck {
|
||||
var routers = sdk.Runtime.GetRouter()
|
||||
q := sdk.Runtime.GetMemoryQueue("")
|
||||
q := sdk.Runtime.GetQueuePrefix("")
|
||||
mp := make(map[string]interface{})
|
||||
mp["List"] = routers
|
||||
message, err := sdk.Runtime.GetStreamMessage("", global.ApiCheck, mp)
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/utils"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/utils"
|
||||
"github.com/spf13/cobra"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ package cmd
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"go-admin/cmd/app"
|
||||
"go-admin/common/global"
|
||||
"os"
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/config/source/file"
|
||||
"github.com/go-admin-team/go-admin-core/v2/config/source/file"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -43,20 +43,28 @@ func (e *Migration) Migrate() {
|
||||
}
|
||||
var err error
|
||||
var count int64
|
||||
applied := 0
|
||||
for _, v := range versions {
|
||||
err = e.db.Table("sys_migration").Where("version = ?", v).Count(&count).Error
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
if count > 0 {
|
||||
log.Println(count)
|
||||
// Already applied. This used to print the bare count, so a mature
|
||||
// database wrote a screen of "1" at every start.
|
||||
count = 0
|
||||
continue
|
||||
}
|
||||
err = (e.version[v])(e.db.Debug(), v)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
log.Printf("applying migration %s", v)
|
||||
if err = (e.version[v])(e.db.Debug(), v); err != nil {
|
||||
log.Fatalf("migration %s failed: %v", v, err)
|
||||
}
|
||||
applied++
|
||||
}
|
||||
if applied == 0 {
|
||||
log.Println("no migrations to apply")
|
||||
} else {
|
||||
log.Printf("applied %d migration(s)", applied)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"runtime"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/demo/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// 示例模块的建表迁移。
|
||||
//
|
||||
// 迁移文件名前 13 位是版本号(Unix 毫秒时间戳),框架按文件名升序执行,
|
||||
// 已执行的版本记录在 sys_migration 表中,不会重复运行。
|
||||
//
|
||||
// 本文件放在 version/ 是因为它随框架一起分发;**业务项目自己的迁移应放
|
||||
// version-local/**,该目录已被 .gitignore 忽略,不会与上游冲突。
|
||||
//
|
||||
// 生成新的迁移骨架:go run main.go migrate -c config/settings.yml -g
|
||||
//
|
||||
// 注意:已执行过的迁移文件不可再修改——版本号已入表,改动不会重跑。
|
||||
// 需要调整时新建一个迁移。
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700000000DemoProduct)
|
||||
}
|
||||
|
||||
func _1786700000000DemoProduct(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
// 1. 建表
|
||||
if err := tx.Migrator().AutoMigrate(new(models.DemoProduct)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 初始化数据(可选)。此处留空,示例模块不写入业务数据。
|
||||
|
||||
// 3. 记录版本号——必须,否则该迁移每次启动都会重跑
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
"go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// 为 app/demo 模块写入菜单、接口与权限种子数据。
|
||||
//
|
||||
// 一个业务模块要在界面上可用,需要四类数据:
|
||||
//
|
||||
// 1. sys_api —— 后端路由的登记,Casbin 据此判定权限
|
||||
// 2. sys_menu —— 侧边栏菜单(目录 M / 菜单 C / 按钮 F)
|
||||
// 3. sys_menu_api_rule —— 菜单与接口的多对多关联,角色保存时据此生成策略
|
||||
// 4. casbin_rule —— 实际生效的权限策略
|
||||
//
|
||||
// 注意 casbin_rule 才是 adapter 实际使用的表;models.CasbinRule 对应的
|
||||
// sys_casbin_rule 是历史遗留,其 7 列 size:512 唯一索引在 MySQL 下会超出
|
||||
// 索引长度限制,不要将其纳入 AutoMigrate。
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700001000DemoMenu)
|
||||
}
|
||||
|
||||
const (
|
||||
demoMenuId = 9000 // 目录:示例模块
|
||||
demoProductId = 9001 // 菜单:商品管理
|
||||
demoApiBaseId = 9000 // sys_api 起始 id
|
||||
adminRoleKey = "admin"
|
||||
)
|
||||
|
||||
func _1786700001000DemoMenu(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
// ---- 1. 接口登记 ----
|
||||
apis := []models.SysApi{
|
||||
{Id: demoApiBaseId + 1, Handle: "go-admin/common/actions.IndexAction.func1", Title: "示例商品列表", Path: "/api/v1/demo-product", Type: "SYS", Action: "GET"},
|
||||
{Id: demoApiBaseId + 2, Handle: "go-admin/common/actions.ViewAction.func1", Title: "示例商品详情", Path: "/api/v1/demo-product/:id", Type: "SYS", Action: "GET"},
|
||||
{Id: demoApiBaseId + 3, Handle: "go-admin/common/actions.CreateAction.func1", Title: "示例商品新增", Path: "/api/v1/demo-product", Type: "SYS", Action: "POST"},
|
||||
{Id: demoApiBaseId + 4, Handle: "go-admin/common/actions.UpdateAction.func1", Title: "示例商品修改", Path: "/api/v1/demo-product/:id", Type: "SYS", Action: "PUT"},
|
||||
{Id: demoApiBaseId + 5, Handle: "go-admin/common/actions.DeleteAction.func1", Title: "示例商品删除", Path: "/api/v1/demo-product", Type: "SYS", Action: "DELETE"},
|
||||
}
|
||||
for i := range apis {
|
||||
if err := upsert(tx, &models.SysApi{}, "id = ?", apis[i].Id, &apis[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 2. 菜单 ----
|
||||
// 目录本身不对应页面,component 固定为 Layout
|
||||
dir := models.SysMenu{
|
||||
MenuId: demoMenuId, MenuName: "Demo", Title: "示例模块", Icon: "example",
|
||||
Path: "/demo", Paths: "/0/9000", MenuType: "M", ParentId: 0,
|
||||
// sort is `gorm:"size:4"`, which MySQL builds as a tinyint - anything
|
||||
// over 127 is rejected outright. The seeded menus run to 100, so 110
|
||||
// still puts this last.
|
||||
Component: "Layout", Sort: 110, Visible: "0", IsFrame: "1",
|
||||
}
|
||||
if err := upsert(tx, &models.SysMenu{}, "menu_id = ?", dir.MenuId, &dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 菜单的 menu_name 需与前端组件 name 一致,否则 keep-alive 缓存无法命中
|
||||
page := models.SysMenu{
|
||||
MenuId: demoProductId, MenuName: "DemoProduct", Title: "商品管理", Icon: "documentation",
|
||||
Path: "/demo/product", Paths: "/0/9000/9001", MenuType: "C", ParentId: demoMenuId,
|
||||
Component: "/demo/product/index", Sort: 1, Visible: "0", IsFrame: "1",
|
||||
SysApi: apis,
|
||||
}
|
||||
if err := upsert(tx, &models.SysMenu{}, "menu_id = ?", page.MenuId, &page); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 按钮级权限,permission 需与前端 v-permisaction 中的标识一致
|
||||
buttons := []models.SysMenu{
|
||||
{MenuId: 9002, MenuName: "DemoProductAdd", Title: "新增", MenuType: "F", ParentId: demoProductId, Permission: "demo:product:add", Sort: 1, Visible: "0", IsFrame: "1"},
|
||||
{MenuId: 9003, MenuName: "DemoProductEdit", Title: "修改", MenuType: "F", ParentId: demoProductId, Permission: "demo:product:edit", Sort: 2, Visible: "0", IsFrame: "1"},
|
||||
{MenuId: 9004, MenuName: "DemoProductDel", Title: "删除", MenuType: "F", ParentId: demoProductId, Permission: "demo:product:delete", Sort: 3, Visible: "0", IsFrame: "1"},
|
||||
}
|
||||
for i := range buttons {
|
||||
buttons[i].Paths = "/0/9000/9001/" + strconv.Itoa(buttons[i].MenuId)
|
||||
if err := upsert(tx, &models.SysMenu{}, "menu_id = ?", buttons[i].MenuId, &buttons[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 3. 授权给 admin 角色 ----
|
||||
var role models.SysRole
|
||||
err := tx.Where("role_key = ?", adminRoleKey).First(&role).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 尚未初始化角色数据时跳过授权,不阻断迁移
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range []int{demoMenuId, demoProductId, 9002, 9003, 9004} {
|
||||
if err = tx.Exec(
|
||||
"INSERT INTO sys_role_menu (role_id, menu_id) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu WHERE role_id = ? AND menu_id = ?)",
|
||||
role.RoleId, id, role.RoleId, id,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 4. Casbin 策略 ----
|
||||
// admin 角色在中间件中直接放行,此处仍写入策略以便复制该角色配置时可继承
|
||||
for _, a := range apis {
|
||||
if err = tx.Exec(
|
||||
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) SELECT 'p', ?, ?, ?, '', '', '' WHERE NOT EXISTS (SELECT 1 FROM casbin_rule WHERE ptype='p' AND v0=? AND v1=? AND v2=?)",
|
||||
role.RoleKey, a.Path, a.Action, role.RoleKey, a.Path, a.Action,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// upsert 存在则更新、不存在则插入。迁移可能在已有数据的库上运行,
|
||||
// 直接 Create 会因主键冲突失败。
|
||||
func upsert(tx *gorm.DB, model interface{}, where string, id int, value interface{}) error {
|
||||
var count int64
|
||||
if err := tx.Model(model).Where(where, id).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return tx.Model(model).Where(where, id).Updates(value).Error
|
||||
}
|
||||
return tx.Create(value).Error
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
"go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// 清理 GET /api/v1/refresh_token 的残留权限数据。
|
||||
//
|
||||
// 该路由已随 issue #820 的修复移除:它允许用业务 token 换取新 token,而续期
|
||||
// 上限 MaxRefresh 依据的 orig_iat 每次续期都被重置,上限永远无法到达。
|
||||
//
|
||||
// 路由虽已删除(请求会返回 404),但已有部署的库中仍残留三类记录:接口登记、
|
||||
// 菜单与接口的绑定、Casbin 策略。留着会让「接口管理」列出一个不存在的端点,
|
||||
// 角色配置里也仍可勾选,造成误解。
|
||||
//
|
||||
// 按 path 匹配而非固定 id —— 使用者若执行过 `server -a` 重新注册接口,id 会
|
||||
// 与官方种子数据不同。
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700002000RemoveRefreshTokenApi)
|
||||
}
|
||||
|
||||
const refreshTokenPath = "/api/v1/refresh_token"
|
||||
|
||||
func _1786700002000RemoveRefreshTokenApi(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var apiIds []int
|
||||
if err := tx.Model(&models.SysApi{}).
|
||||
Where("path = ? AND action = ?", refreshTokenPath, "GET").
|
||||
Pluck("id", &apiIds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(apiIds) > 0 {
|
||||
// 先断开绑定,再删接口本身,避免遗留悬空外键
|
||||
// 连接表由 GORM many2many 生成,列名为 sys_api_id 而非 api_id
|
||||
if err := tx.Exec("DELETE FROM sys_menu_api_rule WHERE sys_api_id IN ?", apiIds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("id IN ?", apiIds).Delete(&models.SysApi{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// casbin_rule 才是 adapter 实际使用的表,策略按路径存储,与 sys_api 的
|
||||
// id 无关,因此即使上面没匹配到接口也要清理
|
||||
if err := tx.Exec(
|
||||
"DELETE FROM casbin_rule WHERE ptype = 'p' AND v1 = ?", refreshTokenPath,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Convert deleted_at from a nullable timestamp to a non-null millisecond
|
||||
// marker, then put a unique index on the three natural keys.
|
||||
//
|
||||
// Those keys had no unique index at all. Uniqueness was a SELECT COUNT
|
||||
// followed by an INSERT, which two concurrent requests both pass.
|
||||
//
|
||||
// The index cannot be on the key alone: a soft-deleted row keeps occupying the
|
||||
// name, so a deleted user's username could never be used again. It has to
|
||||
// include the delete marker — and the marker has to be non-null, because two
|
||||
// live rows are (name, NULL) and (name, NULL), and NULL is not equal to NULL.
|
||||
// An index over a nullable marker permits both rows. It looks like a
|
||||
// constraint and enforces nothing.
|
||||
//
|
||||
// DDL does not roll back on MySQL, so this is written to be re-runnable rather
|
||||
// than transactional: every step asks whether it has already been taken.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700003000SoftDeleteMarker)
|
||||
}
|
||||
|
||||
// Every table whose model embeds common.ModelTime.
|
||||
var softDeleteTables = []string{
|
||||
"sys_api", "sys_config", "sys_dept", "sys_dict_data", "sys_dict_type",
|
||||
"sys_job", "sys_menu", "sys_post", "sys_role", "sys_user", "demo_product",
|
||||
}
|
||||
|
||||
// The keys that gain a constraint, and the column that makes it possible.
|
||||
var naturalKeys = []struct {
|
||||
table, column, index string
|
||||
}{
|
||||
{"sys_user", "username", "uk_sys_user_username"},
|
||||
{"sys_role", "role_key", "uk_sys_role_role_key"},
|
||||
{"sys_dict_type", "dict_type", "uk_sys_dict_type_dict_type"},
|
||||
}
|
||||
|
||||
const tempColumn = "deleted_at_ms"
|
||||
|
||||
func _1786700003000SoftDeleteMarker(db *gorm.DB, version string) error {
|
||||
// Refused before anything is altered: creating the index on a table that
|
||||
// already holds duplicates fails halfway through, and the operator is left
|
||||
// guessing which rows to reconcile.
|
||||
if err := refuseOnDuplicates(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, table := range softDeleteTables {
|
||||
if err := convertDeletedAt(db, table); err != nil {
|
||||
return fmt.Errorf("%s: %w", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, k := range naturalKeys {
|
||||
if err := createUniqueIndex(db, k.table, k.column, k.index); err != nil {
|
||||
return fmt.Errorf("%s.%s: %w", k.table, k.column, err)
|
||||
}
|
||||
}
|
||||
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
}
|
||||
|
||||
// refuseOnDuplicates reports the values that would make the index impossible,
|
||||
// rather than the index failing to build and saying only that it did.
|
||||
func refuseOnDuplicates(db *gorm.DB) error {
|
||||
for _, k := range naturalKeys {
|
||||
if !db.Migrator().HasTable(k.table) {
|
||||
continue
|
||||
}
|
||||
|
||||
var dupes []string
|
||||
q := fmt.Sprintf(
|
||||
"SELECT %s FROM %s WHERE deleted_at IS NULL GROUP BY %s HAVING COUNT(*) > 1",
|
||||
k.column, k.table, k.column,
|
||||
)
|
||||
if !db.Migrator().HasColumn(k.table, "deleted_at") {
|
||||
// Already converted; live rows carry zero rather than null.
|
||||
q = fmt.Sprintf(
|
||||
"SELECT %s FROM %s WHERE deleted_at = 0 GROUP BY %s HAVING COUNT(*) > 1",
|
||||
k.column, k.table, k.column,
|
||||
)
|
||||
}
|
||||
if err := db.Raw(q).Scan(&dupes).Error; err != nil {
|
||||
return fmt.Errorf("checking %s.%s for duplicates: %w", k.table, k.column, err)
|
||||
}
|
||||
if len(dupes) > 0 {
|
||||
return fmt.Errorf(
|
||||
"%s.%s already holds duplicates %v; reconcile them before this migration can add its unique index",
|
||||
k.table, k.column, dupes,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertDeletedAt(db *gorm.DB, table string) error {
|
||||
m := db.Migrator()
|
||||
if !m.HasTable(table) {
|
||||
return nil
|
||||
}
|
||||
|
||||
converted, err := isConverted(db, table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if converted {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !m.HasColumn(table, tempColumn) {
|
||||
if err := db.Exec(addBigIntColumn(db, table, tempColumn)).Error; err != nil {
|
||||
return fmt.Errorf("adding %s: %w", tempColumn, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Converted in Go rather than in SQL: turning a timestamp into epoch
|
||||
// milliseconds is spelled differently by every dialect this supports, and
|
||||
// the row counts here do not justify four versions of it.
|
||||
if err := copyTimestamps(db, table); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// SQLite refuses to drop a column an index still refers to, where MySQL and
|
||||
// PostgreSQL drop the dependent indexes along with it. Drop them first, on
|
||||
// every dialect: the column is about to be replaced by one of the same name,
|
||||
// and gorm recreates the index from the model's tag.
|
||||
if err := dropIndexesOn(db, table, "deleted_at"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.Exec(dropColumn(db, table, "deleted_at")).Error; err != nil {
|
||||
return fmt.Errorf("dropping deleted_at: %w", err)
|
||||
}
|
||||
if err := db.Exec(renameColumn(db, table, tempColumn, "deleted_at")).Error; err != nil {
|
||||
return fmt.Errorf("renaming %s: %w", tempColumn, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isConverted reports whether deleted_at already holds the marker. A table
|
||||
// mid-conversion still has both columns, and is finished rather than skipped.
|
||||
func isConverted(db *gorm.DB, table string) (bool, error) {
|
||||
types, err := db.Migrator().ColumnTypes(table)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, c := range types {
|
||||
if c.Name() != "deleted_at" {
|
||||
continue
|
||||
}
|
||||
if nullable, ok := c.Nullable(); ok && !nullable {
|
||||
return !db.Migrator().HasColumn(table, tempColumn), nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
// No such column: nothing to convert.
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func copyTimestamps(db *gorm.DB, table string) error {
|
||||
type row struct {
|
||||
Id int64
|
||||
DeletedAt *time.Time
|
||||
}
|
||||
|
||||
// The tables walked here do not agree on what the key is called: sys_dept
|
||||
// keys on dept_id, sys_user on user_id, and only some on id.
|
||||
key, err := primaryKeyOf(db, table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var rows []row
|
||||
q := fmt.Sprintf("SELECT %s AS id, deleted_at FROM %s WHERE deleted_at IS NOT NULL", key, table)
|
||||
if err := db.Raw(q).Scan(&rows).Error; err != nil {
|
||||
return fmt.Errorf("reading deleted rows: %w", err)
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
if r.DeletedAt == nil {
|
||||
continue
|
||||
}
|
||||
u := fmt.Sprintf("UPDATE %s SET %s = ? WHERE %s = ?", table, tempColumn, key)
|
||||
if err := db.Exec(u, r.DeletedAt.UnixMilli(), r.Id).Error; err != nil {
|
||||
return fmt.Errorf("marking row %d deleted: %w", r.Id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// primaryKeyOf asks the database which column is the primary key, rather than
|
||||
// assuming the name.
|
||||
func primaryKeyOf(db *gorm.DB, table string) (string, error) {
|
||||
columns, err := db.Migrator().ColumnTypes(table)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading columns of %s: %w", table, err)
|
||||
}
|
||||
for _, c := range columns {
|
||||
if isKey, ok := c.PrimaryKey(); ok && isKey {
|
||||
return c.Name(), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no primary key on %s", table)
|
||||
}
|
||||
|
||||
func createUniqueIndex(db *gorm.DB, table, column, name string) error {
|
||||
if db.Migrator().HasIndex(table, name) {
|
||||
return nil
|
||||
}
|
||||
return db.Exec(fmt.Sprintf(
|
||||
"CREATE UNIQUE INDEX %s ON %s (%s, deleted_at)", name, table, column,
|
||||
)).Error
|
||||
}
|
||||
|
||||
// dropIndexesOn removes every index on table that mentions column, so the
|
||||
// column can be dropped. Named indexes are asked of the migrator rather than
|
||||
// guessed, because the names differ between a schema gorm created and one a
|
||||
// hand-written migration did.
|
||||
func dropIndexesOn(db *gorm.DB, table, column string) error {
|
||||
names, err := indexNamesFor(db, table, column)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing indexes on %s.%s: %w", table, column, err)
|
||||
}
|
||||
m := db.Migrator()
|
||||
for _, name := range names {
|
||||
if !m.HasIndex(table, name) {
|
||||
continue
|
||||
}
|
||||
if err := m.DropIndex(table, name); err != nil {
|
||||
return fmt.Errorf("dropping index %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// indexNamesFor asks the database which indexes cover column.
|
||||
func indexNamesFor(db *gorm.DB, table, column string) ([]string, error) {
|
||||
indexes, err := db.Migrator().GetIndexes(table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
for _, idx := range indexes {
|
||||
for _, c := range idx.Columns() {
|
||||
if c == column {
|
||||
names = append(names, idx.Name())
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// The three statements every dialect spells differently.
|
||||
|
||||
func addBigIntColumn(db *gorm.DB, table, column string) string {
|
||||
if db.Dialector.Name() == "sqlserver" {
|
||||
return fmt.Sprintf("ALTER TABLE %s ADD %s BIGINT NOT NULL DEFAULT 0", table, column)
|
||||
}
|
||||
return fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s BIGINT NOT NULL DEFAULT 0", table, column)
|
||||
}
|
||||
|
||||
func dropColumn(db *gorm.DB, table, column string) string {
|
||||
return fmt.Sprintf("ALTER TABLE %s DROP COLUMN %s", table, column)
|
||||
}
|
||||
|
||||
func renameColumn(db *gorm.DB, table, from, to string) string {
|
||||
switch db.Dialector.Name() {
|
||||
case "sqlserver":
|
||||
return fmt.Sprintf("EXEC sp_rename '%s.%s', '%s', 'COLUMN'", table, from, to)
|
||||
case "mysql":
|
||||
return fmt.Sprintf("ALTER TABLE %s CHANGE %s %s BIGINT NOT NULL DEFAULT 0", table, from, to)
|
||||
default:
|
||||
return fmt.Sprintf("ALTER TABLE %s RENAME COLUMN %s TO %s", table, from, to)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// oldUser is the shape of the table before this migration: deleted_at is a
|
||||
// nullable timestamp, and nothing constrains the username.
|
||||
//
|
||||
// It mirrors the real sys_user in the two respects that matter here, both of
|
||||
// which the migration got wrong against a live database while an id-keyed,
|
||||
// unindexed table let it pass: the key is user_id rather than id, and
|
||||
// deleted_at carries an index, which SQLite will not let a column be dropped
|
||||
// out from under.
|
||||
type oldUser struct {
|
||||
UserId int64 `gorm:"column:user_id;primaryKey;autoIncrement"`
|
||||
Username string
|
||||
DeletedAt *time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
func (oldUser) TableName() string { return "sys_user" }
|
||||
|
||||
func openWithOldSchema(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(&oldUser{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestConvertsDeletedAtAndKeepsWhoWasDeleted(t *testing.T) {
|
||||
db := openWithOldSchema(t)
|
||||
|
||||
gone := time.Now().Add(-time.Hour)
|
||||
rows := []oldUser{
|
||||
{Username: "alice"},
|
||||
{Username: "bob", DeletedAt: &gone},
|
||||
}
|
||||
for i := range rows {
|
||||
if err := db.Create(&rows[i]).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := convertDeletedAt(db, "sys_user"); err != nil {
|
||||
t.Fatalf("convert: %v", err)
|
||||
}
|
||||
|
||||
var marks []struct {
|
||||
Username string
|
||||
DeletedAt int64
|
||||
}
|
||||
if err := db.Raw("SELECT username, deleted_at FROM sys_user ORDER BY user_id").Scan(&marks).Error; err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if len(marks) != 2 {
|
||||
t.Fatalf("got %d rows, want 2", len(marks))
|
||||
}
|
||||
if marks[0].DeletedAt != 0 {
|
||||
t.Errorf("a live row carries %d, want 0", marks[0].DeletedAt)
|
||||
}
|
||||
if want := gone.UnixMilli(); marks[1].DeletedAt != want {
|
||||
t.Errorf("the deleted row carries %d, want %d: the timestamp was lost", marks[1].DeletedAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Running it twice must be safe: DDL does not roll back on MySQL, so an
|
||||
// operator whose first attempt failed halfway has nothing to do but run it
|
||||
// again.
|
||||
func TestConversionIsRepeatable(t *testing.T) {
|
||||
db := openWithOldSchema(t)
|
||||
if err := db.Create(&oldUser{Username: "alice"}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := convertDeletedAt(db, "sys_user"); err != nil {
|
||||
t.Fatalf("convert %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The point of the whole exercise: the constraint has to reject a second live
|
||||
// row and accept one whose predecessor was deleted.
|
||||
func TestUniqueIndexBindsLiveRowsOnly(t *testing.T) {
|
||||
db := openWithOldSchema(t)
|
||||
if err := db.Create(&oldUser{Username: "alice"}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := convertDeletedAt(db, "sys_user"); err != nil {
|
||||
t.Fatalf("convert: %v", err)
|
||||
}
|
||||
if err := createUniqueIndex(db, "sys_user", "username", "uk_sys_user_username"); err != nil {
|
||||
t.Fatalf("index: %v", err)
|
||||
}
|
||||
|
||||
t.Run("a second live row is rejected", func(t *testing.T) {
|
||||
err := db.Exec("INSERT INTO sys_user (username, deleted_at) VALUES ('alice', 0)").Error
|
||||
if err == nil {
|
||||
t.Fatal("a duplicate username was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the name is free once the row is deleted", func(t *testing.T) {
|
||||
if err := db.Exec("UPDATE sys_user SET deleted_at = ? WHERE username = 'alice'", time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO sys_user (username, deleted_at) VALUES ('alice', 0)").Error; err != nil {
|
||||
t.Errorf("the name stayed taken after its row was deleted: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the same name can be deleted more than once", func(t *testing.T) {
|
||||
if err := db.Exec("UPDATE sys_user SET deleted_at = ? WHERE deleted_at = 0", time.Now().UnixMilli()+1).Error; err != nil {
|
||||
t.Errorf("a second deletion collided with the first: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A table that already holds duplicates cannot take the index, and the
|
||||
// migration says which values rather than letting the index fail.
|
||||
func TestRefusesWhenDuplicatesAlreadyExist(t *testing.T) {
|
||||
db := openWithOldSchema(t)
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := db.Create(&oldUser{Username: "alice"}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err := refuseOnDuplicates(db)
|
||||
if err == nil {
|
||||
t.Fatal("the migration accepted a table that already holds duplicates")
|
||||
}
|
||||
if !contains(err.Error(), "alice") {
|
||||
t.Errorf("the error does not name the value: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Why the column has to change at all, demonstrated rather than argued: the
|
||||
// same index over the column as it was accepts both live rows, because each
|
||||
// carries NULL and NULL is not equal to NULL. The constraint exists and binds
|
||||
// nothing — which is worse than its absence, because it reads as protection.
|
||||
func TestTheIndexOverANullableMarkerEnforcesNothing(t *testing.T) {
|
||||
db := openWithOldSchema(t)
|
||||
|
||||
if err := db.Exec("CREATE UNIQUE INDEX uk_nullable ON sys_user (username, deleted_at)").Error; err != nil {
|
||||
t.Fatalf("index: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := db.Create(&oldUser{Username: "alice"}).Error; err != nil {
|
||||
t.Fatalf("the nullable marker rejected a duplicate after all, which would make this migration unnecessary: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var live int64
|
||||
if err := db.Raw("SELECT COUNT(*) FROM sys_user WHERE deleted_at IS NULL").Scan(&live).Error; err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if live != 2 {
|
||||
t.Fatalf("got %d live rows, want 2", live)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Convert the two generator tables to the same delete marker every other table
|
||||
// got in 1786700003000.
|
||||
//
|
||||
// They were left out of that list, and the mismatch is invisible until it is
|
||||
// not: the tables are built from cmd/migrate/migration/models, whose ModelTime
|
||||
// still carries a nullable gorm.DeletedAt, while the runtime models in
|
||||
// app/other/models/tools embed common.ModelTime, which is the millisecond
|
||||
// marker. GORM therefore queries them with deleted_at = 0 against a datetime
|
||||
// column holding NULL, and every row is invisible - so the code generator
|
||||
// listed no tables at all.
|
||||
//
|
||||
// tb_demo has the same shape and is deliberately not here: nothing reads it at
|
||||
// runtime, so there is no mismatch to fix.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700004000GeneratorTablesMarker)
|
||||
}
|
||||
|
||||
var generatorTables = []string{"sys_columns", "sys_tables"}
|
||||
|
||||
func _1786700004000GeneratorTablesMarker(db *gorm.DB, version string) error {
|
||||
for _, table := range generatorTables {
|
||||
if err := convertDeletedAt(db, table); err != nil {
|
||||
return fmt.Errorf("%s: %w", table, err)
|
||||
}
|
||||
}
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Fields tagged gorm:"size:4" become a tinyint on MySQL, which holds -128..127.
|
||||
// sqlite ignores the width, so a value that overflows passes every local test
|
||||
// and fails on a real install - and because the migration is not transactional,
|
||||
// it fails partway, leaving later migrations unapplied.
|
||||
//
|
||||
// That is what happened: a seeded menu with Sort: 900 stopped the run at
|
||||
// 1786700001000, so the soft-delete conversion never ran, deleted_at stayed
|
||||
// NULL, and nobody could log in.
|
||||
var narrowColumns = map[string]struct{ min, max int64 }{
|
||||
"Sort": {math.MinInt8, math.MaxInt8},
|
||||
"Status": {math.MinInt8, math.MaxInt8},
|
||||
}
|
||||
|
||||
func TestSeededValuesFitTheirColumns(t *testing.T) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := filepath.Glob(filepath.Join(dir, "*.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
checked := 0
|
||||
for _, path := range files {
|
||||
if strings.HasSuffix(path, "_test.go") {
|
||||
continue
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, path, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", path, err)
|
||||
}
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
kv, ok := n.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
key, ok := kv.Key.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
limits, watched := narrowColumns[key.Name]
|
||||
if !watched {
|
||||
return true
|
||||
}
|
||||
lit, ok := kv.Value.(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.INT {
|
||||
return true
|
||||
}
|
||||
v, err := strconv.ParseInt(lit.Value, 10, 64)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
checked++
|
||||
if v < limits.min || v > limits.max {
|
||||
t.Errorf("%s:%d: %s: %d does not fit a tinyint (%d..%d);\n"+
|
||||
" MySQL rejects it with Error 1264 and the migration stops there",
|
||||
filepath.Base(path), fset.Position(lit.Pos()).Line, key.Name, v, limits.min, limits.max)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if checked == 0 {
|
||||
t.Fatal("no seeded values were examined; the scan is broken, not the code")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The repository carries two ModelTime types. The one in
|
||||
// cmd/migrate/migration/models still has a nullable gorm.DeletedAt and is what
|
||||
// builds the tables; the one in common/models is the millisecond marker and is
|
||||
// what queries them. A table whose runtime model embeds the second but which no
|
||||
// migration converts is queried with deleted_at = 0 against a datetime column,
|
||||
// and every row is invisible - silently, and only in production.
|
||||
//
|
||||
// sys_columns and sys_tables were in exactly that state: the code generator
|
||||
// listed no tables at all.
|
||||
func TestEveryRuntimeSoftDeleteTableIsConverted(t *testing.T) {
|
||||
converted := map[string]bool{}
|
||||
for _, name := range append(append([]string{}, softDeleteTables...), generatorTables...) {
|
||||
converted[name] = true
|
||||
}
|
||||
|
||||
// tb_demo is built with the marker-less model and has no runtime model at
|
||||
// all, so nothing ever queries it with deleted_at = 0.
|
||||
const noRuntimeModel = "tb_demo"
|
||||
|
||||
for table, file := range runtimeSoftDeleteTables(t) {
|
||||
if table == noRuntimeModel {
|
||||
continue
|
||||
}
|
||||
if !converted[table] {
|
||||
t.Errorf("%s (%s) embeds common.ModelTime but no migration converts its deleted_at;\n"+
|
||||
" it will be queried with deleted_at = 0 against a nullable datetime and return nothing",
|
||||
table, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeSoftDeleteTables maps table name to the file declaring it, for every
|
||||
// model under app/ that embeds the marker-carrying ModelTime.
|
||||
func runtimeSoftDeleteTables(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
root := repoRoot(t)
|
||||
found := map[string]string{}
|
||||
|
||||
err := filepath.Walk(filepath.Join(root, "app"), func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return err
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, path, nil, 0)
|
||||
if err != nil {
|
||||
return nil // not this test's business
|
||||
}
|
||||
// Only files importing the runtime models package can embed its ModelTime.
|
||||
if !importsRuntimeModels(f) {
|
||||
return nil
|
||||
}
|
||||
for name, table := range tablesWithModelTime(f) {
|
||||
_ = name
|
||||
found[table] = strings.TrimPrefix(path, root+"/")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk app/: %v", err)
|
||||
}
|
||||
if len(found) == 0 {
|
||||
t.Fatal("found no runtime models at all; the scan is broken, not the code")
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func importsRuntimeModels(f *ast.File) bool {
|
||||
for _, imp := range f.Imports {
|
||||
p, err := strconv.Unquote(imp.Path.Value)
|
||||
if err == nil && p == "go-admin/common/models" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// tablesWithModelTime returns struct name -> table name for structs that embed
|
||||
// ModelTime and declare a TableName.
|
||||
func tablesWithModelTime(f *ast.File) map[string]string {
|
||||
embeds := map[string]bool{}
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
ts, ok := n.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
st, ok := ts.Type.(*ast.StructType)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
for _, field := range st.Fields.List {
|
||||
if len(field.Names) != 0 {
|
||||
continue // named field, not an embed
|
||||
}
|
||||
if sel, ok := field.Type.(*ast.SelectorExpr); ok && sel.Sel.Name == "ModelTime" {
|
||||
embeds[ts.Name.Name] = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
out := map[string]string{}
|
||||
for name := range embeds {
|
||||
if table := tableNameOf(f, name); table != "" {
|
||||
out[name] = table
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tableNameOf finds the string returned by func (T) TableName() string.
|
||||
func tableNameOf(f *ast.File, structName string) string {
|
||||
var table string
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
fn, ok := n.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != "TableName" || fn.Recv == nil || len(fn.Recv.List) != 1 {
|
||||
return true
|
||||
}
|
||||
if receiverName(fn.Recv.List[0].Type) != structName {
|
||||
return true
|
||||
}
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.BasicLit)
|
||||
if ok && lit.Kind == token.STRING {
|
||||
if s, err := strconv.Unquote(lit.Value); err == nil && table == "" {
|
||||
table = s
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return true
|
||||
})
|
||||
return table
|
||||
}
|
||||
|
||||
func receiverName(expr ast.Expr) string {
|
||||
switch t := expr.(type) {
|
||||
case *ast.Ident:
|
||||
return t.Name
|
||||
case *ast.StarExpr:
|
||||
return receiverName(t.X)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir
|
||||
}
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
t.Fatal("go.mod not found above the test directory")
|
||||
return ""
|
||||
}
|
||||
@@ -3,16 +3,16 @@ package migrate
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"strconv"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/config/source/file"
|
||||
"github.com/go-admin-team/go-admin-core/v2/config/source/file"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
_ "go-admin/cmd/migrate/migration/version"
|
||||
_ "go-admin/cmd/migrate/migration/version-local"
|
||||
@@ -62,10 +62,10 @@ func migrateModel() error {
|
||||
if host == "" {
|
||||
host = "*"
|
||||
}
|
||||
db := sdk.Runtime.GetDbByKey(host)
|
||||
db := sdk.Runtime.GetDbByTenant(host)
|
||||
if db == nil {
|
||||
if len(sdk.Runtime.GetDb()) == 1 && host == "*" {
|
||||
for k, v := range sdk.Runtime.GetDb() {
|
||||
if len(sdk.Runtime.GetAllDb()) == 1 && host == "*" {
|
||||
for k, v := range sdk.Runtime.GetAllDb() {
|
||||
db = v
|
||||
host = k
|
||||
break
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
|
||||
"github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
|
||||
"go-admin/common/dto"
|
||||
"go-admin/common/models"
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
|
||||
"go-admin/common/dto"
|
||||
"go-admin/common/models"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user