mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-24 19:17:43 +00:00
Compare commits
95
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b59c7f0d46 | ||
|
|
d3a44a2a6b | ||
|
|
5c3c3907d5 | ||
|
|
f2215e132e | ||
|
|
a69afab34f | ||
|
|
e0132db1b9 | ||
|
|
7c3f55a873 | ||
|
|
f7c0247394 | ||
|
|
ac23556029 | ||
|
|
f64115e03a | ||
|
|
a2524c31bf | ||
|
|
71d6211c61 | ||
|
|
c67760bc39 | ||
|
|
d3e7f46a46 | ||
|
|
55866682ae | ||
|
|
550e95ff43 | ||
|
|
060b6cfd64 | ||
|
|
89a4738394 | ||
|
|
d8529289cf | ||
|
|
4a8f97b1ee | ||
|
|
d54ac844ef | ||
|
|
379fba515f | ||
|
|
58105cb478 | ||
|
|
47af6f4306 | ||
|
|
7d29c9953a | ||
|
|
0729624c2f | ||
|
|
b3a740ab2a | ||
|
|
adf617f5d0 | ||
|
|
049a20cd04 | ||
|
|
be3c4452e3 | ||
|
|
5b01c9ada8 | ||
|
|
ffd82a6a10 | ||
|
|
dd8d89a990 | ||
|
|
2e5b23565e | ||
|
|
f4e3f04d30 | ||
|
|
954ebdc9eb | ||
|
|
2840010dfd | ||
|
|
b147d9b833 | ||
|
|
b3ecb81614 | ||
|
|
ce4581bb99 | ||
|
|
f406ca0160 | ||
|
|
39ea1f6aef | ||
|
|
b2053f507a | ||
|
|
9520117914 | ||
|
|
7a5fc7d440 | ||
|
|
bd5e83d464 | ||
|
|
63dd40a8d7 | ||
|
|
32bd88504d | ||
|
|
d70818a9db | ||
|
|
9d4a425fc0 | ||
|
|
4d6456a588 | ||
|
|
07ff92aa55 | ||
|
|
4156387eb9 | ||
|
|
34773a0a81 | ||
|
|
36a018400b | ||
|
|
7bb02c5f1d | ||
|
|
15fb128236 | ||
|
|
3581e060ec | ||
|
|
0604a29596 | ||
|
|
d8a2958797 | ||
|
|
ab28fa7bed | ||
|
|
e88d751039 | ||
|
|
b836945eea | ||
|
|
d7a8e66753 | ||
|
|
68780a845c | ||
|
|
487dc94a2e | ||
|
|
016e977776 | ||
|
|
dcfe512204 | ||
|
|
fe6ebfd47c | ||
|
|
eba5fba3da | ||
|
|
b7e9a79225 | ||
|
|
deffb19fd8 | ||
|
|
c858b322bd | ||
|
|
1b5b52f0f1 | ||
|
|
ecb31a158b | ||
|
|
1aecc140dc | ||
|
|
e464a4aedd | ||
|
|
595c4a6be5 | ||
|
|
d115c5299c | ||
|
|
9bd542bb59 | ||
|
|
0fa015b6d0 | ||
|
|
ec7d838ebd | ||
|
|
26e116c16c | ||
|
|
90d98893f5 | ||
|
|
10f162bf5d | ||
|
|
19909746f5 | ||
|
|
205febdb8a | ||
|
|
5aec4ba32b | ||
|
|
8f1ea50dfe | ||
|
|
1483ca401d | ||
|
|
ed74623a73 | ||
|
|
1bc2e22833 | ||
|
|
cd8edfa5d4 | ||
|
|
dcc2c8e175 | ||
|
|
d991a285ba |
@@ -49,8 +49,18 @@ model、dto、router 三个文件,完整写法照抄 `app/demo/` 的结构。
|
||||
### 4. 写菜单、接口与权限种子数据
|
||||
|
||||
这一步最容易被漏掉——代码能编译、接口能测通,但界面上看不到菜单、点了按钮说
|
||||
没权限,往往就是漏了这一步。**完整参照 `cmd/migrate/migration/version/1786700001000_demo_menu.go`**
|
||||
——那是可运行、幂等(用 `upsert`,重复跑不会报错)的真实例子,逐字照抄结构,只换 ID 和业务字段。
|
||||
没权限,往往就是漏了这一步。结构参照 `cmd/migrate/migration/version/1786700001000_demo_menu.go`
|
||||
——它是可运行、幂等(用 `upsert`,重复跑不会报错)的真实例子。
|
||||
|
||||
:::danger
|
||||
**但不要照抄它的 import。** 那个文件用的是 `cmd/migrate/migration/models`,
|
||||
只因为它的版本号排在软删除转换(`1786700003000`)之前才是安全的。
|
||||
|
||||
**你新写的迁移版本号在转换之后,必须改用 `app/` 下的运行时模型**
|
||||
(`app/admin/models.SysApi`、`SysMenu`),否则第一条 insert 就会
|
||||
`NOT NULL constraint failed: sys_api.deleted_at`。
|
||||
`TestPostConversionMigrationsAvoidFrozenSeedModels` 会拦住这个错误。
|
||||
:::
|
||||
|
||||
一个模块要在界面上可用,需要四类数据,缺一样都不行:
|
||||
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
name: Build
|
||||
|
||||
# Documentation-only changes skip this workflow entirely.
|
||||
#
|
||||
# A push to master here does not just build - it pushes an image, runs the
|
||||
# migrations and restarts the demo container, so the site takes a short outage.
|
||||
# Paying that for a README edit is waste at best; at worst a deploy fails for a
|
||||
# reason unrelated to anything in the change. Code coverage is unaffected,
|
||||
# because go.yml still builds every push and pull request.
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
- 'LICENSE*'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
- 'LICENSE*'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
|
||||
# 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
|
||||
|
||||
@@ -28,9 +28,23 @@ jobs:
|
||||
|
||||
- name: Get dependencies
|
||||
run: go mod tidy
|
||||
|
||||
# go build does not compile _test.go, so building alone never ran a single
|
||||
# test. This is the only workflow that fires on every push and pull request,
|
||||
# which makes it the one place a test gate belongs.
|
||||
- name: Test
|
||||
run: make test
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
# Fails the build on the silent-failure classes listed in
|
||||
# tools/checksilent, one of which is the contract boundary: nothing under
|
||||
# common/ may import app/. A boundary that is only written down erodes; this
|
||||
# is what keeps it true.
|
||||
- name: Silent-failure checks
|
||||
run: make checksilent
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
+11
@@ -6,6 +6,10 @@ main.exe
|
||||
*.exe
|
||||
go-admin
|
||||
go-admin.exe
|
||||
# `go build ./tools/checksilent` drops the binary here, next to the one for the
|
||||
# server. Anchored with a leading slash: unanchored, the same pattern matches
|
||||
# tools/checksilent/ as well and the tool's own source never gets committed.
|
||||
/checksilent
|
||||
temp/
|
||||
!temp
|
||||
vendor
|
||||
@@ -29,3 +33,10 @@ CLAUDE.md
|
||||
.claude/skills/*
|
||||
!.claude/skills/new-business-module/
|
||||
config/settings.local.dev.yml
|
||||
|
||||
# Go workspace files. They exist to point this module at a local checkout of
|
||||
# go-admin-core while the two are developed together, which is a private
|
||||
# arrangement between one machine's directories - committing one would break
|
||||
# the build for everyone else.
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
@@ -117,6 +117,21 @@ func (SysPost) TableName() string { return "sys_post" }
|
||||
|
||||
`TableName()` 必须显式声明(GORM 配置了 `SingularTable`,不会自动推导复数)。
|
||||
|
||||
## 公共契约面
|
||||
|
||||
第三方应用(`app/` 下的业务模块)可以稳定依赖哪些包、路由与迁移怎么注册、
|
||||
哪些约束是硬的,见 `docs/contract.md`。
|
||||
|
||||
两条与主仓贡献者直接相关的:
|
||||
|
||||
- **`common/`、`core/` 不得 import `app/`** —— `make checksilent` 在 CI 里守着,违反即红。
|
||||
- **从 core 契约包声明出来的类型必须写成别名**(`type X = pkg.Y`,不是 `type X pkg.Y`)
|
||||
—— `contract-shim-alias` 检查守着。defined type 会丢掉整个方法集,
|
||||
而且**不一定在本仓编译失败**,理由见 `docs/contract.md` 末节。
|
||||
- **注册类 API(`AppRouters` / `sdk.Runtime.SetAppRouters` / `migration.ForApp`)
|
||||
必须在 `runStartupHooks()` 之前调用完** —— `init()` 是最省事的位置,
|
||||
但约束的是**顺序**,不是写在哪个函数里;晚到的注册会被丢弃并只记一条 ERROR。
|
||||
|
||||
## 路由注册
|
||||
|
||||
通过 `init()` 自注册,不在中心文件手工添加:
|
||||
@@ -179,7 +194,8 @@ go run -tags sqlite3 . server -c config/settings.sqlite.yml
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
文件名前 13 位为时间戳版本号。**已执行过的迁移文件不可修改** ——
|
||||
文件名前 13 位为毫秒时间戳版本号,不合规的名字会在启动时 panic 并报出该文件名。
|
||||
**已执行过的迁移文件不可修改** ——
|
||||
`sys_migration` 表按版本号去重,改动不会重跑,只能新增一个迁移来修正。
|
||||
|
||||
放哪个目录取决于身份:
|
||||
@@ -193,6 +209,46 @@ go run -tags sqlite3 . server -c config/settings.sqlite.yml
|
||||
`git status` 看不到,PR 里也不会出现。两个目录的包名分别是 `version` 与
|
||||
`version_local`(后者与目录名不一致,因为标识符不能含连字符)。
|
||||
|
||||
### 写种子数据用哪个 models 包
|
||||
|
||||
`1786700003000` 之后新增的迁移,**种子数据要用 `app/` 下的运行时模型**
|
||||
(如 `app/admin/models.SysApi`、`SysMenu`),**不要用 `cmd/migrate/migration/models`**。
|
||||
|
||||
后者的 `ModelTime` 声明的是可空的 `gorm.DeletedAt`,这对它之前的迁移是对的(那正是
|
||||
当时列的形状),转换之后就不再成立,两个方向都会出问题:
|
||||
|
||||
- **写**:往 NOT NULL 列里塞 NULL,第一条 insert 就 `NOT NULL constraint failed`
|
||||
- **读**:GORM 拼 `WHERE deleted_at IS NULL`,而活跃行存的是 `0`,静默查不到——
|
||||
照抄 `demo_menu.go` 的授权段落会因此跳过授权,菜单建好、权限没授、迁移仍记为成功
|
||||
|
||||
干净库跑不出这个问题,今天所有用该包的迁移都排在转换之前。完整推导见
|
||||
`schema_coverage_test.go` 里 `TestPostConversionMigrationsAvoidFrozenSeedModels`
|
||||
的注释,那个测试也守着这条边界。
|
||||
|
||||
## 静默失败校验
|
||||
|
||||
`make checksilent` 检查七类**不报错、不记日志、行为悄悄变得不对**的问题,
|
||||
CI 会跑,命中 ERROR 即失败:
|
||||
|
||||
| 检查 | 级别 | 静默后果 |
|
||||
|---|---|---|
|
||||
| `modeltime-mix` | ERROR | 两个 `ModelTime` 混用,整张表查不到数据 |
|
||||
| `menu-sort-overflow` | ERROR | 菜单 `sort` 超 127,MySQL tinyint 拒绝写入,迁移中断 |
|
||||
| `config-value-truncation` | ERROR | `sys_config.config_value` 超 255 字符被静默截断 |
|
||||
| `menu-id-collision` | ERROR | 两个模块硬编码同一菜单 ID,互相覆盖 |
|
||||
| `contract-import-boundary` | ERROR | 契约包 import `app/`,应用无法独立编译 |
|
||||
| `contract-shim-alias` | ERROR | 契约薄壳写成 defined type 而非别名,方法集丢失,本仓可能照常编译、第三方应用编译不过 |
|
||||
| `menu-name-mismatch` | WARN | 菜单名与前端组件 `name` 不一致,keep-alive 缓存静默失效 |
|
||||
|
||||
最后一条要跨仓库比对,只能做正则启发式,因此是 WARN,**不影响退出码**,
|
||||
且默认跳过;要跑它得指定前端目录:
|
||||
|
||||
```bash
|
||||
make checksilent UI_DIR=../go-admin-ui/src
|
||||
```
|
||||
|
||||
升级门槛:连续 2 个发版周期零误报后转为 ERROR。
|
||||
|
||||
## 提交规范
|
||||
|
||||
格式 `type+emoji: 描述`:
|
||||
|
||||
@@ -37,9 +37,27 @@ stop:
|
||||
#@echo "go-admin stop success"
|
||||
|
||||
|
||||
#.PHONY: test
|
||||
#test:
|
||||
# go test -v ./... -cover
|
||||
# -race is worth the extra minute here: common/actions reuses model instances
|
||||
# across concurrent requests, so a Generate() that returns in place instead of
|
||||
# a copy leaks data between them - and that is invisible to a single-threaded
|
||||
# test run.
|
||||
.PHONY: test
|
||||
test:
|
||||
go test -race -cover ./...
|
||||
|
||||
# Reports the failures that do not announce themselves - see
|
||||
# tools/checksilent. Exits non-zero on an ERROR; the one WARN-level check
|
||||
# prints and does not fail the build.
|
||||
#
|
||||
# Pass UI_DIR to enable the cross-repository menu-name check, which is skipped
|
||||
# without it: make checksilent UI_DIR=../go-admin-ui/src
|
||||
.PHONY: checksilent
|
||||
checksilent:
|
||||
ifdef UI_DIR
|
||||
go run ./tools/checksilent -ui-dir $(UI_DIR)
|
||||
else
|
||||
go run ./tools/checksilent
|
||||
endif
|
||||
|
||||
#.PHONY: docker
|
||||
#docker:
|
||||
|
||||
+7
-7
@@ -3,11 +3,11 @@
|
||||
<img align="right" width="320" src="https://doc-image.zhangwj.com/img/go-admin.svg">
|
||||
|
||||
|
||||
[](https://github.com/go-admin-team/go-admin)
|
||||
[](https://github.com/go-admin-team/go-admin)
|
||||
[](https://github.com/go-admin-team/go-admin/releases)
|
||||
[](https://github.com/go-admin-team/go-admin)
|
||||
[](https://github.com/go-admin-team/go-admin)
|
||||
|
||||
[English](https://github.com/go-admin-team/go-admin/blob/master/README.md) | 简体中文
|
||||
[English](https://github.com/go-admin-team/go-admin/blob/master/README.md) | 简体中文 | [繁體中文](https://github.com/go-admin-team/go-admin/blob/master/README.zh-TW.md) | [日本語](https://github.com/go-admin-team/go-admin/blob/master/README.ja-JP.md)
|
||||
|
||||
基于Gin + Vue + Element UI OR Arco Design OR Ant Design的前后端分离权限管理系统,系统初始化极度简单,只需要配置文件中,修改数据库连接,系统支持多指令操作,迁移指令可以让初始化数据库信息变得更简单,服务指令可以很简单的启动api服务
|
||||
|
||||
@@ -78,9 +78,9 @@ antd 体验(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admi
|
||||
|
||||
### 轻松实现go-admin写出第一个应用 - 文档教程
|
||||
|
||||
[步骤一 - 基础内容介绍](https://doc.go-admin.dev/guide/intro/tutorial01.html)
|
||||
[步骤一 - 基础内容介绍](https://www.go-admin.pro/guide/intro/tutorial01.html)
|
||||
|
||||
[步骤二 - 实际应用 - 编写增删改查](https://doc.go-admin.dev/guide/intro/tutorial02.html)
|
||||
[步骤二 - 实际应用 - 编写增删改查](https://www.go-admin.pro/guide/intro/tutorial02.html)
|
||||
|
||||
### 手把手教你从入门到放弃 - 视频教程
|
||||
|
||||
@@ -173,7 +173,7 @@ D:\Code\go-admin>go build
|
||||
cgo: exec gcc: exec: "gcc": executable file not found in %PATH%
|
||||
```
|
||||
|
||||
[解决cgo问题进入](https://doc.go-admin.dev/zh-CN/guide/faq#cgo-%E7%9A%84%E9%97%AE%E9%A2%98)
|
||||
[解决cgo问题进入](https://www.go-admin.pro/zh-CN/guide/faq#cgo-%E7%9A%84%E9%97%AE%E9%A2%98)
|
||||
|
||||
|
||||
#### 初始化数据库,以及服务启动
|
||||
@@ -327,7 +327,7 @@ pnpm dev
|
||||
4. [gin](https://github.com/gin-gonic/gin)
|
||||
5. [casbin](https://github.com/casbin/casbin)
|
||||
6. [spf13/viper](https://github.com/spf13/viper)
|
||||
7. [gorm](https://github.com/jinzhu/gorm)
|
||||
7. [gorm](https://github.com/go-gorm/gorm)
|
||||
8. [gin-swagger](https://github.com/swaggo/gin-swagger)
|
||||
9. [golang-jwt](https://github.com/golang-jwt/jwt)
|
||||
10. [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
|
||||
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
# go-admin
|
||||
|
||||
<img align="right" width="320" src="https://doc-image.zhangwj.com/img/go-admin.svg">
|
||||
|
||||
|
||||
[](https://github.com/go-admin-team/go-admin)
|
||||
[](https://github.com/go-admin-team/go-admin/releases)
|
||||
[](https://github.com/go-admin-team/go-admin)
|
||||
|
||||
[English](https://github.com/go-admin-team/go-admin/blob/master/README.md) | [简体中文](https://github.com/go-admin-team/go-admin/blob/master/README.Zh-cn.md) | [繁體中文](https://github.com/go-admin-team/go-admin/blob/master/README.zh-TW.md) | 日本語
|
||||
|
||||
Gin + Vue + Element UI / Arco Design / Ant Design による、フロントエンドとバックエンドを分離した権限管理システムです。初期化は非常に簡単で、設定ファイルのデータベース接続情報を変更するだけで動作します。複数のコマンドに対応しており、マイグレーションコマンドでデータベースの初期化が容易になり、サーバーコマンドで API を手軽に起動できます。
|
||||
|
||||
[オンラインドキュメント](https://www.go-admin.pro)
|
||||
|
||||
[フロントエンドプロジェクト](https://github.com/go-admin-team/go-admin-ui)
|
||||
|
||||
[動画チュートリアル](https://space.bilibili.com/565616721/channel/detail?cid=125737)
|
||||
|
||||
## 🎬 オンラインデモ
|
||||
|
||||
Element Plus vue3 デモ:[https://vue.go-admin.pro](https://vue.go-admin.pro/#/login)
|
||||
> ⚠️⚠️⚠️ アカウント / パスワード: admin / 123456
|
||||
|
||||
antd デモ(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admin.pro/)
|
||||
> ⚠️⚠️⚠️ アカウント / パスワード: admin / 123456
|
||||
|
||||
## ✨ 特徴
|
||||
|
||||
- RESTful API の設計規約に準拠
|
||||
|
||||
- GIN WEB API フレームワークをベースに、豊富なミドルウェアを提供(ユーザー認証、CORS、アクセスログ、トレース ID など)
|
||||
|
||||
- Casbin による RBAC アクセス制御モデル
|
||||
|
||||
- JWT 認証
|
||||
|
||||
- Swagger ドキュメントに対応(swaggo ベース)
|
||||
|
||||
- GORM によるデータベース永続化、複数種類のデータベースに拡張可能
|
||||
|
||||
- 設定ファイルからモデルへの単純なマッピングで、必要な設定をすぐに取得
|
||||
|
||||
- コード生成ツール
|
||||
|
||||
- フォームビルダー
|
||||
|
||||
- マルチコマンド方式
|
||||
|
||||
- マルチテナント対応
|
||||
|
||||
- TODO: ユニットテスト
|
||||
|
||||
## 🎁 標準機能
|
||||
|
||||
1. マルチテナント:デフォルトで対応。データベース単位で分離し、1 データベースにつき 1 テナント。
|
||||
1. ユーザー管理:システムの操作者であるユーザーの設定を行います。
|
||||
2. 部門管理:組織構造(会社・部門・グループ)を設定します。ツリー構造で表示し、データ権限に対応します。
|
||||
3. 役職管理:ユーザーが担当する職務を設定します。
|
||||
4. メニュー管理:メニュー、操作権限、ボタン権限識別子、API 権限などを設定します。
|
||||
5. ロール管理:ロールへのメニュー権限の割り当て、および組織単位でのデータ範囲権限の設定を行います。
|
||||
6. 辞書管理:システム内で頻繁に使う固定的なデータを管理します。
|
||||
7. パラメータ管理:よく使うパラメータを動的に設定します。
|
||||
8. 操作ログ:正常系の操作ログと異常情報のログを記録・検索します。
|
||||
9. ログインログ:ログイン履歴を記録・検索します。ログイン異常も含みます。
|
||||
1. API ドキュメント:業務コードから API ドキュメントを自動生成します。
|
||||
1. コード生成:テーブル定義から CRUD 業務を生成します。すべて画面上で操作でき、基本的な業務をコードなしで実現できます。
|
||||
1. フォームビルダー:ページのスタイルをカスタマイズし、ドラッグ&ドロップでレイアウトを作成します。
|
||||
1. サービス監視:サーバーの基本情報を確認します。
|
||||
1. コンテンツ管理:デモ機能。カテゴリ管理とコンテンツ管理を含み、入門用の参考実装として利用できます。
|
||||
1. スケジュールタスク:自動実行タスク。現在は API 呼び出しと関数呼び出しに対応しています。
|
||||
|
||||
## 事前準備
|
||||
|
||||
ローカルに [go] [gin] [node](http://nodejs.org/) と [git](https://git-scm.com/) をインストールしてください。
|
||||
|
||||
ダウンロードから使いこなすまでを解説した動画とドキュメントのチュートリアルを用意しています。本プロジェクトを試す前に、まずこれらに目を通すことを強くおすすめします。
|
||||
|
||||
### go-admin で最初のアプリケーションを作る - ドキュメント
|
||||
|
||||
[ステップ 1 - 基礎の紹介](https://www.go-admin.pro/guide/intro/tutorial01.html)
|
||||
|
||||
[ステップ 2 - 実践 - CRUD を書く](https://www.go-admin.pro/guide/intro/tutorial02.html)
|
||||
|
||||
### 動画チュートリアル
|
||||
|
||||
[go-admin の起動方法](https://www.bilibili.com/video/BV1z5411x7JG)
|
||||
|
||||
[生成ツールで業務を手軽に実装する](https://www.bilibili.com/video/BV1Dg4y1i79D)
|
||||
|
||||
[v1.1.0 のコード生成ツール](https://www.bilibili.com/video/BV1N54y1i71P) [応用]
|
||||
|
||||
[マルチコマンドでの起動方法と IDE 設定](https://www.bilibili.com/video/BV1Fg4y1q7ph)
|
||||
|
||||
[go-admin のメニュー設定](https://www.bilibili.com/video/BV1Wp4y1D715) [必見]
|
||||
|
||||
[メニュー情報と API 情報の設定方法](https://www.bilibili.com/video/BV1zv411B7nG) [必見]
|
||||
|
||||
[go-admin の権限設定](https://www.bilibili.com/video/BV1rt4y197d3) [必見]
|
||||
|
||||
[go-admin のデータ権限](https://www.bilibili.com/video/BV1LK4y1s71e) [必見]
|
||||
|
||||
**不明点はまず上記のドキュメントと記事をご確認ください。解決しない場合は issue や pr をお寄せください。動画とドキュメントは継続的に更新しています**
|
||||
|
||||
## 📦 ローカル開発
|
||||
|
||||
### 動作要件
|
||||
|
||||
go 1.26.5
|
||||
|
||||
node バージョン: v22 以上(v24 LTS 推奨)
|
||||
|
||||
パッケージマネージャー: pnpm v9 以上(UI プロジェクトは pnpm を使用)
|
||||
|
||||
### 開発ディレクトリの作成
|
||||
|
||||
```bash
|
||||
|
||||
# 開発ディレクトリを作成
|
||||
mkdir goadmin
|
||||
cd goadmin
|
||||
```
|
||||
|
||||
### コードの取得
|
||||
|
||||
> 重要:2 つのプロジェクトは同じディレクトリに配置してください。
|
||||
|
||||
```bash
|
||||
# バックエンドのコードを取得
|
||||
git clone https://github.com/go-admin-team/go-admin.git
|
||||
|
||||
# フロントエンドのコードを取得
|
||||
git clone https://github.com/go-admin-team/go-admin-ui.git
|
||||
|
||||
```
|
||||
|
||||
### 起動方法
|
||||
|
||||
#### サーバーの起動
|
||||
|
||||
```bash
|
||||
# go-admin バックエンドプロジェクトへ移動
|
||||
cd ./go-admin
|
||||
|
||||
# 依存関係を整理
|
||||
go mod tidy
|
||||
|
||||
# ビルド
|
||||
go build
|
||||
|
||||
# 設定を変更
|
||||
# ファイルパス go-admin/config/settings.yml
|
||||
vi ./config/settings.yml
|
||||
|
||||
# 1. 設定ファイル内のデータベース情報を変更
|
||||
# 注意: settings.database 配下の設定項目
|
||||
# 2. log のパスを確認
|
||||
```
|
||||
|
||||
⚠️注意 Windows 環境で CGO が未導入の場合、次のエラーが発生します。
|
||||
|
||||
```bash
|
||||
E:\go-admin>go build
|
||||
# github.com/mattn/go-sqlite3
|
||||
cgo: exec /missing-cc: exec: "/missing-cc": file does not exist
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
D:\Code\go-admin>go build
|
||||
# github.com/mattn/go-sqlite3
|
||||
cgo: exec gcc: exec: "gcc": executable file not found in %PATH%
|
||||
```
|
||||
|
||||
[cgo の問題の解決方法はこちら](https://www.go-admin.pro/zh-CN/guide/faq#cgo-%E7%9A%84%E9%97%AE%E9%A2%98)
|
||||
|
||||
|
||||
#### データベースの初期化とサービス起動
|
||||
|
||||
``` bash
|
||||
# 初回はデータベースのリソース情報を初期化する必要があります
|
||||
# macOS または linux の場合
|
||||
$ ./go-admin migrate -c config/settings.dev.yml
|
||||
|
||||
# ⚠️注意: windows の場合
|
||||
$ go-admin.exe migrate -c config/settings.dev.yml
|
||||
|
||||
|
||||
# プロジェクトを起動します。IDE からデバッグ実行することもできます
|
||||
# macOS または linux の場合
|
||||
$ ./go-admin server -c config/settings.yml
|
||||
|
||||
|
||||
# ⚠️注意: windows の場合
|
||||
$ go-admin.exe server -c config/settings.yml
|
||||
```
|
||||
|
||||
#### sys_api テーブルへのデータ追加方法
|
||||
|
||||
起動時に `-a true` を付けると、不足している API データが自動的に追加されます。
|
||||
```bash
|
||||
./go-admin server -c config/settings.yml -a true
|
||||
```
|
||||
|
||||
#### docker でのビルドと起動
|
||||
|
||||
```shell
|
||||
# イメージをビルド
|
||||
docker build -t go-admin .
|
||||
|
||||
# コンテナを起動します。1 つ目の go-admin はコンテナ名、2 つ目はイメージ名です
|
||||
# -v は設定ファイルのマウント ローカルパス:コンテナ内パス
|
||||
docker run --name go-admin -p 8000:8000 -v /config/settings.yml:/config/settings.yml -d go-admin-server
|
||||
```
|
||||
|
||||
#### ドキュメント生成
|
||||
|
||||
```bash
|
||||
go generate
|
||||
```
|
||||
|
||||
#### クロスコンパイル
|
||||
|
||||
```bash
|
||||
# windows
|
||||
env GOOS=windows GOARCH=amd64 go build main.go
|
||||
|
||||
# or
|
||||
# linux
|
||||
env GOOS=linux GOARCH=amd64 go build main.go
|
||||
```
|
||||
|
||||
### UI 側の起動方法
|
||||
|
||||
```bash
|
||||
# pnpm をインストール(未導入の場合)
|
||||
npm install -g pnpm
|
||||
|
||||
# 依存関係をインストール
|
||||
pnpm install
|
||||
|
||||
# 中国本土のネットワークではミラーを指定すると高速化できます
|
||||
pnpm install --registry=https://registry.npmmirror.com
|
||||
|
||||
# 開発サーバーを起動
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 📨 コミュニティ
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/wx.png" width="180px"></td>
|
||||
<td><img src="https://doc-image.zhangwj.com/img/qrcode_for_gh_b798dc7db30c_258.jpg" width="180px"></td>
|
||||
<td><img src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/qq2.png" width="200px"></td>
|
||||
<td><a href="https://space.bilibili.com/565616721">wenjianzhang</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>微信</td>
|
||||
<td>公众号🔥🔥🔥</td>
|
||||
<td><a target="_blank" href="https://shang.qq.com/wpa/qunwpa?idkey=0f2bf59f5f2edec6a4550c364242c0641f870aa328e468c4ee4b7dbfb392627b"><img border="0" src="https://pub.idqqimg.com/wpa/images/group.png" alt="go-admin技术交流乙号" title="go-admin技术交流乙号"></a></td>
|
||||
<td>哔哩哔哩🔥🔥🔥</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 💎 コントリビューター
|
||||
|
||||
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wenjianzhang" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/3890175?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/G-Akiraka" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45746659?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/lwnmengjing" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/12806223?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/bing127" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31166183?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/chengxiao" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/1379545?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/NightFire0307" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/19854086?v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/appleboy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/21979?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/misakichan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16569274?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zhuxuyang" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/19301024?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/mss-boot" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/109259065?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/AuroraV" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/37330199?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Vingurzhou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/57127283?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/haimait" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/40926384?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zyd" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/3446278?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/43291304?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/DemoLiang
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/23476007?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/jfcg
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/1410597?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Nicole0724
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/10487328?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
## JetBrains のオープンソースライセンス支援
|
||||
|
||||
`go-admin` は一貫して JetBrains 社の GoLand 統合開発環境で開発されています。**free JetBrains Open Source license(s)** による正規の無償ライセンス提供に、この場を借りて感謝を申し上げます。
|
||||
|
||||
<a href="https://www.jetbrains.com/?from=kubeadm-ha" target="_blank"><img src="https://raw.githubusercontent.com/panjf2000/illustrations/master/jetbrains/jetbrains-variant-4.png" width="250" align="middle"/></a>
|
||||
|
||||
## 🤝 謝辞
|
||||
|
||||
1. [ant-design](https://github.com/ant-design/ant-design)
|
||||
2. [ant-design-pro](https://github.com/ant-design/ant-design-pro)
|
||||
2. [arco-design](https://github.com/arco-design/arco-design)
|
||||
2. [arco-design-pro](https://github.com/arco-design/arco-design-pro)
|
||||
4. [gin](https://github.com/gin-gonic/gin)
|
||||
5. [casbin](https://github.com/casbin/casbin)
|
||||
6. [spf13/viper](https://github.com/spf13/viper)
|
||||
7. [gorm](https://github.com/go-gorm/gorm)
|
||||
8. [gin-swagger](https://github.com/swaggo/gin-swagger)
|
||||
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)
|
||||
|
||||
## 🤟 支援
|
||||
|
||||
> このプロジェクトがお役に立ちましたら、作者にジュースを一杯おごる形で応援いただけます :tropical_drink:
|
||||
|
||||
<img class="no-margin" src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/pay.png" height="200px" >
|
||||
|
||||
## 🤝 関連リンク
|
||||
|
||||
- [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) 2026 wenjianzhang
|
||||
@@ -4,15 +4,15 @@
|
||||
<img align="right" width="320" src="https://raw.githubusercontent.com/wenjianzhang/image/203c5930b9ed08d5cf2fcb4516b85e412f8e0e60/img/go-admin.svg">
|
||||
|
||||
|
||||
[](https://github.com/go-admin-team/go-admin)
|
||||
[](https://github.com/go-admin-team/go-admin)
|
||||
[](https://github.com/go-admin-team/go-admin/releases)
|
||||
[](https://github.com/go-admin-team/go-admin)
|
||||
[](https://github.com/go-admin-team/go-admin)
|
||||
|
||||
English | [简体中文](https://github.com/go-admin-team/go-admin/blob/master/README.Zh-cn.md)
|
||||
English | [简体中文](https://github.com/go-admin-team/go-admin/blob/master/README.Zh-cn.md) | [繁體中文](https://github.com/go-admin-team/go-admin/blob/master/README.zh-TW.md) | [日本語](https://github.com/go-admin-team/go-admin/blob/master/README.ja-JP.md)
|
||||
|
||||
The front-end and back-end separation authority management system based on Gin + Vue + Element UI OR Arco Design is extremely simple to initialize the system. You only need to modify the database connection in the configuration file. The system supports multi-instruction operations. Migration instructions can make it easier to initialize database information. Service instructions It's easy to start the api service.
|
||||
The front-end and back-end separation authority management system based on Gin + Vue + Element UI OR Arco Design OR Ant Design is extremely simple to initialize the system. You only need to modify the database connection in the configuration file. The system supports multi-instruction operations. Migration instructions can make it easier to initialize database information. Service instructions It's easy to start the api service.
|
||||
|
||||
[documentation](https://www.go-admin.dev)
|
||||
[documentation](https://www.go-admin.pro)
|
||||
|
||||
[Front-end project](https://github.com/go-admin-team/go-admin-ui)
|
||||
|
||||
@@ -76,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.go-admin.dev/guide/intro/tutorial01.html)
|
||||
[Step 1 - basic content introduction](https://www.go-admin.pro/guide/intro/tutorial01.html)
|
||||
|
||||
[Step 2 - Practical application - writing database operations](https://doc.go-admin.dev/guide/intro/tutorial02.html)
|
||||
[Step 2 - Practical application - writing database operations](https://www.go-admin.pro/guide/intro/tutorial02.html)
|
||||
|
||||
### Teach you from getting started to giving up-video tutorial
|
||||
|
||||
@@ -155,7 +155,7 @@ vi ./config/settings.yml
|
||||
# 2. Confirm the log path
|
||||
```
|
||||
|
||||
:::tip ⚠️Note that this problem will occur if CGO is not installed in the windows10+ environment;
|
||||
⚠️ Note that this problem will occur if CGO is not installed in the windows10+ environment;
|
||||
|
||||
```bash
|
||||
E:\go-admin>go build
|
||||
@@ -171,9 +171,7 @@ D:\Code\go-admin>go build
|
||||
cgo: exec gcc: exec: "gcc": executable file not found in %PATH%
|
||||
```
|
||||
|
||||
[Solve the cgo problem and enter](https://doc.go-admin.dev/guide/faq#cgo-%E7%9A%84%E9%97%AE%E9%A2%98)
|
||||
|
||||
:::
|
||||
[Solve the cgo problem and enter](https://www.go-admin.pro/guide/faq#cgo-%E7%9A%84%E9%97%AE%E9%A2%98)
|
||||
|
||||
#### Initialize the database, and start the service
|
||||
|
||||
@@ -318,7 +316,7 @@ The `go-admin` project has always been developed in the GoLand integrated develo
|
||||
2. [gin](https://github.com/gin-gonic/gin)
|
||||
2. [casbin](https://github.com/casbin/casbin)
|
||||
2. [spf13/viper](https://github.com/spf13/viper)
|
||||
2. [gorm](https://github.com/jinzhu/gorm)
|
||||
2. [gorm](https://github.com/go-gorm/gorm)
|
||||
2. [gin-swagger](https://github.com/swaggo/gin-swagger)
|
||||
2. [golang-jwt](https://github.com/golang-jwt/jwt)
|
||||
2. [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
|
||||
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
# go-admin
|
||||
|
||||
<img align="right" width="320" src="https://doc-image.zhangwj.com/img/go-admin.svg">
|
||||
|
||||
|
||||
[](https://github.com/go-admin-team/go-admin)
|
||||
[](https://github.com/go-admin-team/go-admin/releases)
|
||||
[](https://github.com/go-admin-team/go-admin)
|
||||
|
||||
[English](https://github.com/go-admin-team/go-admin/blob/master/README.md) | [简体中文](https://github.com/go-admin-team/go-admin/blob/master/README.Zh-cn.md) | 繁體中文 | [日本語](https://github.com/go-admin-team/go-admin/blob/master/README.ja-JP.md)
|
||||
|
||||
基於 Gin + Vue + Element UI OR Arco Design OR Ant Design 的前後端分離權限管理系統。系統初始化極為簡單,只需在設定檔中修改資料庫連線資訊即可。系統支援多指令操作:遷移指令讓資料庫初始化變得更簡單,服務指令則能輕鬆啟動 API 服務。
|
||||
|
||||
[線上文件](https://www.go-admin.pro)
|
||||
|
||||
[前端專案](https://github.com/go-admin-team/go-admin-ui)
|
||||
|
||||
[影片教學](https://space.bilibili.com/565616721/channel/detail?cid=125737)
|
||||
|
||||
## 🎬 線上體驗
|
||||
|
||||
Element Plus vue3 體驗:[https://vue.go-admin.pro](https://vue.go-admin.pro/#/login)
|
||||
> ⚠️⚠️⚠️ 帳號 / 密碼: admin / 123456
|
||||
|
||||
antd 體驗(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admin.pro/)
|
||||
> ⚠️⚠️⚠️ 帳號 / 密碼: admin / 123456
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 遵循 RESTful API 設計規範
|
||||
|
||||
- 基於 GIN WEB API 框架,提供豐富的中介軟體支援(使用者認證、跨域、存取日誌、追蹤 ID 等)
|
||||
|
||||
- 基於 Casbin 的 RBAC 存取控制模型
|
||||
|
||||
- JWT 認證
|
||||
|
||||
- 支援 Swagger 文件(基於 swaggo)
|
||||
|
||||
- 基於 GORM 的資料庫儲存,可擴充多種類型資料庫
|
||||
|
||||
- 設定檔簡單的模型映射,快速取得所需設定
|
||||
|
||||
- 程式碼產生工具
|
||||
|
||||
- 表單建構工具
|
||||
|
||||
- 多指令模式
|
||||
|
||||
- 多租戶的支援
|
||||
|
||||
- TODO: 單元測試
|
||||
|
||||
## 🎁 內建
|
||||
|
||||
1. 多租戶:系統預設支援多租戶,按資料庫分離,一個資料庫一個租戶。
|
||||
1. 使用者管理:使用者是系統操作者,該功能主要完成系統使用者設定。
|
||||
2. 部門管理:設定系統組織架構(公司、部門、小組),以樹狀結構呈現並支援資料權限。
|
||||
3. 職位管理:設定系統使用者所擔任的職務。
|
||||
4. 選單管理:設定系統選單、操作權限、按鈕權限標識、介面權限等。
|
||||
5. 角色管理:角色選單權限分配、設定角色按機構進行資料範圍權限劃分。
|
||||
6. 字典管理:對系統中經常使用且較為固定的資料進行維護。
|
||||
7. 參數管理:對系統動態設定常用參數。
|
||||
8. 操作日誌:系統正常操作的日誌記錄與查詢;系統異常資訊的日誌記錄與查詢。
|
||||
9. 登入日誌:系統登入日誌記錄查詢,包含登入異常。
|
||||
1. 介面文件:根據業務程式碼自動產生相關的 API 介面文件。
|
||||
1. 程式碼產生:根據資料表結構產生對應的增刪改查業務,全程視覺化操作,讓基本業務可以零程式碼實現。
|
||||
1. 表單建構:自訂頁面樣式,拖拉放實現頁面佈局。
|
||||
1. 服務監控:檢視伺服器的基本資訊。
|
||||
1. 內容管理:demo 功能,下設分類管理、內容管理,可參考使用以快速入門。
|
||||
1. 排程任務:自動化任務,目前支援介面呼叫與函式呼叫。
|
||||
|
||||
## 準備工作
|
||||
|
||||
你需要在本機安裝 [go] [gin] [node](http://nodejs.org/) 和 [git](https://git-scm.com/)
|
||||
|
||||
同時配套了系列教學(含影片與文件),說明如何從下載到熟練使用。強烈建議先看完這些教學再來實作本專案!!!
|
||||
|
||||
### 輕鬆用 go-admin 寫出第一個應用 - 文件教學
|
||||
|
||||
[步驟一 - 基礎內容介紹](https://www.go-admin.pro/guide/intro/tutorial01.html)
|
||||
|
||||
[步驟二 - 實際應用 - 撰寫增刪改查](https://www.go-admin.pro/guide/intro/tutorial02.html)
|
||||
|
||||
### 手把手教你從入門到放棄 - 影片教學
|
||||
|
||||
[如何啟動 go-admin](https://www.bilibili.com/video/BV1z5411x7JG)
|
||||
|
||||
[使用產生工具輕鬆實現業務](https://www.bilibili.com/video/BV1Dg4y1i79D)
|
||||
|
||||
[v1.1.0 版本程式碼產生工具 - 釋放雙手](https://www.bilibili.com/video/BV1N54y1i71P) [進階]
|
||||
|
||||
[多指令啟動方式講解以及 IDE 設定](https://www.bilibili.com/video/BV1Fg4y1q7ph)
|
||||
|
||||
[go-admin 選單的設定說明](https://www.bilibili.com/video/BV1Wp4y1D715) [必看]
|
||||
|
||||
[如何設定選單資訊以及介面資訊](https://www.bilibili.com/video/BV1zv411B7nG) [必看]
|
||||
|
||||
[go-admin 權限設定使用說明](https://www.bilibili.com/video/BV1rt4y197d3) [必看]
|
||||
|
||||
[go-admin 資料權限使用說明](https://www.bilibili.com/video/BV1LK4y1s71e) [必看]
|
||||
|
||||
**如有問題請先參閱上述文件與文章,若仍無法解決,歡迎提出 issue 與 pr。影片教學與文件持續更新中**
|
||||
|
||||
## 📦 本機開發
|
||||
|
||||
### 環境需求
|
||||
|
||||
go 1.26.5
|
||||
|
||||
node 版本: v22+(建議 v24 LTS)
|
||||
|
||||
套件管理器: pnpm v9+(UI 專案使用 pnpm)
|
||||
|
||||
### 建立開發目錄
|
||||
|
||||
```bash
|
||||
|
||||
# 建立開發目錄
|
||||
mkdir goadmin
|
||||
cd goadmin
|
||||
```
|
||||
|
||||
### 取得程式碼
|
||||
|
||||
> 重點注意:兩個專案必須放在同一資料夾下;
|
||||
|
||||
```bash
|
||||
# 取得後端程式碼
|
||||
git clone https://github.com/go-admin-team/go-admin.git
|
||||
|
||||
# 取得前端程式碼
|
||||
git clone https://github.com/go-admin-team/go-admin-ui.git
|
||||
|
||||
```
|
||||
|
||||
### 啟動說明
|
||||
|
||||
#### 伺服器端啟動說明
|
||||
|
||||
```bash
|
||||
# 進入 go-admin 後端專案
|
||||
cd ./go-admin
|
||||
|
||||
# 更新整理相依套件
|
||||
go mod tidy
|
||||
|
||||
# 編譯專案
|
||||
go build
|
||||
|
||||
# 修改設定
|
||||
# 檔案路徑 go-admin/config/settings.yml
|
||||
vi ./config/settings.yml
|
||||
|
||||
# 1. 在設定檔中修改資料庫資訊
|
||||
# 注意: settings.database 下對應的設定資料
|
||||
# 2. 確認 log 路徑
|
||||
```
|
||||
|
||||
⚠️注意 在 Windows 環境若未安裝 CGO,會出現這個問題;
|
||||
|
||||
```bash
|
||||
E:\go-admin>go build
|
||||
# github.com/mattn/go-sqlite3
|
||||
cgo: exec /missing-cc: exec: "/missing-cc": file does not exist
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
D:\Code\go-admin>go build
|
||||
# github.com/mattn/go-sqlite3
|
||||
cgo: exec gcc: exec: "gcc": executable file not found in %PATH%
|
||||
```
|
||||
|
||||
[解決 cgo 問題請進入](https://www.go-admin.pro/zh-CN/guide/faq#cgo-%E7%9A%84%E9%97%AE%E9%A2%98)
|
||||
|
||||
|
||||
#### 初始化資料庫,以及服務啟動
|
||||
|
||||
``` bash
|
||||
# 首次設定需要初始化資料庫資源資訊
|
||||
# macOS or linux 下使用
|
||||
$ ./go-admin migrate -c config/settings.dev.yml
|
||||
|
||||
# ⚠️注意:windows 下使用
|
||||
$ go-admin.exe migrate -c config/settings.dev.yml
|
||||
|
||||
|
||||
# 啟動專案,也可以用 IDE 進行除錯
|
||||
# macOS or linux 下使用
|
||||
$ ./go-admin server -c config/settings.yml
|
||||
|
||||
|
||||
# ⚠️注意:windows 下使用
|
||||
$ go-admin.exe server -c config/settings.yml
|
||||
```
|
||||
|
||||
#### sys_api 表的資料如何新增
|
||||
|
||||
在專案啟動時,使用 `-a true` 系統會自動新增缺少的介面資料
|
||||
```bash
|
||||
./go-admin server -c config/settings.yml -a true
|
||||
```
|
||||
|
||||
#### 使用 docker 編譯啟動
|
||||
|
||||
```shell
|
||||
# 編譯映像檔
|
||||
docker build -t go-admin .
|
||||
|
||||
# 啟動容器,第一個 go-admin 是容器名稱,第二個 go-admin 是映像檔名稱
|
||||
# -v 映射設定檔 本機路徑:容器路徑
|
||||
docker run --name go-admin -p 8000:8000 -v /config/settings.yml:/config/settings.yml -d go-admin-server
|
||||
```
|
||||
|
||||
#### 文件產生
|
||||
|
||||
```bash
|
||||
go generate
|
||||
```
|
||||
|
||||
#### 交叉編譯
|
||||
|
||||
```bash
|
||||
# windows
|
||||
env GOOS=windows GOARCH=amd64 go build main.go
|
||||
|
||||
# or
|
||||
# linux
|
||||
env GOOS=linux GOARCH=amd64 go build main.go
|
||||
```
|
||||
|
||||
### UI 互動端啟動說明
|
||||
|
||||
```bash
|
||||
# 安裝 pnpm(若未安裝)
|
||||
npm install -g pnpm
|
||||
|
||||
# 安裝相依套件
|
||||
pnpm install
|
||||
|
||||
# 中國大陸網路可指定鏡像來源加速
|
||||
pnpm install --registry=https://registry.npmmirror.com
|
||||
|
||||
# 啟動服務
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 📨 互動
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/wx.png" width="180px"></td>
|
||||
<td><img src="https://doc-image.zhangwj.com/img/qrcode_for_gh_b798dc7db30c_258.jpg" width="180px"></td>
|
||||
<td><img src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/qq2.png" width="200px"></td>
|
||||
<td><a href="https://space.bilibili.com/565616721">wenjianzhang</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>微信</td>
|
||||
<td>公众号🔥🔥🔥</td>
|
||||
<td><a target="_blank" href="https://shang.qq.com/wpa/qunwpa?idkey=0f2bf59f5f2edec6a4550c364242c0641f870aa328e468c4ee4b7dbfb392627b"><img border="0" src="https://pub.idqqimg.com/wpa/images/group.png" alt="go-admin技术交流乙号" title="go-admin技术交流乙号"></a></td>
|
||||
<td>哔哩哔哩🔥🔥🔥</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 💎 貢獻者
|
||||
|
||||
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wenjianzhang" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/3890175?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/G-Akiraka" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45746659?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/lwnmengjing" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/12806223?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/bing127" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31166183?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/chengxiao" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/1379545?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/NightFire0307" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/19854086?v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/appleboy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/21979?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/misakichan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16569274?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zhuxuyang" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/19301024?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/mss-boot" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/109259065?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/AuroraV" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/37330199?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Vingurzhou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/57127283?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/haimait" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/40926384?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zyd" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/3446278?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/43291304?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/DemoLiang
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/23476007?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/jfcg
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/1410597?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Nicole0724
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/10487328?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
## JetBrains 開源證書支援
|
||||
|
||||
`go-admin` 專案一直以來都是在 JetBrains 公司旗下的 GoLand 整合開發環境中進行開發,基於 **free JetBrains Open Source license(s)** 正版免費授權,在此表達我的謝意。
|
||||
|
||||
<a href="https://www.jetbrains.com/?from=kubeadm-ha" target="_blank"><img src="https://raw.githubusercontent.com/panjf2000/illustrations/master/jetbrains/jetbrains-variant-4.png" width="250" align="middle"/></a>
|
||||
|
||||
## 🤝 特別感謝
|
||||
|
||||
1. [ant-design](https://github.com/ant-design/ant-design)
|
||||
2. [ant-design-pro](https://github.com/ant-design/ant-design-pro)
|
||||
2. [arco-design](https://github.com/arco-design/arco-design)
|
||||
2. [arco-design-pro](https://github.com/arco-design/arco-design-pro)
|
||||
4. [gin](https://github.com/gin-gonic/gin)
|
||||
5. [casbin](https://github.com/casbin/casbin)
|
||||
6. [spf13/viper](https://github.com/spf13/viper)
|
||||
7. [gorm](https://github.com/go-gorm/gorm)
|
||||
8. [gin-swagger](https://github.com/swaggo/gin-swagger)
|
||||
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)
|
||||
|
||||
## 🤟 贊助
|
||||
|
||||
> 如果你覺得這個專案幫助到了你,可以幫作者買一杯果汁表示鼓勵 :tropical_drink:
|
||||
|
||||
<img class="no-margin" src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/pay.png" height="200px" >
|
||||
|
||||
## 🤝 連結
|
||||
|
||||
- [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) 2026 wenjianzhang
|
||||
@@ -21,13 +21,16 @@ func (e System) GenerateCaptchaHandler(c *gin.Context) {
|
||||
e.Error(500, err, "服务初始化失败!")
|
||||
return
|
||||
}
|
||||
id, b64s, answer, err := captcha.DriverDigitFunc()
|
||||
// The answer is deliberately discarded rather than logged. It used to be
|
||||
// written at info level, which put a currently valid captcha answer in the
|
||||
// application log - anyone able to read the log could bypass the check the
|
||||
// captcha exists to enforce.
|
||||
id, b64s, _, err := captcha.DriverDigitFunc()
|
||||
if err != nil {
|
||||
e.Logger.Errorf("DriverDigitFunc error, %s", err.Error())
|
||||
e.Error(500, err, "验证码获取失败")
|
||||
return
|
||||
}
|
||||
e.Logger.Infof("DriverDigitFunc answer: %s", answer)
|
||||
e.Custom(gin.H{
|
||||
"code": 200,
|
||||
"data": b64s,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"go-admin/app/admin/models"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"go-admin/app/admin/service"
|
||||
"go-admin/app/admin/service/dto"
|
||||
"go-admin/common/actions"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
type SysUser struct {
|
||||
@@ -149,12 +151,34 @@ func (e SysUser) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
callerId := user.GetUserId(c)
|
||||
|
||||
// This route is in CasbinExclude so the personal-center screen can edit
|
||||
// the caller's own record without a policy grant (see settings.go). That
|
||||
// exclusion covers the whole route, not just the caller's own record, and
|
||||
// the request carries the target userId in the body - so without this
|
||||
// check here, any authenticated caller could edit any other user, up to
|
||||
// and including their roleId. When the target is someone else, ask Casbin
|
||||
// directly for the permission AuthCheckRole skipped.
|
||||
if req.UserId != callerId {
|
||||
allowed, err := middleware.EnforceRoleFor(c, c.Request.URL.Path, c.Request.Method)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
e.Error(http.StatusForbidden, errors.New("无权更新其他用户数据"), "对不起,您没有该接口访问权限,请联系管理员")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
req.SetUpdateBy(callerId)
|
||||
|
||||
//数据权限检查
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
|
||||
err = s.Update(&req, p)
|
||||
err = s.Update(&req, p, callerId)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
mycasbin "github.com/go-admin-team/go-admin-core/v2/casbin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
)
|
||||
|
||||
// PUT /api/v1/sys-user is in settings.go's CasbinExclude so the
|
||||
// personal-center screen (go-admin-ui's userInfo.vue) can edit the caller's
|
||||
// own record without holding a policy grant on this route. AuthCheckRole
|
||||
// skips Enforce entirely for an excluded route, so this file's job is to pin
|
||||
// what the handler itself now has to hold shut: the target userId comes from
|
||||
// the request body, and nothing upstream of the handler ever checked it
|
||||
// against the caller.
|
||||
|
||||
// setupPrivescDB wires an in-memory database and a Casbin enforcer with an
|
||||
// empty policy - the state of a fresh install for any role but admin - under
|
||||
// a tenant unique to the calling test, so mycasbin's process-wide enforcer
|
||||
// cache can't hand one test's database to another.
|
||||
func setupPrivescDB(t *testing.T) (*gorm.DB, string) {
|
||||
t.Helper()
|
||||
|
||||
// Fatalf, not Skipf: this database is in-memory sqlite with no external
|
||||
// dependency, so failing to open or migrate it means the environment is
|
||||
// actually broken. Skipping here would let these two anti-privesc
|
||||
// regression tests silently stop running while CI stays green - a
|
||||
// standing assertion that never fires is worse than no assertion.
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite unavailable: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SysUser{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
|
||||
tenant := "sys-user-privesc-" + t.Name()
|
||||
|
||||
previousInterval := mycasbin.ReloadInterval
|
||||
mycasbin.ReloadInterval = 0 // opt out of the background reload goroutine; the test never writes a policy
|
||||
t.Cleanup(func() { mycasbin.ReloadInterval = previousInterval })
|
||||
|
||||
e := mycasbin.Setup(db, tenant)
|
||||
previousEnforcer := sdk.Runtime.GetCasbinByTenant(tenant)
|
||||
sdk.Runtime.SetCasbinByTenant(tenant, e)
|
||||
t.Cleanup(func() { sdk.Runtime.SetCasbinByTenant(tenant, previousEnforcer) })
|
||||
|
||||
return db, tenant
|
||||
}
|
||||
|
||||
// callUpdate drives SysUser.Update the way the router does for an
|
||||
// authenticated, non-admin caller: JWT claims already decoded into the
|
||||
// context (that is jwtauth's job, not this handler's) and a database - but
|
||||
// without AuthCheckRole, since that middleware never runs Enforce for this
|
||||
// route at all.
|
||||
func callUpdate(t *testing.T, db *gorm.DB, tenant string, callerId int, body map[string]interface{}) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request body: %v", err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/sys-user", bytes.NewReader(raw))
|
||||
c.Request.Host = tenant
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
c.Set("db", db)
|
||||
c.Set(pkg.LoggerKey, logger.NewHelper(logger.DefaultLogger))
|
||||
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{
|
||||
"identity": float64(callerId),
|
||||
"rolekey": "ordinary-role", // holds no Casbin policy anywhere in this test
|
||||
})
|
||||
|
||||
SysUser{}.Update(c)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestUpdate_CannotEscalatePrivilegeThroughAnotherUsersRecord is the
|
||||
// regression for H6. Before the fix, an ordinary authenticated user could PUT
|
||||
// a body naming another user's id and change that user's roleId - the route
|
||||
// being Casbin-excluded meant no permission check ever ran, and the data
|
||||
// permission scope that would otherwise gate this is off by default.
|
||||
func TestUpdate_CannotEscalatePrivilegeThroughAnotherUsersRecord(t *testing.T) {
|
||||
db, tenant := setupPrivescDB(t)
|
||||
|
||||
victim := models.SysUser{Username: "bob", NickName: "Bob", RoleId: 2, DeptId: 1, Status: "1"}
|
||||
if err := db.Create(&victim).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attacker := models.SysUser{Username: "alice", NickName: "Alice", RoleId: 2, DeptId: 1, Status: "1"}
|
||||
if err := db.Create(&attacker).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const elevatedRoleId = 1 // a role the attacker does not hold and has no policy for
|
||||
|
||||
callUpdate(t, db, tenant, attacker.UserId, map[string]interface{}{
|
||||
"userId": victim.UserId,
|
||||
"username": victim.Username,
|
||||
"nickName": "pwned",
|
||||
"phone": "13800000000",
|
||||
"email": "bob@example.com",
|
||||
"roleId": elevatedRoleId,
|
||||
"deptId": victim.DeptId,
|
||||
"status": victim.Status,
|
||||
})
|
||||
|
||||
var after models.SysUser
|
||||
if err := db.First(&after, victim.UserId).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.RoleId == elevatedRoleId {
|
||||
t.Fatalf("an attacker with no Casbin permission on this route escalated the victim's roleId to %d", after.RoleId)
|
||||
}
|
||||
if after.NickName == "pwned" {
|
||||
t.Fatalf("an attacker with no Casbin permission on this route modified another user's record: %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdate_SelfEditCannotChangePrivilegedFields covers the case the
|
||||
// CasbinExclude entry exists for: the personal-center screen has to keep
|
||||
// working for the caller's own record. The fields that screen exposes
|
||||
// (nickName/phone/email/sex) must still save, while roleId/deptId/status stay
|
||||
// whatever the database already had even if the request carries something
|
||||
// else - a compromised or hand-crafted client is the only way that request
|
||||
// would ever differ from what the honest form sends.
|
||||
func TestUpdate_SelfEditCannotChangePrivilegedFields(t *testing.T) {
|
||||
db, tenant := setupPrivescDB(t)
|
||||
|
||||
self := models.SysUser{Username: "carol", NickName: "Carol", RoleId: 2, DeptId: 1, Status: "1"}
|
||||
if err := db.Create(&self).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const elevatedRoleId = 1
|
||||
|
||||
callUpdate(t, db, tenant, self.UserId, map[string]interface{}{
|
||||
"userId": self.UserId,
|
||||
"username": self.Username,
|
||||
"nickName": "Carol Updated",
|
||||
"phone": "13900000000",
|
||||
"email": "carol@example.com",
|
||||
"roleId": elevatedRoleId, // tampered; must not take effect
|
||||
"deptId": self.DeptId,
|
||||
"status": self.Status,
|
||||
})
|
||||
|
||||
var after models.SysUser
|
||||
if err := db.First(&after, self.UserId).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.RoleId == elevatedRoleId {
|
||||
t.Fatalf("a self-edit changed the caller's own roleId to %d", after.RoleId)
|
||||
}
|
||||
if after.NickName != "Carol Updated" {
|
||||
t.Fatalf("the legitimate personal-center edit did not go through: %+v", after)
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"gorm.io/gorm"
|
||||
|
||||
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 {
|
||||
DataScope string
|
||||
UserId int
|
||||
DeptId int
|
||||
RoleId int
|
||||
}
|
||||
|
||||
func (e *DataPermission) GetDataScope(tableName string, db *gorm.DB) (*gorm.DB, error) {
|
||||
|
||||
if !config.ApplicationConfig.EnableDP {
|
||||
usageStr := `数据权限已经为您` + pkg.Green(`关闭`) + `,如需开启请参考配置文件字段说明`
|
||||
log.Debug("%s\n", usageStr)
|
||||
return db, nil
|
||||
}
|
||||
user := new(SysUser)
|
||||
role := new(SysRole)
|
||||
err := db.Find(user, e.UserId).Error
|
||||
if err != nil {
|
||||
return nil, errors.New("获取用户数据出错 msg:" + err.Error())
|
||||
}
|
||||
err = db.Find(role, user.RoleId).Error
|
||||
if err != nil {
|
||||
return nil, errors.New("获取用户数据出错 msg:" + err.Error())
|
||||
}
|
||||
if role.DataScope == "2" {
|
||||
db = db.Where(tableName+".create_by in (select sys_user.user_id from sys_role_dept left join sys_user on sys_user.dept_id=sys_role_dept.dept_id where sys_role_dept.role_id = ?)", user.RoleId)
|
||||
}
|
||||
if role.DataScope == "3" {
|
||||
db = db.Where(tableName+".create_by in (SELECT user_id from sys_user where dept_id = ? )", user.DeptId)
|
||||
}
|
||||
if role.DataScope == "4" {
|
||||
db = db.Where(tableName+".create_by in (SELECT user_id from sys_user where sys_user.dept_id in(select dept_id from sys_dept where dept_path like ? ))", "%"+pkg.IntToString(user.DeptId)+"%")
|
||||
}
|
||||
if role.DataScope == "5" || role.DataScope == "" {
|
||||
db = db.Where(tableName+".create_by = ?", e.UserId)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
//func DataScopes(tableName string, userId int) func(db *gorm.DB) *gorm.DB {
|
||||
// return func(db *gorm.DB) *gorm.DB {
|
||||
// user := new(SysUser)
|
||||
// role := new(SysRole)
|
||||
// user.UserId = userId
|
||||
// err := db.Find(user, userId).Error
|
||||
// if err != nil {
|
||||
// db.Error = errors.New("获取用户数据出错 msg:" + err.Error())
|
||||
// return db
|
||||
// }
|
||||
// err = db.Find(role, user.RoleId).Error
|
||||
// if err != nil {
|
||||
// db.Error = errors.New("获取用户数据出错 msg:" + err.Error())
|
||||
// return db
|
||||
// }
|
||||
// if role.DataScope == "2" {
|
||||
// return db.Where(tableName+".create_by in (select sys_user.user_id from sys_role_dept left join sys_user on sys_user.dept_id=sys_role_dept.dept_id where sys_role_dept.role_id = ?)", user.RoleId)
|
||||
// }
|
||||
// if role.DataScope == "3" {
|
||||
// return db.Where(tableName+".create_by in (SELECT user_id from sys_user where dept_id = ? )", user.DeptId)
|
||||
// }
|
||||
// if role.DataScope == "4" {
|
||||
// return db.Where(tableName+".create_by in (SELECT user_id from sys_user where sys_user.dept_id in(select dept_id from sys_dept where dept_path like ? ))", "%"+pkg.IntToString(user.DeptId)+"%")
|
||||
// }
|
||||
// if role.DataScope == "5" || role.DataScope == "" {
|
||||
// return db.Where(tableName+".create_by = ?", userId)
|
||||
// }
|
||||
// return db
|
||||
// }
|
||||
//}
|
||||
@@ -23,6 +23,10 @@ type SysApi struct {
|
||||
Path string `json:"path" gorm:"size:128;comment:地址"`
|
||||
Action string `json:"action" gorm:"size:16;comment:请求类型"`
|
||||
Type string `json:"type" gorm:"size:16;comment:接口类型"`
|
||||
// AppCode identifies which application's seed.SeedMenus call wrote this
|
||||
// row; empty for the host's own built-in APIs. Same NOT NULL DEFAULT ''
|
||||
// reasoning as SysMenu.AppCode.
|
||||
AppCode string `json:"appCode" gorm:"type:varchar(64);not null;default:'';index:idx_sys_api_app_code;comment:AppCode"`
|
||||
models.ModelTime
|
||||
models.ControlBy
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ type SysMenu struct {
|
||||
RoleId int `gorm:"-"`
|
||||
Children []SysMenu `json:"children,omitempty" gorm:"-"`
|
||||
IsSelect bool `json:"is_select" gorm:"-"`
|
||||
// AppCode identifies which application's seed.SeedMenus call wrote this
|
||||
// row; empty for the host's own built-in menus. NOT NULL DEFAULT '' for
|
||||
// the same reason sys_migration.app_code is (see contract/models.Migration):
|
||||
// AutoMigrate adding this column to an existing table leaves every
|
||||
// pre-existing row reading back as "" rather than NULL.
|
||||
AppCode string `json:"appCode" gorm:"type:varchar(64);not null;default:'';index:idx_sys_menu_app_code;comment:AppCode"`
|
||||
models.ControlBy
|
||||
models.ModelTime
|
||||
}
|
||||
|
||||
@@ -42,19 +42,35 @@ func (e *SysUser) GetId() interface{} {
|
||||
return e.UserId
|
||||
}
|
||||
|
||||
// Encrypt 加密
|
||||
func (e *SysUser) Encrypt() (err error) {
|
||||
// Encrypt hashes Password, unless it already holds a hash.
|
||||
//
|
||||
// The hooks below run on whatever is in the struct, and a user read from the
|
||||
// database carries the stored hash in that field. Hashing it again produces a
|
||||
// hash of a hash, and the password that user knows no longer matches anything:
|
||||
// they cannot log in, and nothing reports an error. The only thing preventing
|
||||
// that today is an Omit("password") on the one update that loads a user first,
|
||||
// which makes every other write to this model one line away from destroying
|
||||
// credentials.
|
||||
//
|
||||
// bcrypt.Cost parses a hash and fails on anything else, so it distinguishes
|
||||
// the two cases without the call site having to say which it is. The cost is
|
||||
// that a password which is itself a well-formed bcrypt hash would be stored
|
||||
// unchanged - a 60-character string beginning "$2a$", not something a person
|
||||
// types, and it grants whoever set it no access they did not already have.
|
||||
func (e *SysUser) Encrypt() error {
|
||||
if e.Password == "" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if _, err := bcrypt.Cost([]byte(e.Password)); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var hash []byte
|
||||
if hash, err = bcrypt.GenerateFromPassword([]byte(e.Password), bcrypt.DefaultCost); err != nil {
|
||||
return
|
||||
} else {
|
||||
e.Password = string(hash)
|
||||
return
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(e.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.Password = string(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *SysUser) BeforeCreate(_ *gorm.DB) error {
|
||||
@@ -62,11 +78,7 @@ func (e *SysUser) BeforeCreate(_ *gorm.DB) error {
|
||||
}
|
||||
|
||||
func (e *SysUser) BeforeUpdate(_ *gorm.DB) error {
|
||||
var err error
|
||||
if e.Password != "" {
|
||||
err = e.Encrypt()
|
||||
}
|
||||
return err
|
||||
return e.Encrypt()
|
||||
}
|
||||
|
||||
func (e *SysUser) AfterFind(_ *gorm.DB) error {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const knownPassword = "correct-horse-battery-staple"
|
||||
|
||||
// A user loaded from the database carries the stored hash in Password, and the
|
||||
// hooks run on whatever is in the struct. Hashing it a second time produces a
|
||||
// hash of a hash: the password the user knows stops matching, they cannot log
|
||||
// in, and nothing reports an error.
|
||||
//
|
||||
// Only an Omit("password") on one call site stood between this and every write
|
||||
// to the model. This is the test that removes the need for it.
|
||||
func TestEncryptLeavesAnAlreadyHashedPasswordAlone(t *testing.T) {
|
||||
fresh := SysUser{Password: knownPassword}
|
||||
if err := fresh.Encrypt(); err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
stored := fresh.Password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(stored), []byte(knownPassword)); err != nil {
|
||||
t.Fatalf("setup failed: the password was not hashed: %v", err)
|
||||
}
|
||||
|
||||
// What a query puts in the struct, and what an update then hands the hook.
|
||||
loaded := SysUser{Password: stored}
|
||||
if err := loaded.Encrypt(); err != nil {
|
||||
t.Fatalf("Encrypt on a loaded user: %v", err)
|
||||
}
|
||||
if loaded.Password != stored {
|
||||
t.Error("Encrypt re-hashed a stored hash; the user can no longer log in")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(loaded.Password), []byte(knownPassword)); err != nil {
|
||||
t.Errorf("the user can no longer log in with their password: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half: a password that is not a hash still gets hashed, on create
|
||||
// and on update alike.
|
||||
func TestEncryptHashesAPlaintextPassword(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
hook func(*SysUser) error
|
||||
}{
|
||||
{"BeforeCreate", func(u *SysUser) error { return u.BeforeCreate(nil) }},
|
||||
{"BeforeUpdate", func(u *SysUser) error { return u.BeforeUpdate(nil) }},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
u := SysUser{Password: knownPassword}
|
||||
if err := c.hook(&u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.Password == knownPassword {
|
||||
t.Fatal("the password was stored as it was typed")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(knownPassword)); err != nil {
|
||||
t.Errorf("the stored value does not verify the password: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An empty Password means "not being set", and must not become a hash of "".
|
||||
func TestEncryptIgnoresAnEmptyPassword(t *testing.T) {
|
||||
u := SysUser{}
|
||||
if err := u.Encrypt(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.Password != "" {
|
||||
t.Errorf("an unset password became %q", u.Password)
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt runs on every update of this model, including the ones that change
|
||||
// something else entirely. What it costs when there is nothing to do is the
|
||||
// difference between a profile update and a bcrypt round; the correctness test
|
||||
// above is what catches a regression, this reports the size of it.
|
||||
func BenchmarkEncrypt(b *testing.B) {
|
||||
fresh := SysUser{Password: knownPassword}
|
||||
if err := fresh.Encrypt(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
b.Run("already hashed", func(b *testing.B) {
|
||||
u := SysUser{Password: fresh.Password}
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := u.Encrypt(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("plaintext", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
u := SysUser{Password: knownPassword}
|
||||
if err := u.Encrypt(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -25,11 +25,9 @@ func InitRouter() {
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
// the jwt middleware
|
||||
authMiddleware, err := common.AuthInit()
|
||||
if err != nil {
|
||||
log.Fatalf("JWT Init Error, %s", err.Error())
|
||||
}
|
||||
// the jwt middleware: shared instance InitMiddleware built at startup,
|
||||
// not one built here per module (see common/middleware.GetAuthMiddleware).
|
||||
authMiddleware := common.GetAuthMiddleware()
|
||||
|
||||
// 注册系统路由
|
||||
InitSysRouter(r, authMiddleware)
|
||||
|
||||
@@ -5,12 +5,17 @@ import (
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/common/dto"
|
||||
"go-admin/common/global"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Deprecated: use global.OperaStatusEnabled / global.OperaStatusDisabled.
|
||||
// These two names are kept - misspelling and all - because forks import them;
|
||||
// the values moved to common/global so common/middleware no longer has to
|
||||
// import this package. See docs/contract.md.
|
||||
const (
|
||||
OperaStatusEnabel = "1" // 状态-正常
|
||||
OperaStatusDisable = "2" // 状态-关闭
|
||||
OperaStatusEnabel = global.OperaStatusEnabled // 状态-正常
|
||||
OperaStatusDisable = global.OperaStatusDisabled // 状态-关闭
|
||||
)
|
||||
|
||||
type SysOperaLogGetPageReq struct {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/common/global"
|
||||
)
|
||||
|
||||
// The values moved to common/global so common/middleware would stop importing
|
||||
// this package; these two names stayed behind as aliases, misspelling and all,
|
||||
// because forks import them.
|
||||
//
|
||||
// If they ever drift apart, rows written through the two spellings land in
|
||||
// different buckets and the operation-log filter silently misses half of them.
|
||||
func TestDeprecatedStatusAliasesStillMatch(t *testing.T) {
|
||||
if OperaStatusEnabel != global.OperaStatusEnabled {
|
||||
t.Errorf("OperaStatusEnabel = %q, global.OperaStatusEnabled = %q",
|
||||
OperaStatusEnabel, global.OperaStatusEnabled)
|
||||
}
|
||||
if OperaStatusDisable != global.OperaStatusDisabled {
|
||||
t.Errorf("OperaStatusDisable = %q, global.OperaStatusDisabled = %q",
|
||||
OperaStatusDisable, global.OperaStatusDisabled)
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ type SysRoleInsertReq struct {
|
||||
Flag string `form:"flag" comment:"标记"` // 标记
|
||||
Remark string `form:"remark" comment:"备注"` // 备注
|
||||
Admin bool `form:"admin" comment:"是否管理员"`
|
||||
DataScope string `form:"dataScope"`
|
||||
DataScope string `form:"dataScope" vd:"$=='1'||$=='2'||$=='3'||$=='4'||$=='5'"` // must be one of actions.DataScope{All,Custom,Dept,DeptTree,Self}; PRD 006 F14/H2
|
||||
SysMenu []models.SysMenu `form:"sysMenu"`
|
||||
MenuIds []int `form:"menuIds"`
|
||||
SysDept []models.SysDept `form:"sysDept"`
|
||||
@@ -79,7 +79,7 @@ type SysRoleUpdateReq struct {
|
||||
Flag string `form:"flag" comment:"标记"` // 标记
|
||||
Remark string `form:"remark" comment:"备注"` // 备注
|
||||
Admin bool `form:"admin" comment:"是否管理员"`
|
||||
DataScope string `form:"dataScope"`
|
||||
DataScope string `form:"dataScope" vd:"$=='1'||$=='2'||$=='3'||$=='4'||$=='5'"` // must be one of actions.DataScope{All,Custom,Dept,DeptTree,Self}; PRD 006 F14/H2
|
||||
SysMenu []models.SysMenu `form:"sysMenu"`
|
||||
MenuIds []int `form:"menuIds"`
|
||||
SysDept []models.SysDept `form:"sysDept"`
|
||||
@@ -147,7 +147,7 @@ func (s *SysRoleDeleteReq) GetId() interface{} {
|
||||
// RoleDataScopeReq 角色数据权限修改
|
||||
type RoleDataScopeReq struct {
|
||||
RoleId int `json:"roleId" binding:"required"`
|
||||
DataScope string `json:"dataScope" binding:"required"`
|
||||
DataScope string `json:"dataScope" binding:"required" vd:"$=='1'||$=='2'||$=='3'||$=='4'||$=='5'"` // must be one of actions.DataScope{All,Custom,Dept,DeptTree,Self}; PRD 006 F14/H2
|
||||
DeptIds []int `json:"deptIds"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
vd "github.com/bytedance/go-tagexpr/v2/validator"
|
||||
)
|
||||
|
||||
// api.Bind calls vd.Validate unconditionally on every request, regardless of
|
||||
// which binding stage ran, so a vd tag on DataScope is enough to reject
|
||||
// anything actions.Permission's fail-closed default would otherwise have to
|
||||
// deal with. PRD 006 F14/H2 named this the real trigger for the default
|
||||
// branch: SysRoleInsertReq.DataScope had no validation at all, so leaving
|
||||
// dataScope out of a create-role request wrote an empty string straight to
|
||||
// sys_role.
|
||||
func TestDataScopeRejectsWhatPermissionCannotRecognize(t *testing.T) {
|
||||
invalid := []string{"", "0", "6", "all", " 1", "1 "}
|
||||
valid := []string{"1", "2", "3", "4", "5"}
|
||||
|
||||
t.Run("SysRoleInsertReq", func(t *testing.T) {
|
||||
for _, s := range invalid {
|
||||
req := SysRoleInsertReq{RoleName: "r", RoleKey: "r", DataScope: s}
|
||||
if err := vd.Validate(&req); err == nil {
|
||||
t.Errorf("DataScope %q was accepted, want rejected", s)
|
||||
}
|
||||
}
|
||||
for _, s := range valid {
|
||||
req := SysRoleInsertReq{RoleName: "r", RoleKey: "r", DataScope: s}
|
||||
if err := vd.Validate(&req); err != nil {
|
||||
t.Errorf("DataScope %q was rejected: %v", s, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SysRoleUpdateReq", func(t *testing.T) {
|
||||
for _, s := range invalid {
|
||||
req := SysRoleUpdateReq{RoleName: "r", RoleKey: "r", DataScope: s}
|
||||
if err := vd.Validate(&req); err == nil {
|
||||
t.Errorf("DataScope %q was accepted, want rejected", s)
|
||||
}
|
||||
}
|
||||
for _, s := range valid {
|
||||
req := SysRoleUpdateReq{RoleName: "r", RoleKey: "r", DataScope: s}
|
||||
if err := vd.Validate(&req); err != nil {
|
||||
t.Errorf("DataScope %q was rejected: %v", s, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RoleDataScopeReq", func(t *testing.T) {
|
||||
for _, s := range invalid {
|
||||
req := RoleDataScopeReq{RoleId: 1, DataScope: s}
|
||||
if err := vd.Validate(&req); err == nil {
|
||||
t.Errorf("DataScope %q was accepted, want rejected", s)
|
||||
}
|
||||
}
|
||||
for _, s := range valid {
|
||||
req := RoleDataScopeReq{RoleId: 1, DataScope: s}
|
||||
if err := vd.Validate(&req); err != nil {
|
||||
t.Errorf("DataScope %q was rejected: %v", s, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
)
|
||||
|
||||
// adminSeeder is go-admin's own implementation of seed.Seeder: it turns the
|
||||
// MenuSpec/ApiSpec values a third-party application asks for into rows
|
||||
// across the four tables a visible, working menu entry needs - sys_api,
|
||||
// sys_menu, sys_menu_api_rule, and sys_role_menu/casbin_rule - following the
|
||||
// same shape cmd/migrate/migration/version/1786700001000_demo_menu.go
|
||||
// already hand-writes for the host's own demo module.
|
||||
//
|
||||
// See go-admin-core's docs/contract.md, "Application-supplied menu and API
|
||||
// entries", for the requirements this satisfies, and the security note on
|
||||
// seed.Seeder for what this boundary does and does not protect against: an
|
||||
// application already holds the same *gorm.DB this receives and could write
|
||||
// sys_menu/sys_api/casbin_rule directly, bypassing this entirely.
|
||||
type adminSeeder struct{}
|
||||
|
||||
func init() {
|
||||
seed.RegisterSeeder(adminSeeder{})
|
||||
}
|
||||
|
||||
// adminRoleKey is the role every seeded menu is granted to. This mirrors
|
||||
// 1786700001000_demo_menu.go's own convention rather than inventing a
|
||||
// second one: MenuSpec carries no "which roles should see this" field for a
|
||||
// Seeder to consult instead, and admin is the one role guaranteed to exist
|
||||
// once the framework's own seed data has run.
|
||||
const adminRoleKey = "admin"
|
||||
|
||||
// menuSortRange is what sys_menu.sort's column type actually holds.
|
||||
//
|
||||
// sort is `gorm:"size:4"`, which MySQL builds as a tinyint (-128..127);
|
||||
// sqlite ignores the width and accepts anything, so this only ever surfaces
|
||||
// on a real install, mid-migration, as Error 1264 - by which point the
|
||||
// migration has already run other, non-transactional DDL that will not be
|
||||
// retried. tools/checksilent's menu-sort-overflow check catches this for
|
||||
// every MenuSpec-shaped literal committed to this repository, but it walks
|
||||
// the repository's own source tree: a third-party application living in the
|
||||
// module cache is invisible to it. This is the equivalent check for that
|
||||
// application, run when its migration actually calls SeedMenus rather than
|
||||
// never.
|
||||
const (
|
||||
menuSortMin = -128
|
||||
menuSortMax = 127
|
||||
)
|
||||
|
||||
func (adminSeeder) SeedMenus(tx *gorm.DB, appCode string, menus []seed.MenuSpec, apis []seed.ApiSpec) error {
|
||||
apiRows, err := seedApis(tx, appCode, apis)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed: app %q: apis: %w", appCode, err)
|
||||
}
|
||||
|
||||
menuIDs, err := seedMenuTree(tx, appCode, menus, apiRows)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed: app %q: menus: %w", appCode, err)
|
||||
}
|
||||
|
||||
// Not `len(menuIDs) == 0`: grantToAdminRole grants two independent
|
||||
// things, and an application is free to register apis without menus -
|
||||
// endpoints another service calls, or a UI mounted somewhere else.
|
||||
// Skipping the whole call on an empty menu list wrote the sys_api rows
|
||||
// and then no casbin rule for them, so those endpoints were denied to
|
||||
// everyone, admin included, with a migration that reported success.
|
||||
if len(menuIDs) == 0 && len(apiRows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return grantToAdminRole(tx, menuIDs, apiRows)
|
||||
}
|
||||
|
||||
// seedApis writes one sys_api row per ApiSpec and returns them keyed by
|
||||
// ApiSpec.Code, so seedMenuTree can resolve a MenuSpec's ApiCodes into the
|
||||
// rows sys_menu_api_rule needs to reference.
|
||||
//
|
||||
// sys_api.id is left to autoincrement rather than assigned by the caller,
|
||||
// unlike 1786700001000_demo_menu.go's hand-picked ids: that migration is
|
||||
// the one file tools/checksilent's menu-id-collision check can see, because
|
||||
// it lives in this repository; nothing plays that role for a third-party
|
||||
// application's ids in the module cache. Never accepting a caller-chosen id
|
||||
// here removes the collision this Seeder has no way to detect instead of
|
||||
// trying to detect it after the fact.
|
||||
func seedApis(tx *gorm.DB, appCode string, apis []seed.ApiSpec) (map[string]models.SysApi, error) {
|
||||
seen := make(map[string]bool, len(apis))
|
||||
rows := make(map[string]models.SysApi, len(apis))
|
||||
for _, a := range apis {
|
||||
if a.Code == "" {
|
||||
return nil, errors.New("ApiSpec.Code must not be empty")
|
||||
}
|
||||
if seen[a.Code] {
|
||||
return nil, fmt.Errorf("duplicate ApiSpec.Code %q", a.Code)
|
||||
}
|
||||
seen[a.Code] = true
|
||||
|
||||
row := models.SysApi{
|
||||
Handle: a.Handle,
|
||||
Title: a.Title,
|
||||
Path: a.Path,
|
||||
Action: a.Method,
|
||||
Type: "SYS",
|
||||
AppCode: appCode,
|
||||
}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return nil, fmt.Errorf("api %q: %w", a.Code, err)
|
||||
}
|
||||
rows[a.Code] = row
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// seedMenuTree writes one sys_menu row per MenuSpec, resolving Parent/Code
|
||||
// references into parent_id/paths, and returns every menu id created so the
|
||||
// caller can grant them to a role.
|
||||
//
|
||||
// Specs do not have to be given in parent-before-child order: this makes
|
||||
// repeated passes over the remaining specs, creating whichever ones have
|
||||
// their Parent (if any) already created, until every spec is placed. A
|
||||
// spec whose Parent never resolves - naming a Code missing from this call,
|
||||
// or only reachable through a cycle - stops making progress and is reported
|
||||
// rather than looping forever.
|
||||
func seedMenuTree(tx *gorm.DB, appCode string, specs []seed.MenuSpec, apiRows map[string]models.SysApi) ([]int, error) {
|
||||
byCode := make(map[string]seed.MenuSpec, len(specs))
|
||||
for _, s := range specs {
|
||||
if s.Code == "" {
|
||||
return nil, errors.New("MenuSpec.Code must not be empty")
|
||||
}
|
||||
if _, dup := byCode[s.Code]; dup {
|
||||
return nil, fmt.Errorf("duplicate MenuSpec.Code %q", s.Code)
|
||||
}
|
||||
if err := validateMenuSpec(s); err != nil {
|
||||
return nil, fmt.Errorf("%q: %w", s.Code, err)
|
||||
}
|
||||
byCode[s.Code] = s
|
||||
}
|
||||
|
||||
created := make(map[string]models.SysMenu, len(specs))
|
||||
ids := make([]int, 0, len(specs))
|
||||
|
||||
for len(created) < len(specs) {
|
||||
progressed := false
|
||||
for _, s := range specs {
|
||||
if _, done := created[s.Code]; done {
|
||||
continue
|
||||
}
|
||||
|
||||
var parentRow models.SysMenu
|
||||
if s.Parent != "" {
|
||||
parent, ok := created[s.Parent]
|
||||
if !ok {
|
||||
if _, exists := byCode[s.Parent]; !exists {
|
||||
return nil, fmt.Errorf("%q: Parent %q is not a Code in this call", s.Code, s.Parent)
|
||||
}
|
||||
continue // s.Parent exists but has not been created yet; retry next pass
|
||||
}
|
||||
parentRow = parent
|
||||
}
|
||||
|
||||
row := models.SysMenu{
|
||||
MenuName: menuName(appCode, s.Code),
|
||||
Title: s.Title,
|
||||
Icon: s.Icon,
|
||||
Path: s.Path,
|
||||
MenuType: s.Kind,
|
||||
Permission: s.Permission,
|
||||
ParentId: parentRow.MenuId,
|
||||
Component: s.Component,
|
||||
Sort: s.Sort,
|
||||
// Visible "0" is shown, not hidden - the same defaults
|
||||
// 1786700001000_demo_menu.go seeds its own menu with. A
|
||||
// freshly installed application's menu should not need an
|
||||
// administrator to first find and unhide it.
|
||||
Visible: "0",
|
||||
IsFrame: "1",
|
||||
AppCode: appCode,
|
||||
}
|
||||
for _, code := range s.ApiCodes {
|
||||
api, ok := apiRows[code]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%q: ApiCodes references %q, which is not an ApiSpec.Code in this call", s.Code, code)
|
||||
}
|
||||
// The full row, not just {Id: api.Id}: gorm's many2many
|
||||
// association save upserts an associated row whose primary
|
||||
// key is already set, so a stub carrying only Id would
|
||||
// overwrite every other column of an sys_api row this same
|
||||
// call just wrote with zero values.
|
||||
row.SysApi = append(row.SysApi, api)
|
||||
}
|
||||
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return nil, fmt.Errorf("%q: %w", s.Code, err)
|
||||
}
|
||||
|
||||
// paths is a materialized path from the root ("/0"), built from
|
||||
// ids that only exist once the row above is created - the same
|
||||
// two-step create-then-update 1786700001000_demo_menu.go's
|
||||
// hand-assigned ids let it do in one literal, sequenced here
|
||||
// instead.
|
||||
if s.Parent == "" {
|
||||
row.Paths = "/0/" + strconv.Itoa(row.MenuId)
|
||||
} else {
|
||||
row.Paths = parentRow.Paths + "/" + strconv.Itoa(row.MenuId)
|
||||
}
|
||||
if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", row.MenuId).
|
||||
Update("paths", row.Paths).Error; err != nil {
|
||||
return nil, fmt.Errorf("%q: writing paths: %w", s.Code, err)
|
||||
}
|
||||
|
||||
created[s.Code] = row
|
||||
ids = append(ids, row.MenuId)
|
||||
progressed = true
|
||||
}
|
||||
if !progressed {
|
||||
return nil, fmt.Errorf("unresolved Parent reference(s) among %d remaining spec(s); check for a cycle", len(specs)-len(created))
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// validateMenuSpec rejects the malformed input tools/checksilent's
|
||||
// menu-sort-overflow and Kind-adjacent checks would catch for an in-tree
|
||||
// seed but cannot for a third-party application's - see menuSortRange's doc
|
||||
// comment.
|
||||
func validateMenuSpec(s seed.MenuSpec) error {
|
||||
switch s.Kind {
|
||||
case contractmodels.Directory, contractmodels.Menu, contractmodels.Button:
|
||||
default:
|
||||
return fmt.Errorf("Kind %q is not one of Directory/Menu/Button", s.Kind)
|
||||
}
|
||||
if s.Sort < menuSortMin || s.Sort > menuSortMax {
|
||||
return fmt.Errorf("Sort %d does not fit sys_menu.sort's tinyint column (%d..%d)", s.Sort, menuSortMin, menuSortMax)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// menuName synthesizes sys_menu.menu_name from appCode and the spec's Code,
|
||||
// since MenuSpec carries no field of its own for it - contract/seed's
|
||||
// package doc says a MenuSpec is what rendering a menu and checking a
|
||||
// button permission need, not a mirror of sys_menu's columns.
|
||||
//
|
||||
// PascalCasing both and concatenating them, rather than using Code alone,
|
||||
// is what keeps two applications that both picked the plain word "list" as
|
||||
// a Code from producing the identical menu_name: the frontend's keep-alive
|
||||
// cache matches a route by this exact string, not by (appCode, Code), so a
|
||||
// collision there is a UI bug, not a database error, and nothing else here
|
||||
// would ever surface it.
|
||||
func menuName(appCode, code string) string {
|
||||
return pascalCase(appCode) + pascalCase(code)
|
||||
}
|
||||
|
||||
func pascalCase(s string) string {
|
||||
var b strings.Builder
|
||||
for _, part := range strings.FieldsFunc(s, func(r rune) bool { return r == '-' || r == '_' }) {
|
||||
b.WriteString(strings.ToUpper(part[:1]))
|
||||
b.WriteString(part[1:])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// grantToAdminRole is sys_role_menu and casbin_rule: the two tables
|
||||
// go-admin-core's contract.md requires alongside sys_menu/sys_api, without
|
||||
// which a seeded menu is invisible to every role and its apis are
|
||||
// authorized for no one.
|
||||
//
|
||||
// It follows 1786700001000_demo_menu.go's exact pattern, including
|
||||
// tolerating a missing admin role: a database that has not yet run the
|
||||
// framework's own seed data (config/db.sql, inside 1599190683659_tables.go)
|
||||
// has nothing to grant to yet, and namespacedKey's ordering guarantee - every
|
||||
// framework migration sorts before every app-prefixed one - means that
|
||||
// should not happen in practice, but failing this call over it would be
|
||||
// worse than a menu with no grant yet.
|
||||
func grantToAdminRole(tx *gorm.DB, menuIDs []int, apiRows map[string]models.SysApi) error {
|
||||
var role models.SysRole
|
||||
if err := tx.Where("role_key = ?", adminRoleKey).First(&role).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range menuIDs {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range apiRows {
|
||||
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 nil
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
)
|
||||
|
||||
// newSeedTestDB builds the tables adminSeeder.SeedMenus writes to. sys_menu,
|
||||
// sys_api, sys_role and sys_role_menu (GORM's own join table for
|
||||
// SysRole.SysMenu) come from AutoMigrate; casbin_rule does not have a GORM
|
||||
// model anywhere in this codebase - see 1786700001000_demo_menu.go's own
|
||||
// comment on why models.CasbinRule (-> sys_casbin_rule) is the wrong table -
|
||||
// so it is created directly, matching the columns grantToAdminRole's INSERT
|
||||
// addresses.
|
||||
func newSeedTestDB(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(&models.SysMenu{}, &models.SysApi{}, &models.SysRole{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE casbin_rule (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ptype TEXT, v0 TEXT, v1 TEXT, v2 TEXT, v3 TEXT, v4 TEXT, v5 TEXT
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create casbin_rule: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func seedAdminRole(t *testing.T, db *gorm.DB) models.SysRole {
|
||||
t.Helper()
|
||||
role := models.SysRole{RoleName: "Administrator", RoleKey: adminRoleKey}
|
||||
if err := db.Create(&role).Error; err != nil {
|
||||
t.Fatalf("seed admin role: %v", err)
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
// This is the acceptance case go-admin-core's docs/contract.md requires: one
|
||||
// SeedMenus call populates all four tables a visible, working menu entry
|
||||
// needs, every row tagged with the appCode it was called with, and the
|
||||
// parent/child tree resolved into sys_menu's parent_id/paths.
|
||||
func TestSeedMenusPopulatesAllFourTables(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
seedAdminRole(t, db)
|
||||
|
||||
menus := []seed.MenuSpec{
|
||||
{Code: "dir", Kind: contractmodels.Directory, Title: "Order Example", Path: "/apps/order", Component: "Layout", Sort: 10},
|
||||
{Code: "list", Parent: "dir", Kind: contractmodels.Menu, Title: "Orders", Path: "list", Component: "apps/order/order/index", Sort: 1, ApiCodes: []string{"list"}},
|
||||
{Code: "btn-create", Parent: "list", Kind: contractmodels.Button, Title: "Create", Permission: "order:order:create", Sort: 1},
|
||||
}
|
||||
apis := []seed.ApiSpec{
|
||||
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SeedMenus: %v", err)
|
||||
}
|
||||
|
||||
var apiRows []models.SysApi
|
||||
if err := db.Find(&apiRows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(apiRows) != 1 || apiRows[0].AppCode != "order" || apiRows[0].Path != "/api/v1/order" {
|
||||
t.Fatalf("sys_api = %+v", apiRows)
|
||||
}
|
||||
|
||||
var menuRows []models.SysMenu
|
||||
if err := db.Order("sort").Find(&menuRows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(menuRows) != 3 {
|
||||
t.Fatalf("sys_menu has %d rows, want 3: %+v", len(menuRows), menuRows)
|
||||
}
|
||||
byName := map[string]models.SysMenu{}
|
||||
for _, m := range menuRows {
|
||||
if m.AppCode != "order" {
|
||||
t.Errorf("menu %q app_code = %q, want order", m.MenuName, m.AppCode)
|
||||
}
|
||||
byName[m.MenuName] = m
|
||||
}
|
||||
dir, ok := byName[menuName("order", "dir")]
|
||||
if !ok || dir.ParentId != 0 || dir.Paths != "/0/"+strconv.Itoa(dir.MenuId) {
|
||||
t.Fatalf("dir menu = %+v", dir)
|
||||
}
|
||||
list, ok := byName[menuName("order", "list")]
|
||||
if !ok || list.ParentId != dir.MenuId || list.Paths != dir.Paths+"/"+strconv.Itoa(list.MenuId) {
|
||||
t.Fatalf("list menu = %+v (dir=%+v)", list, dir)
|
||||
}
|
||||
btn, ok := byName[menuName("order", "btn-create")]
|
||||
if !ok || btn.ParentId != list.MenuId {
|
||||
t.Fatalf("btn menu = %+v (list=%+v)", btn, list)
|
||||
}
|
||||
|
||||
// sys_menu_api_rule: gorm's own many2many join table for SysMenu.SysApi.
|
||||
var joinCount int64
|
||||
if err := db.Table("sys_menu_api_rule").
|
||||
Where("sys_menu_menu_id = ? AND sys_api_id = ?", list.MenuId, apiRows[0].Id).
|
||||
Count(&joinCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if joinCount != 1 {
|
||||
t.Errorf("sys_menu_api_rule has %d row(s) linking list to its api, want 1", joinCount)
|
||||
}
|
||||
|
||||
// sys_role_menu: every seeded menu granted to the admin role.
|
||||
var roleMenuCount int64
|
||||
if err := db.Table("sys_role_menu").Count(&roleMenuCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if roleMenuCount != 3 {
|
||||
t.Errorf("sys_role_menu has %d row(s), want 3 (one per seeded menu)", roleMenuCount)
|
||||
}
|
||||
|
||||
// casbin_rule: the api's path/method granted to the admin role.
|
||||
var casbinCount int64
|
||||
if err := db.Table("casbin_rule").
|
||||
Where("ptype = 'p' AND v0 = ? AND v1 = ? AND v2 = ?", adminRoleKey, "/api/v1/order", "GET").
|
||||
Count(&casbinCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if casbinCount != 1 {
|
||||
t.Errorf("casbin_rule has %d matching row(s), want 1", casbinCount)
|
||||
}
|
||||
}
|
||||
|
||||
// A database that has not run the framework's own seed data yet (no admin
|
||||
// role) must not fail SeedMenus - 1786700001000_demo_menu.go tolerates
|
||||
// exactly the same condition for the host's own demo module.
|
||||
func TestSeedMenusToleratesMissingAdminRole(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return adminSeeder{}.SeedMenus(tx, "order", []seed.MenuSpec{
|
||||
{Code: "dir", Kind: contractmodels.Directory, Title: "Order"},
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SeedMenus: %v", err)
|
||||
}
|
||||
|
||||
var roleMenuCount int64
|
||||
if err := db.Table("sys_role_menu").Count(&roleMenuCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if roleMenuCount != 0 {
|
||||
t.Errorf("sys_role_menu has %d row(s) with no role to grant to", roleMenuCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedMenusRejectsMalformedSpecs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
menus []seed.MenuSpec
|
||||
apis []seed.ApiSpec
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "duplicate menu code",
|
||||
menus: []seed.MenuSpec{{Code: "a", Kind: contractmodels.Directory}, {Code: "a", Kind: contractmodels.Directory}},
|
||||
want: `duplicate MenuSpec.Code "a"`,
|
||||
},
|
||||
{
|
||||
name: "unresolved parent",
|
||||
menus: []seed.MenuSpec{{Code: "a", Parent: "missing", Kind: contractmodels.Menu}},
|
||||
want: `Parent "missing" is not a Code in this call`,
|
||||
},
|
||||
{
|
||||
name: "unresolved api code",
|
||||
menus: []seed.MenuSpec{{Code: "a", Kind: contractmodels.Menu, ApiCodes: []string{"missing"}}},
|
||||
want: `ApiCodes references "missing"`,
|
||||
},
|
||||
{
|
||||
name: "unknown kind",
|
||||
menus: []seed.MenuSpec{{Code: "a", Kind: "X"}},
|
||||
want: `Kind "X" is not one of Directory/Menu/Button`,
|
||||
},
|
||||
{
|
||||
name: "sort overflows a tinyint",
|
||||
menus: []seed.MenuSpec{{Code: "a", Kind: contractmodels.Directory, Sort: 900}},
|
||||
want: `Sort 900 does not fit sys_menu.sort's tinyint column`,
|
||||
},
|
||||
{
|
||||
name: "duplicate api code",
|
||||
apis: []seed.ApiSpec{{Code: "x"}, {Code: "x"}},
|
||||
want: `duplicate ApiSpec.Code "x"`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return adminSeeder{}.SeedMenus(tx, "order", tc.menus, tc.apis)
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want it to contain %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeederIsRegistered pins the registration itself, not the behaviour.
|
||||
//
|
||||
// Every other test here calls adminSeeder{}.SeedMenus directly, which proves
|
||||
// the implementation is right and proves nothing about whether anything ever
|
||||
// reaches it: delete the RegisterSeeder call in init() and they all stay
|
||||
// green, while a real migrate fails with ErrNoSeeder and no menu is written.
|
||||
// Going through the package-level SeedMenus is what closes that gap - it is
|
||||
// the door an application actually knocks on.
|
||||
func TestSeederIsRegistered(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return seed.SeedMenus(tx, "probe", []seed.MenuSpec{{
|
||||
Code: "root", Kind: contractmodels.Directory, Title: "Probe", Sort: 1,
|
||||
}}, nil)
|
||||
})
|
||||
if errors.Is(err, seed.ErrNoSeeder) {
|
||||
t.Fatal("no Seeder is registered: an application's SeedMenus would write no menu at all")
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("SeedMenus through the package-level entry point: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An application is free to register apis with no menus at all - endpoints
|
||||
// another service calls, or a UI mounted somewhere else. Skipping
|
||||
// grantToAdminRole on an empty menu list wrote the sys_api rows and then no
|
||||
// casbin rule for them, so every one of those endpoints was denied to
|
||||
// everyone including admin, from a migration that reported success.
|
||||
func TestSeedMenusGrantsApisWhenThereAreNoMenus(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
role := seedAdminRole(t, db)
|
||||
|
||||
apis := []seed.ApiSpec{
|
||||
{Code: "hook", Title: "Inbound hook", Path: "/api/v1/hook", Method: "POST", Handle: "hook.Receive"},
|
||||
{Code: "sync", Title: "Sync", Path: "/api/v1/sync", Method: "GET", Handle: "hook.Sync"},
|
||||
}
|
||||
if err := (adminSeeder{}).SeedMenus(db, "hooks", nil, apis); err != nil {
|
||||
t.Fatalf("SeedMenus: %v", err)
|
||||
}
|
||||
|
||||
var apiCount int64
|
||||
db.Model(&models.SysApi{}).Where("app_code = ?", "hooks").Count(&apiCount)
|
||||
if apiCount != int64(len(apis)) {
|
||||
t.Fatalf("sys_api rows = %d, want %d", apiCount, len(apis))
|
||||
}
|
||||
|
||||
for _, a := range apis {
|
||||
var n int64
|
||||
db.Table("casbin_rule").
|
||||
Where("ptype = 'p' AND v0 = ? AND v1 = ? AND v2 = ?", role.RoleKey, a.Path, a.Method).
|
||||
Count(&n)
|
||||
if n != 1 {
|
||||
t.Errorf("casbin_rule for %s %s = %d rows, want 1: the endpoint is denied to admin", a.Method, a.Path, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same guard: nothing registered at all must stay a
|
||||
// no-op rather than start touching sys_role_menu or casbin_rule.
|
||||
func TestSeedMenusWithNothingRegisteredWritesNothing(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
seedAdminRole(t, db)
|
||||
|
||||
if err := (adminSeeder{}).SeedMenus(db, "empty", nil, nil); err != nil {
|
||||
t.Fatalf("SeedMenus: %v", err)
|
||||
}
|
||||
for _, table := range []string{"casbin_rule", "sys_role_menu"} {
|
||||
var n int64
|
||||
db.Table(table).Count(&n)
|
||||
if n != 0 {
|
||||
t.Errorf("%s has %d rows, want 0", table, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service/dto"
|
||||
"go-admin/common/actions"
|
||||
@@ -74,9 +76,18 @@ func (e *SysApi) Get(d *dto.SysApiGetReq, p *actions.DataPermission, model *mode
|
||||
// Update 修改SysApi对象
|
||||
func (e *SysApi) Update(c *dto.SysApiUpdateReq, p *actions.DataPermission) error {
|
||||
var model = models.SysApi{}
|
||||
db := e.Orm.Debug().First(&model, c.GetId())
|
||||
if db.RowsAffected == 0 {
|
||||
return errors.New("无权更新该数据")
|
||||
db := e.Orm.Scopes(
|
||||
actions.Permission(model.TableName(), p),
|
||||
).First(&model, c.GetId())
|
||||
if err := db.Error; err != nil {
|
||||
// First reports a row the data permission excluded exactly as it
|
||||
// reports one that does not exist, and the caller should not be able
|
||||
// to tell those apart either.
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("无权更新该数据")
|
||||
}
|
||||
e.Log.Errorf("Service UpdateSysApi error:%s", err)
|
||||
return err
|
||||
}
|
||||
c.Generate(&model)
|
||||
db = e.Orm.Save(&model)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/service"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service/dto"
|
||||
"go-admin/common/actions"
|
||||
)
|
||||
|
||||
// An update the data permission excludes has to be refused, and refused in a
|
||||
// way that does not tell the caller whether the row exists. First reports both
|
||||
// cases the same way - no rows - so the message has to come from there rather
|
||||
// than from a RowsAffected check the error return has already skipped past.
|
||||
func TestSysApiUpdateRefusesARowOutsideTheDataPermission(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:sysapi-perm?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Skipf("sqlite unavailable: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SysApi{}); err != nil {
|
||||
t.Skipf("automigrate: %v", err)
|
||||
}
|
||||
|
||||
prev := config.ApplicationConfig.EnableDP
|
||||
config.ApplicationConfig.EnableDP = true
|
||||
t.Cleanup(func() { config.ApplicationConfig.EnableDP = prev })
|
||||
|
||||
// Owned by user 1.
|
||||
row := models.SysApi{Handle: "h", Title: "t", Path: "/api/v1/probe", Type: "BUS", Action: "GET"}
|
||||
row.CreateBy = 1
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
e := &SysApi{Service: service.Service{Orm: db, Log: logger.NewHelper(logger.DefaultLogger)}}
|
||||
req := &dto.SysApiUpdateReq{Id: row.Id, Title: "changed"}
|
||||
|
||||
// User 2, scope 5: only rows they created.
|
||||
outsider := &actions.DataPermission{DataScope: "5", UserId: 2, DeptId: 1, RoleId: 2}
|
||||
err = e.Update(req, outsider)
|
||||
if err == nil {
|
||||
t.Fatal("the update was allowed on a row the data permission excludes")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "无权更新该数据") {
|
||||
t.Errorf("refused with %q, want the permission message; a raw database error tells the "+
|
||||
"caller the row exists", err)
|
||||
}
|
||||
|
||||
var after models.SysApi
|
||||
if err := db.First(&after, row.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Title != "t" {
|
||||
t.Errorf("the row was modified: title is now %q", after.Title)
|
||||
}
|
||||
|
||||
// The owner still gets through, so the scope is refusing rather than
|
||||
// everything failing.
|
||||
owner := &actions.DataPermission{DataScope: "5", UserId: 1, DeptId: 1, RoleId: 1}
|
||||
if err := e.Update(&dto.SysApiUpdateReq{Id: row.Id, Title: "by owner"}, owner); err != nil {
|
||||
t.Fatalf("the owner could not update their own row: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,16 @@ func (e *SysUser) Insert(c *dto.SysUserInsertReq) error {
|
||||
}
|
||||
|
||||
// Update 修改SysUser对象
|
||||
func (e *SysUser) Update(c *dto.SysUserUpdateReq, p *actions.DataPermission) error {
|
||||
//
|
||||
// callerId is who is asking, not who SetUpdateBy recorded - that field only
|
||||
// says who to blame, it never constrained who could be edited. When the
|
||||
// target is the caller themselves, roleId/deptId/status are kept at whatever
|
||||
// the database already has no matter what the request body carries: this is
|
||||
// the personal-center screen's route (see CasbinExclude in settings.go, and
|
||||
// the check in the API handler ahead of this call), and letting a caller
|
||||
// grant themselves a different role or department through it would be a
|
||||
// privilege escalation the exclusion was never meant to open.
|
||||
func (e *SysUser) Update(c *dto.SysUserUpdateReq, p *actions.DataPermission, callerId int) error {
|
||||
var err error
|
||||
var model models.SysUser
|
||||
db := e.Orm.Scopes(
|
||||
@@ -98,6 +107,11 @@ func (e *SysUser) Update(c *dto.SysUserUpdateReq, p *actions.DataPermission) err
|
||||
return errors.New("无权更新该数据")
|
||||
|
||||
}
|
||||
if model.UserId == callerId {
|
||||
c.RoleId = model.RoleId
|
||||
c.DeptId = model.DeptId
|
||||
c.Status = model.Status
|
||||
}
|
||||
c.Generate(&model)
|
||||
update := e.Orm.Model(&model).Where("user_id = ?", &model.UserId).Omit("password", "salt").Updates(&model)
|
||||
if err = update.Error; err != nil {
|
||||
|
||||
@@ -33,11 +33,9 @@ func InitRouter() {
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
// the jwt middleware
|
||||
authMiddleware, err := common.AuthInit()
|
||||
if err != nil {
|
||||
log.Fatalf("JWT Init Error, %s", err.Error())
|
||||
}
|
||||
// the jwt middleware: shared instance InitMiddleware built at startup,
|
||||
// not one built here per module (see common/middleware.GetAuthMiddleware).
|
||||
authMiddleware := common.GetAuthMiddleware()
|
||||
|
||||
// 注册业务路由
|
||||
InitBusinessRouter(r, authMiddleware)
|
||||
|
||||
@@ -26,10 +26,9 @@ func InitRouter() {
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
authMiddleware, err := common.AuthInit()
|
||||
if err != nil {
|
||||
log.Fatalf("JWT Init Error, %s", err.Error())
|
||||
}
|
||||
// the jwt middleware: shared instance InitMiddleware built at startup,
|
||||
// not one built here per module (see common/middleware.GetAuthMiddleware).
|
||||
authMiddleware := common.GetAuthMiddleware()
|
||||
|
||||
// 注册业务路由
|
||||
initRouter(r, authMiddleware)
|
||||
|
||||
@@ -25,10 +25,9 @@ func InitRouter() {
|
||||
os.Exit(-1)
|
||||
}
|
||||
// the jwt middleware
|
||||
authMiddleware, err := common.AuthInit()
|
||||
if err != nil {
|
||||
log.Fatalf("JWT Init Error, %s", err.Error())
|
||||
}
|
||||
// the jwt middleware: shared instance InitMiddleware built at startup,
|
||||
// not one built here per module (see common/middleware.GetAuthMiddleware).
|
||||
authMiddleware := common.GetAuthMiddleware()
|
||||
|
||||
// 注册业务路由
|
||||
// TODO: 这里可存放业务路由,里边并无实际路由只有演示代码
|
||||
|
||||
+77
-13
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -82,13 +83,11 @@ func run() error {
|
||||
}
|
||||
initRouter()
|
||||
|
||||
for _, f := range AppRouters {
|
||||
f()
|
||||
}
|
||||
runStartupHooks()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
|
||||
Handler: sdk.Runtime.GetEngine(),
|
||||
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
|
||||
Handler: sdk.Runtime.GetEngine(),
|
||||
ReadTimeout: time.Duration(config.ApplicationConfig.ReadTimeout) * time.Second,
|
||||
WriteTimeout: time.Duration(config.ApplicationConfig.WriterTimeout) * time.Second,
|
||||
}
|
||||
@@ -116,6 +115,13 @@ func run() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Armed before the server starts serving, and well before the readiness
|
||||
// banner: a signal arriving between "the process is up" and "the process
|
||||
// is listening for signals" reaches the default handler and kills it
|
||||
// without any of the shutdown below. That window is the whole reason
|
||||
// arming is separate from waiting.
|
||||
quit, disarmStopSignals := armStopSignals()
|
||||
|
||||
go func() {
|
||||
// 服务连接
|
||||
if config.SslConfig.Enable {
|
||||
@@ -137,23 +143,81 @@ func run() error {
|
||||
fmt.Printf("- Local: http://localhost:%d/swagger/admin/index.html \r\n", config.ApplicationConfig.Port)
|
||||
fmt.Printf("- Network: %s://%s:%d/swagger/admin/index.html \r\n", "http", pkg.GetLocalHost(), config.ApplicationConfig.Port)
|
||||
fmt.Printf("%s Enter Control + C Shutdown Server \r\n", pkg.GetCurrentTimeStr())
|
||||
// 等待中断信号以优雅地关闭服务器(设置 5 秒的超时时间)
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt)
|
||||
|
||||
<-quit
|
||||
// Restored here, not deferred: from this point a second signal must reach
|
||||
// the default handler, so a shutdown that hangs can still be interrupted.
|
||||
disarmStopSignals()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
log.Info("Shutdown Server ... ")
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatal("Server Shutdown:", err)
|
||||
if err := shutdownServer(srv, shutdownTimeout); err != nil {
|
||||
// Not log.Fatal: that is an unconditional os.Exit(1), and Shutdown
|
||||
// reports an error exactly when connections were still in flight -
|
||||
// which is when the cleanup that follows matters most.
|
||||
log.Error("Server Shutdown: ", err)
|
||||
}
|
||||
log.Info("Server exiting")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// shutdownTimeout is how long Shutdown waits for in-flight requests. It plus
|
||||
// whatever cleanup follows has to stay inside the orchestrator's grace period
|
||||
// - `docker stop` allows 10s by default before it sends SIGKILL.
|
||||
const shutdownTimeout = 5 * time.Second
|
||||
|
||||
// armStopSignals registers for the stop signals and returns the channel they
|
||||
// arrive on together with the function that restores the default disposition.
|
||||
//
|
||||
// SIGTERM is what actually arrives in production: `docker stop`, a Kubernetes
|
||||
// pod deletion and `systemctl stop` all send it, and Go terminates the process
|
||||
// immediately for a signal nobody listens for. Registering only os.Interrupt
|
||||
// meant every graceful shutdown below the wait was dead code outside a
|
||||
// terminal.
|
||||
//
|
||||
// Registering is separate from waiting so a caller can arm before it announces
|
||||
// that it is ready: a signal that arrives between the two is delivered to the
|
||||
// default handler, which for both of these means the process dies without
|
||||
// running any of this.
|
||||
func armStopSignals() (<-chan os.Signal, func()) {
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
return quit, func() { signal.Stop(quit) }
|
||||
}
|
||||
|
||||
// shutdownServer stops srv, giving in-flight requests up to timeout to finish.
|
||||
//
|
||||
// It returns the error instead of exiting on it. A caller that exits here skips
|
||||
// its own cleanup, and Shutdown fails precisely when there was something left
|
||||
// to clean up after.
|
||||
func shutdownServer(srv *http.Server, timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// runStartupHooks runs the router registries and then the before callbacks.
|
||||
//
|
||||
// The package-level slice runs first and in its existing order, so a fork that
|
||||
// only ever appended to AppRouters sees no change at all. The core registry
|
||||
// runs second, through RunAppRouters: a module can register through
|
||||
// sdk.Runtime.SetAppRouters and no longer has to import this command package -
|
||||
// which is a main package's plumbing - just to be routed.
|
||||
//
|
||||
// The loop over the core registry now lives in core, which is what brings the
|
||||
// panic guard and the registration seal with it. RunBefore closes a gap rather
|
||||
// than moving one: the open-source edition never executed the before callbacks
|
||||
// at all, so SetBefore was accepted and silently ignored. It has to stay ahead
|
||||
// of ListenAndServe, because a callback registered WithFatal exits the process
|
||||
// and that must not happen to one that is already serving.
|
||||
func runStartupHooks() {
|
||||
for _, f := range AppRouters {
|
||||
f()
|
||||
}
|
||||
sdk.Runtime.RunAppRouters()
|
||||
sdk.Runtime.RunBefore()
|
||||
}
|
||||
|
||||
//var Router runtime.Router
|
||||
|
||||
func tip() {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
)
|
||||
|
||||
// freshRuntime hands the test its own Runtime and puts the old one back.
|
||||
//
|
||||
// Both registries close permanently the first time they are run, and
|
||||
// sdk.Runtime is a process-wide singleton, so a test that runs the startup
|
||||
// hooks would otherwise leave every later test in this binary registering into
|
||||
// a closed registry - which is only an ERROR log, not a failure. The symptom
|
||||
// is a test that passes alone and loses its routes when run with the others.
|
||||
func freshRuntime(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := sdk.Runtime
|
||||
t.Cleanup(func() { sdk.Runtime = previous })
|
||||
sdk.Runtime = runtime.NewConfig()
|
||||
}
|
||||
|
||||
// Acceptance 1 and 2 together: the package-level slice a fork appends to and
|
||||
// the core registry a module registers through both run, package-level first,
|
||||
// registration order preserved inside each.
|
||||
//
|
||||
// The order matters beyond neatness. A module that appends to AppRouters has to
|
||||
// import go-admin/cmd/api, which is why every module used to need a seven-line
|
||||
// file in the command package; SetAppRouters is the way out of that. Running
|
||||
// the old registry first is what makes the change invisible to anyone who never
|
||||
// takes it.
|
||||
func TestRunStartupHooksRunsBothRegistriesInOrder(t *testing.T) {
|
||||
freshRuntime(t)
|
||||
|
||||
savedPackage := AppRouters
|
||||
t.Cleanup(func() { AppRouters = savedPackage })
|
||||
|
||||
var order []string
|
||||
AppRouters = []func(){
|
||||
func() { order = append(order, "package-1") },
|
||||
func() { order = append(order, "package-2") },
|
||||
}
|
||||
sdk.Runtime.SetAppRouters(func() { order = append(order, "runtime-1") })
|
||||
sdk.Runtime.SetAppRouters(func() { order = append(order, "runtime-2") })
|
||||
|
||||
runStartupHooks()
|
||||
|
||||
const want = "package-1,package-2,runtime-1,runtime-2"
|
||||
if got := strings.Join(order, ","); got != want {
|
||||
t.Errorf("ran %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance 17: a before callback registered through core actually runs.
|
||||
//
|
||||
// It did not, ever: core stored the callbacks and nothing executed them, so
|
||||
// SetBefore was accepted and silently did nothing. The gap survived because
|
||||
// core offered the registry without ever running it, leaving each consumer to
|
||||
// write - or forget - its own loop.
|
||||
func TestBeforeCallbacksRun(t *testing.T) {
|
||||
freshRuntime(t)
|
||||
|
||||
savedPackage := AppRouters
|
||||
t.Cleanup(func() { AppRouters = savedPackage })
|
||||
AppRouters = nil
|
||||
|
||||
var order []string
|
||||
sdk.Runtime.SetBefore(func() { order = append(order, "before-1") })
|
||||
sdk.Runtime.SetBefore(func() { order = append(order, "before-2") })
|
||||
sdk.Runtime.SetAppRouters(func() { order = append(order, "router") })
|
||||
|
||||
runStartupHooks()
|
||||
|
||||
// Routers first, then before: both happen ahead of ListenAndServe, and a
|
||||
// router callback is what puts the engine in place for anything that comes
|
||||
// after it.
|
||||
const want = "router,before-1,before-2"
|
||||
if got := strings.Join(order, ","); got != want {
|
||||
t.Errorf("ran %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A panicking module must not take the server down with it. The guard lives in
|
||||
// core; this asserts that go-admin actually goes through it rather than around
|
||||
// it with a loop of its own.
|
||||
func TestAPanickingRouterDoesNotStopStartup(t *testing.T) {
|
||||
freshRuntime(t)
|
||||
|
||||
savedPackage := AppRouters
|
||||
t.Cleanup(func() { AppRouters = savedPackage })
|
||||
AppRouters = nil
|
||||
|
||||
var order []string
|
||||
sdk.Runtime.SetAppRouters(func() { order = append(order, "first") })
|
||||
sdk.Runtime.SetAppRouters(func() { panic("a third-party module blew up") })
|
||||
sdk.Runtime.SetAppRouters(func() { order = append(order, "third") })
|
||||
sdk.Runtime.SetBefore(func() { order = append(order, "before") })
|
||||
|
||||
runStartupHooks()
|
||||
|
||||
const want = "first,third,before"
|
||||
if got := strings.Join(order, ","); got != want {
|
||||
t.Errorf("ran %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The default AppRouters must keep the admin routes on it. Emptying the slice
|
||||
// would not fail to compile anywhere - it would just serve a server with no
|
||||
// admin API and no error.
|
||||
func TestAdminRouterIsRegisteredOnThePackageSlice(t *testing.T) {
|
||||
if len(AppRouters) == 0 {
|
||||
t.Fatal("AppRouters is empty; the admin router is registered in init()")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The signal path cannot be exercised in-process: delivering a signal to the
|
||||
// test binary would race with the test framework, and the disposition changes
|
||||
// are global. So the test re-executes itself as a child, and the child runs the
|
||||
// same armStopSignals / shutdownServer the server does.
|
||||
//
|
||||
// The child deliberately serves an empty http.Server rather than the real one:
|
||||
// this repository's CI has no database (.github/workflows/go.yml runs neither
|
||||
// MySQL nor a sqlite-tagged build), and none of what is under test needs one.
|
||||
const (
|
||||
childEnv = "GO_ADMIN_SIGNAL_CHILD"
|
||||
childStuckEnv = "GO_ADMIN_SIGNAL_CHILD_STUCK"
|
||||
childHangConn = "GO_ADMIN_SIGNAL_CHILD_HANGCONN"
|
||||
markerReady = "CHILD-READY"
|
||||
markerSignal = "CHILD-SIGNAL"
|
||||
markerShutdown = "CHILD-SHUTDOWN-OK"
|
||||
markerExiting = "CHILD-EXITING"
|
||||
)
|
||||
|
||||
// TestSignalChild is the child process. It is skipped in a normal run.
|
||||
func TestSignalChild(t *testing.T) {
|
||||
if os.Getenv(childEnv) != "1" {
|
||||
t.Skip("child process entry point")
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
fmt.Println("listen:", err)
|
||||
os.Exit(3)
|
||||
}
|
||||
// accepted fires once the server has taken a connection off the listener.
|
||||
// Dialling is not enough: Shutdown only waits for connections the server
|
||||
// has already accepted, so calling it between the dial and the accept
|
||||
// finds nothing to wait for and returns immediately.
|
||||
accepted := make(chan struct{}, 1)
|
||||
srv := &http.Server{
|
||||
Handler: http.NewServeMux(),
|
||||
ConnState: func(_ net.Conn, state http.ConnState) {
|
||||
if state == http.StateNew {
|
||||
select {
|
||||
case accepted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
|
||||
// Arm before announcing readiness. Doing it the other way round leaves a
|
||||
// window in which the parent's signal reaches the default handler and
|
||||
// kills the child before any of this runs - which is exactly the failure
|
||||
// this whole change is about, so the test must not reproduce it by
|
||||
// accident.
|
||||
quit, disarm := armStopSignals()
|
||||
|
||||
fmt.Println(markerReady)
|
||||
os.Stdout.Sync()
|
||||
|
||||
sig := <-quit
|
||||
disarm()
|
||||
fmt.Println(markerSignal, sig)
|
||||
os.Stdout.Sync()
|
||||
|
||||
if os.Getenv(childStuckEnv) == "1" {
|
||||
// Stand in for a cleanup hook that never finishes. The point of
|
||||
// restoring the signal disposition is that a second signal still
|
||||
// reaches the default handler and kills this.
|
||||
time.Sleep(2 * time.Minute)
|
||||
}
|
||||
|
||||
timeout := shutdownTimeout
|
||||
if os.Getenv(childHangConn) == "1" {
|
||||
// Dialled here, not at start-up. net/http stops counting a StateNew
|
||||
// connection against Shutdown once it is more than five seconds old,
|
||||
// so a connection opened before the wait would age out on a slow CI
|
||||
// run and Shutdown would succeed - leaving the test asserting nothing.
|
||||
c, err := net.Dial("tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
fmt.Println("dial:", err)
|
||||
os.Exit(5)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
// And wait for the accept, for the opposite reason: an unaccepted
|
||||
// connection is not one Shutdown waits for either.
|
||||
select {
|
||||
case <-accepted:
|
||||
case <-time.After(10 * time.Second):
|
||||
fmt.Println("the server never accepted the stalling connection")
|
||||
os.Exit(6)
|
||||
}
|
||||
|
||||
// A connection that has sent nothing keeps Shutdown busy: net/http
|
||||
// only treats a StateNew connection as idle once it is more than five
|
||||
// seconds old. A short budget makes the timeout deterministic without
|
||||
// waiting out the real one.
|
||||
timeout = 300 * time.Millisecond
|
||||
}
|
||||
|
||||
if err := shutdownServer(srv, timeout); err != nil {
|
||||
// Deliberately not fatal, and deliberately not a bare return: the
|
||||
// point is that whatever follows still runs.
|
||||
fmt.Println("shutdown error:", err)
|
||||
} else {
|
||||
fmt.Println(markerShutdown)
|
||||
}
|
||||
fmt.Println(markerExiting)
|
||||
os.Stdout.Sync()
|
||||
}
|
||||
|
||||
func startChild(t *testing.T, stuck bool, extraEnv ...string) (*exec.Cmd, *os.File, chan string) {
|
||||
t.Helper()
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe: %v", err)
|
||||
}
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestSignalChild", "-test.v")
|
||||
cmd.Env = append(os.Environ(), childEnv+"=1")
|
||||
if stuck {
|
||||
cmd.Env = append(cmd.Env, childStuckEnv+"=1")
|
||||
}
|
||||
cmd.Env = append(cmd.Env, extraEnv...)
|
||||
cmd.Stdout = w
|
||||
cmd.Stderr = w
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start child: %v", err)
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
lines := make(chan string, 64)
|
||||
go func() {
|
||||
defer close(lines)
|
||||
buf := make([]byte, 4096)
|
||||
var acc strings.Builder
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
acc.Write(buf[:n])
|
||||
for {
|
||||
s := acc.String()
|
||||
i := strings.IndexByte(s, '\n')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
lines <- s[:i]
|
||||
acc.Reset()
|
||||
acc.WriteString(s[i+1:])
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if acc.Len() > 0 {
|
||||
lines <- acc.String()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
_ = r.Close()
|
||||
})
|
||||
return cmd, r, lines
|
||||
}
|
||||
|
||||
// await drains lines until one contains want, or the deadline passes. It
|
||||
// returns everything it saw, so a failure says what the child actually did.
|
||||
func await(t *testing.T, lines chan string, want string, d time.Duration) []string {
|
||||
t.Helper()
|
||||
var seen []string
|
||||
deadline := time.After(d)
|
||||
for {
|
||||
select {
|
||||
case l, ok := <-lines:
|
||||
if !ok {
|
||||
t.Fatalf("child output ended before %q; saw:\n%s", want, strings.Join(seen, "\n"))
|
||||
}
|
||||
seen = append(seen, l)
|
||||
if strings.Contains(l, want) {
|
||||
return seen
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out waiting for %q; saw:\n%s", want, strings.Join(seen, "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance 19. Registering only os.Interrupt meant SIGTERM - the signal
|
||||
// `docker stop`, Kubernetes and systemd all send - terminated the process
|
||||
// before any of the shutdown path ran. Both must now reach it.
|
||||
func TestBothSignalsRunTheShutdownPath(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
sig syscall.Signal
|
||||
}{
|
||||
{"SIGINT", syscall.SIGINT},
|
||||
{"SIGTERM", syscall.SIGTERM},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd, _, lines := startChild(t, false)
|
||||
await(t, lines, markerReady, 30*time.Second)
|
||||
|
||||
if err := cmd.Process.Signal(tc.sig); err != nil {
|
||||
t.Fatalf("signal: %v", err)
|
||||
}
|
||||
|
||||
await(t, lines, markerSignal, 10*time.Second)
|
||||
await(t, lines, markerShutdown, 10*time.Second)
|
||||
await(t, lines, markerExiting, 10*time.Second)
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("child exited with %v, want a clean exit", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance 20. quit is a buffered channel and signal.Notify stays armed, so
|
||||
// without restoring the disposition a second signal only refills the buffer:
|
||||
// once SIGTERM is registered, a shutdown that hangs could not be interrupted by
|
||||
// anything short of SIGKILL.
|
||||
func TestASecondSignalStillKillsAStuckShutdown(t *testing.T) {
|
||||
cmd, _, lines := startChild(t, true)
|
||||
await(t, lines, markerReady, 30*time.Second)
|
||||
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("first signal: %v", err)
|
||||
}
|
||||
await(t, lines, markerSignal, 10*time.Second)
|
||||
|
||||
// The child is now inside a cleanup that will not finish on its own.
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("second signal: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("child exited cleanly; it was supposed to be killed by the second signal")
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("the second signal did not kill a stuck shutdown - the escape hatch is gone")
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance 21. srv.Shutdown reports an error exactly when connections were
|
||||
// still in flight, and the old code answered that with log.Fatal - an
|
||||
// unconditional os.Exit(1). Everything after it, which is where the cleanup
|
||||
// hooks will hang, never ran. A failed Shutdown must not end the process.
|
||||
func TestShutdownTimeoutDoesNotStopWhatFollows(t *testing.T) {
|
||||
cmd, _, lines := startChild(t, false, childHangConn+"=1")
|
||||
await(t, lines, markerReady, 30*time.Second)
|
||||
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("signal: %v", err)
|
||||
}
|
||||
await(t, lines, markerSignal, 10*time.Second)
|
||||
|
||||
seen := await(t, lines, markerExiting, 20*time.Second)
|
||||
|
||||
var timedOut bool
|
||||
for _, l := range seen {
|
||||
if strings.Contains(l, "shutdown error:") {
|
||||
timedOut = true
|
||||
}
|
||||
}
|
||||
if !timedOut {
|
||||
t.Fatalf("Shutdown did not time out, so this test proves nothing; saw:\n%s",
|
||||
strings.Join(seen, "\n"))
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("child exited with %v after a failed Shutdown, want a clean exit", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
)
|
||||
|
||||
// A mistyped --app used to be indistinguishable from an up-to-date database on
|
||||
// all three paths: `migrate` printed that the app was unknown and exited 0,
|
||||
// while `--dry-run` and `status` printed "nothing to apply" and "none
|
||||
// recorded" - the same words a database with nothing pending produces. An
|
||||
// operator scripting `migrate --app crmm && deploy` therefore deployed against
|
||||
// a database the migrations never touched.
|
||||
func TestAppRegistrationErrorRejectsAnUnknownCode(t *testing.T) {
|
||||
restore := appCode
|
||||
t.Cleanup(func() { appCode = restore })
|
||||
|
||||
appCode = "doesnotexist"
|
||||
err := appRegistrationError()
|
||||
if err == nil {
|
||||
t.Fatal("an unregistered app code must be an error, not an empty run")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `"doesnotexist"`) {
|
||||
t.Errorf("the message must quote what was typed; got %q", err)
|
||||
}
|
||||
// Listing what is registered is what turns the error into a fix: the typo
|
||||
// is usually one letter away from something in this list.
|
||||
if !strings.Contains(err.Error(), migration.FrameworkAppCode) {
|
||||
t.Errorf("the message must list the registered codes; got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppRegistrationErrorAcceptsWhatIsRegistered(t *testing.T) {
|
||||
restore := appCode
|
||||
t.Cleanup(func() { appCode = restore })
|
||||
|
||||
for _, code := range []string{
|
||||
"", // no --app at all: every migration runs
|
||||
migration.FrameworkAppCode, // "core", the framework's own
|
||||
strings.ToUpper(migration.FrameworkAppCode), // codes normalize to lower case
|
||||
} {
|
||||
appCode = code
|
||||
if err := appRegistrationError(); err != nil {
|
||||
t.Errorf("appCode %q must be accepted; got %v", code, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+312
-15
@@ -1,21 +1,46 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration"
|
||||
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
var Migrate = &Migration{
|
||||
version: make(map[string]func(db *gorm.DB, version string) error),
|
||||
var Migrate = newMigration()
|
||||
|
||||
// contractSnapshot is contractmigration.Snapshot, indirected through a
|
||||
// package-level variable so tests can substitute an isolated
|
||||
// *contractmigration.Registry's Snapshot instead of reaching into
|
||||
// go-admin-core's single process-wide registry, which every *Migration in
|
||||
// this process - test-local or the package-level Migrate - reads through the
|
||||
// same call. See mergedEntries.
|
||||
var contractSnapshot = contractmigration.Snapshot
|
||||
|
||||
func newMigration() *Migration {
|
||||
return &Migration{version: make(map[string]versionEntry)}
|
||||
}
|
||||
|
||||
// versionEntry is one registered migration plus the app it belongs to. The
|
||||
// empty app code means the framework itself, which is also what the
|
||||
// sys_migration.app_code column defaults to, so history written before this
|
||||
// field existed reads back correctly with no backfill.
|
||||
type versionEntry struct {
|
||||
appCode string
|
||||
fn func(db *gorm.DB, version string) error
|
||||
}
|
||||
|
||||
type Migration struct {
|
||||
db *gorm.DB
|
||||
version map[string]func(db *gorm.DB, version string) error
|
||||
version map[string]versionEntry
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
@@ -27,20 +52,285 @@ func (e *Migration) SetDb(db *gorm.DB) {
|
||||
e.db = db
|
||||
}
|
||||
|
||||
// SetVersion registers a migration owned by the framework. Signature and
|
||||
// behaviour are unchanged: every existing call site in version/*.go keeps
|
||||
// compiling and keeps writing common.Migration{Version: version} with no app
|
||||
// code, which is the correct meaning of "framework".
|
||||
func (e *Migration) SetVersion(k string, f func(db *gorm.DB, version string) error) {
|
||||
e.mutex.Lock()
|
||||
defer e.mutex.Unlock()
|
||||
e.version[k] = f
|
||||
e.setVersion(k, "", f)
|
||||
}
|
||||
|
||||
func (e *Migration) Migrate() {
|
||||
versions := make([]string, 0)
|
||||
for k := range e.version {
|
||||
func (e *Migration) setVersion(k, appCode string, f func(db *gorm.DB, version string) error) {
|
||||
e.mutex.Lock()
|
||||
defer e.mutex.Unlock()
|
||||
e.version[k] = versionEntry{appCode: appCode, fn: f}
|
||||
}
|
||||
|
||||
// AppMigrationFunc is the signature of a migration registered through ForApp.
|
||||
//
|
||||
// It receives appCode explicitly because the migration - not the framework -
|
||||
// writes its own completion row, normally as the last statement inside its own
|
||||
// transaction. That is what makes "the schema change and the record of it
|
||||
// commit together" true, and the framework cannot insert the row on the
|
||||
// migration's behalf without giving that up. Handing the code to the function
|
||||
// is what stops an app's migrations from silently recording themselves as the
|
||||
// framework's.
|
||||
type AppMigrationFunc func(db *gorm.DB, version, appCode string) error
|
||||
|
||||
// AppRegistrar is a per-app view over a registry.
|
||||
type AppRegistrar struct {
|
||||
m *Migration
|
||||
appCode string
|
||||
}
|
||||
|
||||
// FrameworkAppCode is the name migrate status prints for migrations that belong
|
||||
// to the framework rather than to an app, and the name --app accepts to select
|
||||
// them. The stored app code for those is the empty string; this is only the
|
||||
// spelling humans use. It is reserved - ForApp rejects it - so that every group
|
||||
// heading status prints is also a value --app understands.
|
||||
const FrameworkAppCode = "core"
|
||||
|
||||
// ForApp returns a registrar that records migrations under code.
|
||||
//
|
||||
// The code is lower-cased: sys_migration.version sorts as ASCII, so mixed case
|
||||
// would order MyApp before crm for no reason a reader could guess, and the two
|
||||
// spellings would group as two different apps in migrate status.
|
||||
//
|
||||
// An empty or reserved code panics rather than falling back to the framework.
|
||||
// Registration happens in init(), so this fires the first time the binary runs
|
||||
// anywhere, which is the point: an app whose migrations quietly file themselves
|
||||
// under the framework is exactly the class of silent failure this work is meant
|
||||
// to remove. Framework migrations call Migrate.SetVersion directly.
|
||||
func ForApp(code string) *AppRegistrar { return Migrate.ForApp(code) }
|
||||
|
||||
// ForApp is the same on an explicit registry, which is what tests use.
|
||||
func (e *Migration) ForApp(code string) *AppRegistrar {
|
||||
code = NormalizeAppCode(code)
|
||||
switch code {
|
||||
case "":
|
||||
panic("migration.ForApp: empty app code; framework migrations use Migrate.SetVersion")
|
||||
case FrameworkAppCode:
|
||||
panic("migration.ForApp: app code " + FrameworkAppCode + " is reserved for the framework")
|
||||
}
|
||||
return &AppRegistrar{m: e, appCode: code}
|
||||
}
|
||||
|
||||
// AppCode reports the code this registrar files migrations under, after
|
||||
// normalisation.
|
||||
func (r *AppRegistrar) AppCode() string { return r.appCode }
|
||||
|
||||
// SetVersion registers an app-owned migration under k, which is the bare
|
||||
// timestamp taken from the file name exactly as framework migrations do.
|
||||
//
|
||||
// What reaches sys_migration.version is the namespaced form; the version string
|
||||
// handed to f is that same namespaced string, so a migration that writes
|
||||
// common.Migration{Version: version, AppCode: appCode} records the key the
|
||||
// registry will look for next time.
|
||||
func (r *AppRegistrar) SetVersion(k string, f AppMigrationFunc) {
|
||||
key := namespacedKey(r.appCode, k)
|
||||
r.m.setVersion(key, r.appCode, func(db *gorm.DB, version string) error {
|
||||
return f(db, version, r.appCode)
|
||||
})
|
||||
}
|
||||
|
||||
// namespacedKey scopes k to appCode so two apps cannot collide on the
|
||||
// sys_migration.version primary key by minting the same millisecond timestamp.
|
||||
// Framework migrations (appCode == "") stay bare, matching every version string
|
||||
// already in production.
|
||||
func namespacedKey(appCode, k string) string {
|
||||
if appCode == "" {
|
||||
return k
|
||||
}
|
||||
return appCode + "-" + k
|
||||
}
|
||||
|
||||
// mergedEntries returns every migration this process knows about: the
|
||||
// host's own registry (e.version, filled by version/*.go and
|
||||
// version-local/*.go through SetVersion/ForApp) plus whatever a third-party
|
||||
// application registered through go-admin-core's sdk/contract/migration
|
||||
// package (PRD 006, F9's host wiring).
|
||||
//
|
||||
// That package keeps its own process-wide registry, entirely separate from
|
||||
// e.version, because a third-party application cannot reach into this
|
||||
// process to call an unexported method on *Migration - contract/migration's
|
||||
// package-level ForApp/Snapshot are the only door open to it. Without this
|
||||
// merge, migrate/status/--dry-run would only ever see the host's own
|
||||
// migrations: an application's ForApp("crm").SetVersion(...) would compile,
|
||||
// register successfully into contract/migration's registry, and then never
|
||||
// run, with no error anywhere - the exact silent gap this method closes.
|
||||
//
|
||||
// Entry and versionEntry are structurally identical (an app code plus a
|
||||
// func(db, version) error); the conversion below exists only because they
|
||||
// are two distinct named types, one per package, not because the data
|
||||
// differs.
|
||||
func (e *Migration) mergedEntries() map[string]versionEntry {
|
||||
e.mutex.Lock()
|
||||
out := make(map[string]versionEntry, len(e.version))
|
||||
for k, v := range e.version {
|
||||
out[k] = v
|
||||
}
|
||||
e.mutex.Unlock()
|
||||
|
||||
for k, entry := range contractSnapshot() {
|
||||
if _, exists := out[k]; exists {
|
||||
// contract/migration.ForApp namespaces every app-owned key as
|
||||
// appCode + "-" + k, and appCode is reserved from ""/"core", so
|
||||
// this should never collide with a host-registered key. If it
|
||||
// somehow does, the host's own registration wins rather than
|
||||
// silently overwriting it.
|
||||
continue
|
||||
}
|
||||
out[k] = versionEntry{appCode: entry.AppCode, fn: entry.Fn}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StatusEntry is one row of migrate status.
|
||||
type StatusEntry struct {
|
||||
AppCode string
|
||||
Version string
|
||||
Registered bool
|
||||
Applied bool
|
||||
ApplyTime *time.Time
|
||||
}
|
||||
|
||||
// Status merges the in-process registry with sys_migration, so it reports all
|
||||
// three shapes at once: registered but not applied, registered and applied, and
|
||||
// applied while nothing registers it any more - a row left behind by a
|
||||
// migration file that was deleted, or by an app that was uninstalled.
|
||||
//
|
||||
// It only reads. Nothing here creates or alters a table, which is what lets
|
||||
// both `status` and `--dry-run` run against a database without touching it.
|
||||
func (e *Migration) Status() ([]StatusEntry, error) {
|
||||
if e.db == nil {
|
||||
return nil, fmt.Errorf("migration: no database configured")
|
||||
}
|
||||
|
||||
all := e.mergedEntries()
|
||||
registered := make(map[string]string, len(all))
|
||||
for k, v := range all {
|
||||
registered[k] = v.appCode
|
||||
}
|
||||
|
||||
applied := make(map[string]common.Migration)
|
||||
// A database that has never been migrated has no sys_migration table.
|
||||
// Reporting everything as pending is the honest answer there; erroring out
|
||||
// would make status useless in exactly the case it is most wanted.
|
||||
if e.db.Migrator().HasTable(&common.Migration{}) {
|
||||
var rows []common.Migration
|
||||
if err := e.db.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
applied[r.Version] = r
|
||||
}
|
||||
}
|
||||
|
||||
versions := make(map[string]struct{}, len(registered)+len(applied))
|
||||
for k := range registered {
|
||||
versions[k] = struct{}{}
|
||||
}
|
||||
for k := range applied {
|
||||
versions[k] = struct{}{}
|
||||
}
|
||||
list := make([]string, 0, len(versions))
|
||||
for k := range versions {
|
||||
list = append(list, k)
|
||||
}
|
||||
sort.Strings(list)
|
||||
|
||||
out := make([]StatusEntry, 0, len(list))
|
||||
for _, v := range list {
|
||||
entry := StatusEntry{Version: v}
|
||||
if code, ok := registered[v]; ok {
|
||||
entry.Registered = true
|
||||
entry.AppCode = code
|
||||
}
|
||||
if row, ok := applied[v]; ok {
|
||||
entry.Applied = true
|
||||
t := row.ApplyTime
|
||||
entry.ApplyTime = &t
|
||||
if !entry.Registered {
|
||||
// Nothing registers this version any more, so the database is
|
||||
// the only source left for what it belonged to.
|
||||
entry.AppCode = row.AppCode
|
||||
}
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Migrate applies every registered migration that has not been applied yet,
|
||||
// across all apps. Existing callers are unaffected.
|
||||
func (e *Migration) Migrate() { e.run(allApps) }
|
||||
|
||||
// MigrateApp applies only the migrations registered under appCode. Pass
|
||||
// FrameworkAppCode for the framework's own migrations.
|
||||
func (e *Migration) MigrateApp(appCode string) { e.run(AppFilter(appCode)) }
|
||||
|
||||
// NormalizeAppCode applies the same rule ForApp does, so a code typed on the
|
||||
// command line matches one written in an init().
|
||||
func NormalizeAppCode(code string) string {
|
||||
return strings.ToLower(strings.TrimSpace(code))
|
||||
}
|
||||
|
||||
// AppFilter turns a code as typed into the code stored in the registry, so
|
||||
// "core" selects the framework's migrations, whose stored code is empty.
|
||||
func AppFilter(code string) string {
|
||||
code = NormalizeAppCode(code)
|
||||
if code == FrameworkAppCode {
|
||||
return ""
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// DisplayAppCode is the inverse: what to print for a stored code.
|
||||
func DisplayAppCode(code string) string {
|
||||
if code == "" {
|
||||
return FrameworkAppCode
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// AppCodes lists the app codes with at least one registered migration, framework
|
||||
// included under its display name, sorted.
|
||||
func (e *Migration) AppCodes() []string {
|
||||
all := e.mergedEntries()
|
||||
seen := map[string]struct{}{}
|
||||
for _, v := range all {
|
||||
seen[DisplayAppCode(v.appCode)] = struct{}{}
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(seen))
|
||||
for code := range seen {
|
||||
out = append(out, code)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *Migration) run(appCode string) {
|
||||
all := e.mergedEntries()
|
||||
versions := make([]string, 0, len(all))
|
||||
entries := make(map[string]versionEntry, len(all))
|
||||
for k, v := range all {
|
||||
if appCode != allApps && v.appCode != appCode {
|
||||
continue
|
||||
}
|
||||
versions = append(versions, k)
|
||||
entries[k] = v
|
||||
}
|
||||
if !sort.StringsAreSorted(versions) {
|
||||
sort.Strings(versions)
|
||||
sort.Strings(versions)
|
||||
|
||||
// A mistyped --app would otherwise select nothing and report "no
|
||||
// migrations to apply", which reads exactly like "already up to date".
|
||||
if appCode != allApps && len(versions) == 0 {
|
||||
log.Printf("no migrations are registered for app %q; registered: %s",
|
||||
DisplayAppCode(appCode), strings.Join(e.AppCodes(), ", "))
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
var count int64
|
||||
applied := 0
|
||||
@@ -56,7 +346,7 @@ func (e *Migration) Migrate() {
|
||||
continue
|
||||
}
|
||||
log.Printf("applying migration %s", v)
|
||||
if err = (e.version[v])(e.db.Debug(), v); err != nil {
|
||||
if err = entries[v].fn(e.db.Debug(), v); err != nil {
|
||||
log.Fatalf("migration %s failed: %v", v, err)
|
||||
}
|
||||
applied++
|
||||
@@ -68,7 +358,14 @@ func (e *Migration) Migrate() {
|
||||
}
|
||||
}
|
||||
|
||||
// allApps is the sentinel run() takes to mean "do not filter". It is distinct
|
||||
// from the empty app code, which selects the framework's own migrations.
|
||||
const allApps = "\x00all"
|
||||
|
||||
// GetFilename derives a migration's version from its file name. The rule
|
||||
// lives in contract/migration, because an application registering through
|
||||
// that package names its files by the same convention and must land on the
|
||||
// same version string; a second copy here is a second thing to keep in step.
|
||||
func GetFilename(s string) string {
|
||||
s = filepath.Base(s)
|
||||
return s[:13]
|
||||
return contractmigration.GetFilename(s)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration"
|
||||
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// withContractRegistry points contractSnapshot at an isolated
|
||||
// *contractmigration.Registry for the duration of one test, instead of
|
||||
// go-admin-core's single process-wide one - see contractSnapshot's doc
|
||||
// comment for why that indirection exists. Restored on cleanup so other
|
||||
// tests in this package keep seeing an empty contract registry regardless of
|
||||
// run order.
|
||||
func withContractRegistry(t *testing.T) *contractmigration.Registry {
|
||||
t.Helper()
|
||||
reg := contractmigration.NewRegistry()
|
||||
orig := contractSnapshot
|
||||
contractSnapshot = reg.Snapshot
|
||||
t.Cleanup(func() { contractSnapshot = orig })
|
||||
return reg
|
||||
}
|
||||
|
||||
func newTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err = db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// recordFor is what an app's migration is expected to do: write its own
|
||||
// completion row, with the version it was handed and the app code it was told
|
||||
// it belongs to.
|
||||
func recordFor(db *gorm.DB, version, appCode string) error {
|
||||
return db.Create(&common.Migration{Version: version, AppCode: appCode}).Error
|
||||
}
|
||||
|
||||
func rowsByVersion(t *testing.T, db *gorm.DB) map[string]common.Migration {
|
||||
t.Helper()
|
||||
var rows []common.Migration
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
t.Fatalf("read sys_migration: %v", err)
|
||||
}
|
||||
out := make(map[string]common.Migration, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.Version] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Acceptance 9: a migration registered through ForApp("x") lands in
|
||||
// sys_migration with app_code "x".
|
||||
//
|
||||
// The registry cannot write that row for the migration, because the row is the
|
||||
// migration's own last statement inside its own transaction. So the only thing
|
||||
// that can make this true is handing the code to the function - which is why
|
||||
// AppMigrationFunc takes three parameters.
|
||||
func TestForAppRecordsItsAppCode(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
m.ForApp("x").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
m.Migrate()
|
||||
|
||||
rows := rowsByVersion(t, db)
|
||||
row, ok := rows["x-1786800001000"]
|
||||
if !ok {
|
||||
t.Fatalf("no row for x-1786800001000; got %v", rows)
|
||||
}
|
||||
if row.AppCode != "x" {
|
||||
t.Errorf("app_code = %q, want %q", row.AppCode, "x")
|
||||
}
|
||||
}
|
||||
|
||||
// The framework path is untouched: same signature, and an empty app code, which
|
||||
// is what the column defaults to and what every row written before this field
|
||||
// existed reads back as.
|
||||
func TestSetVersionStillRecordsTheFrameworkAsEmpty(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
m.Migrate()
|
||||
|
||||
rows := rowsByVersion(t, db)
|
||||
row, ok := rows["1786700009000"]
|
||||
if !ok {
|
||||
t.Fatalf("no row for 1786700009000; got %v", rows)
|
||||
}
|
||||
if row.AppCode != "" {
|
||||
t.Errorf("app_code = %q, want empty (framework)", row.AppCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance 12: --app x runs x's migrations and touches nothing else.
|
||||
func TestMigrateAppRunsOnlyThatApp(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
ran := map[string]bool{}
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
|
||||
ran["core"] = true
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
m.ForApp("x").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
ran["x"] = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
m.ForApp("y").SetVersion("1786800002000", func(db *gorm.DB, version, appCode string) error {
|
||||
ran["y"] = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
m.MigrateApp("x")
|
||||
|
||||
if !ran["x"] {
|
||||
t.Error("x did not run")
|
||||
}
|
||||
if ran["y"] || ran["core"] {
|
||||
t.Errorf("MigrateApp(x) also ran %v", ran)
|
||||
}
|
||||
rows := rowsByVersion(t, db)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("sys_migration has %d rows, want 1: %v", len(rows), rows)
|
||||
}
|
||||
}
|
||||
|
||||
// "core" is what status prints for the framework, so --app core has to select
|
||||
// it. The stored code is the empty string; AppFilter is the translation.
|
||||
func TestMigrateAppCoreSelectsTheFramework(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
ran := map[string]bool{}
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
|
||||
ran["core"] = true
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
m.ForApp("x").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
ran["x"] = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
m.MigrateApp(FrameworkAppCode)
|
||||
|
||||
if !ran["core"] {
|
||||
t.Error("framework migration did not run")
|
||||
}
|
||||
if ran["x"] {
|
||||
t.Error("--app core also ran x")
|
||||
}
|
||||
}
|
||||
|
||||
// Zero-argument Migrate keeps meaning "everything", which is what every
|
||||
// existing caller relies on.
|
||||
func TestMigrateRunsEveryApp(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
var order []string
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
|
||||
order = append(order, version)
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
m.ForApp("bbb").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
order = append(order, version)
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
m.ForApp("aaa").SetVersion("1786800002000", func(db *gorm.DB, version, appCode string) error {
|
||||
order = append(order, version)
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
m.Migrate()
|
||||
|
||||
// Namespacing puts every framework migration - bare digits - ahead of every
|
||||
// app migration, and orders apps by code rather than by whose timestamp
|
||||
// happened to be smaller. aaa's file is the newer of the two and still runs
|
||||
// first. Cross-app order is not promised, but this is the order, and it is
|
||||
// the one to notice changed.
|
||||
want := []string{"1786700009000", "aaa-1786800002000", "bbb-1786800001000"}
|
||||
if len(order) != len(want) {
|
||||
t.Fatalf("ran %v, want %v", order, want)
|
||||
}
|
||||
for i := range want {
|
||||
if order[i] != want[i] {
|
||||
t.Fatalf("ran %v, want %v", order, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two apps minting the same millisecond timestamp used to mean one of them was
|
||||
// read as already applied and silently skipped. The namespace prefix is what
|
||||
// makes that impossible without changing the primary key.
|
||||
func TestNamespacingKeepsTwoAppsWithTheSameTimestampApart(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
const sameTimestamp = "1786800001000"
|
||||
ran := 0
|
||||
for _, app := range []string{"crm", "oms"} {
|
||||
m.ForApp(app).SetVersion(sameTimestamp, func(db *gorm.DB, version, appCode string) error {
|
||||
ran++
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
}
|
||||
m.Migrate()
|
||||
|
||||
if ran != 2 {
|
||||
t.Errorf("ran %d migrations, want 2", ran)
|
||||
}
|
||||
rows := rowsByVersion(t, db)
|
||||
for _, want := range []string{"crm-" + sameTimestamp, "oms-" + sameTimestamp} {
|
||||
if _, ok := rows[want]; !ok {
|
||||
t.Errorf("missing %s; got %v", want, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespacedKeyLeavesFrameworkVersionsBare(t *testing.T) {
|
||||
if got := namespacedKey("", "1786700009000"); got != "1786700009000" {
|
||||
t.Errorf("framework version was rewritten to %q", got)
|
||||
}
|
||||
if got := namespacedKey("crm", "1786800001000"); got != "crm-1786800001000" {
|
||||
t.Errorf("namespacedKey = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An app code differing only in case would group as two apps in status and sort
|
||||
// before every lower-case one, for no reason a reader could guess.
|
||||
func TestForAppNormalisesTheCode(t *testing.T) {
|
||||
m := newMigration()
|
||||
if got := m.ForApp(" CRM ").AppCode(); got != "crm" {
|
||||
t.Errorf("AppCode = %q, want crm", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForAppRejectsReservedCodes(t *testing.T) {
|
||||
for _, code := range []string{"", " ", FrameworkAppCode, "CORE"} {
|
||||
t.Run("code="+code, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("ForApp(%q) did not panic", code)
|
||||
}
|
||||
}()
|
||||
newMigration().ForApp(code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusReportsPendingAppliedAndOrphaned(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
applied := time.Date(2026, 8, 25, 14, 3, 11, 0, time.UTC)
|
||||
if err := db.Create(&common.Migration{Version: "1786700009000", ApplyTime: applied}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Recorded, but nothing registers it any more.
|
||||
if err := db.Create(&common.Migration{Version: "gone-1786800000000", ApplyTime: applied, AppCode: "gone"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error { return nil })
|
||||
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error { return nil })
|
||||
|
||||
entries, err := m.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byVersion := map[string]StatusEntry{}
|
||||
for _, e := range entries {
|
||||
byVersion[e.Version] = e
|
||||
}
|
||||
|
||||
if e := byVersion["1786700009000"]; !e.Applied || !e.Registered || e.AppCode != "" {
|
||||
t.Errorf("framework entry = %+v", e)
|
||||
} else if e.ApplyTime == nil || !e.ApplyTime.Equal(applied) {
|
||||
t.Errorf("framework apply time = %v, want %v", e.ApplyTime, applied)
|
||||
}
|
||||
if e := byVersion["crm-1786800001000"]; e.Applied || !e.Registered || e.AppCode != "crm" {
|
||||
t.Errorf("crm entry = %+v", e)
|
||||
}
|
||||
if e := byVersion["gone-1786800000000"]; !e.Applied || e.Registered || e.AppCode != "gone" {
|
||||
t.Errorf("orphaned entry = %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance 11 rests on this: status and --dry-run both go through Status, and
|
||||
// Status must not create the table it reads.
|
||||
func TestStatusDoesNotCreateItsTable(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error { return nil })
|
||||
|
||||
entries, err := m.Status()
|
||||
if err != nil {
|
||||
t.Fatalf("Status on a database with no sys_migration: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Applied {
|
||||
t.Errorf("entries = %+v, want one pending", entries)
|
||||
}
|
||||
if db.Migrator().HasTable(&common.Migration{}) {
|
||||
t.Error("Status created sys_migration; it must only read")
|
||||
}
|
||||
}
|
||||
|
||||
// The completion row is the migration's own last statement, inside its own
|
||||
// transaction. A migration that fails must leave no record of having run, or
|
||||
// the next run skips it and the schema stays half-changed with nothing to say
|
||||
// so.
|
||||
func TestFailedMigrationLeavesNoRecord(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := recordFor(tx, version, appCode); err != nil {
|
||||
return err
|
||||
}
|
||||
return errTestMigrationFailed
|
||||
})
|
||||
})
|
||||
|
||||
// run() calls log.Fatal on failure, which would take the test binary with
|
||||
// it, so drive the registered function directly - the point here is the
|
||||
// transaction boundary, not the scheduler.
|
||||
entry := m.version["crm-1786800001000"]
|
||||
if err := entry.fn(db, "crm-1786800001000"); err == nil {
|
||||
t.Fatal("migration reported success")
|
||||
}
|
||||
if rows := rowsByVersion(t, db); len(rows) != 0 {
|
||||
t.Errorf("sys_migration has %v after a failed migration", rows)
|
||||
}
|
||||
}
|
||||
|
||||
var errTestMigrationFailed = &testError{"boom"}
|
||||
|
||||
type testError struct{ s string }
|
||||
|
||||
func (e *testError) Error() string { return e.s }
|
||||
|
||||
// A mistyped --app used to select nothing and print "no migrations to apply",
|
||||
// which reads as "already up to date" - the command reports success and does
|
||||
// nothing, which is the failure mode this whole batch exists to remove.
|
||||
func TestMigrateAppOnAnUnknownCodeSaysSo(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
log.SetOutput(&buf)
|
||||
t.Cleanup(func() { log.SetOutput(os.Stderr) })
|
||||
|
||||
m.MigrateApp("crmm")
|
||||
|
||||
if !strings.Contains(buf.String(), `no migrations are registered for app "crmm"`) {
|
||||
t.Errorf("output = %q", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "registered: core, crm") {
|
||||
t.Errorf("the message must list what is registered; got %q", buf.String())
|
||||
}
|
||||
if rows := rowsByVersion(t, db); len(rows) != 0 {
|
||||
t.Errorf("a typo ran %v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// This is the acceptance test for PRD 006's host-wiring gap: a migration
|
||||
// registered through contract/migration.ForApp - the only door open to a
|
||||
// third-party application - must actually run, be recorded under its app
|
||||
// code, and show up in AppCodes/Status/--app the same as one registered
|
||||
// through the host's own m.ForApp. Before mergedEntries existed, m.Migrate()
|
||||
// never looked at contract/migration's registry at all, so this compiled,
|
||||
// registered, and silently never ran.
|
||||
func TestMergedEntriesRunsAContractRegisteredAppMigration(t *testing.T) {
|
||||
reg := withContractRegistry(t)
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
ran := false
|
||||
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error {
|
||||
ran = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
m.Migrate()
|
||||
|
||||
if !ran {
|
||||
t.Fatal("contract-registered migration did not run")
|
||||
}
|
||||
rows := rowsByVersion(t, db)
|
||||
row, ok := rows["order-1793800000000"]
|
||||
if !ok {
|
||||
t.Fatalf("no row for order-1793800000000; got %v", rows)
|
||||
}
|
||||
if row.AppCode != "order" {
|
||||
t.Errorf("app_code = %q, want %q", row.AppCode, "order")
|
||||
}
|
||||
}
|
||||
|
||||
// migrate status and --dry-run both read Status; a contract-registered
|
||||
// migration has to appear there under its app code exactly like a
|
||||
// host-registered one, both before and after it is applied.
|
||||
func TestMergedEntriesStatusIncludesContractRegisteredMigrations(t *testing.T) {
|
||||
reg := withContractRegistry(t)
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
entries, err := m.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byVersion := map[string]StatusEntry{}
|
||||
for _, e := range entries {
|
||||
byVersion[e.Version] = e
|
||||
}
|
||||
e, ok := byVersion["order-1793800000000"]
|
||||
if !ok || !e.Registered || e.Applied || e.AppCode != "order" {
|
||||
t.Fatalf("pending contract entry = %+v (ok=%v)", e, ok)
|
||||
}
|
||||
|
||||
m.Migrate()
|
||||
|
||||
entries, err = m.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byVersion = map[string]StatusEntry{}
|
||||
for _, e := range entries {
|
||||
byVersion[e.Version] = e
|
||||
}
|
||||
if e := byVersion["order-1793800000000"]; !e.Applied {
|
||||
t.Errorf("applied contract entry = %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
// AppCodes feeds both --app's typo detection (appRegistrationError) and the
|
||||
// group headings status prints; a contract-registered app has to appear
|
||||
// there or a real "go-admin migrate --app order" would be told the app does
|
||||
// not exist.
|
||||
func TestMergedEntriesAppCodesIncludesContractRegisteredApps(t *testing.T) {
|
||||
reg := withContractRegistry(t)
|
||||
m := newMigration()
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error { return nil })
|
||||
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error { return nil })
|
||||
|
||||
got := m.AppCodes()
|
||||
want := []string{"core", "order"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("AppCodes = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("AppCodes = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --app order has to actually run only order's migrations - the same
|
||||
// per-app isolation MigrateApp already gives host-registered apps - even
|
||||
// though order is registered in a different registry entirely.
|
||||
func TestMergedEntriesMigrateAppRunsOnlyThatContractApp(t *testing.T) {
|
||||
reg := withContractRegistry(t)
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
ran := map[string]bool{}
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
|
||||
ran["core"] = true
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error {
|
||||
ran["order"] = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
m.MigrateApp("order")
|
||||
|
||||
if !ran["order"] {
|
||||
t.Error("order did not run")
|
||||
}
|
||||
if ran["core"] {
|
||||
t.Errorf("MigrateApp(order) also ran %v", ran)
|
||||
}
|
||||
}
|
||||
|
||||
// A host-registered key is not supposed to collide with a namespaced
|
||||
// contract key (see mergedEntries' doc comment), but if it somehow did, the
|
||||
// host's own registration must win rather than a third-party application
|
||||
// silently overwriting a framework migration under the same key.
|
||||
func TestMergedEntriesHostRegistrationWinsOnKeyCollision(t *testing.T) {
|
||||
reg := withContractRegistry(t)
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
hostRan, contractRan := false, false
|
||||
m.ForApp("dup").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
hostRan = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
reg.ForApp("dup").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
contractRan = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
m.Migrate()
|
||||
|
||||
if !hostRan {
|
||||
t.Error("host registration did not run")
|
||||
}
|
||||
if contractRan {
|
||||
t.Error("contract registration ran; host registration should have won the collision")
|
||||
}
|
||||
}
|
||||
|
||||
// GetFilename must stay the same rule the contract package applies, since an
|
||||
// application registering through contract/migration names its files by that
|
||||
// convention and has to land on the same version string. Pinning the reject
|
||||
// case is what catches a re-divergence: a local copy that only sliced would
|
||||
// return "add_orders.go" here and register a migration under a key that never
|
||||
// matches anything.
|
||||
func TestGetFilenameDelegatesToTheContractRule(t *testing.T) {
|
||||
if got := GetFilename("version/1786700001000_demo_menu.go"); got != "1786700001000" {
|
||||
t.Fatalf("GetFilename = %q, want %q", got, "1786700001000")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("a file name carrying no version did not panic")
|
||||
}
|
||||
}()
|
||||
GetFilename("version/add_orders.go")
|
||||
}
|
||||
@@ -15,6 +15,14 @@ type Model struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement;comment:主键编码"`
|
||||
}
|
||||
|
||||
// ModelTime is frozen at the schema shape these tables had before
|
||||
// 1786700003000 converted deleted_at to a NOT NULL millisecond marker. That is
|
||||
// correct for the migrations ordered before the conversion, and wrong for any
|
||||
// added after it: writes put NULL into a NOT NULL column, and reads are scoped
|
||||
// "WHERE deleted_at IS NULL" and match nothing.
|
||||
//
|
||||
// Migrations after that version seed through the runtime models in app/.
|
||||
// TestPostConversionMigrationsAvoidFrozenSeedModels enforces this.
|
||||
type ModelTime struct {
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"comment:创建时间"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"comment:最后更新时间"`
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Normalize sys_role.data_scope to one of the five values
|
||||
// actions.Permission recognizes, ahead of PRD 006 F14/H2 making its
|
||||
// unrecognized-scope branch fail closed instead of fail open.
|
||||
//
|
||||
// Before that change, an empty or unrecognized data_scope fell into
|
||||
// Permission's default branch, which returned the query untouched - exactly
|
||||
// the same SQL as data_scope "1" (全部数据权限). The seed data shipped
|
||||
// precisely that: config/db.sql's built-in admin role (role_id 1) carries an
|
||||
// empty data_scope rather than "1". Once the default starts matching no
|
||||
// rows instead, that role would silently lose all visibility everywhere
|
||||
// actions.Permission is used, the moment a deployment turns EnableDP on.
|
||||
//
|
||||
// Rewriting every value outside {1,2,3,4,5} to "1" keeps each such role's
|
||||
// effective visibility exactly what it already was - a role that intended a
|
||||
// tighter scope was never getting it under the old fail-open default either,
|
||||
// so this does not tighten anything a deployment was relying on. Whether to
|
||||
// tighten it further is left to whoever owns that role.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700005000NormalizeRoleDataScope)
|
||||
}
|
||||
|
||||
func _1786700005000NormalizeRoleDataScope(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := normalizeRoleDataScope(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeRoleDataScope is split out so tests can run it against a database
|
||||
// that only has sys_role, without also standing up sys_migration.
|
||||
//
|
||||
// The explicit "IS NULL OR" matters: sys_role.data_scope has no NOT NULL
|
||||
// constraint, and SQL's three-valued logic makes `NULL NOT IN (...)`
|
||||
// evaluate to NULL rather than TRUE, so a bare NOT IN clause silently skips
|
||||
// NULL rows instead of normalizing them.
|
||||
func normalizeRoleDataScope(tx *gorm.DB) error {
|
||||
return tx.Exec(
|
||||
"UPDATE sys_role SET data_scope = '1' WHERE data_scope IS NULL OR data_scope NOT IN ('1', '2', '3', '4', '5')",
|
||||
).Error
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type roleDataScopeRow struct {
|
||||
RoleId int `gorm:"column:role_id;primaryKey;autoIncrement"`
|
||||
DataScope string `gorm:"column:data_scope"`
|
||||
}
|
||||
|
||||
func (roleDataScopeRow) TableName() string { return "sys_role" }
|
||||
|
||||
func openRoleTable(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(&roleDataScopeRow{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// The migration exists because the shipped admin role is exactly this case:
|
||||
// config/db.sql's role_id 1 carries an empty data_scope. Reproduces the seed
|
||||
// data literally rather than a made-up example.
|
||||
func TestNormalizesTheEmptyDataScopeTheSeedDataShips(t *testing.T) {
|
||||
db := openRoleTable(t)
|
||||
if err := db.Create(&roleDataScopeRow{RoleId: 1, DataScope: ""}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if err := normalizeRoleDataScope(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
var row roleDataScopeRow
|
||||
if err := db.First(&row, 1).Error; err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if row.DataScope != "1" {
|
||||
t.Fatalf("data_scope = %q, want %q", row.DataScope, "1")
|
||||
}
|
||||
}
|
||||
|
||||
// The five recognized values must survive untouched - this migration
|
||||
// normalizes what Permission cannot make sense of, not what it already can.
|
||||
func TestLeavesRecognizedScopesAlone(t *testing.T) {
|
||||
db := openRoleTable(t)
|
||||
valid := []string{"1", "2", "3", "4", "5"}
|
||||
for i, scope := range valid {
|
||||
if err := db.Create(&roleDataScopeRow{RoleId: i + 1, DataScope: scope}).Error; err != nil {
|
||||
t.Fatalf("seed %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := normalizeRoleDataScope(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
var rows []roleDataScopeRow
|
||||
if err := db.Order("role_id").Find(&rows).Error; err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
for i, row := range rows {
|
||||
if row.DataScope != valid[i] {
|
||||
t.Errorf("role %d: data_scope = %q, want %q (untouched)", row.RoleId, row.DataScope, valid[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A garbage value (not just empty) must be normalized the same way as empty -
|
||||
// both are "not one of the five", and the migration's WHERE clause has to
|
||||
// catch both.
|
||||
func TestNormalizesGarbageScopesToo(t *testing.T) {
|
||||
db := openRoleTable(t)
|
||||
if err := db.Create(&roleDataScopeRow{RoleId: 1, DataScope: "6"}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if err := normalizeRoleDataScope(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
var row roleDataScopeRow
|
||||
if err := db.First(&row, 1).Error; err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if row.DataScope != "1" {
|
||||
t.Fatalf("data_scope = %q, want %q", row.DataScope, "1")
|
||||
}
|
||||
}
|
||||
|
||||
// A NULL data_scope must be normalized too. sys_role.data_scope has no NOT
|
||||
// NULL constraint, and `NULL NOT IN (...)` evaluates to NULL rather than
|
||||
// TRUE under SQL's three-valued logic, so a bare NOT IN clause would leave
|
||||
// this row untouched - the exact gap that let a NULL-scoped role go blind
|
||||
// once Permission's default branch starts fail-closing.
|
||||
func TestNormalizesNullDataScope(t *testing.T) {
|
||||
db := openRoleTable(t)
|
||||
if err := db.Exec("INSERT INTO sys_role (role_id, data_scope) VALUES (1, NULL)").Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if err := normalizeRoleDataScope(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
var row roleDataScopeRow
|
||||
if err := db.First(&row, 1).Error; err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if row.DataScope != "1" {
|
||||
t.Fatalf("data_scope = %q, want %q", row.DataScope, "1")
|
||||
}
|
||||
}
|
||||
|
||||
// Running it twice must be safe: it is a plain UPDATE, not DDL, but
|
||||
// sys_migration only records success once, and an operator who reruns
|
||||
// `migrate` on a partially-applied database has to be able to trust that.
|
||||
func TestNormalizeRoleDataScopeIsRepeatable(t *testing.T) {
|
||||
db := openRoleTable(t)
|
||||
if err := db.Create(&roleDataScopeRow{RoleId: 1, DataScope: ""}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := normalizeRoleDataScope(db); err != nil {
|
||||
t.Fatalf("migrate %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
var row roleDataScopeRow
|
||||
if err := db.First(&row, 1).Error; err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if row.DataScope != "1" {
|
||||
t.Fatalf("data_scope = %q, want %q", row.DataScope, "1")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Add sys_menu.app_code and sys_api.app_code ahead of PRD 006 F9's Seeder.
|
||||
//
|
||||
// Every row a third-party application's migration writes through
|
||||
// seed.SeedMenus must be attributable to the app that wrote it, so
|
||||
// installing, auditing, or removing one application does not require
|
||||
// guessing which rows belong to it - see go-admin-core's docs/contract.md,
|
||||
// "Application-supplied menu and API entries", for the requirement this
|
||||
// satisfies.
|
||||
//
|
||||
// Ordered after 1786700003000, so importing cmd/migrate/migration/models is
|
||||
// banned here (see schema_coverage_test.go's
|
||||
// TestPostConversionMigrationsAvoidFrozenSeedModels): AddColumn instead
|
||||
// reads the runtime models' own gorm tags directly, which is also what
|
||||
// makes the column this adds match the one the admin Seeder writes through
|
||||
// those same structs.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700006000AppCodeColumns)
|
||||
}
|
||||
|
||||
func _1786700006000AppCodeColumns(db *gorm.DB, version string) error {
|
||||
m := db.Migrator()
|
||||
if !m.HasColumn(&adminmodels.SysMenu{}, "AppCode") {
|
||||
if err := m.AddColumn(&adminmodels.SysMenu{}, "AppCode"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !m.HasColumn(&adminmodels.SysApi{}, "AppCode") {
|
||||
if err := m.AddColumn(&adminmodels.SysApi{}, "AppCode"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
)
|
||||
|
||||
// The repository carries two ModelTime types. The one in
|
||||
@@ -79,9 +81,13 @@ func runtimeSoftDeleteTables(t *testing.T) map[string]string {
|
||||
}
|
||||
|
||||
func importsRuntimeModels(f *ast.File) bool {
|
||||
return importsPackage(f, "go-admin/common/models")
|
||||
}
|
||||
|
||||
func importsPackage(f *ast.File, pkg string) bool {
|
||||
for _, imp := range f.Imports {
|
||||
p, err := strconv.Unquote(imp.Path.Value)
|
||||
if err == nil && p == "go-admin/common/models" {
|
||||
if err == nil && p == pkg {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -171,3 +177,89 @@ func repoRoot(t *testing.T) string {
|
||||
t.Fatal("go.mod not found above the test directory")
|
||||
return ""
|
||||
}
|
||||
|
||||
// softDeleteConversion is the version at which sys_api, sys_menu and the rest
|
||||
// stop storing deleted_at as a nullable timestamp and start storing the NOT
|
||||
// NULL millisecond marker.
|
||||
const softDeleteConversion = 1786700003000
|
||||
|
||||
// versionPrefixLen is the width migration.GetFilename slices off a filename.
|
||||
const versionPrefixLen = 13
|
||||
|
||||
// Migrations ordered after the conversion must not seed rows through
|
||||
// cmd/migrate/migration/models.
|
||||
//
|
||||
// That package's ModelTime still declares a nullable gorm.DeletedAt, which is
|
||||
// correct for the migrations that predate the conversion - it is the shape the
|
||||
// column had when they ran. Reusing it afterwards writes NULL into a NOT NULL
|
||||
// column and the migration fails on its first insert:
|
||||
//
|
||||
// NOT NULL constraint failed: sys_api.deleted_at
|
||||
//
|
||||
// A fresh database never catches this, because every migration using that
|
||||
// package today is ordered before the conversion and so runs while the column
|
||||
// is still nullable. Only a migration added afterwards hits it, which in
|
||||
// practice means the next person adding a business module - the reference
|
||||
// they copy, 1786700001000_demo_menu.go, is itself one of the safe ones.
|
||||
//
|
||||
// Reads through that package are worse than writes, which is why the whole
|
||||
// import is banned rather than just the inserts. gorm scopes a nullable
|
||||
// DeletedAt as "WHERE deleted_at IS NULL", and after the conversion live rows
|
||||
// hold 0, so the row is simply not there:
|
||||
//
|
||||
// frozen SysRole -> record not found
|
||||
// runtime SysRole -> roleId=1
|
||||
//
|
||||
// 1786700001000_demo_menu.go looks the admin role up that way and treats
|
||||
// ErrRecordNotFound as "roles are not seeded yet, skip authorisation". A
|
||||
// post-conversion copy that switched its inserts to the runtime models but
|
||||
// kept this lookup would seed the menu, grant nothing, and still record the
|
||||
// migration as applied - the menu appears, its buttons do nothing, and no
|
||||
// error is reported anywhere.
|
||||
func TestPostConversionMigrationsAvoidFrozenSeedModels(t *testing.T) {
|
||||
const frozenModels = "go-admin/cmd/migrate/migration/models"
|
||||
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
checked := 0
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
// GetFilename is what every migration uses to derive its own version,
|
||||
// so the two stay in step if the filename convention ever changes.
|
||||
if len(name) < versionPrefixLen {
|
||||
continue
|
||||
}
|
||||
version, err := strconv.ParseInt(migration.GetFilename(name), 10, 64)
|
||||
if err != nil || version <= softDeleteConversion {
|
||||
continue // not a versioned migration, or one that predates the change
|
||||
}
|
||||
checked++
|
||||
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", name, err)
|
||||
}
|
||||
if importsPackage(f, frozenModels) {
|
||||
t.Errorf("%s is ordered after the soft-delete conversion but seeds through %s;\n"+
|
||||
" that package writes a nullable deleted_at and will fail with\n"+
|
||||
" \"NOT NULL constraint failed\" on its first insert.\n"+
|
||||
" Use the runtime models under app/ instead - they carry the marker.",
|
||||
name, frozenModels)
|
||||
}
|
||||
}
|
||||
|
||||
if checked == 0 {
|
||||
t.Fatal("no post-conversion migrations found; the scan is broken, not the code")
|
||||
}
|
||||
}
|
||||
|
||||
+141
-8
@@ -3,12 +3,16 @@ package migrate
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/config/source/file"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -25,6 +29,8 @@ var (
|
||||
generate bool
|
||||
goAdmin bool
|
||||
host string
|
||||
appCode string
|
||||
dryRun bool
|
||||
StartCmd = &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "Initialize the database",
|
||||
@@ -33,14 +39,31 @@ var (
|
||||
run()
|
||||
},
|
||||
}
|
||||
statusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "List applied and pending migrations, grouped by app",
|
||||
Example: "go-admin migrate status -c config/settings.yml",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runStatus()
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// fixme 在您看不见代码的时候运行迁移,我觉得是不安全的,所以编译后最好不要去执行迁移
|
||||
func init() {
|
||||
StartCmd.PersistentFlags().StringVarP(&configYml, "config", "c", "config/settings.yml", "Start server with provided configuration file")
|
||||
StartCmd.PersistentFlags().BoolVarP(&generate, "generate", "g", false, "generate migration file")
|
||||
StartCmd.PersistentFlags().BoolVarP(&goAdmin, "goAdmin", "a", false, "generate go-admin migration file")
|
||||
StartCmd.PersistentFlags().BoolVarP(&goAdmin, "goAdmin", "a", false, "with -g, write the generated file to version/ instead of version-local/ (does not affect which migrations run)")
|
||||
StartCmd.PersistentFlags().StringVarP(&host, "domain", "d", "*", "select tenant host")
|
||||
|
||||
// --app is deliberately long-only. -a already means "generate into
|
||||
// version/ rather than version-local/", which is about writing a template
|
||||
// file, not about which migrations run; giving the two the same letter
|
||||
// would be a trap.
|
||||
StartCmd.PersistentFlags().StringVar(&appCode, "app", "", "limit to the migrations of one app (\""+migration.FrameworkAppCode+"\" for the framework's own)")
|
||||
StartCmd.Flags().BoolVar(&dryRun, "dry-run", false, "list what would be applied, in order, and write nothing")
|
||||
|
||||
StartCmd.AddCommand(statusCmd)
|
||||
}
|
||||
|
||||
func run() {
|
||||
@@ -58,7 +81,12 @@ func run() {
|
||||
}
|
||||
}
|
||||
|
||||
func migrateModel() error {
|
||||
// resolveDB picks the tenant database and hands it to the registry.
|
||||
//
|
||||
// It creates and alters nothing, which is what lets status and --dry-run share
|
||||
// it: those two must be able to run against a production database without
|
||||
// leaving a trace.
|
||||
func resolveDB() (*gorm.DB, error) {
|
||||
if host == "" {
|
||||
host = "*"
|
||||
}
|
||||
@@ -73,29 +101,134 @@ func migrateModel() error {
|
||||
}
|
||||
}
|
||||
if db == nil {
|
||||
return fmt.Errorf("未找到数据库配置")
|
||||
return nil, fmt.Errorf("未找到数据库配置")
|
||||
}
|
||||
if config.DatabasesConfig[host].Driver == "mysql" {
|
||||
//初始化数据库时候用
|
||||
db.Set("gorm:table_options", "ENGINE=InnoDB CHARSET=utf8mb4")
|
||||
}
|
||||
err := db.Debug().AutoMigrate(&models.Migration{})
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// exitUnlessAppRegistered ends the command when --app names something no
|
||||
// migration was registered under.
|
||||
//
|
||||
// Every path took a typo as "nothing matched" and reported success: `migrate`
|
||||
// printed that the app was unknown and still exited 0, while `--dry-run` and
|
||||
// `status` said "nothing to apply" and "none recorded" - which is what an
|
||||
// up-to-date database says too, so the output does not even hint at the typo.
|
||||
// An operator running `go-admin migrate --app crmm && deploy` gets the deploy.
|
||||
//
|
||||
// Checked against the registry, which init() has already filled, so this runs
|
||||
// before any database work and costs nothing. It lives in the command layer
|
||||
// because the exit code does: the migration package stays callable from a test
|
||||
// without taking the process down with it.
|
||||
func exitUnlessAppRegistered() {
|
||||
if err := appRegistrationError(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// appRegistrationError carries the decision on its own so it can be tested;
|
||||
// exitUnlessAppRegistered is only the os.Exit around it. Nil means --app was
|
||||
// either empty or names a registered app.
|
||||
func appRegistrationError() error {
|
||||
if appCode == "" {
|
||||
return nil
|
||||
}
|
||||
want := migration.DisplayAppCode(migration.AppFilter(appCode))
|
||||
registered := migration.Migrate.AppCodes()
|
||||
for _, c := range registered {
|
||||
if c == want {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("no migrations are registered for app %q; registered: %s",
|
||||
want, strings.Join(registered, ", "))
|
||||
}
|
||||
|
||||
func migrateModel() error {
|
||||
db, err := resolveDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// sys_migration is the one table that never goes through a versioned
|
||||
// migration - it is the table that records them. AutoMigrate realigns it
|
||||
// on every run, which is how the app_code column reaches an existing
|
||||
// database without anyone writing a migration for it.
|
||||
if err = db.Debug().AutoMigrate(&models.Migration{}); err != nil {
|
||||
return err
|
||||
}
|
||||
migration.Migrate.SetDb(db.Debug())
|
||||
if appCode != "" {
|
||||
migration.Migrate.MigrateApp(appCode)
|
||||
return nil
|
||||
}
|
||||
migration.Migrate.Migrate()
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func initDB() {
|
||||
// Before the database is touched, so a typo cannot get as far as looking
|
||||
// like a successful no-op on either path below.
|
||||
exitUnlessAppRegistered()
|
||||
|
||||
//3. 初始化数据库链接
|
||||
database.Setup()
|
||||
|
||||
if dryRun {
|
||||
db, err := resolveDB()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
migration.Migrate.SetDb(db)
|
||||
entries, err := migration.Migrate.Status()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
if err = printPending(os.Stdout, entries, appCode); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
//4. 数据库迁移
|
||||
fmt.Println("数据库迁移开始")
|
||||
_ = migrateModel()
|
||||
if err := migrateModel(); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(`数据库基础数据初始化成功`)
|
||||
}
|
||||
|
||||
func runStatus() {
|
||||
config.Setup(
|
||||
file.NewSource(file.WithPath(configYml)),
|
||||
func() {
|
||||
exitUnlessAppRegistered()
|
||||
|
||||
database.Setup()
|
||||
db, err := resolveDB()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
migration.Migrate.SetDb(db)
|
||||
entries, err := migration.Migrate.Status()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
if err = printStatus(os.Stdout, entries, appCode); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func genFile() error {
|
||||
t1, err := template.ParseFiles("template/migrate.template")
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
)
|
||||
|
||||
const applyTimeLayout = "2006-01-02 15:04:05"
|
||||
|
||||
// printStatus lists every migration this binary knows about together with every
|
||||
// row already in sys_migration, grouped by app.
|
||||
//
|
||||
// filter is an app code as typed on the command line; empty means every app.
|
||||
func printStatus(w io.Writer, entries []migration.StatusEntry, filter string) error {
|
||||
entries = filterByApp(entries, filter)
|
||||
|
||||
groups, order := groupByApp(entries)
|
||||
if len(order) == 0 {
|
||||
_, err := fmt.Fprintln(w, "no migrations registered and none recorded")
|
||||
return err
|
||||
}
|
||||
|
||||
// One width for the whole listing rather than one per group: the versions
|
||||
// of two apps line up, so a long list can be read down the column.
|
||||
width := versionWidth(entries)
|
||||
|
||||
var applied, pending, orphaned int
|
||||
for i, app := range order {
|
||||
if i > 0 {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintf(w, "[%s]\n", app)
|
||||
for _, e := range groups[app] {
|
||||
state := "pending"
|
||||
switch {
|
||||
case e.Applied && !e.Registered:
|
||||
state = "orphaned"
|
||||
orphaned++
|
||||
case e.Applied:
|
||||
state = "applied"
|
||||
applied++
|
||||
default:
|
||||
pending++
|
||||
}
|
||||
fmt.Fprintln(w, strings.TrimRight(
|
||||
fmt.Sprintf(" %-*s%-*s%s", stateWidth, state, width, e.Version, formatApplyTime(e.ApplyTime)), " "))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\n%d applied, %d pending across %d app(s)\n", applied, pending, len(order))
|
||||
if orphaned > 0 {
|
||||
fmt.Fprintf(w, "%d orphaned: recorded in sys_migration, but nothing in this binary registers them.\n"+
|
||||
"Expected after a migration file is removed or an app is uninstalled; they will not run again.\n", orphaned)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// printPending is --dry-run: the same data as status, narrowed to what an
|
||||
// actual run would do and printed in the order it would do it.
|
||||
//
|
||||
// It reads and prints. Every write path - AutoMigrate on sys_migration
|
||||
// included - is on the other branch in initDB, so a dry run leaves the database
|
||||
// byte for byte as it found it.
|
||||
func printPending(w io.Writer, entries []migration.StatusEntry, filter string) error {
|
||||
entries = filterByApp(entries, filter)
|
||||
|
||||
fmt.Fprintln(w, "dry-run: nothing will be written")
|
||||
|
||||
pending := make([]migration.StatusEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
// An orphaned row is recorded and unregistered; a real run cannot
|
||||
// apply it, so a dry run must not offer to.
|
||||
if !e.Applied && e.Registered {
|
||||
pending = append(pending, e)
|
||||
}
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
_, err := fmt.Fprintln(w, "nothing to apply")
|
||||
return err
|
||||
}
|
||||
|
||||
appWidth := 0
|
||||
for _, e := range pending {
|
||||
if n := len(migration.DisplayAppCode(e.AppCode)) + 2; n > appWidth {
|
||||
appWidth = n
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "would apply, in this order:")
|
||||
for _, e := range pending {
|
||||
fmt.Fprintf(w, " %-*s%s\n", appWidth+2, "["+migration.DisplayAppCode(e.AppCode)+"]", e.Version)
|
||||
}
|
||||
fmt.Fprintf(w, "\n%d migration(s) pending\n", len(pending))
|
||||
return nil
|
||||
}
|
||||
|
||||
// stateWidth is the width of the applied/pending/orphaned column, sized to the
|
||||
// longest of the three plus a gap.
|
||||
const stateWidth = len("orphaned") + 2
|
||||
|
||||
func versionWidth(entries []migration.StatusEntry) int {
|
||||
width := 0
|
||||
for _, e := range entries {
|
||||
if n := len(e.Version) + 2; n > width {
|
||||
width = n
|
||||
}
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
// filterByApp keeps the entries of one app. The filter is matched after the
|
||||
// same normalisation ForApp applies, so --app CRM finds crm.
|
||||
func filterByApp(entries []migration.StatusEntry, filter string) []migration.StatusEntry {
|
||||
if filter == "" {
|
||||
return entries
|
||||
}
|
||||
want := migration.AppFilter(filter)
|
||||
out := make([]migration.StatusEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.AppCode == want {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// groupByApp buckets entries by display name and returns the buckets plus the
|
||||
// order to print them in: the framework first, then apps alphabetically. That
|
||||
// is also the order a full run executes them in, because version strings sort
|
||||
// as ASCII and the framework's are bare digits.
|
||||
func groupByApp(entries []migration.StatusEntry) (map[string][]migration.StatusEntry, []string) {
|
||||
groups := make(map[string][]migration.StatusEntry)
|
||||
for _, e := range entries {
|
||||
app := migration.DisplayAppCode(e.AppCode)
|
||||
groups[app] = append(groups[app], e)
|
||||
}
|
||||
order := make([]string, 0, len(groups))
|
||||
for app := range groups {
|
||||
order = append(order, app)
|
||||
}
|
||||
sort.Slice(order, func(i, j int) bool {
|
||||
if (order[i] == migration.FrameworkAppCode) != (order[j] == migration.FrameworkAppCode) {
|
||||
return order[i] == migration.FrameworkAppCode
|
||||
}
|
||||
return order[i] < order[j]
|
||||
})
|
||||
return groups, order
|
||||
}
|
||||
|
||||
func formatApplyTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format(applyTimeLayout)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
)
|
||||
|
||||
func at(s string) *time.Time {
|
||||
t, err := time.Parse(applyTimeLayout, s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
// The order Status returns: version strings sorted as ASCII.
|
||||
func sampleEntries() []migration.StatusEntry {
|
||||
return []migration.StatusEntry{
|
||||
{Version: "1786700001000", AppCode: "", Registered: true, Applied: true, ApplyTime: at("2026-08-20 10:00:00")},
|
||||
{Version: "1786700005000", AppCode: "", Registered: true},
|
||||
{Version: "crm-1786800001000", AppCode: "crm", Registered: true, Applied: true, ApplyTime: at("2026-08-25 14:03:11")},
|
||||
{Version: "crm-1786800002000", AppCode: "crm", Registered: true},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintStatusGroupsByApp(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := printStatus(&buf, sampleEntries(), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := buf.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"[core]",
|
||||
"[crm]",
|
||||
"applied 1786700001000 2026-08-20 10:00:00",
|
||||
"pending 1786700005000",
|
||||
"applied crm-1786800001000 2026-08-25 14:03:11",
|
||||
"pending crm-1786800002000",
|
||||
"2 applied, 2 pending across 2 app(s)",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
// The framework heads the list, because that is the order a full run
|
||||
// executes in.
|
||||
if strings.Index(got, "[core]") > strings.Index(got, "[crm]") {
|
||||
t.Errorf("core is not listed first:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A row nobody registers any more is neither applied-and-current nor pending.
|
||||
// Calling it applied would say the migration is in this binary, which is what
|
||||
// sends someone looking for a file that was deleted.
|
||||
func TestPrintStatusMarksOrphanedRows(t *testing.T) {
|
||||
entries := append(sampleEntries(), migration.StatusEntry{
|
||||
Version: "gone-1786800000000", AppCode: "gone", Applied: true, ApplyTime: at("2026-08-01 09:00:00"),
|
||||
})
|
||||
var buf bytes.Buffer
|
||||
if err := printStatus(&buf, entries, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "orphaned gone-1786800000000") {
|
||||
t.Errorf("orphaned row not marked:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "nothing in this binary registers them") {
|
||||
t.Errorf("orphaned rows need an explanation:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "2 applied, 2 pending") {
|
||||
t.Errorf("orphaned rows must not be counted as applied:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintStatusFiltersByApp(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := printStatus(&buf, sampleEntries(), "crm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := buf.String()
|
||||
if strings.Contains(got, "[core]") {
|
||||
t.Errorf("--app crm listed the framework:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "across 1 app(s)") {
|
||||
t.Errorf("output = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// status prints [core]; --app core has to mean the same thing.
|
||||
func TestPrintStatusAppCoreSelectsTheFramework(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := printStatus(&buf, sampleEntries(), migration.FrameworkAppCode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := buf.String()
|
||||
if strings.Contains(got, "[crm]") {
|
||||
t.Errorf("--app core listed crm:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "[core]") {
|
||||
t.Errorf("--app core listed nothing:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintStatusOnAnEmptyRegistry(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := printStatus(&buf, nil, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "no migrations registered and none recorded") {
|
||||
t.Errorf("output = %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintPendingListsOnlyPendingInOrder(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := printPending(&buf, sampleEntries(), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := buf.String()
|
||||
|
||||
if !strings.Contains(got, "dry-run: nothing will be written") {
|
||||
t.Errorf("dry-run must say it writes nothing:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "1786700001000\n") || strings.Contains(got, "crm-1786800001000") {
|
||||
t.Errorf("dry-run listed already applied migrations:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "[core] 1786700005000") || !strings.Contains(got, "[crm] crm-1786800002000") {
|
||||
t.Errorf("dry-run is missing pending migrations:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "2 migration(s) pending") {
|
||||
t.Errorf("output = %s", got)
|
||||
}
|
||||
if strings.Index(got, "1786700005000") > strings.Index(got, "crm-1786800002000") {
|
||||
t.Errorf("dry-run order does not match run order:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An orphaned row is applied and unregistered; a dry run must not offer to
|
||||
// apply it, because a real run cannot.
|
||||
func TestPrintPendingSkipsOrphanedRows(t *testing.T) {
|
||||
entries := []migration.StatusEntry{
|
||||
{Version: "gone-1786800000000", AppCode: "gone", Applied: true, ApplyTime: at("2026-08-01 09:00:00")},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := printPending(&buf, entries, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "nothing to apply") {
|
||||
t.Errorf("output = %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintPendingFiltersByApp(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := printPending(&buf, sampleEntries(), "CRM"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := buf.String()
|
||||
if strings.Contains(got, "[core]") {
|
||||
t.Errorf("--app CRM listed the framework:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "1 migration(s) pending") {
|
||||
t.Errorf("output = %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
|
||||
"go-admin/common/actions"
|
||||
"go-admin/common/dto"
|
||||
"go-admin/common/models"
|
||||
)
|
||||
|
||||
// capturingLogger records every SQL statement GORM actually executes, so a
|
||||
// test can inspect it the way inspecting a *gorm.DB's own Statement cannot:
|
||||
// IndexAction builds and executes its query in one unbroken chain
|
||||
// (db.Model(...).Scopes(...).Find(...)...Count(...)) and never hands the
|
||||
// built statement back to its caller.
|
||||
type capturingLogger struct {
|
||||
gormlogger.Interface
|
||||
mu sync.Mutex
|
||||
stmts []string
|
||||
}
|
||||
|
||||
func (l *capturingLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||
sql, _ := fc()
|
||||
l.mu.Lock()
|
||||
l.stmts = append(l.stmts, sql)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *capturingLogger) all() string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return strings.Join(l.stmts, "\n")
|
||||
}
|
||||
|
||||
// probeRow is a minimal model satisfying models.ActiveRecord through the
|
||||
// same embeds a real app/admin model uses, so IndexAction sees exactly the
|
||||
// shape it is written against.
|
||||
type probeRow struct {
|
||||
models.Model
|
||||
models.ControlBy
|
||||
Name string
|
||||
}
|
||||
|
||||
func (probeRow) TableName() string { return "action_probe_row" }
|
||||
func (e *probeRow) Generate() models.ActiveRecord { o := *e; return &o }
|
||||
func (e *probeRow) GetId() interface{} { return e.Id }
|
||||
|
||||
// probeIndexReq is a minimal dto.Index: no search tags, page defaults.
|
||||
type probeIndexReq struct {
|
||||
dto.Pagination `search:"-"`
|
||||
}
|
||||
|
||||
// Generate returns a copy, the way every dto.Index in this repository does:
|
||||
// IndexAction closes over one instance and serves every request to the route
|
||||
// from it, so returning the receiver would share one struct across them. The
|
||||
// probe has to model that faithfully or it is not the shape IndexAction is
|
||||
// written against.
|
||||
func (p *probeIndexReq) Generate() dto.Index { o := *p; return &o }
|
||||
func (p *probeIndexReq) Bind(*gin.Context) error { return nil }
|
||||
func (p *probeIndexReq) GetNeedSearch() interface{} { return *p }
|
||||
|
||||
type pageEnvelope struct {
|
||||
Code int32 `json:"code"`
|
||||
}
|
||||
|
||||
// TestIndexActionAppliesDataPermission is an end-to-end guard core's own
|
||||
// test suite cannot provide. The five generic CRUD actions in this package
|
||||
// (create/delete/index/update/view.go) were not lowered to core (PRD 006
|
||||
// F3) - they still call actions.Permission directly, in this repository, on
|
||||
// a code path core knows nothing about. core's tests pin down what
|
||||
// Permission does for a given scope; nothing pinned down whether this
|
||||
// package's own Actions still remember to call it at all. This runs
|
||||
// IndexAction exactly as a real request would, against a real in-memory
|
||||
// database, and inspects the SQL GORM actually executed - not just that
|
||||
// the handler returned success, which it would just as happily do with no
|
||||
// filter applied at all.
|
||||
func TestProbeIndexReqGenerateReturnsAFreshInstance(t *testing.T) {
|
||||
p := &probeIndexReq{}
|
||||
got := p.Generate()
|
||||
if got == dto.Index(p) {
|
||||
t.Fatal("Generate returned the receiver; IndexAction would share one instance across every request to the route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexActionAppliesDataPermission(t *testing.T) {
|
||||
previous := config.ApplicationConfig.EnableDP
|
||||
config.ApplicationConfig.EnableDP = true
|
||||
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
|
||||
|
||||
cl := &capturingLogger{Interface: gormlogger.Default.LogMode(gormlogger.Silent)}
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: cl})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&probeRow{}); err != nil {
|
||||
t.Fatalf("AutoMigrate: %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Set("db", db)
|
||||
c.Set(actions.PermissionKey, &actions.DataPermission{DataScope: actions.DataScopeSelf, UserId: 7})
|
||||
|
||||
actions.IndexAction(&probeRow{}, &probeIndexReq{}, func() interface{} { return &[]probeRow{} })(c)
|
||||
|
||||
var body pageEnvelope
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decoding response body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
if body.Code != http.StatusOK {
|
||||
t.Fatalf("response code = %d, want %d; body=%s", body.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
|
||||
sql := cl.all()
|
||||
const wantFragment = "action_probe_row.create_by = "
|
||||
if !strings.Contains(sql, wantFragment) {
|
||||
t.Fatalf("IndexAction did not apply the data-permission scope to its query; want SQL containing %q, got:\n%s", wantFragment, sql)
|
||||
}
|
||||
}
|
||||
+32
-122
@@ -1,138 +1,48 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
"gorm.io/gorm"
|
||||
|
||||
contractactions "github.com/go-admin-team/go-admin-core/v2/sdk/contract/actions"
|
||||
)
|
||||
|
||||
type DataPermission struct {
|
||||
DataScope string
|
||||
UserId int
|
||||
DeptId int
|
||||
RoleId int
|
||||
}
|
||||
// DataPermission is a thin alias of go-admin-core's sdk/contract/actions
|
||||
// (PRD 006 F3/F5).
|
||||
type DataPermission = contractactions.DataPermission
|
||||
|
||||
// The five values sys_role.data_scope can hold, referenced directly from
|
||||
// go-admin-core's sdk/contract/actions rather than restated as literals -
|
||||
// see that package's DataScope* doc comment and PRD 006's hard constraint 4.
|
||||
const (
|
||||
DataScopeAll = contractactions.DataScopeAll
|
||||
DataScopeCustom = contractactions.DataScopeCustom
|
||||
DataScopeDept = contractactions.DataScopeDept
|
||||
DataScopeDeptTree = contractactions.DataScopeDeptTree
|
||||
DataScopeSelf = contractactions.DataScopeSelf
|
||||
)
|
||||
|
||||
// PermissionAction, Permission, GetPermissionFromContext and
|
||||
// IsValidDataScope forward to go-admin-core's sdk/contract/actions (PRD 006
|
||||
// F3/F5). create.go/delete.go/index.go/update.go/view.go in this package
|
||||
// (the generic CRUD actions, which do not move to core) call Permission and
|
||||
// GetPermissionFromContext by these same names and are unchanged by the
|
||||
// move: the names now resolve to forwards instead of local definitions, and
|
||||
// the behaviour is identical either way.
|
||||
func PermissionAction() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Permission() below returns the query untouched when data permission
|
||||
// is off, so the lookup that feeds it has nothing to feed. It used to
|
||||
// run anyway: a sys_user join on every list, detail, update and delete,
|
||||
// with the result discarded.
|
||||
if !config.ApplicationConfig.EnableDP {
|
||||
c.Set(PermissionKey, new(DataPermission))
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
userId := user.GetUserIdStr(c)
|
||||
if userId == "" {
|
||||
c.Set(PermissionKey, new(DataPermission))
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// The token already carries what the scope is decided by. Reading it
|
||||
// there costs nothing, and goes no more stale than rolekey does - which
|
||||
// Casbin has always read from the token.
|
||||
if p, ok := permissionFromClaims(c); ok {
|
||||
c.Set(PermissionKey, p)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
db, err := pkg.GetOrm(c)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return
|
||||
}
|
||||
msgID := pkg.GenerateMsgIDFromContext(c)
|
||||
p, err := newDataPermission(db, userId)
|
||||
if err != nil {
|
||||
log.Errorf("MsgID[%s] PermissionAction error: %s", msgID, err)
|
||||
response.Error(c, 500, err, "权限范围鉴定错误")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(PermissionKey, p)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// permissionFromClaims builds the scope from the token, reporting false when
|
||||
// the token predates deptid being carried. Such a token still exists until it
|
||||
// expires, and it has to keep working.
|
||||
func permissionFromClaims(c *gin.Context) (*DataPermission, bool) {
|
||||
claims := user.ExtractClaims(c)
|
||||
if claims["deptid"] == nil || claims["datascope"] == nil {
|
||||
return nil, false
|
||||
}
|
||||
scope, ok := claims["datascope"].(string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return &DataPermission{
|
||||
DataScope: scope,
|
||||
UserId: user.GetUserId(c),
|
||||
DeptId: user.GetDeptId(c),
|
||||
RoleId: user.GetRoleId(c),
|
||||
}, true
|
||||
}
|
||||
|
||||
func newDataPermission(tx *gorm.DB, userId interface{}) (*DataPermission, error) {
|
||||
var err error
|
||||
p := &DataPermission{}
|
||||
|
||||
err = tx.Table("sys_user").
|
||||
Select("sys_user.user_id", "sys_role.role_id", "sys_user.dept_id", "sys_role.data_scope").
|
||||
Joins("left join sys_role on sys_role.role_id = sys_user.role_id").
|
||||
Where("sys_user.user_id = ?", userId).
|
||||
Scan(p).Error
|
||||
if err != nil {
|
||||
err = errors.New("获取用户数据出错 msg:" + err.Error())
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
return contractactions.PermissionAction()
|
||||
}
|
||||
|
||||
func Permission(tableName string, p *DataPermission) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
if !config.ApplicationConfig.EnableDP {
|
||||
return db
|
||||
}
|
||||
switch p.DataScope {
|
||||
case "2":
|
||||
return db.Where(tableName+".create_by in (select sys_user.user_id from sys_role_dept left join sys_user on sys_user.dept_id=sys_role_dept.dept_id where sys_role_dept.role_id = ?)", p.RoleId)
|
||||
case "3":
|
||||
return db.Where(tableName+".create_by in (SELECT user_id from sys_user where dept_id = ? )", p.DeptId)
|
||||
case "4":
|
||||
return db.Where(tableName+".create_by in (SELECT user_id from sys_user where sys_user.dept_id in(select dept_id from sys_dept where dept_path like ? ))", "%/"+pkg.IntToString(p.DeptId)+"/%")
|
||||
case "5":
|
||||
return db.Where(tableName+".create_by = ?", p.UserId)
|
||||
default:
|
||||
return db
|
||||
}
|
||||
}
|
||||
return contractactions.Permission(tableName, p)
|
||||
}
|
||||
|
||||
func getPermissionFromContext(c *gin.Context) *DataPermission {
|
||||
p := new(DataPermission)
|
||||
if pm, ok := c.Get(PermissionKey); ok {
|
||||
switch pm.(type) {
|
||||
case *DataPermission:
|
||||
p = pm.(*DataPermission)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// GetPermissionFromContext 提供非action写法数据范围约束
|
||||
func GetPermissionFromContext(c *gin.Context) *DataPermission {
|
||||
return getPermissionFromContext(c)
|
||||
return contractactions.GetPermissionFromContext(c)
|
||||
}
|
||||
|
||||
// IsValidDataScope reports whether s is one of the five values Permission
|
||||
// recognizes. See go-admin-core's sdk/contract/actions.IsValidDataScope.
|
||||
func IsValidDataScope(s string) bool {
|
||||
return contractactions.IsValidDataScope(s)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package actions
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -6,76 +6,101 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
|
||||
"go-admin/common/actions"
|
||||
)
|
||||
|
||||
// No database is placed in the context on purpose. The middleware needs one
|
||||
// only to run the sys_user join, so reaching the handler proves it did not.
|
||||
func runPermission(t *testing.T, claims jwt.MapClaims) (*DataPermission, bool) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
// The detailed data-permission regression suite (claims parsing, the
|
||||
// GetOrm-unavailable abort, the SQL each data scope produces) now lives in
|
||||
// go-admin-core's sdk/contract/actions, alongside the logic itself (PRD 006
|
||||
// F3). What is left to test here is the shim's own wiring: that this
|
||||
// package's exported names still round-trip through the same *gin.Context
|
||||
// key core's PermissionAction and GetPermissionFromContext use.
|
||||
//
|
||||
// This file lives in package actions_test, an external test, deliberately:
|
||||
// it exercises PermissionAction and GetPermissionFromContext exactly as an
|
||||
// app/admin Service does, through this package's public API only, not
|
||||
// through anything internal a forward could paper over.
|
||||
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if claims != nil {
|
||||
c.Set(jwt.JwtPayloadKey, claims)
|
||||
}
|
||||
|
||||
PermissionAction()(c)
|
||||
|
||||
value, exists := c.Get(PermissionKey)
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
p, _ := value.(*DataPermission)
|
||||
return p, true
|
||||
}
|
||||
|
||||
// Permission() returns the query untouched when data permission is off, so the
|
||||
// lookup feeding it has nothing to feed. It used to run regardless: a sys_user
|
||||
// join on every list, detail, update and delete, discarded immediately.
|
||||
func TestNoLookupWhenDataPermissionIsOff(t *testing.T) {
|
||||
previous := config.ApplicationConfig.EnableDP
|
||||
config.ApplicationConfig.EnableDP = false
|
||||
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
|
||||
|
||||
if _, ok := runPermission(t, jwt.MapClaims{"identity": float64(7)}); !ok {
|
||||
t.Fatal("the request needed a database even though data permission is off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeComesFromTheTokenWhenItCarriesOne(t *testing.T) {
|
||||
// TestPermissionKeyMatchesWhatPermissionActionSets guards PRD 006's hard
|
||||
// constraint 4: PermissionKey must be declared as
|
||||
// `const PermissionKey = contractactions.PermissionKey`, a direct
|
||||
// reference, never a restated literal (see type.go). PermissionAction is
|
||||
// core's middleware and always writes under core's own key. This test reads
|
||||
// the value back with actions.PermissionKey exactly as code outside
|
||||
// GetPermissionFromContext would - c.Get(actions.PermissionKey) is a real,
|
||||
// if uncommon, way to read the value go-admin has always allowed, and it is
|
||||
// the one call site where an independently declared PermissionKey would
|
||||
// stop working without GetPermissionFromContext's own forward hiding it.
|
||||
//
|
||||
// If PermissionKey were ever re-declared as an independent literal in this
|
||||
// package, a later edit to core's copy would make this test fail without a
|
||||
// single byte of this package having changed - which is the silent-failure
|
||||
// mode hard constraint 4 exists to rule out (evaluation S2).
|
||||
func TestPermissionKeyMatchesWhatPermissionActionSets(t *testing.T) {
|
||||
previous := config.ApplicationConfig.EnableDP
|
||||
config.ApplicationConfig.EnableDP = true
|
||||
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
|
||||
|
||||
p, ok := runPermission(t, jwt.MapClaims{
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{
|
||||
"identity": float64(7),
|
||||
"roleid": float64(3),
|
||||
"deptid": float64(5),
|
||||
"datascope": "4",
|
||||
"datascope": actions.DataScopeDeptTree,
|
||||
})
|
||||
|
||||
actions.PermissionAction()(c)
|
||||
|
||||
value, ok := c.Get(actions.PermissionKey)
|
||||
if !ok {
|
||||
t.Fatal("the token carried the scope and a database was still needed")
|
||||
t.Fatal("PermissionAction did not set the key actions.PermissionKey names; the two have diverged")
|
||||
}
|
||||
if p.DataScope != "4" || p.UserId != 7 || p.DeptId != 5 || p.RoleId != 3 {
|
||||
t.Fatalf("scope read as %+v", p)
|
||||
p, ok := value.(*actions.DataPermission)
|
||||
if !ok || p.DataScope != actions.DataScopeDeptTree || p.DeptId != 5 {
|
||||
t.Fatalf("value under actions.PermissionKey = %#v, want a DataPermission carrying the token's scope", value)
|
||||
}
|
||||
}
|
||||
|
||||
// A token minted before deptid was carried is still valid until it expires, and
|
||||
// has to keep working - by falling back to the query, which needs a database.
|
||||
func TestATokenWithoutDeptIdFallsBackToTheQuery(t *testing.T) {
|
||||
// TestGetPermissionFromContextRoundTrips is the same guard from the other
|
||||
// exported entry point: GetPermissionFromContext must read back exactly
|
||||
// what PermissionAction wrote, both reached through this package's own
|
||||
// forwards rather than core's directly.
|
||||
func TestGetPermissionFromContextRoundTrips(t *testing.T) {
|
||||
previous := config.ApplicationConfig.EnableDP
|
||||
config.ApplicationConfig.EnableDP = true
|
||||
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
|
||||
|
||||
if _, ok := runPermission(t, jwt.MapClaims{
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{
|
||||
"identity": float64(7),
|
||||
"roleid": float64(3),
|
||||
"datascope": "4",
|
||||
}); ok {
|
||||
t.Fatal("an old token was served from claims it does not have")
|
||||
"deptid": float64(5),
|
||||
"datascope": actions.DataScopeSelf,
|
||||
})
|
||||
|
||||
actions.PermissionAction()(c)
|
||||
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
if p.DataScope != actions.DataScopeSelf || p.UserId != 7 {
|
||||
t.Fatalf("GetPermissionFromContext() = %+v, want DataScope=%q UserId=7", p, actions.DataScopeSelf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidDataScope(t *testing.T) {
|
||||
for _, s := range []string{actions.DataScopeAll, actions.DataScopeCustom, actions.DataScopeDept, actions.DataScopeDeptTree, actions.DataScopeSelf} {
|
||||
if !actions.IsValidDataScope(s) {
|
||||
t.Errorf("IsValidDataScope(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
if actions.IsValidDataScope("6") {
|
||||
t.Error(`IsValidDataScope("6") = true, want false`)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -1,5 +1,13 @@
|
||||
package actions
|
||||
|
||||
const (
|
||||
PermissionKey = "dataPermission"
|
||||
)
|
||||
import contractactions "github.com/go-admin-team/go-admin-core/v2/sdk/contract/actions"
|
||||
|
||||
// PermissionKey is a direct reference to go-admin-core's sdk/contract/actions
|
||||
// constant, not a restated literal - see that package's PermissionKey doc
|
||||
// comment. PRD 006's hard constraint 4 requires this form for exactly this
|
||||
// symbol: PermissionAction (below) sets the gin context key it owns, and
|
||||
// GetPermissionFromContext reads it back; an independently declared literal
|
||||
// here would let the two silently drift apart if core's copy ever changed
|
||||
// without this one following. common/actions/shim_test.go carries the
|
||||
// regression test for that failure mode.
|
||||
const PermissionKey = contractactions.PermissionKey
|
||||
|
||||
@@ -63,7 +63,11 @@ func setupSimpleDatabase(host string, c *toolsConfig.Database) {
|
||||
log.Info(pkg.Green(c.Driver + " connect success !"))
|
||||
}
|
||||
|
||||
e := mycasbin.Setup(db, "")
|
||||
// Keyed by host, matching the database this enforcer reads from. Passing
|
||||
// the same key for every host would hand each one the enforcer built from
|
||||
// whichever database was configured first, and the rest would be decided
|
||||
// by a casbin_rule table that is not theirs.
|
||||
e := mycasbin.Setup(db, host)
|
||||
|
||||
sdk.Runtime.SetDbByTenant(host, db)
|
||||
sdk.Runtime.SetCasbinByTenant(host, e)
|
||||
|
||||
+12
-71
@@ -1,74 +1,15 @@
|
||||
package dto
|
||||
|
||||
type AutoForm struct {
|
||||
Fields []Field `json:"fields"`
|
||||
FormRef string `json:"formRef"`
|
||||
FormModel string `json:"formModel"`
|
||||
Size string `json:"size"`
|
||||
LabelPosition string `json:"labelPosition"`
|
||||
LabelWidth int `json:"labelWidth"`
|
||||
FormRules string `json:"formRules"`
|
||||
Gutter int `json:"gutter"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Span int `json:"span"`
|
||||
FormBtns bool `json:"formBtns"`
|
||||
}
|
||||
import contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
|
||||
|
||||
type Config struct {
|
||||
Label string `json:"label"`
|
||||
LabelWidth interface{} `json:"labelWidth"`
|
||||
ShowLabel bool `json:"showLabel"`
|
||||
ChangeTag bool `json:"changeTag"`
|
||||
Tag string `json:"tag"`
|
||||
TagIcon string `json:"tagIcon"`
|
||||
Required bool `json:"required"`
|
||||
Layout string `json:"layout"`
|
||||
Span int `json:"span"`
|
||||
Document string `json:"document"`
|
||||
RegList []interface{} `json:"regList"`
|
||||
FormId int `json:"formId"`
|
||||
RenderKey int64 `json:"renderKey"`
|
||||
DefaultValue interface{} `json:"defaultValue"`
|
||||
ShowTip bool `json:"showTip,omitempty"`
|
||||
ButtonText string `json:"buttonText,omitempty"`
|
||||
FileSize int `json:"fileSize,omitempty"`
|
||||
SizeUnit string `json:"sizeUnit,omitempty"`
|
||||
}
|
||||
|
||||
type Option struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type Slot struct {
|
||||
Prepend string `json:"prepend,omitempty"`
|
||||
Append string `json:"append,omitempty"`
|
||||
ListType bool `json:"list-type,omitempty"`
|
||||
Options []Option `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type Field struct {
|
||||
Config Config `json:"__config__"`
|
||||
Slot Slot `json:"__slot__"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Style Style `json:"style,omitempty"`
|
||||
Clearable bool `json:"clearable,omitempty"`
|
||||
PrefixIcon string `json:"prefix-icon,omitempty"`
|
||||
SuffixIcon string `json:"suffix-icon,omitempty"`
|
||||
Maxlength interface{} `json:"maxlength"`
|
||||
ShowWordLimit bool `json:"show-word-limit,omitempty"`
|
||||
Readonly bool `json:"readonly,omitempty"`
|
||||
Disabled bool `json:"disabled"`
|
||||
VModel string `json:"__vModel__"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Accept string `json:"accept,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
AutoUpload bool `json:"auto-upload,omitempty"`
|
||||
ListType string `json:"list-type,omitempty"`
|
||||
Multiple bool `json:"multiple,omitempty"`
|
||||
Filterable bool `json:"filterable,omitempty"`
|
||||
}
|
||||
|
||||
type Style struct {
|
||||
Width string `json:"width"`
|
||||
}
|
||||
// AutoForm and the types below describe a form built by go-admin-ui's form
|
||||
// designer. They are thin aliases of go-admin-core's sdk/contract/dto (PRD
|
||||
// 006 F2/F5).
|
||||
type (
|
||||
AutoForm = contractdto.AutoForm
|
||||
Config = contractdto.Config
|
||||
Option = contractdto.Option
|
||||
Slot = contractdto.Slot
|
||||
Field = contractdto.Field
|
||||
Style = contractdto.Style
|
||||
)
|
||||
|
||||
+7
-102
@@ -1,106 +1,11 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
vd "github.com/bytedance/go-tagexpr/v2/validator"
|
||||
"net/http"
|
||||
import contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
// ObjectById, ObjectGetReq and ObjectDeleteReq are thin aliases of
|
||||
// go-admin-core's sdk/contract/dto (PRD 006 F2/F5).
|
||||
type (
|
||||
ObjectById = contractdto.ObjectById
|
||||
ObjectGetReq = contractdto.ObjectGetReq
|
||||
ObjectDeleteReq = contractdto.ObjectDeleteReq
|
||||
)
|
||||
|
||||
type ObjectById struct {
|
||||
Id int `uri:"id"`
|
||||
Ids []int `json:"ids"`
|
||||
}
|
||||
|
||||
func (s *ObjectById) Bind(ctx *gin.Context) error {
|
||||
var err error
|
||||
log := api.GetRequestLogger(ctx)
|
||||
err = ctx.ShouldBindUri(s)
|
||||
if err != nil {
|
||||
log.Warnf("ShouldBindUri error: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
if ctx.Request.Method == http.MethodDelete {
|
||||
err = ctx.ShouldBind(&s)
|
||||
if err != nil {
|
||||
log.Warnf("ShouldBind error: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
if len(s.Ids) > 0 {
|
||||
return nil
|
||||
}
|
||||
if s.Ids == nil {
|
||||
s.Ids = make([]int, 0)
|
||||
}
|
||||
if s.Id != 0 {
|
||||
s.Ids = append(s.Ids, s.Id)
|
||||
}
|
||||
}
|
||||
if err = vd.Validate(s); err != nil {
|
||||
log.Errorf("Validate error: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ObjectById) GetId() interface{} {
|
||||
if len(s.Ids) > 0 {
|
||||
s.Ids = append(s.Ids, s.Id)
|
||||
return s.Ids
|
||||
}
|
||||
return s.Id
|
||||
}
|
||||
|
||||
type ObjectGetReq struct {
|
||||
Id int `uri:"id"`
|
||||
}
|
||||
|
||||
func (s *ObjectGetReq) Bind(ctx *gin.Context) error {
|
||||
var err error
|
||||
log := api.GetRequestLogger(ctx)
|
||||
err = ctx.ShouldBindUri(s)
|
||||
if err != nil {
|
||||
log.Warnf("ShouldBindUri error: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
if err = vd.Validate(s); err != nil {
|
||||
log.Errorf("Validate error: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ObjectGetReq) GetId() interface{} {
|
||||
return s.Id
|
||||
}
|
||||
|
||||
type ObjectDeleteReq struct {
|
||||
Ids []int `json:"ids"`
|
||||
}
|
||||
|
||||
func (s *ObjectDeleteReq) Bind(ctx *gin.Context) error {
|
||||
var err error
|
||||
log := api.GetRequestLogger(ctx)
|
||||
err = ctx.ShouldBind(&s)
|
||||
if err != nil {
|
||||
log.Warnf("ShouldBind error: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
if len(s.Ids) > 0 {
|
||||
return nil
|
||||
}
|
||||
if s.Ids == nil {
|
||||
s.Ids = make([]int, 0)
|
||||
}
|
||||
|
||||
if err = vd.Validate(s); err != nil {
|
||||
log.Errorf("Validate error: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ObjectDeleteReq) GetId() interface{} {
|
||||
return s.Ids
|
||||
}
|
||||
|
||||
+6
-4
@@ -2,11 +2,13 @@ package dto
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
|
||||
)
|
||||
|
||||
// OrderDest forwards to go-admin-core's sdk/contract/dto (PRD 006 F2/F5). A
|
||||
// function cannot be aliased the way a type can, so this is a pure
|
||||
// pass-through rather than a `func X = pkg.X` form Go does not have.
|
||||
func OrderDest(sort string, bl bool) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order(clause.OrderByColumn{Column: clause.Column{Name: sort}, Desc: bl})
|
||||
}
|
||||
return contractdto.OrderDest(sort, bl)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
package dto
|
||||
|
||||
type Pagination struct {
|
||||
PageIndex int `form:"pageIndex"`
|
||||
PageSize int `form:"pageSize"`
|
||||
}
|
||||
import contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
|
||||
|
||||
func (m *Pagination) GetPageIndex() int {
|
||||
if m.PageIndex <= 0 {
|
||||
m.PageIndex = 1
|
||||
}
|
||||
return m.PageIndex
|
||||
}
|
||||
|
||||
func (m *Pagination) GetPageSize() int {
|
||||
if m.PageSize <= 0 {
|
||||
m.PageSize = 10
|
||||
}
|
||||
return m.PageSize
|
||||
}
|
||||
// Pagination is a thin alias of go-admin-core's sdk/contract/dto (PRD 006
|
||||
// F2/F5).
|
||||
type Pagination = contractdto.Pagination
|
||||
|
||||
+19
-68
@@ -1,80 +1,31 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/go-admin-team/go-admin-core/v2/tools/search"
|
||||
"go-admin/common/global"
|
||||
"gorm.io/gorm"
|
||||
|
||||
contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
|
||||
)
|
||||
|
||||
type GeneralDelDto struct {
|
||||
Id int `uri:"id" json:"id" validate:"required"`
|
||||
Ids []int `json:"ids"`
|
||||
}
|
||||
|
||||
func (g GeneralDelDto) GetIds() []int {
|
||||
ids := make([]int, 0)
|
||||
// Id 此前在 else 分支里被重复追加:仅传 Id 时会得到 [5 5],
|
||||
// 同一条记录被执行两次删除
|
||||
if g.Id > 0 {
|
||||
ids = append(ids, g.Id)
|
||||
}
|
||||
for _, id := range g.Ids {
|
||||
if id > 0 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
//方式全部删除
|
||||
ids = append(ids, 0)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
type GeneralGetDto struct {
|
||||
Id int `uri:"id" json:"id" validate:"required"`
|
||||
}
|
||||
// GeneralDelDto and GeneralGetDto are thin aliases of go-admin-core's
|
||||
// sdk/contract/dto (PRD 006 F2/F5).
|
||||
type (
|
||||
GeneralDelDto = contractdto.GeneralDelDto
|
||||
GeneralGetDto = contractdto.GeneralGetDto
|
||||
)
|
||||
|
||||
// MakeCondition and Paginate forward to go-admin-core's sdk/contract/dto
|
||||
// (PRD 006 F2/F5). This file used to read go-admin/common/global.Driver to
|
||||
// pick the SQL dialect MakeCondition resolves search tags against; the
|
||||
// lowered version instead reads db.Dialector.Name() from inside the closure
|
||||
// it returns, which is always the driver the caller's own *gorm.DB is bound
|
||||
// to - correct even when a multi-tenant host has more than one database
|
||||
// open with different drivers, which a single package-level variable could
|
||||
// never be. global.Driver itself is untouched and still readable, but
|
||||
// nothing in this package reads it anymore.
|
||||
func MakeCondition(q interface{}) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
condition := &search.GormCondition{
|
||||
GormPublic: search.GormPublic{},
|
||||
Join: make([]*search.GormJoin, 0),
|
||||
}
|
||||
search.ResolveSearchQuery(global.Driver, q, condition)
|
||||
for _, join := range condition.Join {
|
||||
if join == nil {
|
||||
continue
|
||||
}
|
||||
db = db.Joins(join.JoinOn)
|
||||
for k, v := range join.Where {
|
||||
db = db.Where(k, v...)
|
||||
}
|
||||
for k, v := range join.Or {
|
||||
db = db.Or(k, v...)
|
||||
}
|
||||
for _, o := range join.Order {
|
||||
db = db.Order(o)
|
||||
}
|
||||
}
|
||||
for k, v := range condition.Where {
|
||||
db = db.Where(k, v...)
|
||||
}
|
||||
for k, v := range condition.Or {
|
||||
db = db.Or(k, v...)
|
||||
}
|
||||
for _, o := range condition.Order {
|
||||
db = db.Order(o)
|
||||
}
|
||||
return db
|
||||
}
|
||||
return contractdto.MakeCondition(q)
|
||||
}
|
||||
|
||||
func Paginate(pageSize, pageIndex int) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
offset := (pageIndex - 1) * pageSize
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
return db.Offset(offset).Limit(pageSize)
|
||||
}
|
||||
return contractdto.Paginate(pageSize, pageIndex)
|
||||
}
|
||||
|
||||
+7
-18
@@ -1,21 +1,10 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"go-admin/common/models"
|
||||
import contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
|
||||
|
||||
// Index and Control are thin aliases of go-admin-core's sdk/contract/dto
|
||||
// (PRD 006 F2/F5).
|
||||
type (
|
||||
Index = contractdto.Index
|
||||
Control = contractdto.Control
|
||||
)
|
||||
|
||||
type Index interface {
|
||||
Generate() Index
|
||||
Bind(ctx *gin.Context) error
|
||||
GetPageIndex() int
|
||||
GetPageSize() int
|
||||
GetNeedSearch() interface{}
|
||||
}
|
||||
|
||||
type Control interface {
|
||||
Generate() Control
|
||||
Bind(ctx *gin.Context) error
|
||||
GenerateM() (models.ActiveRecord, error)
|
||||
GetId() interface{}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,12 @@ const (
|
||||
|
||||
var (
|
||||
// Driver 数据库驱动
|
||||
//
|
||||
// Deprecated: common/dto.MakeCondition stopped reading this after PRD
|
||||
// 006 F2/F5 - it now takes the dialect from the *gorm.DB passed to the
|
||||
// scope it returns instead of this process-wide variable. Driver is
|
||||
// still set (common/database/initialize.go) and still readable for fork
|
||||
// code that reads it directly, but it is no longer this framework's own
|
||||
// path to the current SQL dialect.
|
||||
Driver string
|
||||
)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package global
|
||||
|
||||
// Status values written to sys_opera_log.status.
|
||||
//
|
||||
// They live here rather than in app/admin/service/dto because
|
||||
// common/middleware/logger.go writes the operation-log message and needs them.
|
||||
// A package promised as a stable contract must not compile-depend on a
|
||||
// business module: a fork that replaces or drops app/admin would otherwise
|
||||
// stop compiling common/middleware, which is not something a contract package
|
||||
// is allowed to do. See docs/contract.md.
|
||||
const (
|
||||
OperaStatusEnabled = "1"
|
||||
OperaStatusDisabled = "2"
|
||||
)
|
||||
@@ -3,11 +3,19 @@ package middleware
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"go-admin/common/middleware/handler"
|
||||
)
|
||||
|
||||
// authMiddleware is the single JWT middleware instance the whole process
|
||||
// shares. InitMiddleware builds it once, before any module registers its
|
||||
// routes; GetAuthMiddleware is how a module gets it back instead of calling
|
||||
// AuthInit itself and building another, functionally-equivalent-but-distinct
|
||||
// instance.
|
||||
var authMiddleware *jwt.GinJWTMiddleware
|
||||
|
||||
// AuthInit jwt验证new
|
||||
func AuthInit() (*jwt.GinJWTMiddleware, error) {
|
||||
timeout := time.Hour
|
||||
@@ -33,4 +41,23 @@ func AuthInit() (*jwt.GinJWTMiddleware, error) {
|
||||
TimeFunc: time.Now,
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// GetAuthMiddleware returns the shared JWT middleware instance InitMiddleware
|
||||
// built at startup. Application modules (app/admin, app/jobs, app/other,
|
||||
// app/demo) call this instead of AuthInit so their router chains - which
|
||||
// still need the instance itself for authMiddleware.MiddlewareFunc() and
|
||||
// authMiddleware.LoginHandler, not just the bound closure registered under
|
||||
// sdk.Runtime's JwtTokenCheck key - end up using the same instance the host
|
||||
// registered, rather than one each.
|
||||
//
|
||||
// It fails loudly instead of returning nil: an InitRouter that runs before
|
||||
// InitMiddleware has a real startup-ordering bug, not a case to paper over
|
||||
// with a nil *jwt.GinJWTMiddleware that would panic much further down the
|
||||
// call chain with a far less useful stack trace.
|
||||
func GetAuthMiddleware() *jwt.GinJWTMiddleware {
|
||||
if authMiddleware == nil {
|
||||
log.Fatal("JWT middleware not initialized; InitMiddleware must run before any module's InitRouter")
|
||||
}
|
||||
return authMiddleware
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/common"
|
||||
"net/http"
|
||||
|
||||
@@ -163,19 +162,31 @@ func LogOut(c *gin.Context) {
|
||||
|
||||
}
|
||||
|
||||
// Authorizator decides whether a parsed identity may proceed. It authorizes
|
||||
// every identity IdentityHandler was able to build, which is what it has always
|
||||
// done.
|
||||
//
|
||||
// It used to also assert data["user"] and data["role"] into app/admin/models
|
||||
// types and copy five fields onto the context. Those two keys are not in the
|
||||
// map: IdentityHandler builds it from the token claims and puts in
|
||||
// IdentityKey / UserName / RoleKey / UserId / RoleIds / DataScope. Both
|
||||
// assertions therefore failed on every request, and because the ok result was
|
||||
// discarded, the five c.Set calls stored zero values and the function returned
|
||||
// true regardless.
|
||||
//
|
||||
// Nothing in this repository or in go-admin-core reads role / roleIds /
|
||||
// userId / userName / dataScope off the context - the open-source data
|
||||
// permission path reads the JWT claims through
|
||||
// common/actions.Permission -> user.GetUserIdStr(c). Dropping the block
|
||||
// therefore removes five zero values nobody read, and with them the last
|
||||
// import of app/admin from a contract package.
|
||||
//
|
||||
// Anything maintaining its own copy of this file must check its own consumers
|
||||
// before taking this change: a codebase that does read those keys off the
|
||||
// context needs Authorizator to keep setting them.
|
||||
func Authorizator(data interface{}, c *gin.Context) bool {
|
||||
|
||||
if v, ok := data.(map[string]interface{}); ok {
|
||||
u, _ := v["user"].(models.SysUser)
|
||||
r, _ := v["role"].(models.SysRole)
|
||||
c.Set("role", r.RoleName)
|
||||
c.Set("roleIds", r.RoleId)
|
||||
c.Set("userId", u.UserId)
|
||||
c.Set("userName", u.Username)
|
||||
c.Set("dataScope", r.DataScope)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
_, ok := data.(map[string]interface{})
|
||||
return ok
|
||||
}
|
||||
|
||||
func Unauthorized(c *gin.Context, code int, message string) {
|
||||
|
||||
@@ -2,15 +2,20 @@ package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
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/runtime"
|
||||
"go-admin/common/actions"
|
||||
)
|
||||
|
||||
// These alias core's own constants (see sdk/runtime.GetHandlerFunc's contract
|
||||
// doc, section 9) rather than redeclaring the same three strings, so a typo
|
||||
// here can no longer split registration and lookup into two different keys
|
||||
// that both happen to compile.
|
||||
const (
|
||||
JwtTokenCheck string = "JwtToken"
|
||||
RoleCheck string = "AuthCheckRole"
|
||||
PermissionCheck string = "PermissionAction"
|
||||
JwtTokenCheck = runtime.JwtTokenCheck
|
||||
RoleCheck = runtime.RoleCheck
|
||||
PermissionCheck = runtime.PermissionCheck
|
||||
)
|
||||
|
||||
func InitMiddleware(r *gin.Engine) {
|
||||
@@ -29,7 +34,26 @@ func InitMiddleware(r *gin.Engine) {
|
||||
r.Use(Secure)
|
||||
// 链路追踪
|
||||
//r.Use(middleware.Trace())
|
||||
sdk.Runtime.SetMiddleware(JwtTokenCheck, (*jwt.GinJWTMiddleware).MiddlewareFunc)
|
||||
|
||||
// Build the shared JWT middleware instance here, before any module
|
||||
// registers routes (initRouter runs ahead of runStartupHooks, which is
|
||||
// what invokes each module's InitRouter - see cmd/api/server.go). Doing
|
||||
// it once here, instead of once per module via AuthInit, is what makes
|
||||
// GetAuthMiddleware and sdk.Runtime.GetHandlerFunc(JwtTokenCheck) both
|
||||
// resolve to a single, meaningful instance instead of "whichever module
|
||||
// happened to initialize last".
|
||||
//
|
||||
// SetMiddleware must be given a bound closure (authMiddleware.MiddlewareFunc()),
|
||||
// not the unbound method expression (*jwt.GinJWTMiddleware).MiddlewareFunc:
|
||||
// the latter has no receiver bound to it, so GetHandlerFunc's type
|
||||
// assertion to gin.HandlerFunc always fails for it.
|
||||
var err error
|
||||
authMiddleware, err = AuthInit()
|
||||
if err != nil {
|
||||
// A process with no JWT middleware must not start serving requests.
|
||||
log.Fatalf("JWT Init Error, %s", err.Error())
|
||||
}
|
||||
sdk.Runtime.SetMiddleware(JwtTokenCheck, authMiddleware.MiddlewareFunc())
|
||||
sdk.Runtime.SetMiddleware(RoleCheck, AuthCheckRole())
|
||||
sdk.Runtime.SetMiddleware(PermissionCheck, actions.PermissionAction())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
)
|
||||
|
||||
// freshRuntime hands the test its own Runtime and puts the old one back, the
|
||||
// same pattern cmd/api/server_test.go uses: sdk.Runtime is a process-wide
|
||||
// singleton, and a test that registers into it would otherwise leak state
|
||||
// into every other test in the binary.
|
||||
func freshRuntime(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := sdk.Runtime
|
||||
t.Cleanup(func() { sdk.Runtime = previous })
|
||||
sdk.Runtime = runtime.NewConfig()
|
||||
}
|
||||
|
||||
// TestInitMiddlewareRegistersUsableJwtHandlerFunc is the reverse proof for
|
||||
// hoisting the JWT instance's construction into InitMiddleware:
|
||||
// sdk.Runtime.GetHandlerFunc(JwtTokenCheck) must hand back ok=true and a
|
||||
// non-nil gin.HandlerFunc, not just something GetMiddleware can return as an
|
||||
// untyped interface{}.
|
||||
//
|
||||
// Before this change, InitMiddleware registered the unbound method
|
||||
// expression (*jwt.GinJWTMiddleware).MiddlewareFunc under this key - a value
|
||||
// with no receiver bound to it, which is not a gin.HandlerFunc no matter how
|
||||
// a caller asserts its type. Reverting the registration below to that
|
||||
// expression makes GetHandlerFunc report ok=false; it does not fail to
|
||||
// compile, because (*jwt.GinJWTMiddleware).MiddlewareFunc has a well-formed,
|
||||
// unrelated method-expression type that SetMiddleware's interface{} param
|
||||
// happily accepts.
|
||||
func TestInitMiddlewareRegistersUsableJwtHandlerFunc(t *testing.T) {
|
||||
freshRuntime(t)
|
||||
|
||||
previousSecret := config.JwtConfig.Secret
|
||||
config.JwtConfig.Secret = "test-secret-key"
|
||||
t.Cleanup(func() { config.JwtConfig.Secret = previousSecret })
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
InitMiddleware(gin.New())
|
||||
|
||||
h, ok := sdk.Runtime.GetHandlerFunc(JwtTokenCheck)
|
||||
if !ok {
|
||||
t.Fatal("GetHandlerFunc(JwtTokenCheck) reported ok=false after InitMiddleware ran")
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("GetHandlerFunc(JwtTokenCheck) reported ok=true but returned a nil handler")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInitMiddlewareBuildsOneSharedJwtInstance locks down the fix for the
|
||||
// four-instances problem: GetAuthMiddleware must return the very instance
|
||||
// InitMiddleware built and handed to sdk.Runtime, not a lookalike built
|
||||
// separately by whichever caller asks first.
|
||||
func TestInitMiddlewareBuildsOneSharedJwtInstance(t *testing.T) {
|
||||
freshRuntime(t)
|
||||
|
||||
previousSecret := config.JwtConfig.Secret
|
||||
config.JwtConfig.Secret = "test-secret-key"
|
||||
t.Cleanup(func() { config.JwtConfig.Secret = previousSecret })
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
InitMiddleware(gin.New())
|
||||
|
||||
shared := GetAuthMiddleware()
|
||||
if shared == nil {
|
||||
t.Fatal("GetAuthMiddleware returned nil after InitMiddleware ran")
|
||||
}
|
||||
if shared != authMiddleware {
|
||||
t.Error("GetAuthMiddleware did not return the package-level instance InitMiddleware built")
|
||||
}
|
||||
}
|
||||
+67
-20
@@ -1,19 +1,18 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"go-admin/app/admin/service/dto"
|
||||
"errors"
|
||||
"go-admin/common"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
|
||||
"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"
|
||||
@@ -28,19 +27,14 @@ func LoggerToFile() gin.HandlerFunc {
|
||||
// 开始时间
|
||||
startTime := time.Now()
|
||||
// 处理请求
|
||||
//
|
||||
// The body is only read when it has a destination. operParam below is
|
||||
// the only consumer, and it is written when logger.enableddb is on -
|
||||
// off in the shipped configuration, where reading the body was a copy
|
||||
// of every request made and discarded.
|
||||
var body string
|
||||
switch c.Request.Method {
|
||||
case http.MethodPost, http.MethodPut, http.MethodGet, http.MethodDelete:
|
||||
bf := bytes.NewBuffer(nil)
|
||||
wt := bufio.NewWriter(bf)
|
||||
_, err := io.Copy(wt, c.Request.Body)
|
||||
if err != nil {
|
||||
log.Warnf("copy body error, %s", err.Error())
|
||||
err = nil
|
||||
}
|
||||
rb, _ := ioutil.ReadAll(bf)
|
||||
c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(rb))
|
||||
body = string(rb)
|
||||
if config.LoggerConfig.EnabledDB {
|
||||
body = readOperParam(c, log)
|
||||
}
|
||||
|
||||
c.Next()
|
||||
@@ -100,10 +94,55 @@ func LoggerToFile() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// SetDBOperLog 写入操作日志表 fixme 该方法后续即将弃用
|
||||
func SetDBOperLog(c *gin.Context, clientIP string, statusCode int, reqUri string, reqMethod string, latencyTime time.Duration, body string, result string, status int) {
|
||||
// operParamLimit caps what is copied out of a request body for the operation
|
||||
// log. A file upload is a POST like any other and reaches this middleware
|
||||
// before any handler, so without a limit the whole upload is held in memory to
|
||||
// write a log row - a 16MB upload allocated about 67MB. The limit also keeps
|
||||
// the value inside the column, which is TEXT.
|
||||
const operParamLimit = 32 << 10
|
||||
|
||||
log := api.GetRequestLogger(c)
|
||||
// readOperParam copies the start of the request body for the operation log and
|
||||
// leaves the request readable by the handler.
|
||||
//
|
||||
// The body is not buffered whole: the handler reads the part copied here from
|
||||
// memory and the rest straight from the connection, so what this holds is
|
||||
// bounded by operParamLimit however large the request is.
|
||||
func readOperParam(c *gin.Context, log *logger.Helper) string {
|
||||
switch c.Request.Method {
|
||||
case http.MethodPost, http.MethodPut, http.MethodGet, http.MethodDelete:
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
if c.Request.Body == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
rest := c.Request.Body
|
||||
head := make([]byte, operParamLimit)
|
||||
n, err := io.ReadFull(rest, head)
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
log.Warnf("read body for the operation log: %s", err)
|
||||
}
|
||||
head = head[:n]
|
||||
|
||||
c.Request.Body = readCloser{
|
||||
Reader: io.MultiReader(bytes.NewReader(head), rest),
|
||||
Closer: rest,
|
||||
}
|
||||
return string(head)
|
||||
}
|
||||
|
||||
type readCloser struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}
|
||||
|
||||
// operaLogFields builds the message written to the operation log queue.
|
||||
//
|
||||
// Split out of SetDBOperLog so the field set can be asserted in a test: the
|
||||
// consumer on the other end of the queue reads these keys by name, so a
|
||||
// dropped or renamed key costs a column in sys_opera_log and reports nothing.
|
||||
func operaLogFields(c *gin.Context, clientIP string, statusCode int, reqUri string, reqMethod string, latencyTime time.Duration, body string, result string, status int) map[string]interface{} {
|
||||
l := make(map[string]interface{})
|
||||
l["_fullPath"] = c.FullPath()
|
||||
l["operUrl"] = reqUri
|
||||
@@ -120,10 +159,18 @@ func SetDBOperLog(c *gin.Context, clientIP string, statusCode int, reqUri string
|
||||
l["createBy"] = user.GetUserId(c)
|
||||
l["updateBy"] = user.GetUserId(c)
|
||||
if status == http.StatusOK {
|
||||
l["status"] = dto.OperaStatusEnabel
|
||||
l["status"] = global.OperaStatusEnabled
|
||||
} else {
|
||||
l["status"] = dto.OperaStatusDisable
|
||||
l["status"] = global.OperaStatusDisabled
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// SetDBOperLog 写入操作日志表 fixme 该方法后续即将弃用
|
||||
func SetDBOperLog(c *gin.Context, clientIP string, statusCode int, reqUri string, reqMethod string, latencyTime time.Duration, body string, result string, status int) {
|
||||
|
||||
log := api.GetRequestLogger(c)
|
||||
l := operaLogFields(c, clientIP, statusCode, reqUri, reqMethod, latencyTime, body, result, status)
|
||||
q := sdk.Runtime.GetQueuePrefix(c.Request.Host)
|
||||
message, err := sdk.Runtime.GetStreamMessage("", global.OperateLog, l)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
)
|
||||
|
||||
// serveWithLogger runs one request through the logger middleware and returns
|
||||
// what the handler saw, with logger.enableddb set as given.
|
||||
func serveWithLogger(t testing.TB, enabledDB bool, method, body string) string {
|
||||
t.Helper()
|
||||
|
||||
prev := config.LoggerConfig.EnabledDB
|
||||
config.LoggerConfig.EnabledDB = enabledDB
|
||||
t.Cleanup(func() { config.LoggerConfig.EnabledDB = prev })
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(LoggerToFile())
|
||||
|
||||
var seen string
|
||||
handler := func(c *gin.Context) {
|
||||
b, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
t.Errorf("handler could not read the body: %v", err)
|
||||
}
|
||||
seen = string(b)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
r.Handle(method, "/probe", handler)
|
||||
|
||||
req := httptest.NewRequest(method, "/probe", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(httptest.NewRecorder(), req)
|
||||
return seen
|
||||
}
|
||||
|
||||
// The middleware rewrites Request.Body so it can log the parameters. Whatever
|
||||
// else it does, the handler has to receive the request the client sent - all
|
||||
// of it, whether or not the operation log is on, and whether or not the body
|
||||
// is longer than what gets logged.
|
||||
func TestHandlerStillSeesTheWholeBody(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
enabledDB bool
|
||||
body string
|
||||
}{
|
||||
{"log off, short body", false, `{"username":"admin"}`},
|
||||
{"log on, short body", true, `{"username":"admin"}`},
|
||||
{"log off, empty body", false, ""},
|
||||
{"log on, empty body", true, ""},
|
||||
// Longer than operParamLimit: the logged copy is truncated, the body is not.
|
||||
{"log on, body past the limit", true, strings.Repeat("x", operParamLimit+4096)},
|
||||
{"log off, body past the limit", false, strings.Repeat("y", operParamLimit+4096)},
|
||||
// Exactly at the boundary, where a fencepost error would show.
|
||||
{"log on, body exactly at the limit", true, strings.Repeat("z", operParamLimit)},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
|
||||
if got := serveWithLogger(t, c.enabledDB, method, c.body); got != c.body {
|
||||
t.Errorf("%s: handler saw %d bytes, the client sent %d",
|
||||
method, len(got), len(c.body))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The body is read for one reason - operParam on the operation log row - and
|
||||
// that row is only written when logger.enableddb is on. With it off, reading
|
||||
// the body is a copy of every request made and thrown away, and a file upload
|
||||
// is a POST like any other: 16MB of upload allocated about 67MB here.
|
||||
//
|
||||
// Allocation counts are deterministic across machines; wall-clock is not.
|
||||
func TestBodyIsNotCopiedWhenTheOperationLogIsOff(t *testing.T) {
|
||||
const size = 1 << 20
|
||||
body := strings.Repeat("x", size)
|
||||
|
||||
prev := config.LoggerConfig.EnabledDB
|
||||
config.LoggerConfig.EnabledDB = false
|
||||
t.Cleanup(func() { config.LoggerConfig.EnabledDB = prev })
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(LoggerToFile())
|
||||
r.POST("/probe", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
payload := []byte(body)
|
||||
run := func() {
|
||||
req := httptest.NewRequest(http.MethodPost, "/probe", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
|
||||
var before, after uint64
|
||||
before = heapAllocs()
|
||||
run()
|
||||
after = heapAllocs()
|
||||
|
||||
// The handler never reads the body, so a request that does not copy it
|
||||
// should allocate far less than the body's size. The old middleware
|
||||
// allocated about four times the body.
|
||||
if grew := after - before; grew > size/2 {
|
||||
t.Errorf("a %d-byte request allocated %d bytes with the operation log off; "+
|
||||
"the body should not be read when nothing consumes it", size, grew)
|
||||
}
|
||||
}
|
||||
|
||||
func heapAllocs() uint64 {
|
||||
var m runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&m)
|
||||
return m.TotalAlloc
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go-admin/common/global"
|
||||
)
|
||||
|
||||
// The operation-log consumer reads these keys by name off the queue message.
|
||||
// Losing one costs a column in sys_opera_log and reports nothing - the request
|
||||
// still succeeds, the log row is just wrong.
|
||||
//
|
||||
// This locks the set down across the move of the status constants out of
|
||||
// app/admin/service/dto, which touched every request path.
|
||||
var operaLogKeys = []string{
|
||||
"_fullPath", "operUrl", "operIp", "operLocation", "operName",
|
||||
"requestMethod", "operParam", "operTime", "jsonResult", "latencyTime",
|
||||
"statusCode", "userAgent", "createBy", "updateBy", "status",
|
||||
}
|
||||
|
||||
func TestOperaLogFieldsAreComplete(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/sys-user", nil)
|
||||
c.Request.Header.Set("User-Agent", "go-test")
|
||||
|
||||
l := operaLogFields(c, "127.0.0.1", http.StatusOK, "/api/v1/sys-user", http.MethodPost,
|
||||
12*time.Millisecond, `{"a":1}`, `{"code":200}`, http.StatusOK)
|
||||
|
||||
for _, k := range operaLogKeys {
|
||||
if _, ok := l[k]; !ok {
|
||||
t.Errorf("operation log is missing %q", k)
|
||||
}
|
||||
}
|
||||
if len(l) != len(operaLogKeys) {
|
||||
t.Errorf("operation log has %d fields, expected %d; update operaLogKeys deliberately, not to make this pass",
|
||||
len(l), len(operaLogKeys))
|
||||
}
|
||||
if got := l["operUrl"]; got != "/api/v1/sys-user" {
|
||||
t.Errorf("operUrl = %v", got)
|
||||
}
|
||||
if got := l["userAgent"]; got != "go-test" {
|
||||
t.Errorf("userAgent = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// status is what tells a failed request from a successful one in the log table.
|
||||
// It is a string, and it is the one field whose source package changed.
|
||||
func TestOperaLogStatusMapping(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
want string
|
||||
}{
|
||||
{"ok", http.StatusOK, global.OperaStatusEnabled},
|
||||
{"error", http.StatusInternalServerError, global.OperaStatusDisabled},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
l := operaLogFields(c, "127.0.0.1", tc.status, "/", http.MethodGet, 0, "", "", tc.status)
|
||||
if l["status"] != tc.want {
|
||||
t.Fatalf("status = %v, want %v", l["status"], tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/casbin/casbin/v3/util"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
mycasbin "github.com/go-admin-team/go-admin-core/v2/casbin"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
@@ -26,11 +27,9 @@ func AuthCheckRole() gin.HandlerFunc {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
for _, i := range CasbinExclude {
|
||||
if util.KeyMatch2(c.Request.URL.Path, i.Url) && c.Request.Method == i.Method {
|
||||
casbinExclude = true
|
||||
break
|
||||
}
|
||||
casbinExclude, err = excludedFromCasbin(c.Request.Method, c.Request.URL.Path)
|
||||
if err != nil {
|
||||
log.Errorf("AuthCheckRole: %s", err)
|
||||
}
|
||||
if casbinExclude {
|
||||
log.Infof("Casbin exclusion, no validation method:%s path:%s", c.Request.Method, c.Request.URL.Path)
|
||||
@@ -59,3 +58,59 @@ func AuthCheckRole() gin.HandlerFunc {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// EnforceRoleFor reports whether the caller's role has explicit Casbin
|
||||
// permission to act on path with method.
|
||||
//
|
||||
// AuthCheckRole never calls Enforce for a route CasbinExclude lists - that
|
||||
// is the whole point of the list. A handler on such a route can still need
|
||||
// the real answer for part of what it does: sys_user.go's Update shares its
|
||||
// excluded route between the personal-center screen editing the caller's own
|
||||
// record (which is why the route is excluded at all) and an admin editing
|
||||
// someone else's, and only the second case is meant to require a policy
|
||||
// grant. That handler asks here instead of assuming the middleware already
|
||||
// checked.
|
||||
func EnforceRoleFor(c *gin.Context, path, method string) (bool, error) {
|
||||
data, ok := c.Get(jwtauth.JwtPayloadKey)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
v, ok := data.(jwtauth.MapClaims)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if v["rolekey"] == "admin" {
|
||||
return true, nil
|
||||
}
|
||||
e := sdk.Runtime.GetCasbinByTenant(c.Request.Host)
|
||||
return e.Enforce(v["rolekey"], path, method)
|
||||
}
|
||||
|
||||
// excludedFromCasbin reports whether the route skips the permission check.
|
||||
//
|
||||
// It runs for every non-admin request, so the order matters: the method rules
|
||||
// out most entries with a string compare, where the path test costs a pattern
|
||||
// match. mycasbin.KeyMatch2 answers what casbin's util.KeyMatch2 answers
|
||||
// without recompiling the pattern every time, which is what made this loop
|
||||
// expensive - about 2,500 allocations per request against a 32-entry list.
|
||||
//
|
||||
// A pattern that will not compile is a bug in CasbinExclude rather than in the
|
||||
// request, so the entry is skipped and the scan continues; the error comes
|
||||
// back for the caller to log.
|
||||
func excludedFromCasbin(method, path string) (bool, error) {
|
||||
var bad error
|
||||
for _, i := range CasbinExclude {
|
||||
if method != i.Method {
|
||||
continue
|
||||
}
|
||||
ok, err := mycasbin.KeyMatch2(path, i.Url)
|
||||
if err != nil {
|
||||
bad = fmt.Errorf("CasbinExclude entry %q is not a valid pattern: %w", i.Url, err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
return true, bad
|
||||
}
|
||||
}
|
||||
return false, bad
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package middleware
|
||||
|
||||
import "testing"
|
||||
|
||||
// excluded is excludedFromCasbin with the error dropped: these tests are about
|
||||
// the answer and its cost, and CasbinExclude has no malformed entry to report.
|
||||
func excluded(t testing.TB, path, method string) bool {
|
||||
t.Helper()
|
||||
ok, err := excludedFromCasbin(method, path)
|
||||
if err != nil {
|
||||
t.Fatalf("CasbinExclude holds a pattern that will not compile: %s", err)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// TestCasbinExcludeScanMatches pins the behaviour the scan has to keep: an
|
||||
// excluded route is recognised, a protected one is not, and the method has to
|
||||
// agree.
|
||||
func TestCasbinExcludeScanMatches(t *testing.T) {
|
||||
cases := []struct {
|
||||
path, method string
|
||||
want bool
|
||||
}{
|
||||
{"/api/v1/health", "GET", true},
|
||||
{"/api/v1/login", "POST", true},
|
||||
{"/api/v1/roleMenuTreeselect/12", "GET", true},
|
||||
{"/api/v1/dept", "GET", false},
|
||||
{"/api/v1/sys-user", "GET", false},
|
||||
// Same path, wrong method: sys-user is excluded for PUT only.
|
||||
{"/api/v1/sys-user", "PUT", true},
|
||||
{"/api/v1/health", "POST", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := excluded(t, c.path, c.method); got != c.want {
|
||||
t.Errorf("excludedFromCasbin(%s %s) = %v, want %v", c.method, c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCasbinExcludeScanAllocationBudget is what keeps the scan cheap.
|
||||
//
|
||||
// The list is walked per request with a pattern match per entry, and
|
||||
// casbin's util.KeyMatch2 compiles a regexp on every call - the whole scan
|
||||
// cost about 2,566 allocations that way. Going back to it fails this test.
|
||||
//
|
||||
// Allocation counts are deterministic across machines; wall-clock is not.
|
||||
func TestCasbinExcludeScanAllocationBudget(t *testing.T) {
|
||||
// A protected route, so the scan runs to the end without an early match -
|
||||
// the case every authenticated business request hits.
|
||||
const path, method = "/api/v1/dept", "GET"
|
||||
|
||||
if excluded(t, path, method) {
|
||||
t.Fatalf("setup failed: %s is in the exclusion list", path)
|
||||
}
|
||||
|
||||
// The budget covers the GET entries that carry a path parameter, which
|
||||
// still need a match. Measured at 0 for the cached matcher; the headroom
|
||||
// is for entries being added to the list.
|
||||
const budget = 64
|
||||
|
||||
got := testing.AllocsPerRun(100, func() {
|
||||
_, _ = excludedFromCasbin(method, path)
|
||||
})
|
||||
if got > budget {
|
||||
t.Errorf("scanning CasbinExclude allocates %.0f times, budget is %d\n"+
|
||||
"casbin's util.KeyMatch2 costs about 2566 here; use mycasbin.KeyMatch2",
|
||||
got, budget)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkCasbinExcludeScan reports what the scan adds to a request.
|
||||
func BenchmarkCasbinExcludeScan(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
_, _ = excludedFromCasbin("GET", "/api/v1/dept")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,29 +1,54 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/alibaba/sentinel-golang/core/system"
|
||||
sentinel "github.com/alibaba/sentinel-golang/pkg/adapters/gin"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
|
||||
"go-admin/config"
|
||||
)
|
||||
|
||||
// Sentinel 限流
|
||||
//
|
||||
// The threshold comes from extend.ratelimit.inboundqps; see config.RateLimit
|
||||
// for the values it accepts.
|
||||
func Sentinel() gin.HandlerFunc {
|
||||
qps := config.ExtConfig.RateLimit.Threshold()
|
||||
if qps <= 0 {
|
||||
log.Info("rate limit disabled by extend.ratelimit.inboundqps")
|
||||
return func(c *gin.Context) { c.Next() }
|
||||
}
|
||||
|
||||
if _, err := system.LoadRules([]*system.Rule{
|
||||
{
|
||||
MetricType: system.InboundQPS,
|
||||
TriggerCount: 200,
|
||||
Strategy: system.BBR,
|
||||
TriggerCount: qps,
|
||||
// InboundQPS is compared against TriggerCount directly - the
|
||||
// adaptive strategy is only consulted for Load and CpuUsage. BBR
|
||||
// stood here and read as if the limit adapted to the machine, which
|
||||
// it never did.
|
||||
Strategy: system.NoAdaptive,
|
||||
},
|
||||
}); err != nil {
|
||||
log.Fatalf("Unexpected error: %+v", err)
|
||||
}
|
||||
|
||||
log.Infof("rate limit: %.0f inbound req/s", qps)
|
||||
|
||||
return sentinel.SentinelMiddleware(
|
||||
sentinel.WithBlockFallback(func(ctx *gin.Context) {
|
||||
ctx.AbortWithStatusJSON(200, map[string]interface{}{
|
||||
// 429, not 200. Everything that reads the status line rather than
|
||||
// the body counts a 200 as served: load balancers, metrics,
|
||||
// client-side retry, and load tests - a benchmark against the old
|
||||
// behaviour reported the limiter's own rejections as successful
|
||||
// traffic and overstated throughput by more than tenfold.
|
||||
ctx.AbortWithStatusJSON(http.StatusTooManyRequests, map[string]interface{}{
|
||||
"msg": "too many request; the quota used up!",
|
||||
"code": 500,
|
||||
"code": http.StatusTooManyRequests,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alibaba/sentinel-golang/core/system"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go-admin/config"
|
||||
)
|
||||
|
||||
// serve builds a router with the limiter in front of a handler that always
|
||||
// succeeds, so any non-200 comes from the limiter.
|
||||
func serve(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(Sentinel())
|
||||
r.GET("/ping", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
return r
|
||||
}
|
||||
|
||||
func get(t *testing.T, r *gin.Engine) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ping", nil))
|
||||
return w
|
||||
}
|
||||
|
||||
// TestSentinelRejectsWithTooManyRequests pins the status code. A rejected
|
||||
// request used to answer 200 with the failure only in the body, so every layer
|
||||
// that reads the status line - load balancers, metrics, client retry, load
|
||||
// tests - counted it as served.
|
||||
func TestSentinelRejectsWithTooManyRequests(t *testing.T) {
|
||||
one := 1.0
|
||||
config.ExtConfig.RateLimit = config.RateLimit{InboundQPS: &one}
|
||||
t.Cleanup(func() {
|
||||
config.ExtConfig.RateLimit = config.RateLimit{}
|
||||
_ = system.ClearRules()
|
||||
})
|
||||
|
||||
r := serve(t)
|
||||
|
||||
var rejected *httptest.ResponseRecorder
|
||||
for i := 0; i < 20; i++ {
|
||||
if w := get(t, r); w.Code != http.StatusOK {
|
||||
rejected = w
|
||||
break
|
||||
}
|
||||
}
|
||||
if rejected == nil {
|
||||
t.Fatal("a limit of 1 req/s let 20 requests through; the limiter is not engaged")
|
||||
}
|
||||
if rejected.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("rejected with %d, want %d", rejected.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
// The body's code must agree with the status line; they disagreed before.
|
||||
var body struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(rejected.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("rejection body is not json: %v", err)
|
||||
}
|
||||
if body.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("body code = %d, want %d", body.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
if body.Msg == "" {
|
||||
t.Error("rejection carries no message")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSentinelDisabledByZero covers the escape hatch: a deployment behind its
|
||||
// own gateway has no use for a second limiter.
|
||||
//
|
||||
// It asserts on the loaded rules rather than on traffic. Sentinel measures QPS
|
||||
// over a sliding window, so a burst issued inside one bucket is not counted
|
||||
// before the bucket closes - a few hundred requests sail past a threshold of
|
||||
// 200 in a test, and "no request was rejected" would pass whether or not the
|
||||
// limiter is disabled. Whether a rule was installed at all does not depend on
|
||||
// timing.
|
||||
func TestSentinelDisabledByZero(t *testing.T) {
|
||||
if err := system.ClearRules(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zero := 0.0
|
||||
config.ExtConfig.RateLimit = config.RateLimit{InboundQPS: &zero}
|
||||
t.Cleanup(func() {
|
||||
config.ExtConfig.RateLimit = config.RateLimit{}
|
||||
_ = system.ClearRules()
|
||||
})
|
||||
|
||||
r := serve(t)
|
||||
if rules := system.GetRules(); len(rules) != 0 {
|
||||
t.Errorf("limiter disabled but %d rule(s) were loaded: %+v", len(rules), rules)
|
||||
}
|
||||
|
||||
for i := 0; i < 500; i++ {
|
||||
if w := get(t, r); w.Code != http.StatusOK {
|
||||
t.Fatalf("request %d got %d with the limiter disabled", i, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-37
@@ -1,41 +1,13 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
|
||||
|
||||
"gorm.io/plugin/soft_delete"
|
||||
// ControlBy, Model and ModelTime are thin aliases of go-admin-core's
|
||||
// sdk/contract/models (PRD 006 F1/F5). A type alias is the same type, not a
|
||||
// new one, so every model that embeds these keeps its GORM tags, JSON tags
|
||||
// and method set untouched.
|
||||
type (
|
||||
ControlBy = contractmodels.ControlBy
|
||||
Model = contractmodels.Model
|
||||
ModelTime = contractmodels.ModelTime
|
||||
)
|
||||
|
||||
type ControlBy struct {
|
||||
CreateBy int `json:"createBy" gorm:"index;comment:创建者"`
|
||||
UpdateBy int `json:"updateBy" gorm:"index;comment:更新者"`
|
||||
}
|
||||
|
||||
// SetCreateBy 设置创建人id
|
||||
func (e *ControlBy) SetCreateBy(createBy int) {
|
||||
e.CreateBy = createBy
|
||||
}
|
||||
|
||||
// SetUpdateBy 设置修改人id
|
||||
func (e *ControlBy) SetUpdateBy(updateBy int) {
|
||||
e.UpdateBy = updateBy
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement;comment:主键编码"`
|
||||
}
|
||||
|
||||
type ModelTime struct {
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"comment:创建时间"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"comment:最后更新时间"`
|
||||
|
||||
// DeletedAt is milliseconds since the epoch, zero while the row is live,
|
||||
// and never null.
|
||||
//
|
||||
// A nullable marker cannot take part in a unique index. Two live rows are
|
||||
// (name, NULL) and (name, NULL), and NULL is not equal to NULL, so the
|
||||
// index permits both — it looks like a constraint and enforces nothing.
|
||||
// With zero for live rows the pair collides, while two deletions of the
|
||||
// same name differ by their timestamps and both remain.
|
||||
DeletedAt soft_delete.DeletedAt `json:"-" gorm:"softDelete:milli;index;comment:删除时间"`
|
||||
}
|
||||
|
||||
+11
-7
@@ -1,11 +1,15 @@
|
||||
package models
|
||||
|
||||
// Menu 菜单中的类型枚举值
|
||||
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
|
||||
|
||||
// Directory, Menu and Button are the menu type enum values used by
|
||||
// sys_menu.menu_type, referenced directly from go-admin-core's
|
||||
// sdk/contract/models rather than restated as literals: PRD 006's hard
|
||||
// constraint 4 requires `const X = pkg.X` for exactly this reason - two
|
||||
// independently written copies of the same value can be edited out of step,
|
||||
// where a direct reference cannot.
|
||||
const (
|
||||
// Directory 目录
|
||||
Directory string = "M"
|
||||
// Menu 菜单
|
||||
Menu string = "C"
|
||||
// Button 按钮
|
||||
Button string = "F"
|
||||
Directory = contractmodels.Directory
|
||||
Menu = contractmodels.Menu
|
||||
Button = contractmodels.Button
|
||||
)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
|
||||
|
||||
type Migration struct {
|
||||
Version string `gorm:"primaryKey"`
|
||||
ApplyTime time.Time `gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
func (Migration) TableName() string {
|
||||
return "sys_migration"
|
||||
}
|
||||
// Migration is the sys_migration row model (data). It is unrelated to
|
||||
// cmd/migrate/migration.Migration, the in-process registration table this
|
||||
// package's TableName has nothing to do with - see
|
||||
// go-admin-core's sdk/contract/models.Migration doc comment for why the two
|
||||
// share a name.
|
||||
type Migration = contractmodels.Migration
|
||||
|
||||
@@ -1,30 +1,10 @@
|
||||
package models
|
||||
|
||||
type Response struct {
|
||||
// 代码
|
||||
Code int `json:"code" example:"200"`
|
||||
// 数据集
|
||||
Data interface{} `json:"data"`
|
||||
// 消息
|
||||
Msg string `json:"msg"`
|
||||
RequestId string `json:"requestId"`
|
||||
}
|
||||
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
|
||||
|
||||
type Page struct {
|
||||
List interface{} `json:"list"`
|
||||
Count int `json:"count"`
|
||||
PageIndex int `json:"pageIndex"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
// ReturnOK 正常返回
|
||||
func (res *Response) ReturnOK() *Response {
|
||||
res.Code = 200
|
||||
return res
|
||||
}
|
||||
|
||||
// ReturnError 错误返回
|
||||
func (res *Response) ReturnError(code int) *Response {
|
||||
res.Code = code
|
||||
return res
|
||||
}
|
||||
// Response and Page are thin aliases of go-admin-core's sdk/contract/models
|
||||
// (PRD 006 F1/F5).
|
||||
type (
|
||||
Response = contractmodels.Response
|
||||
Page = contractmodels.Page
|
||||
)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package models
|
||||
|
||||
import "gorm.io/gorm/schema"
|
||||
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
|
||||
|
||||
type ActiveRecord interface {
|
||||
schema.Tabler
|
||||
SetCreateBy(createBy int)
|
||||
SetUpdateBy(updateBy int)
|
||||
Generate() ActiveRecord
|
||||
GetId() interface{}
|
||||
}
|
||||
// ActiveRecord is self-referencing (Generate() ActiveRecord), which is why
|
||||
// it must stay a type alias rather than a defined type: aliasing preserves
|
||||
// identity with go-admin-core's sdk/contract/models.ActiveRecord, so a
|
||||
// model whose Generate() returns that interface still satisfies this one. A
|
||||
// defined type here would break every implementer's method set - see
|
||||
// go-admin-core's sdk/contract/models package tests for the counterproof
|
||||
// (PRD 006 counterproof A).
|
||||
type ActiveRecord = contractmodels.ActiveRecord
|
||||
|
||||
+4
-39
@@ -1,42 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
)
|
||||
|
||||
// BaseUser 密码登录基础用户
|
||||
type BaseUser struct {
|
||||
Username string `json:"username" gorm:"type:varchar(100);comment:用户名"`
|
||||
Salt string `json:"-" gorm:"type:varchar(255);comment:加盐;<-"`
|
||||
PasswordHash string `json:"-" gorm:"type:varchar(128);comment:密码hash;<-"`
|
||||
Password string `json:"password" gorm:"-"`
|
||||
}
|
||||
|
||||
// SetPassword 设置密码
|
||||
func (u *BaseUser) SetPassword(value string) {
|
||||
u.Password = value
|
||||
u.generateSalt()
|
||||
u.PasswordHash = u.GetPasswordHash()
|
||||
}
|
||||
|
||||
// GetPasswordHash 获取密码hash
|
||||
func (u *BaseUser) GetPasswordHash() string {
|
||||
passwordHash, err := pkg.SetPassword(u.Password, u.Salt)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return passwordHash
|
||||
}
|
||||
|
||||
// generateSalt 生成加盐值
|
||||
func (u *BaseUser) generateSalt() {
|
||||
u.Salt = pkg.GenerateRandomKey16()
|
||||
}
|
||||
|
||||
// Verify 验证密码
|
||||
func (u *BaseUser) Verify(db *gorm.DB, tableName string) bool {
|
||||
db.Table(tableName).Where("username = ?", u.Username).First(u)
|
||||
return u.GetPasswordHash() == u.PasswordHash
|
||||
}
|
||||
// BaseUser is a thin alias of go-admin-core's sdk/contract/models (PRD 006
|
||||
// F1/F5).
|
||||
type BaseUser = contractmodels.BaseUser
|
||||
|
||||
@@ -336,6 +336,6 @@ INSERT INTO sys_post (post_id, post_name, post_code, sort, status, remark, creat
|
||||
(2, '首席技术执行官', 'CTO', 2, '2','首席技术执行官', 1, 1,'2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL),
|
||||
(3, '首席运营官', 'COO', 3, '2','测试工程师', 1, 1,'2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
|
||||
INSERT INTO sys_role (role_id, role_name, status, role_key, role_sort, flag, remark, admin, data_scope, create_by, update_by, created_at, updated_at, deleted_at)VALUES
|
||||
(1, '系统管理员', '2', 'admin', 1, '', '', 1, '', 1, 1, '2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
|
||||
(1, '系统管理员', '2', 'admin', 1, '', '', 1, '1', 1, 1, '2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
|
||||
INSERT INTO sys_user VALUES (1, 'admin', '$2a$10$/Glr4g9Svr6O0kvjsRJCXu3f0W8/dsP3XZyVNi1019ratWpSPMyw.', 'zhangwj', '13818888888', 1, '', '', '1', '1@qq.com', 1, 1, '', '2', 1, 1, '2021-05-13 19:56:37.914', '2021-05-13 19:56:40.205', NULL);
|
||||
-- 数据完成 ;
|
||||
+1
-1
@@ -318,6 +318,6 @@ INSERT INTO sys_menu_api_rule VALUES (46, 156);
|
||||
INSERT INTO sys_post VALUES (1, '首席执行官', 'CEO', 0, '2','首席执行官', 1, 1, '2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
|
||||
INSERT INTO sys_post VALUES (2, '首席技术执行官', 'CTO', 2, '2','首席技术执行官', 1, 1,'2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
|
||||
INSERT INTO sys_post VALUES (3, '首席运营官', 'COO', 3, '2','测试工程师', 1, 1,'2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
|
||||
INSERT INTO sys_role VALUES (1, '系统管理员', '2', 'admin', 1, '', '', true, '', 1, 1, '2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
|
||||
INSERT INTO sys_role VALUES (1, '系统管理员', '2', 'admin', 1, '', '', true, '1', 1, 1, '2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
|
||||
INSERT INTO sys_user VALUES (1, 'admin', '$2a$10$/Glr4g9Svr6O0kvjsRJCXu3f0W8/dsP3XZyVNi1019ratWpSPMyw.', 'zhangwj', '13818888888', 1, '', '', '1', '1@qq.com', 1, 1, '', '2', 1, 1, '2021-05-13 19:56:37.914', '2021-05-13 19:56:40.205', NULL);
|
||||
-- 数据完成 ;
|
||||
@@ -12,6 +12,39 @@ var ExtConfig Extend
|
||||
type Extend struct {
|
||||
AMap AMap // 这里配置对应配置文件的结构即可
|
||||
FileStore FileStore
|
||||
RateLimit RateLimit
|
||||
}
|
||||
|
||||
// DefaultInboundQPS is the limit applied when nothing is configured. It is the
|
||||
// value that used to be hard-coded in the middleware, so an existing deployment
|
||||
// that adds nothing to settings.yml keeps the behaviour it already had.
|
||||
const DefaultInboundQPS = 200
|
||||
|
||||
// RateLimit 全局入站限流。
|
||||
//
|
||||
// extend:
|
||||
// ratelimit:
|
||||
// inboundqps: 200 # 每秒入站请求上限;填 0 关闭限流
|
||||
//
|
||||
// The threshold used to live in common/middleware/sentinel.go as a constant,
|
||||
// which made 200 QPS the ceiling of every deployment with nothing in the
|
||||
// configuration to reveal it.
|
||||
type RateLimit struct {
|
||||
// InboundQPS caps inbound requests per second across the process.
|
||||
//
|
||||
// Absent means DefaultInboundQPS, zero disables the limiter, and a positive
|
||||
// value is the threshold. The pointer is what separates "not configured"
|
||||
// from "configured to zero" - the two need different answers and a plain
|
||||
// float64 cannot tell them apart.
|
||||
InboundQPS *float64
|
||||
}
|
||||
|
||||
// Threshold reports the limit to apply. Zero means no limiting.
|
||||
func (r RateLimit) Threshold() float64 {
|
||||
if r.InboundQPS == nil {
|
||||
return DefaultInboundQPS
|
||||
}
|
||||
return *r.InboundQPS
|
||||
}
|
||||
|
||||
type AMap struct {
|
||||
|
||||
@@ -14,3 +14,21 @@ func TestObjectStoreConfigured(t *testing.T) {
|
||||
t.Fatal("partial store reported as configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitThreshold(t *testing.T) {
|
||||
// Absent is the case an existing settings.yml hits after an upgrade: it has
|
||||
// no ratelimit section, and must keep the limit it always had.
|
||||
if got := (RateLimit{}).Threshold(); got != DefaultInboundQPS {
|
||||
t.Errorf("unconfigured limit = %v, want the default %v", got, DefaultInboundQPS)
|
||||
}
|
||||
|
||||
zero := 0.0
|
||||
if got := (RateLimit{InboundQPS: &zero}).Threshold(); got != 0 {
|
||||
t.Errorf("explicit zero = %v, want 0 so the limiter can be turned off", got)
|
||||
}
|
||||
|
||||
custom := 1500.0
|
||||
if got := (RateLimit{InboundQPS: &custom}).Threshold(); got != custom {
|
||||
t.Errorf("configured limit = %v, want %v", got, custom)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The seed files write the built-in admin role's data_scope inline in a SQL
|
||||
// INSERT, not through Go code, so nothing else in the test suite exercises
|
||||
// this value. It has to be one of the five scopes actions.Permission
|
||||
// recognizes: PRD 006 F14/H2 made every other value match no rows, and an
|
||||
// empty string - which is what these files shipped before that fix - is one
|
||||
// such value. Without this the shipped admin account would silently lose
|
||||
// all visibility the moment a deployment turns EnableDP on.
|
||||
func TestSeedAdminRoleHasAValidDataScope(t *testing.T) {
|
||||
cases := map[string]*regexp.Regexp{
|
||||
"db.sql": regexp.MustCompile(
|
||||
`INSERT INTO sys_role VALUES \(1, '系统管理员', '2', 'admin', 1, '', '', true, '([^']*)'`),
|
||||
"db-sqlserver.sql": regexp.MustCompile(
|
||||
`\(1, '系统管理员', '2', 'admin', 1, '', '', 1, '([^']*)'`),
|
||||
}
|
||||
valid := map[string]bool{"1": true, "2": true, "3": true, "4": true, "5": true}
|
||||
|
||||
for file, pattern := range cases {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", file, err)
|
||||
}
|
||||
m := pattern.FindSubmatch(data)
|
||||
if m == nil {
|
||||
t.Fatalf("%s: admin role INSERT not found; the regex may be out of date", file)
|
||||
}
|
||||
if scope := string(m[1]); !valid[scope] {
|
||||
t.Errorf("%s: admin role data_scope = %q, want one of 1-5", file, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,17 @@ settings:
|
||||
source: user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8&parseTime=True&loc=Local&timeout=1000ms
|
||||
# source: sqlite3.db
|
||||
# source: host=myhost port=myport user=gorm dbname=gorm password=mypassword
|
||||
# 连接池。不配置这几项时走 Go 的默认值,其中 MaxIdleConns 默认只有 2:
|
||||
# 高并发下几乎每个请求都要新建 TCP 连接、用完立刻关闭,本机端口很快耗尽,
|
||||
# 表现为 "can't assign requested address" 且请求全部失败——不是变慢,是不可用。
|
||||
#
|
||||
# maxOpenConns 是单个实例的连接上限,多实例部署时总连接数是它乘以实例数,
|
||||
# 需要小于数据库的 max_connections(MySQL 默认 151)。
|
||||
# connMaxLifeTime 单位为秒,应小于数据库的 wait_timeout(MySQL 默认 28800),
|
||||
# 否则会复用到已被服务端关闭的连接。
|
||||
maxIdleConns: 20
|
||||
maxOpenConns: 100
|
||||
connMaxLifeTime: 3600
|
||||
registers:
|
||||
- sources:
|
||||
- user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8&parseTime=True&loc=Local&timeout=1000ms
|
||||
@@ -52,10 +63,25 @@ settings:
|
||||
frontpath: ../go-admin-ui/src
|
||||
queue:
|
||||
memory:
|
||||
poolSize: 100
|
||||
# poolSize 是队列的缓冲长度,不是并发度。队列满时 Append 会丢弃该消息并
|
||||
# 返回错误,而不是阻塞等待,所以这个值实际是「开始丢消息的临界点」。
|
||||
#
|
||||
# 每个 stream 只有一个消费 goroutine,而登录日志、操作日志的消费要写数据库,
|
||||
# 吞吐受限于单条写入耗时。突发流量高于消费速度时,缓冲区是唯一的缓解手段。
|
||||
# 压测中默认的 100 丢弃率超过 60%,1000 为 0。
|
||||
#
|
||||
# 仅在 logger.enableddb 为 true 时才会真正入队。
|
||||
poolSize: 1000
|
||||
extend: # 扩展项使用说明
|
||||
demo:
|
||||
name: data
|
||||
# rateLimit 全局入站限流。不配置时为 200 QPS,与此前写死在
|
||||
# common/middleware/sentinel.go 里的值一致,升级不会改变行为。
|
||||
# 填 0 关闭限流——部署在自带限流的网关后面时用得上。
|
||||
# 超出阈值的请求返回 HTTP 429(旧版本返回 200,只在 body 里写 code:500,
|
||||
# 会被负载均衡、监控和压测统计成成功)。
|
||||
rateLimit:
|
||||
inboundQPS: 200
|
||||
# fileStore 对象存储。上传接口的 source 参数决定走哪一家:
|
||||
# source=1 只存本地,source=2 阿里云 OSS,source=3 七牛 Kodo
|
||||
# 没有填的那一家在被请求时会返回明确错误,不会静默存到别处。
|
||||
|
||||
+27
-1
@@ -32,6 +32,17 @@ settings:
|
||||
driver: mysql
|
||||
# 数据库连接字符串 mysql 缺省信息 charset=utf8&parseTime=True&loc=Local&timeout=1000ms
|
||||
source: user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8&parseTime=True&loc=Local&timeout=1000ms
|
||||
# 连接池。不配置这几项时走 Go 的默认值,其中 MaxIdleConns 默认只有 2:
|
||||
# 高并发下几乎每个请求都要新建 TCP 连接、用完立刻关闭,本机端口很快耗尽,
|
||||
# 表现为 "can't assign requested address" 且请求全部失败——不是变慢,是不可用。
|
||||
#
|
||||
# maxOpenConns 是单个实例的连接上限,多实例部署时总连接数是它乘以实例数,
|
||||
# 需要小于数据库的 max_connections(MySQL 默认 151)。
|
||||
# connMaxLifeTime 单位为秒,应小于数据库的 wait_timeout(MySQL 默认 28800),
|
||||
# 否则会复用到已被服务端关闭的连接。
|
||||
maxIdleConns: 20
|
||||
maxOpenConns: 100
|
||||
connMaxLifeTime: 3600
|
||||
# databases:
|
||||
# 'locaohost:8000':
|
||||
# driver: mysql
|
||||
@@ -48,6 +59,13 @@ settings:
|
||||
extend: # 扩展项使用说明
|
||||
demo:
|
||||
name: data
|
||||
# rateLimit 全局入站限流。不配置时为 200 QPS,与此前写死在
|
||||
# common/middleware/sentinel.go 里的值一致,升级不会改变行为。
|
||||
# 填 0 关闭限流——部署在自带限流的网关后面时用得上。
|
||||
# 超出阈值的请求返回 HTTP 429(旧版本返回 200,只在 body 里写 code:500,
|
||||
# 会被负载均衡、监控和压测统计成成功)。
|
||||
rateLimit:
|
||||
inboundQPS: 200
|
||||
cache:
|
||||
# redis:
|
||||
# addr: 127.0.0.1:6379
|
||||
@@ -57,7 +75,15 @@ settings:
|
||||
memory: ''
|
||||
queue:
|
||||
memory:
|
||||
poolSize: 100
|
||||
# poolSize 是队列的缓冲长度,不是并发度。队列满时 Append 会丢弃该消息并
|
||||
# 返回错误,而不是阻塞等待,所以这个值实际是「开始丢消息的临界点」。
|
||||
#
|
||||
# 每个 stream 只有一个消费 goroutine,而登录日志、操作日志的消费要写数据库,
|
||||
# 吞吐受限于单条写入耗时。突发流量高于消费速度时,缓冲区是唯一的缓解手段。
|
||||
# 压测中默认的 100 丢弃率超过 60%,1000 为 0。
|
||||
#
|
||||
# 仅在 logger.enableddb 为 true 时才会真正入队。
|
||||
poolSize: 1000
|
||||
# redis:
|
||||
# addr: 127.0.0.1:6379
|
||||
# password: xxxxxx
|
||||
|
||||
@@ -0,0 +1,722 @@
|
||||
# 公共契约面
|
||||
|
||||
> 本文写给**第三方应用作者**:你写一个装进 go-admin 的业务模块,可以依赖什么、
|
||||
> 怎么接进来、哪些约定不遵守会**不报错地出错**。
|
||||
>
|
||||
> 主仓贡献者的编码约定见根目录 `AGENTS.md`,设计取舍见 `docs/architecture.md`。
|
||||
|
||||
---
|
||||
|
||||
## 契约面在 core,不在 go-admin
|
||||
|
||||
这份文档以前列的是 go-admin 自己的四个包(`common/actions` 等),依据写的是
|
||||
「把 `app/demo` 的 import 去重之后恰好就是这四个」。
|
||||
|
||||
**那个依据是错的,而且错的方向是把人引向依赖宿主。**
|
||||
|
||||
go-admin 的使用方式是 clone / fork:每个使用者拿到的是一整份代码,然后**改它**。
|
||||
应用如果依赖 `go-admin/common/actions`,它依赖的是一个**每个使用者都不一样、
|
||||
而且随时在变**的东西——你没有办法测试自己的应用在别人改过的 fork 上能不能编译。
|
||||
|
||||
还有一条更硬的:`go-admin` 这个 module path 没有点号,
|
||||
按 Go 的规则**不是合法的可解析模块路径**:
|
||||
|
||||
```
|
||||
$ go get go-admin/common/models
|
||||
go: malformed module path "go-admin/common/models": missing dot in first path element
|
||||
```
|
||||
|
||||
想 import 它就必须写 `replace`,而**非主模块的 `replace` 会被忽略**——
|
||||
你在自己应用里写的 replace 对使用者不生效。所以「应用 require go-admin」
|
||||
这条路不是不优雅,是走不通。
|
||||
|
||||
契约面因此落在 **go-admin-core**:那是唯一一个大家都一样、有版本号、
|
||||
不会被使用者随手改的东西。
|
||||
|
||||
---
|
||||
|
||||
## 承诺稳定的包
|
||||
|
||||
全部在 `github.com/go-admin-team/go-admin-core/v2` 下:
|
||||
|
||||
| 包 | 用途 |
|
||||
|---|---|
|
||||
| `sdk/contract/models` | `Model` / `ControlBy` / `ModelTime` / `ActiveRecord` / `BaseUser` / `Migration`、`sys_menu.menu_type` 的三个枚举值 |
|
||||
| `sdk/contract/dto` | `Pagination` / `MakeCondition` / `Paginate` / `OrderDest` / `ObjectById`、`Index` 与 `Control` 接口 |
|
||||
| `sdk/contract/actions` | 数据权限设施:`DataPermission` / `Permission` / `PermissionAction` / `GetPermissionFromContext`、五个 `DataScope*` 常量与 `IsValidDataScope` |
|
||||
| `sdk/contract/migration` | `Registry` / `AppRegistrar` / `ForApp` / `SetVersion` / `GetFilename` |
|
||||
| `sdk/contract/seed` | `MenuSpec` / `ApiSpec` / `Seeder` / `SeedMenus`——往侧边栏和接口表里登记自己 |
|
||||
| `sdk/pkg` | `GetOrm(c)`:从请求上下文取本租户的数据库连接 |
|
||||
| `sdk/api`、`sdk/service` | 可选的 Api / Service 基类 |
|
||||
| `response` | `OK` / `Error` / `PageOK`:响应格式 |
|
||||
| `jwtauth/user` | 从 token 取当前用户身份 |
|
||||
| `sdk/runtime` | 中间件 key 常量与 `GetHandlerFunc`:复用宿主已注册的鉴权链 |
|
||||
|
||||
`sdk/contract/` 这个前缀的含义就是「**承诺对应用稳定**的那一面」。core 里
|
||||
`sdk/` 下的其他包是框架基础设施,语义不同——上表逐个列了名字,
|
||||
**不要因为「都在 core 里」就认为是契约面**。
|
||||
|
||||
"稳定"的含义:**在 core 的 `v2.x` 内不做破坏性变更**。新增导出符号不算破坏;
|
||||
改签名、改语义、删除导出符号算,会走 major 版本并在 release note 里单列。
|
||||
|
||||
准确的语义以 core 那份文档为准:
|
||||
[go-admin-core `docs/contract.md`](https://github.com/go-admin-team/go-admin-core/blob/main/docs/contract.md)。
|
||||
本文写的是宿主这一侧——它管不着的那些。
|
||||
|
||||
### go-admin 自己的包
|
||||
|
||||
`go-admin/common/models`、`common/dto`、`common/actions` 里的契约类型现在是
|
||||
**指向 core 的类型别名**(`type X = corepkg.X`),主仓和所有 fork 的存量代码
|
||||
一行不用改。别名在编译期就是同一个类型,不是"兼容层"。
|
||||
|
||||
但**新写的应用不要 import 它们**——那样就又依赖上宿主了。
|
||||
|
||||
---
|
||||
|
||||
## 契约面是三层,不是一层
|
||||
|
||||
划分依据不是"应用会 import 哪些包",而是**"哪一条不遵守会静默出错"**:
|
||||
|
||||
| 层 | 内容 | 判据 |
|
||||
|---|---|---|
|
||||
| **一 · 必须遵守** | 路由注册、从 context 取库、响应 shape、`ControlBy`/`ModelTime`、鉴权、数据权限、事务范式 | 不遵守 → **不报错,行为悄悄不对** |
|
||||
| **二 · 可选便利** | `api.Api`、`service.Service`、CRUD Action、`MakeCondition` | 用不用都对 |
|
||||
| **三 · 今天空白** | 应用间调用、领域事件、缓存租户隔离 | **没有。别自己发明** |
|
||||
|
||||
**框架不强制任何一层抽象。** 一个不用任何便利层的 handler 完全合法:
|
||||
|
||||
```go
|
||||
func handler(c *gin.Context) {
|
||||
db, err := pkg.GetOrm(c)
|
||||
if err != nil {
|
||||
response.Error(c, 500, err, "")
|
||||
return
|
||||
}
|
||||
var list []MyModel
|
||||
if err := db.Find(&list).Error; err != nil {
|
||||
response.Error(c, 500, err, "")
|
||||
return
|
||||
}
|
||||
response.OK(c, list, "")
|
||||
}
|
||||
```
|
||||
|
||||
第一层则是不管你用不用便利层都要遵守的,逐条写在下面,每条都附**不遵守会怎样**。
|
||||
|
||||
---
|
||||
|
||||
## 第一层:不遵守就静默出错
|
||||
|
||||
### 1. 路由注册
|
||||
|
||||
见下方「注册路由」一节。
|
||||
|
||||
**不遵守会怎样**:注册表在 `RunAppRouters()` 之后就封闭了,晚到的注册被丢弃,
|
||||
只记一条 ERROR 日志。包级 `AppRouters` 连这个都没有——它就是一个普通 slice,
|
||||
什么时候 append 都"成功",启动钩子之后 append 的那些永远不会执行,**且不出声**。
|
||||
|
||||
### 2. 数据库连接从 context 取,不用全局变量
|
||||
|
||||
```go
|
||||
db, err := pkg.GetOrm(c) // 唯一正确的取法
|
||||
```
|
||||
|
||||
`common/middleware/db.go` 在每个请求上按 `c.Request.Host` 挑出本租户的连接
|
||||
放进 context:
|
||||
|
||||
```go
|
||||
c.Set("db", sdk.Runtime.GetDbByTenant(c.Request.Host).WithContext(c))
|
||||
```
|
||||
|
||||
**不遵守会怎样**:连接是**按租户注册**的(`SetDbByTenant(host, db)`),
|
||||
`GetOrm(c)` 按 `c.Request.Host` 挑。你要是在启动时把某个连接存进包级变量再一直用,
|
||||
多租户部署下所有租户的读写就都落到那一个库上——不报错、不告警,数据串了才发现。
|
||||
|
||||
这个坑在本仓库真踩过:`common/global.Driver` 取的是启动循环
|
||||
**迭代到的第一个**库的驱动(`common/database/initialize.go`),
|
||||
而 Go 的 map 迭代顺序是随机的——两个库用不同驱动时,那个值每次启动都可能不一样。
|
||||
所以「一个进程一个库」这个假设不要写进任何一行代码。
|
||||
|
||||
### 3. 响应 shape
|
||||
|
||||
一律用 `response.OK` / `response.Error` / `response.PageOK`,不要自己
|
||||
`c.JSON`。它们发出去的形状是:
|
||||
|
||||
```jsonc
|
||||
// 成功
|
||||
{"requestId": "...", "code": 200, "data": {...}}
|
||||
// 分页:data 里再套一层
|
||||
{"requestId": "...", "code": 200, "data": {"count": 42, "pageIndex": 1, "pageSize": 10, "list": [...]}}
|
||||
// 失败
|
||||
{"requestId": "...", "code": 500, "msg": "...", "status": "error"}
|
||||
```
|
||||
|
||||
**HTTP 状态码永远是 200**,业务码在 body 的 `code` 里——这是既定行为,
|
||||
`response.Error` 走的是 `c.AbortWithStatusJSON(http.StatusOK, res)`。
|
||||
|
||||
**不遵守会怎样**:前端 `src/utils/request.ts` 的响应拦截器只读 body 的 `code`,
|
||||
`code !== 200` 就弹一条 `msg` 内容的 error toast 并 reject。你自己
|
||||
`c.JSON(200, myThing)` 的话 `code` 是 `undefined`,界面上弹出来的是**一条空的
|
||||
错误提示**,数据到不了页面。列表更安静:`useTable.ts` 读的是
|
||||
`page?.list ?? []` 和 `page?.count ?? 0`,形状对不上就是**一张空表,零报错**。
|
||||
|
||||
### 4. `ControlBy` 与 `ModelTime`
|
||||
|
||||
每张业务表的 model 都嵌这三个:
|
||||
|
||||
```go
|
||||
type Order struct {
|
||||
models.Model // Id
|
||||
// ... 你的字段 ...
|
||||
models.ControlBy // CreateBy / UpdateBy
|
||||
models.ModelTime // CreatedAt / UpdatedAt / DeletedAt
|
||||
}
|
||||
|
||||
func (Order) TableName() string { return "app_order" } // 必须显式声明
|
||||
```
|
||||
|
||||
`ControlBy` 提供 `create_by` 列,**数据权限的每一条 SQL 都 join 在它上面**。
|
||||
`ModelTime` 的 `DeletedAt` 是 `soft_delete.DeletedAt`(毫秒时间戳,活行为 0,
|
||||
永不为 NULL),不是 `gorm.DeletedAt`。
|
||||
|
||||
**不遵守会怎样**:
|
||||
|
||||
- 嵌了 `ControlBy` 但写入时忘了 `SetCreateBy(user.GetUserId(c))`,
|
||||
`create_by` 就是 0。除「全部数据权限」外的每一档都**查不到任何数据**,
|
||||
而且不报错——看起来像"这个用户还没建过数据"。
|
||||
- 用错 `ModelTime` 版本(可空的 `gorm.DeletedAt`):gorm 按
|
||||
`deleted_at IS NULL` 过滤,而活行里存的是 0,于是**整张表一行都查不出来**。
|
||||
主仓的 `sys_columns` / `sys_tables` 真在这个状态下待过——代码生成器
|
||||
一张表都列不出来,没有任何报错。`make checksilent` 的 `modeltime-mix`
|
||||
就是为这条加的。
|
||||
- `TableName()` 忘了写:GORM 配了 `SingularTable`,不会推导复数,表名会是
|
||||
你没预料的那个。
|
||||
|
||||
### 5. 鉴权:用宿主已注册的中间件,不要自己造
|
||||
|
||||
```go
|
||||
jwtCheck, ok := sdk.Runtime.GetHandlerFunc(runtime.JwtTokenCheck)
|
||||
if !ok {
|
||||
log.Fatal("JwtTokenCheck is not registered; is the host started via cmd/api?")
|
||||
}
|
||||
roleCheck, _ := sdk.Runtime.GetHandlerFunc(runtime.RoleCheck)
|
||||
permCheck, _ := sdk.Runtime.GetHandlerFunc(runtime.PermissionCheck)
|
||||
|
||||
g := v1.Group("/order").Use(jwtCheck).Use(roleCheck).Use(permCheck)
|
||||
```
|
||||
|
||||
三个 key 的常量在 `sdk/runtime`,宿主启动时把三个中间件注册进去。
|
||||
|
||||
**不遵守会怎样**:`GetHandlerFunc` 在"没注册"和"注册成了别的类型"两种情况下
|
||||
都返回 `ok=false` 而不是 panic——**因为路由注册跑在 core 的 panic 护栏里面,
|
||||
裸类型断言 panic 之后日志报的是"这个模块一条路由都没注册上",跟真实原因对不上**。
|
||||
所以 `ok` 必须自己判,判出来要**大声失败**:一个跳过鉴权继续注册的路由,
|
||||
就是一条静默的匿名可访问接口。
|
||||
|
||||
**宿主必须注册绑定过的闭包。** 三个 key 存的都得是 `gin.HandlerFunc`——
|
||||
比如 `authMiddleware.MiddlewareFunc()`,**不是** `(*jwt.GinJWTMiddleware).MiddlewareFunc`。
|
||||
后者是方法表达式,没有接收者绑在上面,取回来断言不成 `gin.HandlerFunc`,
|
||||
怎么断言都做不成一个能用的 handler。
|
||||
|
||||
> **当前状态**:`common/middleware/init.go` 里 `RoleCheck` 与 `PermissionCheck`
|
||||
> 注册的是 `AuthCheckRole()` 和 `actions.PermissionAction()`,都是绑定过的闭包,
|
||||
> 取回来就能用;**`JwtTokenCheck` 注册的还是那个方法表达式**,所以今天对它
|
||||
> `GetHandlerFunc` 拿到的是 `ok=false`。上面那段 `log.Fatal` 会在启动时打出来——
|
||||
> 这是有意的,宁可起不来也不要一条没鉴权的路由。主仓这一处的修复见 F10,
|
||||
> 修完之后本段可以删掉。
|
||||
|
||||
还有一条**不影响行为但影响理解**的:主仓今天四个模块各自调一次 `AuthInit()`
|
||||
(`app/admin`、`app/jobs`、`app/other`、`app/demo`),也就是有四个 JWT 实例。
|
||||
这不产生行为差异——配置同源(`config.JwtConfig`),JWT 校验是无状态的,
|
||||
不看实例身份。但它意味着 `GetHandlerFunc(runtime.JwtTokenCheck)` 取回来的是
|
||||
**最后注册进去的那一个**。要让应用拿到一个有意义的共享实例,宿主应当在注册路由
|
||||
之前构造一次,而不是每个模块构造一次。
|
||||
|
||||
**测的时候别用 `admin` 账号。** `AuthCheckRole` 里 `rolekey == "admin"` 直接
|
||||
`c.Next()`,**完全跳过 Casbin**。拿 admin 压任何鉴权路径都测不到东西。
|
||||
|
||||
### 6. 数据权限
|
||||
|
||||
两件事都要做:
|
||||
|
||||
```go
|
||||
// 路由上挂中间件(上一节的 permCheck 就是它)
|
||||
g := v1.Group("/order").Use(permCheck)
|
||||
|
||||
// 查询里组合 scope
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
db.Scopes(actions.Permission(Order{}.TableName(), p)).Find(&list)
|
||||
```
|
||||
|
||||
`sys_role.data_scope` 有五档,`Permission()` 按它拼 WHERE 条件:
|
||||
|
||||
| 值 | 常量 | 含义 | 条件 |
|
||||
|---|---|---|---|
|
||||
| `1` | `DataScopeAll` | 全部数据权限 | 不加条件 |
|
||||
| `2` | `DataScopeCustom` | 自定义数据权限 | `create_by` 属于 `sys_role_dept` 关联到的部门 |
|
||||
| `3` | `DataScopeDept` | 本部门 | `create_by` 属于本部门 |
|
||||
| `4` | `DataScopeDeptTree` | 本部门及以下 | `create_by` 属于 `dept_path` 匹配的子树 |
|
||||
| `5` | `DataScopeSelf` | 仅本人 | `create_by = 当前用户` |
|
||||
|
||||
自己往 `sys_role.data_scope` 写值的话先过一遍 `IsValidDataScope`——
|
||||
写进去的非法值不会在写入时报错,只会在**每一次查询**里静默地什么都查不到。
|
||||
|
||||
**不遵守会怎样**,两种漏法的方向相反,值得分清:
|
||||
|
||||
- **查询里忘了组合 `Permission()`** —— 就是**全量可见**,每个角色都看得到所有人
|
||||
的数据,不报错、不记日志。**这是本框架里最贵的一类静默失败**,所以那一行
|
||||
`db.Scopes(...)` 不是"最佳实践",是契约。
|
||||
- **组合了 `Permission()` 但路由上漏挂中间件** —— 上下文里没有 `PermissionKey`,
|
||||
拿到的是零值,`DataScope` 是空串,落进下面那个 fail-closed 的 default,
|
||||
结果是**一行都查不到**。方向反了,至少还看得见。
|
||||
|
||||
五档之外的值(空串、拼错的、还没迁移的老数据)落到 `default` 分支,
|
||||
那里是 **fail closed**:加一条 `1 = 0`,什么都不返回。注意 `1`(全部数据权限)
|
||||
是**显式列出的一个 case**,不是"落到 default"——两者曾经是同一条路,
|
||||
于是"没配置"和"配置成看全部"产出的 SQL 一个字都不差。
|
||||
|
||||
`3` / `4` 两档在 `DeptId <= 0` 时同样 fail closed。原因是
|
||||
`sys_dept.dept_path` 一律以 `/0/` 开头,`dept_id=0` 会把 LIKE 模式变成
|
||||
`'%/0/%'`,**命中全表**——本来想表达"没有部门",实际表达的是"全部部门"。
|
||||
|
||||
数据权限还有一个**全局开关** `application.enabledp`,默认是 `false`。
|
||||
关掉时 `Permission()` 原样返回查询、`PermissionAction()` 直接放行——
|
||||
**你的应用在默认配置下测不出数据权限的任何行为**,要验证得先把它打开。
|
||||
|
||||
**不要自己重写这段 SQL。** 那 20 行里埋着 8 项内部知识:JWT claims 的私有键名
|
||||
(`datascope` / `deptid`)、`sys_user`↔`sys_role` 的 join、`sys_role_dept`
|
||||
关联表、`sys_dept.dept_path` 的 `/0/1/2/` 编码、`create_by` 的归属约定、
|
||||
`enabledp` 开关、老 token 的回落逻辑。**而且写错的方向是越权。**
|
||||
仓库里有过一份第二实现,`dept_path` 的匹配写成 `"%"+id+"%"` 少了两个斜杠,
|
||||
`dept_id=1` 会匹配上 `/11/`、`/21/`、`/100/`——写它的人比第三方更懂这套约定,
|
||||
仍然写错了。那份实现已经删掉了。
|
||||
|
||||
### 7. 事务范式
|
||||
|
||||
**业务层的事务一律用 `Transaction()` 闭包形式**:
|
||||
|
||||
```go
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
return err // rolled back
|
||||
}
|
||||
return tx.Model(&stock).Where("qty >= ?", n).
|
||||
UpdateColumn("qty", gorm.Expr("qty - ?", n)).Error
|
||||
})
|
||||
```
|
||||
|
||||
GORM 自己处理提交、回滚,以及 **panic 时的回滚**。
|
||||
|
||||
**不要照抄 `app/admin/service/sys_role.go`。** 那里有 5 处手写的
|
||||
`Begin` / `defer` 写法,三个缺陷都是静默的:
|
||||
|
||||
```go
|
||||
tx := e.Orm
|
||||
if config.DatabaseConfig.Driver != "sqlite3" { // 缺陷 2
|
||||
tx = e.Orm.Begin()
|
||||
defer func() {
|
||||
if err != nil { tx.Rollback() } else { tx.Commit() } // 缺陷 1
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
1. **panic 时提交半截事务**——defer 只看 `err`,panic 时 `err` 仍是 nil,走的是
|
||||
`Commit()`
|
||||
2. **sqlite 下根本不开事务**——那一整个特判让 `tx` 就是 `e.Orm` 本身,
|
||||
写一半失败留一半
|
||||
3. **读 `config.DatabaseConfig.Driver`**——那是全局单库配置,多租户下不是
|
||||
当前租户的驱动
|
||||
|
||||
缺陷 1 不止那一处:`app/admin/service/sys_dept.go`、`sys_menu.go`、
|
||||
`app/other/models/tools/sys_tables.go` 用的是同一个 `defer` 写法
|
||||
(没有 sqlite 特判,所以只有缺陷 1)。**整个 `Begin`/`defer` 家族都别照抄。**
|
||||
|
||||
同一个仓库里就有正确的参照:`cmd/migrate/migration/version/` 下 7 个迁移里
|
||||
5 个用的是闭包形式(另外两个是纯 DDL 标记,DDL 在 MySQL 下本来就不进事务),
|
||||
且这条路在 sqlite 下实测跑得通(`make build-sqlite`)。
|
||||
主仓那些写法本批次不改,单独跟。
|
||||
|
||||
**并发保护用条件更新 + `RowsAffected`**,不要"先查后改":
|
||||
|
||||
```go
|
||||
res := tx.Model(&Order{}).Where("id = ? AND status = ?", id, StatusPending).
|
||||
Update("status", StatusPaid)
|
||||
if res.Error != nil { return res.Error }
|
||||
if res.RowsAffected == 0 { return ErrAlreadyPaid } // 别人先改了
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第二层:可选便利
|
||||
|
||||
用不用都对,**不用不会出任何问题**:
|
||||
|
||||
| 东西 | 在哪 | 是什么 |
|
||||
|---|---|---|
|
||||
| `api.Api` | core `sdk/api` | 一条链式糖:`MakeContext` / `Bind` / `MakeOrm` / `OK` / `PageOK` / `Error` |
|
||||
| `service.Service` | core `sdk/service` | 一个装 `Orm` / `Log` / `Cache` / `Error` 的结构体加一个 `AddError` |
|
||||
| `MakeCondition` / `search` tag | core `sdk/contract/dto` | 把 DTO 上的 `search:"type:exact;column:name;table:xx"` 翻成 WHERE |
|
||||
| 通用 CRUD Action | go-admin `common/actions` | `IndexAction` 等五个。**留在 go-admin,没有下沉** |
|
||||
|
||||
最后一行是有意的:CRUD Action 是最需要演进的一类东西(分页参数、批量操作、
|
||||
软删语义、字段级权限),而 core 的每一个导出都是永久承诺——放进去容易,
|
||||
拿出来不可能。想用就把那 294 行抄走,抄走的那份还能按你自己的需要改。
|
||||
主仓唯一的真实业务模块 `app/admin` **一个 CRUD Action 都没用**,全是手写 Service。
|
||||
|
||||
`MakeCondition` 返回的是 `func(db *gorm.DB) *gorm.DB` 闭包,方言从闭包里那个
|
||||
`db.Dialector.Name()` 读,**必然是本租户那个库的驱动**,不需要你设置任何东西。
|
||||
|
||||
---
|
||||
|
||||
## 第三层:今天没有的
|
||||
|
||||
**明说没有,别自己发明**:
|
||||
|
||||
| 能力 | 现状 |
|
||||
|---|---|
|
||||
| 应用间调用 | 零定义。A 应用要调 B 应用只能直接 import 对方的包,循环依赖就回来了 |
|
||||
| 领域事件 / EventBus | 无 |
|
||||
| 缓存的租户隔离 | `service.Service` 有 `Cache` 字段,**是否按租户隔离未验证**。当作没隔离来写 |
|
||||
| 异步任务 | 有队列,但热更新后消费者会丢(issue #892) |
|
||||
|
||||
这几条留给后续批次,按真实需求补——现在凭空设计只会设计错。
|
||||
如果你的应用卡在这里,在 issue 里说一声,那正是我们要的输入。
|
||||
|
||||
---
|
||||
|
||||
## 装一个应用要接两处线
|
||||
|
||||
后端**两处**,漏掉第二处是**静默失败**:
|
||||
|
||||
```go
|
||||
// 1. 路由:cmd/api/<name>.go
|
||||
import _ "github.com/acme/go-admin-app-order/router"
|
||||
|
||||
// 2. 迁移:cmd/migrate/server.go 的 import 块里
|
||||
import _ "github.com/acme/go-admin-app-order/migration"
|
||||
```
|
||||
|
||||
两个都是空导入,作用只是让那个包的 `init()` 跑起来。
|
||||
|
||||
**漏了第二处会怎样**:不报错。`migrate` 命令照常跑完、照常打印成功,
|
||||
你的建表和种子数据**就是不执行**。等到第一个请求打过来才会看到
|
||||
"表不存在",而那时排查方向已经跑偏了。
|
||||
|
||||
`migrate --dry-run` 是确认接线成功的最快方式——它只读,可以直接对生产库跑:
|
||||
|
||||
```bash
|
||||
go-admin migrate --dry-run -c config/settings.yml # 你的迁移应该出现在列表里
|
||||
```
|
||||
|
||||
带界面的应用还有第三处,在前端仓库,见下一节。
|
||||
|
||||
---
|
||||
|
||||
## 前端:菜单 `component` 必须以 `apps/` 开头
|
||||
|
||||
前端那一处接线是 `go-admin-ui` 的 `apps.config.mjs`——加一条
|
||||
`{ code: 'order', source: '...' }`,`source` 指到你的页面目录
|
||||
(兄弟目录的相对路径,或 `./node_modules/@scope/app-order/views/order`)。
|
||||
`scripts/sync-apps.mjs` 会在 `pnpm dev` 与 `pnpm build` 之前把它复制进
|
||||
`src/apps/<code>/`,不需要手工跑。
|
||||
|
||||
`src/stores/permission.ts` 的 `appPath()` **只认路径第一段是 `apps`**,
|
||||
其余一律当成主仓内置视图去 `src/views/` 下找。
|
||||
|
||||
所以你的菜单种子里 `Component` 必须写成:
|
||||
|
||||
```
|
||||
apps/<code>/<该应用内的相对路径>/index
|
||||
```
|
||||
|
||||
比如 `code` 是 `order` 的应用写 `apps/order/index`(开头带不带 `/` 都行,
|
||||
只看第一段)。**不能**写成 `/order/index`。
|
||||
|
||||
**写错会怎样**:第一段是 `order` 而不是 `apps`,前端会去找一个不存在的
|
||||
`src/views/order/index.vue`,页面摔到 `AppNotInstalled` 占位组件。
|
||||
但控制台打印的是 `no component at src/views/order/index.vue`——
|
||||
**跟真实原因(漏了 `apps/` 前缀)对不上**,排查时很容易被这条日志带偏。
|
||||
|
||||
对应的前端约定写在 go-admin-ui 的 `AGENTS.md`。另外一条:`source` 目录的内容
|
||||
**原样**搬进 `src/apps/<code>/`,不会在 `code` 之外再自动插一层——想要
|
||||
`apps/order/index` 这种最短形式,`source` 就要直接指到该应用**这一个页面模块**
|
||||
的目录,而不是应用仓库的 `views` 根目录。
|
||||
|
||||
---
|
||||
|
||||
## 注册路由
|
||||
|
||||
写一个 `func()` 签名的 `InitRouter`(照抄 `app/demo/router/router.go`),
|
||||
然后二选一接进来:
|
||||
|
||||
```go
|
||||
// 方式一(历史写法,仍然有效):在主仓 cmd/api/<name>.go 里
|
||||
AppRouters = append(AppRouters, router.InitRouter)
|
||||
|
||||
// 方式二(推荐):不需要 import go-admin/cmd/api
|
||||
sdk.Runtime.SetAppRouters(router.InitRouter)
|
||||
```
|
||||
|
||||
**第三方应用只能走方式二**——方式一要求 `import "go-admin/cmd/api"`,
|
||||
那就又依赖上宿主了。
|
||||
|
||||
走方式二还多拿到两样东西,都在 core 那边实现:
|
||||
|
||||
- **panic 护栏**——你的 `InitRouter` panic 了,其余模块照常注册、进程不退出,
|
||||
日志里会写明是哪一行注册的
|
||||
- **失败分级**——`sdk.Runtime.SetAppRoutersWith(f, runtime.WithFatal())`
|
||||
声明「我起不来就别启动」
|
||||
|
||||
方式一(包级 `AppRouters`)没有护栏,panic 直接掀桌。
|
||||
|
||||
**执行顺序**:先跑完包级 `AppRouters`,再由 `sdk.Runtime.RunAppRouters()`
|
||||
跑 core 自己的注册表,各自内部保持注册顺序。别依赖跨来源的相对顺序。
|
||||
|
||||
`InitRouter()` 内部:自己拿 `sdk.Runtime.GetEngine()`,按需建
|
||||
`gin.RouterGroup`,通过 `init()` 自注册到你自己包内的
|
||||
`routerCheckRole` / `routerNoCheckRole` 列表,不在任何中心文件手工列举。
|
||||
|
||||
---
|
||||
|
||||
## 注册数据库迁移
|
||||
|
||||
框架自身的迁移用 `SetVersion`;应用的迁移走 `ForApp`:
|
||||
|
||||
```go
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.ForApp("crm").SetVersion(migration.GetFilename(fileName), initCrmTables)
|
||||
}
|
||||
|
||||
func initCrmTables(db *gorm.DB, version, appCode string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
// ... schema / data changes ...
|
||||
return tx.Create(&models.Migration{Version: version, AppCode: appCode}).Error
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
注册面(`ForApp` / `SetVersion` / `GetFilename`)在 core 的
|
||||
`sdk/contract/migration`,是一个**进程级的包级注册表**——`ForApp` 直接当包级函数
|
||||
调,不需要从宿主手里接过什么句柄。**执行面**——读 `sys_migration`、排序、跑事务、
|
||||
`migrate` 与 `migrate status` 两个命令——留在宿主,它通过 `Snapshot()` 读那张表。
|
||||
|
||||
仓库内的模块继续经 `go-admin/cmd/migrate/migration` 走,那个包现在是薄壳,
|
||||
导入路径不变;外置应用直接 import core 的那个包,**两边写法一模一样**。
|
||||
|
||||
五条必须知道的规则:
|
||||
|
||||
1. **完成记录由迁移函数自己写**,而且要写在自己的事务里。框架的调度循环只做
|
||||
"这个 version 在 `sys_migration` 里有没有" 的判断,从不代你插入 —— 这样
|
||||
"数据改完了"和"标记成已完成"才是同一个事务,不会出现改了一半却被记成成功。
|
||||
2. **`AppCode` 必须写进去**。签名多带一个 `appCode` 参数就是为此 —— 忘了写,
|
||||
schema 上那一列等于白加,你的迁移会被记成框架的。
|
||||
3. **落库的 `version` 是加了前缀的**。`ForApp("crm")` 注册 `1786800001000`,
|
||||
实际写进 `sys_migration.version` 的是 `crm-1786800001000`,函数收到的
|
||||
`version` 参数已经是这个带前缀的值,照抄进 `models.Migration{Version: version}`
|
||||
即可。前缀的意义是:两个来源不同的应用哪怕碰巧生成同一个毫秒时间戳,也不会撞主键、
|
||||
不会有一方被误判为"已应用"。
|
||||
4. **应用 code 一律小写**,`ForApp` 会自己 `strings.ToLower` 一遍。`core` 是保留字
|
||||
(`migrate status` 用它表示框架自身,`--app core` 选中框架),`ForApp("core")`
|
||||
会 panic。
|
||||
5. **文件名前 13 位必须是毫秒时间戳**,`GetFilename` 就是从这里取版本号的。
|
||||
不合规的名字会 panic,并把违规文件名报出来 —— 这是**故意的**:调用点全在
|
||||
`init()` 里,没有 error 可返回,而另一条路是把文件名本身注册成"版本号"
|
||||
(`add_orders.go` 恰好 13 个字符,只查长度是拦不住的),那样这条迁移
|
||||
永远不会被执行,且不会有任何提示。宁可启动失败。
|
||||
|
||||
顺序保证:**同一应用内按版本号严格有序**。跨应用顺序不做承诺 —— 由于前缀的存在,
|
||||
今天的实际顺序是"先跑完全部框架迁移,再按 appCode 字母序逐个应用跑完",
|
||||
但这是实现细节,不要依赖它。跨应用依赖(应用 A 的迁移要求应用 B 先跑完)
|
||||
需要依赖拓扑排序,属于后续阶段。
|
||||
|
||||
看当前状态、看这次会跑什么,不用猜:
|
||||
|
||||
```bash
|
||||
go-admin migrate status -c config/settings.yml # 按应用分组列出已应用 / 待应用
|
||||
go-admin migrate --dry-run -c config/settings.yml # 列出会执行什么、什么顺序,不写库
|
||||
go-admin migrate --app crm -c config/settings.yml # 只跑 crm 的迁移
|
||||
```
|
||||
|
||||
`status` 与 `--dry-run` 是纯只读的,不建表、不改表结构,可以直接对生产库执行。
|
||||
|
||||
---
|
||||
|
||||
## 菜单与接口种子
|
||||
|
||||
一个带界面的应用要在侧边栏里出现,需要往四类数据里写东西:`sys_api`、
|
||||
`sys_menu`、`sys_menu_api_rule`(菜单与接口的关联)、以及角色授权与 Casbin
|
||||
策略(`sys_role_menu` / `casbin_rule`)。
|
||||
|
||||
**你不需要知道这些表长什么样。** `sdk/contract/seed` 让你只描述"我要什么",
|
||||
由宿主决定"怎么写进它自己的表":
|
||||
|
||||
```go
|
||||
// 在你自己的迁移里,用它自己的那个事务
|
||||
err := seed.SeedMenus(tx, "order", []seed.MenuSpec{
|
||||
{Code: "root", Kind: models.Directory, Title: "订单"},
|
||||
{Code: "list", Parent: "root", Kind: models.Menu, Title: "订单列表",
|
||||
Path: "/order", Component: "apps/order/index", ApiCodes: []string{"list"}},
|
||||
}, []seed.ApiSpec{
|
||||
{Code: "list", Title: "订单列表", Path: "/api/v1/order", Method: "GET"},
|
||||
})
|
||||
```
|
||||
|
||||
`Kind` 用的就是 `sdk/contract/models` 里 `sys_menu.menu_type` 的那三个值
|
||||
(`Directory` / `Menu` / `Button`),不是另一套同值的常量。
|
||||
|
||||
`Component` 的写法见上面「前端」一节——**这里是最容易写错的一个字段**。
|
||||
|
||||
core 里**没有** `SysMenu`、没有 `SysApi`、没有任何表名。这是刻意划的边界:
|
||||
这个框架的宿主里本来就已经有两份 `SysMenu`(一份冻结在迁移期、一份运行期),
|
||||
两者在软删语义上不一致,害过人,为此专门建了一个仓库内的工具来守。
|
||||
往 core 里再放第三份表结构,就等于在**唯一没有工具守着**的地方重造同一类 bug。
|
||||
|
||||
### `Sort` 有上界,越界会中断整场迁移
|
||||
|
||||
`sys_menu.sort` 声明为 `gorm:"size:4"`,MySQL 据此建成 **tinyint,取值 -128..127**。
|
||||
sqlite 忽略宽度,所以越界值在本地测试里一路绿灯,到真实安装时是 Error 1264 ——
|
||||
而且发生在一次迁移的**中途**,后面的迁移全部不再执行。
|
||||
|
||||
`make checksilent` 的 `menu-sort-overflow` 会扫出仓库树里的越界字面量,
|
||||
**但它扫不到 module cache 里的应用**。外置应用只有宿主 Seeder 的运行期校验兜底。
|
||||
|
||||
### `MenuSpec` 没有菜单名字段,名字由宿主合成
|
||||
|
||||
前端用菜单名做 keep-alive 的缓存键。两个应用如果都取 `Code: "list"`,
|
||||
缓存键就会撞在一起 —— 后打开的那个页面会拿到前一个的缓存实例。
|
||||
|
||||
所以宿主的 Seeder 不直接用 `Code` 当菜单名,而是用
|
||||
**PascalCase(appCode) + PascalCase(Code)** 合成(`order` + `list` → `OrderList`)。
|
||||
你不需要做什么,但要知道两件事:
|
||||
|
||||
- 菜单名不是你能指定的,也不必与 `Title` 一致 —— `Title` 才是界面上显示的文字
|
||||
- 前端组件的 `name` 若要与菜单名对齐(`checksilent` 的 `menu-name-mismatch` 会比对),
|
||||
按合成后的名字写,不是按 `Code`
|
||||
|
||||
---
|
||||
|
||||
## 应用配置节
|
||||
|
||||
不要改宿主的源码去加配置。`sdk/config.RegisterExtend` 让你认领
|
||||
`extend:` 下自己那一节:
|
||||
|
||||
```go
|
||||
type orderConfig struct {
|
||||
PaymentEndpoint string
|
||||
Timeout int
|
||||
}
|
||||
|
||||
// 在 init() 里调,与 SetAppRouters / ForApp 同一约定
|
||||
var getOrderConfig = config.RegisterExtend[orderConfig]("order")
|
||||
|
||||
func handler(c *gin.Context) {
|
||||
cfg := getOrderConfig()
|
||||
_ = cfg.PaymentEndpoint
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
extend:
|
||||
order:
|
||||
PaymentEndpoint: https://payment.internal
|
||||
Timeout: 30
|
||||
```
|
||||
|
||||
每个 key 各自解码,互不覆盖。**同一个 key 注册两次会立刻 panic**——
|
||||
注册期没有"封闭时刻"可以用来拒绝迟到的注册,所以重复只能在注册的那一刻
|
||||
大声报出来,而不是让第二个人静默顶掉第一个人的配置节。
|
||||
|
||||
配置文件是被监听的,改动会触发重载。`RegisterExtend` 每次重载解码进一个全新的
|
||||
`T` 再原子换指针,所以访问器拿到的永远是一个自洽的快照,请求路径上读它不需要加锁。
|
||||
唯一要注意的:**不要跨两次调用拼一个视图**——从同一个返回值上读两个字段是一致的,
|
||||
调两次访问器各读一个字段,中间夹一次重载就不是了。
|
||||
|
||||
---
|
||||
|
||||
## 硬约束:注册要赶在启动钩子之前
|
||||
|
||||
三个注册入口——`AppRouters`、`sdk.Runtime.SetAppRouters`、`migration.ForApp`——
|
||||
都必须在 `cmd/api/server.go` 的 `runStartupHooks()` 执行之前调用完。
|
||||
|
||||
`init()` 是最省事的位置:Go 规范保证包级变量初始化与 `init()` 在 `main()` 之前
|
||||
**单 goroutine 顺序执行**,注册期天然没有并发写。但它不是唯一合法位置——
|
||||
在 `run()` 之类早于启动钩子的地方注册同样成立。**这条规则约束的是顺序,
|
||||
不是你写在哪个函数里。**
|
||||
|
||||
想在代码里判断注册窗口是否还开着:
|
||||
|
||||
```go
|
||||
if sdk.Runtime.AppRoutersSealed() { /* RunAppRouters 已经跑过了 */ }
|
||||
```
|
||||
|
||||
主仓这边补三条 core 那份文档管不着的:
|
||||
|
||||
1. **`RunAppRouters()` 跑过之后,core 的注册表就封闭了**,再调
|
||||
`sdk.Runtime.SetAppRouters` 会被丢弃并记一条 ERROR 日志。包级 `AppRouters`
|
||||
没有这个机制——它就是一个普通 slice,什么时候 append 都"成功",
|
||||
但 `runStartupHooks()` 之后 append 的那些永远不会被执行,且不出声。
|
||||
这是继续推荐方式二的理由之一。
|
||||
2. **封闭是黏性的,而 `sdk.Runtime` 是包级单例。** 写测试时若会触发启动钩子,
|
||||
必须换掉它再还原,否则同一个测试二进制里后面的测试会静默丢注册:
|
||||
|
||||
```go
|
||||
previous := sdk.Runtime
|
||||
t.Cleanup(func() { sdk.Runtime = previous })
|
||||
sdk.Runtime = runtime.NewConfig()
|
||||
```
|
||||
|
||||
`cmd/api/server_test.go` 里的 `freshRuntime` 就是这个。
|
||||
3. **迁移的调度循环是主仓的东西**,core 只有注册面。迁移注册的约束仍然是
|
||||
"赶在调度循环跑起来之前",实践上就是 `init()`。
|
||||
|
||||
---
|
||||
|
||||
## 安全边界:装一个应用等于信任它
|
||||
|
||||
**这一层划不出安全边界,本文不假装划得出。**
|
||||
|
||||
第三方应用的代码在**宿主进程内**运行,与宿主**同权限**。它持有的是裸的
|
||||
`*gorm.DB`——`seed.SeedMenus` 用的就是你自己迁移里那个 `tx`,绕开 `Seeder`
|
||||
直写 `sys_menu`、`sys_api`、甚至 `casbin_rule` 一直都做得到,Go 的类型系统
|
||||
拦不住,本框架的任何一层也拦不住。
|
||||
|
||||
还有一条**不碰 `casbin_rule` 也能走通**的间接路径:把自己的菜单通过
|
||||
`ApiCodes` 关联到别人的接口,然后等管理员在后台把这个菜单授权给某个角色——
|
||||
策略是后台自己生成的,记在管理员头上。
|
||||
|
||||
所以:
|
||||
|
||||
> **装一个应用,等于信任它。** 这和 `import _` 一个 Go 库是同一量级的信任。
|
||||
> `Seeder` 这类设计的目的是让**守规矩的应用不必知道宿主的表结构**,
|
||||
> 不是把不守规矩的应用关起来。
|
||||
|
||||
给使用者的实际建议只有一条:**按信任 Go 依赖的标准来审应用**——看源码、
|
||||
钉版本、认作者。不要因为它叫"应用"就以为它跑在沙箱里。
|
||||
|
||||
---
|
||||
|
||||
## 边界由 CI 守着
|
||||
|
||||
`tools/checksilent` 里有两条盯契约面的检查,`make checksilent` 在 CI 里跑,
|
||||
命中 ERROR 即失败:
|
||||
|
||||
| 检查 | 盯的是 |
|
||||
|---|---|
|
||||
| `contract-import-boundary` | `common/`、`core/` 不得 import `app/`——否则一个删掉 `app/admin` 的 fork 就编译不了它被告知可以依赖的那一面 |
|
||||
| `contract-shim-alias` | 从 core 契约包声明出来的类型必须是**别名**(`type X = pkg.Y`),不能是 defined type。判据是右手边,不是一份包名清单,所以谁在哪加的都算 |
|
||||
|
||||
第二条守的是一条一个字符的差别。`type X = pkg.Y` 和 `type X pkg.Y`
|
||||
看着几乎一样,但后者只拿走底层结构、**丢掉整个方法集**,于是嵌了它的 model
|
||||
不再满足 `ActiveRecord`。麻烦在于这**不一定在本仓编译失败**——本仓只用接口
|
||||
使唤其中一部分类型,没被使唤到的那些在这里编译得好好的,
|
||||
**到第三方应用或某个 fork 里才炸**,而那里没人看着。
|
||||
|
||||
测试文件同样算——一个删掉 `app/admin` 的 fork 也应该能跑 `go test ./...`。
|
||||
|
||||
**这两条工具都只扫仓库树。** 装在 module cache 里的第三方应用,
|
||||
`checksilent` 一个文件都看不到。所以它保的是**这个仓库和它的 fork**,
|
||||
不是你的应用——你的应用要自己跑自己的检查。
|
||||
|
||||
`checksilent` 还检查另外五类"不出声的失败",写模块时值得先看一眼
|
||||
`go run ./tools/checksilent -h`。
|
||||
@@ -0,0 +1,167 @@
|
||||
// Package apis is app-order's HTTP layer: four hand-written gin handlers,
|
||||
// none of them a wrapper around core's generic CRUD Actions. See
|
||||
// service/order.go's package doc for why.
|
||||
package apis
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/actions"
|
||||
|
||||
// models.Response in the @Success annotations below resolves to
|
||||
// go-admin-core's sdk/contract/models.Response, not to this package -
|
||||
// swaggo finds it through --parseDependency. It is the envelope with a
|
||||
// data field; core's response.Response, which the framework's own
|
||||
// handlers name, has no data field and would document these endpoints
|
||||
// as returning none.
|
||||
"github.com/go-admin-team/example-app-order/models"
|
||||
"github.com/go-admin-team/example-app-order/service"
|
||||
orderdto "github.com/go-admin-team/example-app-order/service/dto"
|
||||
)
|
||||
|
||||
// Order embeds api.Api the same way every hand-written go-admin handler
|
||||
// does (see app/admin/apis/sys_post.go): MakeContext/MakeOrm/Bind/
|
||||
// MakeService/OK/Error/PageOK are all core, imported with no dependency on
|
||||
// go-admin itself.
|
||||
type Order struct {
|
||||
api.Api
|
||||
}
|
||||
|
||||
// GetPage
|
||||
// @Summary List orders visible to the caller's data scope
|
||||
// @Tags order
|
||||
// @Param status query string false "status"
|
||||
// @Param orderNo query string false "orderNo"
|
||||
// @Param pageIndex query int false "pageIndex"
|
||||
// @Param pageSize query int false "pageSize"
|
||||
// @Success 200 {object} models.Response
|
||||
// @Router /api/v1/order [get]
|
||||
// @Security Bearer
|
||||
func (e Order) GetPage(c *gin.Context) {
|
||||
s := service.Order{}
|
||||
req := orderdto.OrderSearchReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Error(http.StatusUnprocessableEntity, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
list := make([]models.Order, 0)
|
||||
count, err := s.GetPage(&req, p, &list)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(http.StatusInternalServerError, err, "failed to list orders")
|
||||
return
|
||||
}
|
||||
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "ok")
|
||||
}
|
||||
|
||||
// Get
|
||||
// @Summary Get one order and its items
|
||||
// @Tags order
|
||||
// @Param id path int true "order id"
|
||||
// @Success 200 {object} models.Response
|
||||
// @Router /api/v1/order/{id} [get]
|
||||
// @Security Bearer
|
||||
func (e Order) Get(c *gin.Context) {
|
||||
s := service.Order{}
|
||||
req := orderdto.OrderIdReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Error(http.StatusUnprocessableEntity, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
var order models.Order
|
||||
if err = s.Get(req.Id, p, &order); err != nil {
|
||||
e.Error(http.StatusNotFound, err, "order not found")
|
||||
return
|
||||
}
|
||||
e.OK(order, "ok")
|
||||
}
|
||||
|
||||
// Create
|
||||
// @Summary Place a new order
|
||||
// @Tags order
|
||||
// @Accept application/json
|
||||
// @Param data body orderdto.OrderCreateReq true "data"
|
||||
// @Success 200 {object} models.Response
|
||||
// @Router /api/v1/order [post]
|
||||
// @Security Bearer
|
||||
func (e Order) Create(c *gin.Context) {
|
||||
s := service.Order{}
|
||||
req := orderdto.OrderCreateReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Error(http.StatusUnprocessableEntity, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
order, err := s.Create(&req, user.GetUserId(c))
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrOrderEmpty) {
|
||||
e.Error(http.StatusBadRequest, err, err.Error())
|
||||
return
|
||||
}
|
||||
e.Logger.Error(err)
|
||||
e.Error(http.StatusInternalServerError, err, "failed to create order")
|
||||
return
|
||||
}
|
||||
e.OK(order, "created")
|
||||
}
|
||||
|
||||
// Pay
|
||||
// @Summary Mark a pending order as paid
|
||||
// @Tags order
|
||||
// @Param id path int true "order id"
|
||||
// @Success 200 {object} models.Response
|
||||
// @Router /api/v1/order/{id}/pay [put]
|
||||
// @Security Bearer
|
||||
func (e Order) Pay(c *gin.Context) {
|
||||
s := service.Order{}
|
||||
req := orderdto.OrderIdReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Error(http.StatusUnprocessableEntity, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
if err = s.Pay(req.Id, p); err != nil {
|
||||
if errors.Is(err, service.ErrOrderNotPending) {
|
||||
// Deliberately the same response whether the order does not
|
||||
// exist, is already paid, or is outside p's data scope - see
|
||||
// service.Order.Pay's doc comment.
|
||||
e.Error(http.StatusConflict, err, err.Error())
|
||||
return
|
||||
}
|
||||
e.Logger.Error(err)
|
||||
e.Error(http.StatusInternalServerError, err, "payment failed")
|
||||
return
|
||||
}
|
||||
e.OK(nil, "paid")
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
module github.com/go-admin-team/example-app-order
|
||||
|
||||
go 1.25.13
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.5.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/andeya/ameda v1.5.3 // indirect
|
||||
github.com/andeya/goutil v1.0.1 // indirect
|
||||
github.com/bitly/go-simplejson v0.5.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||
github.com/bytedance/go-tagexpr/v2 v2.9.11 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/casbin/casbin/v3 v3.8.1 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99 // indirect
|
||||
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/ghodss/yaml v1.0.0 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/nyaruka/phonenumbers v1.2.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.22.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gorm.io/plugin/soft_delete v1.2.1 // indirect
|
||||
modernc.org/libc v1.67.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.42.2 // indirect
|
||||
)
|
||||
@@ -0,0 +1,252 @@
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/andeya/ameda v1.5.3 h1:SvqnhQPZwwabS8HQTRGfJwWPl2w9ZIPInHAw9aE1Wlk=
|
||||
github.com/andeya/ameda v1.5.3/go.mod h1:FQDHRe1I995v6GG+8aJ7UIUToEmbdTJn/U26NCPIgXQ=
|
||||
github.com/andeya/goutil v1.0.1 h1:eiYwVyAnnK0dXU5FJsNjExkJW4exUGn/xefPt3k4eXg=
|
||||
github.com/andeya/goutil v1.0.1/go.mod h1:jEG5/QnnhG7yGxwFUX6Q+JGMif7sjdHmmNVjn7nhJDo=
|
||||
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
|
||||
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/go-tagexpr/v2 v2.9.11 h1:jJgmoDKPKacGl0llPYbYL/+/2N+Ng0vV0ipbnVssXHY=
|
||||
github.com/bytedance/go-tagexpr/v2 v2.9.11/go.mod h1:UAyKh4ZRLBPGsyTRFZoPqTni1TlojMdOJXQnEIPCX84=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/casbin/casbin/v3 v3.8.1 h1:D4dEY4knePPR4YgNP5WZtWNaOxD0UK0LpPy9+zxtBwo=
|
||||
github.com/casbin/casbin/v3 v3.8.1/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8=
|
||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99 h1:K62Lb6bsgLOB++z/VAvRvtiEBdNCuMfmQGTGGWMdPpM=
|
||||
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99/go.mod h1:9+sJ9zvvkXC5sPjPEZM3Jpb9n2Q2VtcrGZly0UHYF5I=
|
||||
github.com/chanxuehong/util v0.0.0-20200304121633-ca8141845b13/go.mod h1:XEYt99iTxMqkv+gW85JX/DdUINHUe43Sbe5AtqSaDAQ=
|
||||
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd h1:v3JNsFZmplLO/Cmiyr/rGvR7lW1ld9lB+d5h4yR0MTI=
|
||||
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd/go.mod h1:mysjrtCs9MmN8hqDf4/mc4eQ26Rt9s1p5oO+fhJlLB4=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.5.0 h1:aD1SALklBxizGB9u8cOgm4OT8z656FM83F4fD6dMz9g=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.5.0/go.mod h1:LG/XvEfOplbuadKrPTPm0Nu5pN06aQUNZZC3ao4B4gs=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nyaruka/phonenumbers v1.0.55/go.mod h1:sDaTZ/KPX5f8qyV9qN+hIm+4ZBARJrupC6LuhshJq1U=
|
||||
github.com/nyaruka/phonenumbers v1.2.2 h1:OwVjf7Y4uHoK9VJUrA8ebR0ha2yc6sEYbfrwkq0asCY=
|
||||
github.com/nyaruka/phonenumbers v1.2.2/go.mod h1:wzk2qq7qwsaBKrfbkWKdgHYOOH+QFTesSpIq53ELw8M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
|
||||
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
|
||||
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
|
||||
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
|
||||
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
gorm.io/gorm v1.23.0/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gorm.io/plugin/soft_delete v1.2.1 h1:qx9D/c4Xu6w5KT8LviX8DgLcB9hkKl6JC9f44Tj7cGU=
|
||||
gorm.io/plugin/soft_delete v1.2.1/go.mod h1:Zv7vQctOJTGOsJ/bWgrN1n3od0GBAZgnLjEx+cApLGk=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
|
||||
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
|
||||
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,89 @@
|
||||
// Package migration registers app-order's one migration: create its two
|
||||
// tables and seed the menu/API entries the admin UI needs to expose them.
|
||||
//
|
||||
// It registers through contract/migration.ForApp - the package-level
|
||||
// facade, not a private NewRegistry() - because that is the only registry a
|
||||
// third-party app, which cannot reach into the host process, can register
|
||||
// against and have any hope of the host's own execution engine picking up.
|
||||
// Whether it actually does, today, is a different question: see this
|
||||
// package's test file and the gap list in the accompanying report.
|
||||
package migration
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration"
|
||||
contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed"
|
||||
|
||||
"github.com/go-admin-team/example-app-order/models"
|
||||
)
|
||||
|
||||
// AppCode is app-order's migration.ForApp / seed.SeedMenus identity.
|
||||
const AppCode = "order"
|
||||
|
||||
// version is this migration's sys_migration key before ForApp namespaces
|
||||
// it (see contract/migration.ForApp's doc comment: the stored key becomes
|
||||
// "order-" + version). It follows the framework's own 13-digit millisecond
|
||||
// timestamp convention purely so a human reading sys_migration.version
|
||||
// alongside the framework's own rows can still eyeball roughly when it was
|
||||
// authored; contract/migration.ForApp does not require that shape, just
|
||||
// uniqueness within this app's own namespace.
|
||||
const version = "1793800000000"
|
||||
|
||||
func init() {
|
||||
contractmigration.ForApp(AppCode).SetVersion(version, createOrderSchema)
|
||||
}
|
||||
|
||||
// createOrderSchema creates app_order/app_order_item and seeds the menu and
|
||||
// API entries a host's Seeder turns into sys_menu/sys_api/sys_menu_api_rule
|
||||
// rows (and, once an administrator grants the menu to a role through the
|
||||
// ordinary admin UI, casbin_rule). See seed.Seeder's security note: this
|
||||
// call does not sandbox anything, it only saves app-order from needing to
|
||||
// know go-admin's own schema.
|
||||
func createOrderSchema(db *gorm.DB, migrationVersion, appCode string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&models.Order{}, &models.OrderItem{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
menus := []seed.MenuSpec{
|
||||
{
|
||||
Code: "dir", Kind: contractmodels.Directory,
|
||||
Title: "Order Example", Path: "/apps/order", Component: "Layout",
|
||||
Icon: "shopping", Sort: 20,
|
||||
},
|
||||
{
|
||||
Code: "list", Parent: "dir", Kind: contractmodels.Menu,
|
||||
Title: "Orders", Path: "list",
|
||||
// Component must start with "apps/<code>/" - see
|
||||
// seed.MenuSpec.Component's doc comment. This is the one
|
||||
// concrete rule the report's gap list has nothing bad to
|
||||
// say about: it is documented exactly where a caller
|
||||
// building a MenuSpec would look.
|
||||
Component: "apps/order/order/index",
|
||||
Sort: 1,
|
||||
ApiCodes: []string{"list", "get", "create", "pay"},
|
||||
},
|
||||
{
|
||||
Code: "btn-create", Parent: "list", Kind: contractmodels.Button,
|
||||
Title: "Create", Permission: "order:order:create", Sort: 1,
|
||||
},
|
||||
{
|
||||
Code: "btn-pay", Parent: "list", Kind: contractmodels.Button,
|
||||
Title: "Pay", Permission: "order:order:pay", Sort: 2,
|
||||
},
|
||||
}
|
||||
apis := []seed.ApiSpec{
|
||||
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
|
||||
{Code: "get", Title: "Order detail", Path: "/api/v1/order/:id", Method: "GET", Handle: "apis.Order.Get-fm"},
|
||||
{Code: "create", Title: "Create order", Path: "/api/v1/order", Method: "POST", Handle: "apis.Order.Create-fm"},
|
||||
{Code: "pay", Title: "Pay order", Path: "/api/v1/order/:id/pay", Method: "PUT", Handle: "apis.Order.Pay-fm"},
|
||||
}
|
||||
if err := seed.SeedMenus(tx, appCode, menus, apis); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Create(&contractmodels.Migration{Version: migrationVersion, AppCode: appCode}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration"
|
||||
contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed"
|
||||
|
||||
"github.com/go-admin-team/example-app-order/models"
|
||||
)
|
||||
|
||||
// fakeSeeder stands in for a host's real Seeder (the one wt-shim, as of
|
||||
// this writing, never registers - see the accompanying report's gap list).
|
||||
// It records what it received instead of writing to any table, which is
|
||||
// enough to check app-order's own MenuSpec/ApiSpec assembly without
|
||||
// depending on go-admin's sys_menu/sys_api schema.
|
||||
type fakeSeeder struct {
|
||||
appCode string
|
||||
menus []seed.MenuSpec
|
||||
apis []seed.ApiSpec
|
||||
}
|
||||
|
||||
func (f *fakeSeeder) SeedMenus(tx *gorm.DB, appCode string, menus []seed.MenuSpec, apis []seed.ApiSpec) error {
|
||||
f.appCode = appCode
|
||||
f.menus = menus
|
||||
f.apis = apis
|
||||
return nil
|
||||
}
|
||||
|
||||
// seed.RegisterSeeder panics on a second call in the same process (see its
|
||||
// doc comment) - by design, there is no public way to unregister one. This
|
||||
// package's tests share the one registration below rather than each
|
||||
// registering their own.
|
||||
var fake = &fakeSeeder{}
|
||||
|
||||
func init() {
|
||||
seed.RegisterSeeder(fake)
|
||||
}
|
||||
|
||||
// TestRegistersUnderContractMigrationForApp is this package's core claim:
|
||||
// that createOrderSchema is reachable through contract/migration's
|
||||
// package-level Snapshot, the only registry a third-party module can
|
||||
// register against. It does not confirm any host actually calls Snapshot
|
||||
// today - see the report.
|
||||
func TestRegistersUnderContractMigrationForApp(t *testing.T) {
|
||||
entries := contractmigration.Snapshot()
|
||||
entry, ok := entries[AppCode+"-"+version]
|
||||
if !ok {
|
||||
t.Fatalf("no entry for %s-%s; registered: %v", AppCode, version, keysOf(entries))
|
||||
}
|
||||
if entry.AppCode != AppCode {
|
||||
t.Errorf("Entry.AppCode = %q, want %q", entry.AppCode, AppCode)
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]contractmigration.Entry) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestMigrationCreatesTablesSeedsMenusAndRecordsItself runs the registered
|
||||
// migration function directly against a fresh sqlite database - standing in
|
||||
// for the host's execution engine, which (see the report) does not exist
|
||||
// yet for an externally-registered app. It is the closest thing to an
|
||||
// end-to-end run this example can do without wt-shim's cooperation.
|
||||
func TestMigrationCreatesTablesSeedsMenusAndRecordsItself(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
// sys_migration itself is created by the framework's own first
|
||||
// migration (go-admin's cmd/migrate/migration/version/*_tables.go),
|
||||
// which by the time any app's migration runs has always already run -
|
||||
// simulate that precondition rather than app-order's own migration
|
||||
// creating a table it does not own.
|
||||
if err := db.AutoMigrate(&contractmodels.Migration{}); err != nil {
|
||||
t.Fatalf("automigrate sys_migration: %v", err)
|
||||
}
|
||||
|
||||
entries := contractmigration.Snapshot()
|
||||
entry, ok := entries[AppCode+"-"+version]
|
||||
if !ok {
|
||||
t.Fatalf("no entry for %s-%s", AppCode, version)
|
||||
}
|
||||
if err := entry.Fn(db, AppCode+"-"+version); err != nil {
|
||||
t.Fatalf("running the registered migration: %v", err)
|
||||
}
|
||||
|
||||
if !db.Migrator().HasTable(&models.Order{}) {
|
||||
t.Error("app_order was not created")
|
||||
}
|
||||
if !db.Migrator().HasTable(&models.OrderItem{}) {
|
||||
t.Error("app_order_item was not created")
|
||||
}
|
||||
|
||||
var migrationRow contractmodels.Migration
|
||||
if err := db.Where("version = ?", AppCode+"-"+version).First(&migrationRow).Error; err != nil {
|
||||
t.Fatalf("sys_migration row: %v", err)
|
||||
}
|
||||
if migrationRow.AppCode != AppCode {
|
||||
t.Errorf("sys_migration.app_code = %q, want %q", migrationRow.AppCode, AppCode)
|
||||
}
|
||||
|
||||
if fake.appCode != AppCode {
|
||||
t.Errorf("Seeder saw appCode %q, want %q", fake.appCode, AppCode)
|
||||
}
|
||||
assertMenuGraphIsConsistent(t, fake.menus, fake.apis)
|
||||
}
|
||||
|
||||
// assertMenuGraphIsConsistent checks the two rules that would otherwise
|
||||
// only surface as a broken admin UI at install time: every Parent
|
||||
// reference resolves to a Code in the same batch, and the frontend's
|
||||
// apps/<code>/ convention for a packaged page's Component (documented on
|
||||
// MenuSpec.Component, enforced by nothing - see the report) is actually
|
||||
// followed.
|
||||
func assertMenuGraphIsConsistent(t *testing.T, menus []seed.MenuSpec, apis []seed.ApiSpec) {
|
||||
t.Helper()
|
||||
|
||||
codes := make(map[string]seed.MenuSpec, len(menus))
|
||||
for _, m := range menus {
|
||||
codes[m.Code] = m
|
||||
}
|
||||
apiCodes := make(map[string]bool, len(apis))
|
||||
for _, a := range apis {
|
||||
apiCodes[a.Code] = true
|
||||
}
|
||||
|
||||
for _, m := range menus {
|
||||
if m.Parent != "" {
|
||||
if _, ok := codes[m.Parent]; !ok {
|
||||
t.Errorf("menu %q has Parent %q, which is not a Code in this batch", m.Code, m.Parent)
|
||||
}
|
||||
}
|
||||
for _, ac := range m.ApiCodes {
|
||||
if !apiCodes[ac] {
|
||||
t.Errorf("menu %q references ApiCode %q, which is not in this batch's apis", m.Code, ac)
|
||||
}
|
||||
}
|
||||
if m.Kind == contractmodels.Menu && m.Component != "" {
|
||||
if !strings.HasPrefix(m.Component, "apps/"+AppCode+"/") {
|
||||
t.Errorf("menu %q has Component %q, want it to start with apps/%s/", m.Code, m.Component, AppCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Package models holds app-order's two GORM row models.
|
||||
package models
|
||||
|
||||
import (
|
||||
contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
|
||||
)
|
||||
|
||||
// The two values Order.Status can hold. Kept as narrow strings rather than
|
||||
// an int enum to match sys_role.data_scope's own convention in core, and to
|
||||
// leave room for a future status without a schema change.
|
||||
const (
|
||||
StatusPending = "1" // awaiting payment
|
||||
StatusPaid = "2" // paid; set only by a successful Pay
|
||||
)
|
||||
|
||||
// orderTable is passed to actions.Permission and repeated as
|
||||
// Order.TableName's return value. It is not literally the word "order":
|
||||
// that is a reserved SQL keyword, and actions.Permission builds its WHERE
|
||||
// clause by string-concatenating tableName straight into raw SQL
|
||||
// (`tableName+".create_by = ?"`, see permission.go) with no quoting at all.
|
||||
// A table named exactly "order" would make every data-scope query a syntax
|
||||
// error on MySQL's default (non-ANSI-quotes) mode. This is not something
|
||||
// core enforces or even mentions - Permission's tableName parameter is an
|
||||
// opaque string as far as it is concerned - so avoiding reserved words is
|
||||
// entirely on the caller.
|
||||
const orderTable = "app_order"
|
||||
|
||||
// Order is one customer order. ControlBy is required, not decorative:
|
||||
// actions.Permission's data-scope SQL joins against create_by, so an Order
|
||||
// without it would make every data-scope rule silently match nothing.
|
||||
type Order struct {
|
||||
contractmodels.Model
|
||||
|
||||
OrderNo string `json:"orderNo" gorm:"type:varchar(64);uniqueIndex;comment:order number"`
|
||||
UserId int `json:"userId" gorm:"index;comment:buyer user id"`
|
||||
Status string `json:"status" gorm:"type:varchar(4);index;comment:order status: 1 pending, 2 paid"`
|
||||
TotalCents int64 `json:"totalCents" gorm:"comment:total amount in cents, sum of item price*quantity at creation time"`
|
||||
|
||||
// Items is populated by Preload; it is never set by Order's own migrator
|
||||
// column set (OrderItem.OrderId is the foreign key, not a column here).
|
||||
Items []OrderItem `json:"items,omitempty" gorm:"foreignKey:OrderId"`
|
||||
|
||||
contractmodels.ControlBy
|
||||
contractmodels.ModelTime
|
||||
}
|
||||
|
||||
// TableName pins the row model to app_order regardless of any global
|
||||
// singular/plural table naming strategy the host configures. See orderTable
|
||||
// above for why this is not simply "order".
|
||||
func (Order) TableName() string {
|
||||
return orderTable
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package models
|
||||
|
||||
// orderItemTable mirrors orderTable's naming rationale: not a reserved word,
|
||||
// and namespaced under app_ so a host scanning its schema can tell at a
|
||||
// glance which tables an installed app owns.
|
||||
const orderItemTable = "app_order_item"
|
||||
|
||||
// OrderItem is one line item of an Order. It carries no ControlBy of its
|
||||
// own: data-scope is enforced once, on the parent Order, and an item is
|
||||
// never queried on its own outside that parent (see service.Order.Get's
|
||||
// Preload).
|
||||
//
|
||||
// OrderId+ProductName is unique on purpose, not just to have some index: it
|
||||
// is what OrderService_test.go's mid-transaction-failure test relies on to
|
||||
// force a real constraint violation after the parent Order row has already
|
||||
// been inserted in the same transaction, proving the rollback actually
|
||||
// undoes both writes rather than leaving the Order behind.
|
||||
type OrderItem struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement;comment:primary key"`
|
||||
OrderId int `json:"orderId" gorm:"uniqueIndex:uk_app_order_item_product;comment:parent order id"`
|
||||
ProductName string `json:"productName" gorm:"type:varchar(255);uniqueIndex:uk_app_order_item_product;comment:product name"`
|
||||
Quantity int `json:"quantity" gorm:"comment:quantity"`
|
||||
PriceCents int64 `json:"priceCents" gorm:"comment:unit price in cents"`
|
||||
}
|
||||
|
||||
// TableName pins the row model to app_order_item; see orderItemTable.
|
||||
func (OrderItem) TableName() string {
|
||||
return orderItemTable
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Package router wires app-order's four routes onto a host's gin engine.
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/actions"
|
||||
coreruntime "github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
|
||||
"github.com/go-admin-team/example-app-order/apis"
|
||||
)
|
||||
|
||||
// RegisterRouter mounts app-order's routes under v1.
|
||||
//
|
||||
// Its signature - (v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware)
|
||||
// - is not app-order's own invention: it is the exact shape every in-tree
|
||||
// go-admin app router package already registers into its own routerCheckRole
|
||||
// slice (see app/demo/router/demo_product.go), so a host installs this
|
||||
// exactly where it installs its own app/*/router packages: one file under
|
||||
// cmd/api/ that imports this package and appends RegisterRouter (adjusted to
|
||||
// the host's own registration slice's calling convention) - see
|
||||
// cmd/api/demo.go for the pattern.
|
||||
//
|
||||
// authMiddleware is taken as an explicit parameter rather than fetched
|
||||
// through sdk.Runtime.GetHandlerFunc(coreruntime.JwtTokenCheck). As of this
|
||||
// writing the reference host (go-admin's common/middleware/init.go) registers
|
||||
// that key with an unbound method expression -
|
||||
// sdk.Runtime.SetMiddleware(JwtTokenCheck, (*jwt.GinJWTMiddleware).MiddlewareFunc)
|
||||
// - which is exactly the shape GetHandlerFunc's own doc comment warns
|
||||
// against: the stored value's type is func(*jwt.GinJWTMiddleware)
|
||||
// gin.HandlerFunc, not gin.HandlerFunc, so GetHandlerFunc's type assertion
|
||||
// fails and it reports ok=false every time, for every caller, not just this
|
||||
// one. Taking authMiddleware directly sidesteps that live bug and matches
|
||||
// what every in-tree app already does.
|
||||
func RegisterRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
roleCheck, ok := sdk.Runtime.GetHandlerFunc(coreruntime.RoleCheck)
|
||||
if !ok {
|
||||
// A host that has not wired up RoleCheck has not wired up Casbin
|
||||
// authorization at all. Registering these routes without it would
|
||||
// silently serve every order to every authenticated caller
|
||||
// regardless of role - fail loud at startup instead, the same way
|
||||
// PermissionAction fails loud (Abort, not c.Next) when its own
|
||||
// database lookup errors. See contract/actions.PermissionAction's
|
||||
// doc comment for the same reasoning applied to data-scope instead
|
||||
// of role.
|
||||
panic("app-order: host has not registered core's " + coreruntime.RoleCheck +
|
||||
" middleware (sdk.Runtime.SetMiddleware); refusing to mount unauthorized order routes")
|
||||
}
|
||||
|
||||
e := apis.Order{}
|
||||
r := v1.Group("/order").
|
||||
Use(authMiddleware.MiddlewareFunc()).
|
||||
Use(roleCheck)
|
||||
{
|
||||
// actions.PermissionAction is imported directly from core - a plain
|
||||
// function, not something fetched through sdk.Runtime - because
|
||||
// unlike RoleCheck's Casbin policy tables (host-owned; see
|
||||
// contract/actions's package doc), the data-scope machinery it
|
||||
// installs has no host-specific state at all.
|
||||
r.GET("", actions.PermissionAction(), e.GetPage)
|
||||
r.GET("/:id", actions.PermissionAction(), e.Get)
|
||||
r.POST("", e.Create)
|
||||
r.PUT("/:id/pay", actions.PermissionAction(), e.Pay)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
coreruntime "github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
)
|
||||
|
||||
func testAuthMiddleware(t *testing.T) *jwt.GinJWTMiddleware {
|
||||
t.Helper()
|
||||
mw, err := jwt.New(&jwt.GinJWTMiddleware{
|
||||
Realm: "test",
|
||||
Key: []byte("test-signing-key"),
|
||||
SigningAlgorithm: "HS256",
|
||||
Timeout: 0,
|
||||
TokenLookup: "header: Authorization",
|
||||
TokenHeadName: "Bearer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("building a test JWT middleware: %v", err)
|
||||
}
|
||||
return mw
|
||||
}
|
||||
|
||||
// sdk.Runtime is a single process-wide instance (see its doc comment) with no
|
||||
// way to unregister a middleware key, so this test needs RoleCheck to be
|
||||
// unset - which makes it look order-dependent. It is not: the test that does
|
||||
// register RoleCheck puts it back in a t.Cleanup, and the guard below turns a
|
||||
// wrong order into a loud failure rather than a silent pass. Verified with
|
||||
// `go test -shuffle=<seed>` on seeds that run the two in either order.
|
||||
func TestRegisterRouterPanicsWithoutHostRoleCheck(t *testing.T) {
|
||||
if _, ok := sdk.Runtime.GetHandlerFunc(coreruntime.RoleCheck); ok {
|
||||
t.Fatal("RoleCheck is already registered; this test must run before any test that registers it")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("RegisterRouter did not panic with no host RoleCheck middleware registered")
|
||||
}
|
||||
}()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
v1 := r.Group("/api/v1")
|
||||
RegisterRouter(v1, testAuthMiddleware(t))
|
||||
}
|
||||
|
||||
func TestRegisterRouterMountsRoutesOnceRoleCheckIsRegistered(t *testing.T) {
|
||||
sdk.Runtime.SetMiddleware(coreruntime.RoleCheck, gin.HandlerFunc(func(c *gin.Context) { c.Next() }))
|
||||
// sdk.Runtime has no way to unregister a middleware key (SetMiddleware
|
||||
// only ever adds or overwrites - see its doc comment), so restore the
|
||||
// "as far as GetHandlerFunc is concerned, unregistered" state other
|
||||
// tests in this package depend on: a nil interface{} fails
|
||||
// GetHandlerFunc's gin.HandlerFunc type assertion the same way a never-
|
||||
// set key does. Needed for `go test -count=2` and similar re-runs
|
||||
// within one process, not for a single run.
|
||||
t.Cleanup(func() { sdk.Runtime.SetMiddleware(coreruntime.RoleCheck, nil) })
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
v1 := r.Group("/api/v1")
|
||||
RegisterRouter(v1, testAuthMiddleware(t))
|
||||
|
||||
// A route that exists returns something other than 404, even if the
|
||||
// JWT/Casbin/PermissionAction chain in front of it then rejects the
|
||||
// unauthenticated test request - proving RegisterRouter actually wired
|
||||
// the route up is the point, not exercising the auth chain itself.
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/order", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code == http.StatusNotFound {
|
||||
t.Errorf("GET /api/v1/order was not registered (404)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Package dto holds app-order's request-binding types.
|
||||
//
|
||||
// None of them implement core's dto.Index / dto.Control, and none of them
|
||||
// define their own Bind method: those interfaces (and the Bind method they
|
||||
// require) exist so the framework's generic CRUD Actions
|
||||
// (Create/Delete/Index/Update/ViewAction) can bind a request without
|
||||
// knowing its concrete type - Action itself calls req.Bind(c). app-order's
|
||||
// handlers (apis/order.go) call api.Api.Bind directly on the raw struct
|
||||
// instead, exactly as go-admin's own hand-written handlers do (see
|
||||
// app/admin/apis/sys_post.go and its service/dto/sys_post.go, which is the
|
||||
// same shape: plain structs, no Bind method), so a Bind method here would
|
||||
// never be called by anything and would only mislead a reader into thinking
|
||||
// it is.
|
||||
//
|
||||
// What these types do reuse is dto.Pagination (for the list request's page
|
||||
// index/size) and the `search` struct-tag convention dto.MakeCondition
|
||||
// resolves; both are plain data shapes, not an interface a hand-written
|
||||
// handler would otherwise have to reimplement.
|
||||
package dto
|
||||
|
||||
import (
|
||||
contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
|
||||
)
|
||||
|
||||
// OrderItemReq is one line item in a create-order request.
|
||||
type OrderItemReq struct {
|
||||
ProductName string `json:"productName" validate:"required"`
|
||||
Quantity int `json:"quantity" validate:"gte=1"`
|
||||
PriceCents int64 `json:"priceCents" validate:"gte=0"`
|
||||
}
|
||||
|
||||
// OrderCreateReq is the create-order request body.
|
||||
type OrderCreateReq struct {
|
||||
Items []OrderItemReq `json:"items" validate:"required"`
|
||||
}
|
||||
|
||||
// OrderSearchReq is the list-order query.
|
||||
//
|
||||
// contractdto.MakeCondition reads q's `search` tags through
|
||||
// reflect.TypeOf(q).NumField(), which is only valid for a struct Kind - a
|
||||
// pointer panics rather than returning an error (see
|
||||
// service/order.go:GetPage, which is careful to pass *req, not req). That
|
||||
// distinction is not documented on MakeCondition's exported doc comment.
|
||||
// Pagination `search:"-"` here follows the same convention the framework's
|
||||
// own generic DTOs use to keep Pagination's two fields out of the WHERE
|
||||
// clause the tags on Status/OrderNo build.
|
||||
type OrderSearchReq struct {
|
||||
contractdto.Pagination `search:"-"`
|
||||
|
||||
Status string `form:"status" search:"type:exact;column:status;table:app_order"`
|
||||
OrderNo string `form:"orderNo" search:"type:exact;column:order_no;table:app_order"`
|
||||
}
|
||||
|
||||
// OrderIdReq binds a single :id, for a detail lookup or a Pay request.
|
||||
type OrderIdReq struct {
|
||||
Id int `uri:"id" validate:"required"`
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Package service is app-order's business logic: everything the PRD asked
|
||||
// this example to prove out by hand rather than by wiring up core's generic
|
||||
// CRUD Actions (Create/Delete/Index/Update/ViewAction stay in go-admin, not
|
||||
// in core - see contract/actions's package doc for why). An order's write
|
||||
// path is a cross-table transaction and its one state change needs a
|
||||
// concurrency guard neither generic Action was ever built for, which is
|
||||
// exactly the class of logic real third-party apps almost always have.
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/actions"
|
||||
contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
|
||||
"github.com/go-admin-team/example-app-order/models"
|
||||
orderdto "github.com/go-admin-team/example-app-order/service/dto"
|
||||
)
|
||||
|
||||
// ErrOrderEmpty is returned by Create when the request has no line items.
|
||||
var ErrOrderEmpty = errors.New("app-order: an order must have at least one item")
|
||||
|
||||
// ErrOrderNotPending is returned by Pay when the order could not be paid:
|
||||
// it does not exist, it is not in models.StatusPending, or the caller's
|
||||
// data scope does not include it. Deliberately one error for all three -
|
||||
// see Pay's doc comment for why collapsing them is the fail-closed choice,
|
||||
// not a shortcut.
|
||||
var ErrOrderNotPending = errors.New("app-order: order is not awaiting payment")
|
||||
|
||||
// Order is app-order's hand-written service. It embeds core's
|
||||
// sdk/service.Service purely for the Orm/Log/Cache fields every
|
||||
// api.Api.MakeService caller already wires up the same way go-admin's own
|
||||
// hand-written services do (see app/admin/apis/sys_post.go) - not because
|
||||
// anything here calls a method Service defines.
|
||||
type Order struct {
|
||||
service.Service
|
||||
}
|
||||
|
||||
// Create places a new order. The order row and every item row commit
|
||||
// together: db.Transaction's closure form is what makes that true even
|
||||
// across a panic (it recovers, rolls back, and re-panics - see gorm's own
|
||||
// Transaction implementation), unlike the hand-rolled Begin/defer pattern
|
||||
// go-admin's sys_role.go/sys_dept.go/sys_menu.go/sys_tables.go use, which
|
||||
// commits a half-written transaction on panic, never opens a real
|
||||
// transaction under sqlite, and reads a single global DB handle regardless
|
||||
// of which tenant the request is for.
|
||||
func (e *Order) Create(req *orderdto.OrderCreateReq, userId int) (*models.Order, error) {
|
||||
if len(req.Items) == 0 {
|
||||
return nil, ErrOrderEmpty
|
||||
}
|
||||
|
||||
items := make([]models.OrderItem, 0, len(req.Items))
|
||||
var total int64
|
||||
for _, it := range req.Items {
|
||||
total += it.PriceCents * int64(it.Quantity)
|
||||
items = append(items, models.OrderItem{
|
||||
ProductName: it.ProductName,
|
||||
Quantity: it.Quantity,
|
||||
PriceCents: it.PriceCents,
|
||||
})
|
||||
}
|
||||
|
||||
order := &models.Order{
|
||||
OrderNo: generateOrderNo(),
|
||||
UserId: userId,
|
||||
Status: models.StatusPending,
|
||||
TotalCents: total,
|
||||
}
|
||||
order.SetCreateBy(userId)
|
||||
order.SetUpdateBy(userId)
|
||||
|
||||
err := e.Orm.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range items {
|
||||
items[i].OrderId = order.Id
|
||||
}
|
||||
// A single batch Create, not one Create per item: on the unique
|
||||
// (order_id, product_name) violation the test suite exercises, the
|
||||
// whole statement fails, and nothing about this order - not the
|
||||
// order row created two lines above, not any item before the
|
||||
// duplicate - survives the rollback.
|
||||
if err := tx.Create(&items).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
order.Items = items
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// Get loads one order, scoped to p's data permission, with its items.
|
||||
func (e *Order) Get(id int, p *actions.DataPermission, out *models.Order) error {
|
||||
return e.Orm.
|
||||
Scopes(actions.Permission(orderTableName, p)).
|
||||
Preload("Items").
|
||||
Where("id = ?", id).
|
||||
First(out).Error
|
||||
}
|
||||
|
||||
// GetPage lists orders visible to p's data scope, filtered by req's search
|
||||
// tags and paginated. The Find-then-Count-on-the-same-chain shape mirrors
|
||||
// go-admin's own common/actions.IndexAction: Limit(-1).Offset(-1) undoes
|
||||
// Paginate's LIMIT/OFFSET before the count runs, on the same *gorm.DB
|
||||
// session, so the WHERE clause built by MakeCondition and Permission is not
|
||||
// re-resolved a second time.
|
||||
func (e *Order) GetPage(req *orderdto.OrderSearchReq, p *actions.DataPermission, list *[]models.Order) (int64, error) {
|
||||
var count int64
|
||||
// *req, not req: contractdto.MakeCondition resolves search tags through
|
||||
// reflect.TypeOf(q).NumField(), which panics on a pointer. See
|
||||
// service/dto/order.go's doc comment on OrderSearchReq.
|
||||
err := e.Orm.Model(&models.Order{}).
|
||||
Scopes(
|
||||
contractdto.MakeCondition(*req),
|
||||
contractdto.Paginate(req.GetPageSize(), req.GetPageIndex()),
|
||||
actions.Permission(orderTableName, p),
|
||||
).
|
||||
Find(list).Limit(-1).Offset(-1).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// Pay transitions a pending order to paid.
|
||||
//
|
||||
// The concurrency guard is the WHERE clause, not an application-level lock:
|
||||
// two concurrent payment attempts against the same order both issue this
|
||||
// UPDATE, but only the one that actually flips a row from pending to paid
|
||||
// sees RowsAffected == 1 - the loser's WHERE matches nothing (the row is
|
||||
// already 'paid' by the time its UPDATE runs) and sees 0, becoming
|
||||
// ErrOrderNotPending rather than a second, silently-accepted payment.
|
||||
//
|
||||
// The same RowsAffected==0 outcome also covers "no such order" and "this
|
||||
// order exists but is outside p's data scope" - actions.Permission's own
|
||||
// scope is one of the Scopes below, so a caller paying an order they
|
||||
// cannot see gets the identical error a caller paying an already-paid
|
||||
// order gets. That collapse is deliberate: a distinguishable "exists but
|
||||
// not yours" response would leak which order ids exist to a caller who
|
||||
// should not be able to tell.
|
||||
func (e *Order) Pay(id int, p *actions.DataPermission) error {
|
||||
result := e.Orm.
|
||||
Scopes(actions.Permission(orderTableName, p)).
|
||||
Model(&models.Order{}).
|
||||
Where("id = ? AND status = ?", id, models.StatusPending).
|
||||
Updates(map[string]interface{}{"status": models.StatusPaid})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrOrderNotPending
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// orderTableName is models.Order{}.TableName(), repeated here as a plain
|
||||
// string because actions.Permission takes the table name as a bare string,
|
||||
// not a model - see models/order.go's orderTable doc comment for why it is
|
||||
// not literally "order".
|
||||
const orderTableName = "app_order"
|
||||
|
||||
// generateOrderNo is a placeholder good enough for this example: real
|
||||
// production code would want a collision-proof id source (a sequence, a
|
||||
// snowflake id, or similar). Nothing about the transaction or the
|
||||
// concurrency guard above depends on how this string is built.
|
||||
func generateOrderNo() string {
|
||||
return fmt.Sprintf("ORD%d", time.Now().UnixNano())
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/actions"
|
||||
coreservice "github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
|
||||
"github.com/go-admin-team/example-app-order/models"
|
||||
orderdto "github.com/go-admin-team/example-app-order/service/dto"
|
||||
)
|
||||
|
||||
// testDB returns a fresh, isolated in-memory sqlite database with
|
||||
// app_order/app_order_item created, following the same
|
||||
// glebarez/sqlite-and-no-build-tag setup core's own contract package tests
|
||||
// use (see sdk/contract/actions/permission_test.go and
|
||||
// sdk/contract/seed/seed_test.go).
|
||||
func testDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Order{}, &models.OrderItem{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// enableDataPermission flips on the switch actions.Permission checks before
|
||||
// applying any data-scope filtering at all, restoring the previous value
|
||||
// after the test - the same pattern
|
||||
// sdk/contract/actions/permission_test.go uses.
|
||||
func enableDataPermission(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := config.ApplicationConfig.EnableDP
|
||||
config.ApplicationConfig.EnableDP = true
|
||||
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
|
||||
}
|
||||
|
||||
func newOrderService(t *testing.T, db *gorm.DB) *Order {
|
||||
t.Helper()
|
||||
return &Order{Service: coreservice.Service{Orm: db}}
|
||||
}
|
||||
|
||||
// -- cross-table transaction ------------------------------------------------
|
||||
|
||||
func TestCreate_CommitsOrderAndItemsTogether(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := newOrderService(t, db)
|
||||
|
||||
req := &orderdto.OrderCreateReq{Items: []orderdto.OrderItemReq{
|
||||
{ProductName: "widget", Quantity: 2, PriceCents: 500},
|
||||
{ProductName: "gadget", Quantity: 1, PriceCents: 1200},
|
||||
}}
|
||||
|
||||
order, err := s.Create(req, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if order.TotalCents != 2*500+1200 {
|
||||
t.Errorf("TotalCents = %d, want %d", order.TotalCents, 2*500+1200)
|
||||
}
|
||||
if order.Status != models.StatusPending {
|
||||
t.Errorf("Status = %q, want pending", order.Status)
|
||||
}
|
||||
if order.CreateBy != 42 || order.UpdateBy != 42 {
|
||||
t.Errorf("CreateBy/UpdateBy = %d/%d, want 42/42", order.CreateBy, order.UpdateBy)
|
||||
}
|
||||
|
||||
var itemCount int64
|
||||
db.Model(&models.OrderItem{}).Where("order_id = ?", order.Id).Count(&itemCount)
|
||||
if itemCount != 2 {
|
||||
t.Errorf("persisted %d items, want 2", itemCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_EmptyItemsReturnsErrorAndWritesNothing(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := newOrderService(t, db)
|
||||
|
||||
_, err := s.Create(&orderdto.OrderCreateReq{}, 1)
|
||||
if !errors.Is(err, ErrOrderEmpty) {
|
||||
t.Fatalf("got error %v, want ErrOrderEmpty", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&models.Order{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("an order was written despite the empty-items error")
|
||||
}
|
||||
}
|
||||
|
||||
// A mid-transaction failure must roll back everything written before it in
|
||||
// the same transaction, including the parent row. The duplicate product
|
||||
// name is what forces a real, DB-enforced constraint violation on the
|
||||
// second item's insert - see OrderItem's doc comment.
|
||||
func TestCreate_MidTransactionFailureRollsBackEverything(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := newOrderService(t, db)
|
||||
|
||||
req := &orderdto.OrderCreateReq{Items: []orderdto.OrderItemReq{
|
||||
{ProductName: "widget", Quantity: 1, PriceCents: 100},
|
||||
{ProductName: "widget", Quantity: 1, PriceCents: 100}, // duplicate: violates uk_app_order_item_product
|
||||
}}
|
||||
|
||||
_, err := s.Create(req, 1)
|
||||
if err == nil {
|
||||
t.Fatal("Create succeeded despite a duplicate line item; the unique constraint did not fire")
|
||||
}
|
||||
|
||||
var orderCount, itemCount int64
|
||||
db.Model(&models.Order{}).Count(&orderCount)
|
||||
db.Model(&models.OrderItem{}).Count(&itemCount)
|
||||
if orderCount != 0 {
|
||||
t.Errorf("the order row survived the rollback: %d rows in app_order", orderCount)
|
||||
}
|
||||
if itemCount != 0 {
|
||||
t.Errorf("an item row survived the rollback: %d rows in app_order_item", itemCount)
|
||||
}
|
||||
}
|
||||
|
||||
// A panic partway through the transaction must roll back exactly as
|
||||
// cleanly as a returned error does. This is not testing app-order's own
|
||||
// code so much as the primitive Create is built on: gorm's db.Transaction
|
||||
// recovers a panic, rolls back, and re-panics, which is what makes it safe
|
||||
// to use in place of go-admin's hand-rolled Begin/defer pattern (see
|
||||
// Create's doc comment) - a pattern that, on a panic, commits whatever the
|
||||
// transaction had written so far instead of undoing it.
|
||||
func TestCreate_PanicInsideTransactionRollsBackEverything(t *testing.T) {
|
||||
db := testDB(t)
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("db.Transaction did not propagate the panic")
|
||||
}
|
||||
}()
|
||||
_ = db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&models.Order{OrderNo: "panic-test", Status: models.StatusPending}).Error; err != nil {
|
||||
t.Fatalf("Create inside transaction: %v", err)
|
||||
}
|
||||
panic("simulated failure after a partial write")
|
||||
})
|
||||
}()
|
||||
|
||||
var count int64
|
||||
db.Model(&models.Order{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("the order row survived a panic mid-transaction: %d rows in app_order", count)
|
||||
}
|
||||
}
|
||||
|
||||
// -- status transition / concurrency guard ----------------------------------
|
||||
|
||||
func createPendingOrder(t *testing.T, s *Order, userId int) *models.Order {
|
||||
t.Helper()
|
||||
order, err := s.Create(&orderdto.OrderCreateReq{Items: []orderdto.OrderItemReq{
|
||||
{ProductName: "widget", Quantity: 1, PriceCents: 100},
|
||||
}}, userId)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
func TestPay_TransitionsPendingToPaid(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := newOrderService(t, db)
|
||||
order := createPendingOrder(t, s, 1)
|
||||
|
||||
if err := s.Pay(order.Id, &actions.DataPermission{DataScope: actions.DataScopeAll}); err != nil {
|
||||
t.Fatalf("Pay: %v", err)
|
||||
}
|
||||
|
||||
var got models.Order
|
||||
db.First(&got, order.Id)
|
||||
if got.Status != models.StatusPaid {
|
||||
t.Errorf("Status = %q, want paid", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPay_AlreadyPaidReturnsErrOrderNotPending(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := newOrderService(t, db)
|
||||
order := createPendingOrder(t, s, 1)
|
||||
all := &actions.DataPermission{DataScope: actions.DataScopeAll}
|
||||
|
||||
if err := s.Pay(order.Id, all); err != nil {
|
||||
t.Fatalf("first Pay: %v", err)
|
||||
}
|
||||
if err := s.Pay(order.Id, all); !errors.Is(err, ErrOrderNotPending) {
|
||||
t.Fatalf("second Pay returned %v, want ErrOrderNotPending", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Two concurrent payment attempts against the same pending order: exactly
|
||||
// one must succeed. MaxOpenConns(1) is set on the underlying *sql.DB so the
|
||||
// two goroutines' UPDATEs serialize the way two independent connections
|
||||
// would under MySQL, rather than one of them failing outright with
|
||||
// SQLITE_BUSY - sqlite is a single-writer database with no useful
|
||||
// concurrency of its own to exercise here. What the test actually verifies
|
||||
// is unaffected by that: the guard is the UPDATE ... WHERE status =
|
||||
// 'pending' clause and the RowsAffected check on its result (Pay's doc
|
||||
// comment), and that logic runs once per goroutine regardless of how the
|
||||
// pool schedules the two connections.
|
||||
func TestPay_ConcurrentPaymentsOnlyOneSucceeds(t *testing.T) {
|
||||
db := testDB(t)
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("DB(): %v", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
s := newOrderService(t, db)
|
||||
order := createPendingOrder(t, s, 1)
|
||||
all := &actions.DataPermission{DataScope: actions.DataScopeAll}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
errs[i] = s.Pay(order.Id, all)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
successes, failures := 0, 0
|
||||
for _, err := range errs {
|
||||
switch {
|
||||
case err == nil:
|
||||
successes++
|
||||
case errors.Is(err, ErrOrderNotPending):
|
||||
failures++
|
||||
default:
|
||||
t.Fatalf("unexpected error from a concurrent Pay: %v", err)
|
||||
}
|
||||
}
|
||||
if successes != 1 || failures != 1 {
|
||||
t.Fatalf("got %d successes and %d failures, want exactly 1 and 1", successes, failures)
|
||||
}
|
||||
}
|
||||
|
||||
// -- data permission ---------------------------------------------------------
|
||||
|
||||
func TestGetPage_SelfScopeOnlySeesOwnOrders(t *testing.T) {
|
||||
enableDataPermission(t)
|
||||
db := testDB(t)
|
||||
s := newOrderService(t, db)
|
||||
|
||||
createPendingOrder(t, s, 1) // belongs to user 1
|
||||
createPendingOrder(t, s, 2) // belongs to user 2
|
||||
|
||||
var list []models.Order
|
||||
count, err := s.GetPage(&orderdto.OrderSearchReq{}, &actions.DataPermission{
|
||||
DataScope: actions.DataScopeSelf,
|
||||
UserId: 1,
|
||||
}, &list)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPage: %v", err)
|
||||
}
|
||||
if count != 1 || len(list) != 1 {
|
||||
t.Fatalf("got %d orders, want exactly the 1 belonging to user 1", count)
|
||||
}
|
||||
if list[0].UserId != 1 {
|
||||
t.Errorf("returned order belongs to user %d, not the caller", list[0].UserId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPage_AllScopeSeesEveryOrder(t *testing.T) {
|
||||
enableDataPermission(t)
|
||||
db := testDB(t)
|
||||
s := newOrderService(t, db)
|
||||
|
||||
createPendingOrder(t, s, 1)
|
||||
createPendingOrder(t, s, 2)
|
||||
|
||||
var list []models.Order
|
||||
count, err := s.GetPage(&orderdto.OrderSearchReq{}, &actions.DataPermission{DataScope: actions.DataScopeAll}, &list)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPage: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("got %d orders, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
// An invalid/unrecognized data_scope must fail closed - match nothing -
|
||||
// never fall back to "see everything". This is core's own documented
|
||||
// contract (contract/actions.Permission's default case), exercised here
|
||||
// against app-order's own table to confirm the fail-closed behaviour
|
||||
// actually reaches a hand-written Service's query, not just core's own
|
||||
// unit tests.
|
||||
func TestGetPage_InvalidScopeSeesNothing(t *testing.T) {
|
||||
enableDataPermission(t)
|
||||
db := testDB(t)
|
||||
s := newOrderService(t, db)
|
||||
|
||||
createPendingOrder(t, s, 1)
|
||||
createPendingOrder(t, s, 2)
|
||||
|
||||
var list []models.Order
|
||||
count, err := s.GetPage(&orderdto.OrderSearchReq{}, &actions.DataPermission{DataScope: "not-a-real-scope"}, &list)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPage: %v", err)
|
||||
}
|
||||
if count != 0 || len(list) != 0 {
|
||||
t.Fatalf("an invalid data scope returned %d orders, want 0 (fail closed)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_ReturnsOrderWithItemsPreloaded(t *testing.T) {
|
||||
enableDataPermission(t)
|
||||
db := testDB(t)
|
||||
s := newOrderService(t, db)
|
||||
|
||||
created := createPendingOrder(t, s, 1)
|
||||
|
||||
var got models.Order
|
||||
err := s.Get(created.Id, &actions.DataPermission{DataScope: actions.DataScopeSelf, UserId: 1}, &got)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if len(got.Items) != 1 {
|
||||
t.Fatalf("got %d items, want the 1 created with the order", len(got.Items))
|
||||
}
|
||||
if got.Items[0].ProductName != "widget" {
|
||||
t.Errorf("item ProductName = %q, want widget", got.Items[0].ProductName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_ScopedOutOrderReportsNotFoundNotForbidden(t *testing.T) {
|
||||
enableDataPermission(t)
|
||||
db := testDB(t)
|
||||
s := newOrderService(t, db)
|
||||
|
||||
other := createPendingOrder(t, s, 2)
|
||||
|
||||
var got models.Order
|
||||
err := s.Get(other.Id, &actions.DataPermission{DataScope: actions.DataScopeSelf, UserId: 1}, &got)
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("Get on another user's order returned %v, want gorm.ErrRecordNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ require (
|
||||
github.com/casbin/casbin/v3 v3.8.1
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.1.0
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.5.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible
|
||||
github.com/mssola/user_agent v0.6.0
|
||||
@@ -32,7 +32,6 @@ require (
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/driver/sqlserver v1.6.4
|
||||
gorm.io/gorm v1.31.2
|
||||
gorm.io/plugin/soft_delete v1.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -142,6 +141,7 @@ require (
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gorm.io/plugin/dbresolver v1.6.2 // indirect
|
||||
gorm.io/plugin/soft_delete v1.2.1 // indirect
|
||||
modernc.org/fileutil v1.3.40 // indirect
|
||||
modernc.org/libc v1.67.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -145,8 +145,8 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.1.0 h1:v1RQkRT/sg0YvmS3m11mbtbijj6F3jUKs8EUaK64/+8=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.1.0/go.mod h1:YiJr2+vqC9qV5AoGeL+1W55h3XZ99CB5xWnP3Wo8c5g=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.5.0 h1:aD1SALklBxizGB9u8cOgm4OT8z656FM83F4fD6dMz9g=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.5.0/go.mod h1:LG/XvEfOplbuadKrPTPm0Nu5pN06aQUNZZC3ao4B4gs=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
// Package loadtest measures what one go-admin process sustains over HTTP.
|
||||
//
|
||||
// It is skipped unless GOADMIN_BENCH_ADDR points at a running server, so
|
||||
// `go test ./...` is unaffected. Start a server and run:
|
||||
//
|
||||
// GOADMIN_BENCH_ADDR=http://127.0.0.1:8000 go test ./test/loadtest/ -v -run TestLoadProfile
|
||||
//
|
||||
// Unlike a Go benchmark this reports latency percentiles, which is what
|
||||
// capacity planning needs: an average hides the tail that users actually feel.
|
||||
//
|
||||
// Two caveats when reading the numbers. The load generator runs on the same
|
||||
// machine as the server unless GOADMIN_BENCH_ADDR is remote, so both compete
|
||||
// for the same cores - a split deployment measures higher. And the figures
|
||||
// describe the configured backend: sqlite and MySQL differ by more than the
|
||||
// framework does.
|
||||
package loadtest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
addrEnv = "GOADMIN_BENCH_ADDR"
|
||||
tokenEnv = "GOADMIN_BENCH_TOKEN"
|
||||
userEnv = "GOADMIN_BENCH_USER"
|
||||
passEnv = "GOADMIN_BENCH_PASS"
|
||||
|
||||
// Each concurrency level runs for this long. Long enough to get past
|
||||
// connection setup and let the scheduler settle, short enough that the
|
||||
// whole sweep stays interactive.
|
||||
levelDuration = 3 * time.Second
|
||||
)
|
||||
|
||||
// concurrencyLevels sweeps from a single client to well past core count, so
|
||||
// the point where added concurrency stops buying throughput is visible rather
|
||||
// than assumed. Peak throughput and peak concurrency are not the same number:
|
||||
// past the peak a server takes more work than it can finish and both
|
||||
// throughput and latency get worse, so the sweep has to bracket the turn
|
||||
// rather than stop at the top.
|
||||
//
|
||||
// GOADMIN_BENCH_LEVELS overrides it, comma separated.
|
||||
var concurrencyLevels = parseLevels(os.Getenv("GOADMIN_BENCH_LEVELS"), []int{1, 2, 4, 8, 16, 32, 64, 128, 256, 512})
|
||||
|
||||
func parseLevels(spec string, fallback []int) []int {
|
||||
if spec == "" {
|
||||
return fallback
|
||||
}
|
||||
out := make([]int, 0, 8)
|
||||
for _, f := range strings.Split(spec, ",") {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(f))
|
||||
if err != nil || n <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return fallback
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func addr(t testing.TB) string {
|
||||
t.Helper()
|
||||
a := os.Getenv(addrEnv)
|
||||
if a == "" {
|
||||
t.Skipf("%s not set; skipping load test", addrEnv)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// newClient returns a client whose pool is large enough that the generator
|
||||
// does not become the bottleneck it is trying to measure.
|
||||
func newClient(maxConns int) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: maxConns * 2,
|
||||
MaxIdleConnsPerHost: maxConns * 2,
|
||||
MaxConnsPerHost: maxConns * 2,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
DisableCompression: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// result is one completed request.
|
||||
type result struct {
|
||||
latency time.Duration
|
||||
err bool
|
||||
status int
|
||||
}
|
||||
|
||||
// report is the summary of one concurrency level.
|
||||
type report struct {
|
||||
concurrency int
|
||||
total int64
|
||||
failed int64
|
||||
elapsed time.Duration
|
||||
p50, p95, p99, max time.Duration
|
||||
statuses map[int]int64
|
||||
}
|
||||
|
||||
func (r report) qps() float64 {
|
||||
if r.elapsed == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(r.total) / r.elapsed.Seconds()
|
||||
}
|
||||
|
||||
func (r report) String() string {
|
||||
codes := make([]int, 0, len(r.statuses))
|
||||
for c := range r.statuses {
|
||||
codes = append(codes, c)
|
||||
}
|
||||
sort.Ints(codes)
|
||||
dist := make([]string, 0, len(codes))
|
||||
for _, c := range codes {
|
||||
dist = append(dist, fmt.Sprintf("%d:%d", c, r.statuses[c]))
|
||||
}
|
||||
return fmt.Sprintf("c=%-4d %9.0f req/s p50=%-9s p95=%-9s p99=%-9s max=%-9s failed=%-7d %s",
|
||||
r.concurrency, r.qps(),
|
||||
r.p50.Round(time.Microsecond), r.p95.Round(time.Microsecond),
|
||||
r.p99.Round(time.Microsecond), r.max.Round(time.Microsecond), r.failed,
|
||||
strings.Join(dist, " "))
|
||||
}
|
||||
|
||||
// drive runs `concurrency` workers against req for levelDuration and collects
|
||||
// every latency. Bodies are drained and closed - skipping that silently caps
|
||||
// throughput at the point connections stop being reused.
|
||||
func drive(t testing.TB, concurrency int, want int, mk func() *http.Request) report {
|
||||
t.Helper()
|
||||
|
||||
client := newClient(concurrency)
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), levelDuration)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
samples []time.Duration
|
||||
statuses = map[int]int64{}
|
||||
failed atomic.Int64
|
||||
total atomic.Int64
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
start := time.Now()
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
local := make([]time.Duration, 0, 1024)
|
||||
localStatus := map[int]int64{}
|
||||
for ctx.Err() == nil {
|
||||
req := mk()
|
||||
t0 := time.Now()
|
||||
resp, err := client.Do(req.WithContext(ctx))
|
||||
d := time.Since(t0)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
failed.Add(1)
|
||||
total.Add(1)
|
||||
continue
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
localStatus[resp.StatusCode]++
|
||||
if resp.StatusCode != want {
|
||||
failed.Add(1)
|
||||
}
|
||||
total.Add(1)
|
||||
local = append(local, d)
|
||||
}
|
||||
mu.Lock()
|
||||
samples = append(samples, local...)
|
||||
for code, n := range localStatus {
|
||||
statuses[code] += n
|
||||
}
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] })
|
||||
r := report{
|
||||
concurrency: concurrency,
|
||||
total: total.Load(),
|
||||
failed: failed.Load(),
|
||||
elapsed: elapsed,
|
||||
statuses: statuses,
|
||||
}
|
||||
if n := len(samples); n > 0 {
|
||||
r.p50 = samples[n*50/100]
|
||||
r.p95 = samples[min(n*95/100, n-1)]
|
||||
r.p99 = samples[min(n*99/100, n-1)]
|
||||
r.max = samples[n-1]
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// login obtains a token. GOADMIN_BENCH_TOKEN short-circuits it, which is how a
|
||||
// server in prod mode is reached - there the login endpoint demands a captcha.
|
||||
func login(t testing.TB, base string) string {
|
||||
t.Helper()
|
||||
if tok := os.Getenv(tokenEnv); tok != "" {
|
||||
return tok
|
||||
}
|
||||
|
||||
user, pass := os.Getenv(userEnv), os.Getenv(passEnv)
|
||||
if user == "" {
|
||||
user, pass = "admin", "123456"
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"code": "0",
|
||||
"uuid": "0",
|
||||
})
|
||||
resp, err := http.Post(base+"/api/v1/login", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("login request failed: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login returned %d: %s\n(a server in prod mode requires a captcha; set %s instead)",
|
||||
resp.StatusCode, raw, tokenEnv)
|
||||
}
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil || out.Token == "" {
|
||||
t.Fatalf("no token in login response: %s", raw)
|
||||
}
|
||||
return out.Token
|
||||
}
|
||||
|
||||
// TestLoadProfile sweeps concurrency against three endpoints chosen for what
|
||||
// they isolate:
|
||||
//
|
||||
// - captcha: no auth, no business query. The routing and image-generation
|
||||
// floor.
|
||||
// - dept list: the full authenticated path - JWT parse, casbin check, data
|
||||
// permission scope, database read. This is what a real page costs.
|
||||
// - login: bcrypt. Deliberately slow, and the one endpoint whose ceiling is
|
||||
// set by design rather than by the framework.
|
||||
func TestLoadProfile(t *testing.T) {
|
||||
base := addr(t)
|
||||
token := login(t, base)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
want int
|
||||
mk func() *http.Request
|
||||
}{
|
||||
{
|
||||
// The control. An unrouted path exercises the HTTP stack, gin's
|
||||
// tree lookup and nothing else, so it bounds every other row here.
|
||||
// When a business endpoint reaches this number, the measurement has
|
||||
// stopped describing the endpoint and started describing the
|
||||
// transport - or the load generator, when both share a machine.
|
||||
name: "404 (http+routing floor)",
|
||||
want: 404,
|
||||
mk: func() *http.Request {
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/api/v1/__no_such_route__", nil)
|
||||
return req
|
||||
},
|
||||
},
|
||||
{
|
||||
// The framework on its own: global middleware chain, route lookup,
|
||||
// and a handler that only sets a status. No database, no cache.
|
||||
// Against the 404 row this isolates what the chain costs; against
|
||||
// the rows below it, what the business path adds.
|
||||
//
|
||||
// Numbers from any endpoint that touches a database describe the
|
||||
// database, the driver and the pool as much as the framework - the
|
||||
// MySQL sweeps here moved from collapsing at c=64 to 19k req/s at
|
||||
// c=512 on a pool setting alone, with the framework untouched.
|
||||
name: "health (framework only, no db)",
|
||||
want: 200,
|
||||
mk: func() *http.Request {
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/api/v1/health", nil)
|
||||
return req
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "captcha (no auth)",
|
||||
want: 200,
|
||||
mk: func() *http.Request {
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/api/v1/captcha", nil)
|
||||
return req
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dept list (jwt+casbin+db)",
|
||||
want: 200,
|
||||
mk: func() *http.Request {
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/api/v1/dept", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
return req
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, c := range concurrencyLevels {
|
||||
t.Log(drive(t, c, tc.want, tc.mk))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginThroughput is separated because bcrypt saturates the CPU: running
|
||||
// it alongside the others would distort them. It also writes a login-log row
|
||||
// per attempt when logger.enableddb is on, so the number moves with that
|
||||
// setting.
|
||||
func TestLoginThroughput(t *testing.T) {
|
||||
base := addr(t)
|
||||
if os.Getenv(tokenEnv) != "" {
|
||||
t.Skip("token supplied; login endpoint presumably needs a captcha")
|
||||
}
|
||||
|
||||
user, pass := os.Getenv(userEnv), os.Getenv(passEnv)
|
||||
if user == "" {
|
||||
user, pass = "admin", "123456"
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"username": user, "password": pass, "code": "0", "uuid": "0",
|
||||
})
|
||||
|
||||
mk := func() *http.Request {
|
||||
req, _ := http.NewRequest(http.MethodPost, base+"/api/v1/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
}
|
||||
|
||||
for _, c := range []int{1, 4, 8, 16, 32, 64, 128} {
|
||||
t.Log(drive(t, c, http.StatusOK, mk))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/token"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// structLiteral is a composite literal whose type resolved to a named struct.
|
||||
type structLiteral struct {
|
||||
PkgPath string
|
||||
Name string
|
||||
Lit *ast.CompositeLit
|
||||
}
|
||||
|
||||
// forEachStructLiteral visits every composite literal in the file whose type
|
||||
// resolves to a named type, including the ones written with the type elided.
|
||||
//
|
||||
// The elided form is the one that matters: seed data is written as
|
||||
// []models.SysMenu{{MenuId: 9000}, {MenuId: 9001}}, and the inner literals carry
|
||||
// no type of their own. A walker that only looked at CompositeLit.Type would
|
||||
// silently skip every seed in the repository and report nothing, which for a
|
||||
// tool about silent failure would be its own punchline.
|
||||
func forEachStructLiteral(sf *sourceFile, fn func(structLiteral)) {
|
||||
// The type each type-less literal inherits from the literal containing it.
|
||||
elided := map[*ast.CompositeLit]ast.Expr{}
|
||||
|
||||
var propagate func(lit *ast.CompositeLit, typ ast.Expr)
|
||||
propagate = func(lit *ast.CompositeLit, typ ast.Expr) {
|
||||
child := elementType(typ)
|
||||
if child == nil {
|
||||
return
|
||||
}
|
||||
for _, elt := range lit.Elts {
|
||||
v := elt
|
||||
if kv, ok := elt.(*ast.KeyValueExpr); ok {
|
||||
v = kv.Value
|
||||
}
|
||||
if cl, ok := v.(*ast.CompositeLit); ok && cl.Type == nil {
|
||||
elided[cl] = child
|
||||
propagate(cl, child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ast.Inspect visits a node before its children, so every typed literal
|
||||
// fills in its descendants before the reporting pass reaches them.
|
||||
ast.Inspect(sf.Syntax, func(n ast.Node) bool {
|
||||
cl, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if typ := litType(cl, elided); typ != nil {
|
||||
propagate(cl, typ)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
ast.Inspect(sf.Syntax, func(n ast.Node) bool {
|
||||
cl, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
typ := litType(cl, elided)
|
||||
if typ == nil {
|
||||
return true
|
||||
}
|
||||
if pkg, name, ok := resolveNamed(sf, typ); ok {
|
||||
fn(structLiteral{PkgPath: pkg, Name: name, Lit: cl})
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func litType(cl *ast.CompositeLit, elided map[*ast.CompositeLit]ast.Expr) ast.Expr {
|
||||
if cl.Type != nil {
|
||||
return cl.Type
|
||||
}
|
||||
return elided[cl]
|
||||
}
|
||||
|
||||
// resolveNamed maps a type expression to (import path, type name). A bare
|
||||
// identifier means a type declared in this file's own package.
|
||||
func resolveNamed(sf *sourceFile, typ ast.Expr) (string, string, bool) {
|
||||
switch t := typ.(type) {
|
||||
case *ast.StarExpr:
|
||||
return resolveNamed(sf, t.X)
|
||||
case *ast.Ident:
|
||||
return sf.Pkg, t.Name, true
|
||||
case *ast.SelectorExpr:
|
||||
pkgIdent, ok := t.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
path, ok := sf.imports[pkgIdent.Name]
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return path, t.Sel.Name, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// elementType is the type the children of a composite literal take when they
|
||||
// leave theirs out.
|
||||
func elementType(typ ast.Expr) ast.Expr {
|
||||
switch t := typ.(type) {
|
||||
case *ast.ArrayType:
|
||||
return t.Elt
|
||||
case *ast.MapType:
|
||||
return t.Value
|
||||
case *ast.StarExpr:
|
||||
return elementType(t.X)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// field returns the value written for a named field of a struct literal.
|
||||
func field(lit *ast.CompositeLit, name string) (ast.Expr, bool) {
|
||||
for _, elt := range lit.Elts {
|
||||
kv, ok := elt.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if key, ok := kv.Key.(*ast.Ident); ok && key.Name == name {
|
||||
return kv.Value, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// intValue evaluates an integer field: a literal, a negated literal, or an
|
||||
// identifier naming a constant in the same package.
|
||||
//
|
||||
// Anything computed at run time is skipped rather than guessed at. That is the
|
||||
// one thing these checks miss, and missing is the right way to be wrong here -
|
||||
// a false positive teaches people to add ignore comments, and then the tool is
|
||||
// finished.
|
||||
func intValue(sf *sourceFile, expr ast.Expr) (int64, bool) {
|
||||
switch e := expr.(type) {
|
||||
case *ast.Ident:
|
||||
v, ok := sf.consts[e.Name]
|
||||
return v, ok
|
||||
case *ast.UnaryExpr:
|
||||
if e.Op == token.SUB {
|
||||
if v, ok := intValue(sf, e.X); ok {
|
||||
return -v, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return intLiteral(expr)
|
||||
}
|
||||
|
||||
func intLiteral(expr ast.Expr) (int64, bool) {
|
||||
lit, ok := expr.(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.INT {
|
||||
return 0, false
|
||||
}
|
||||
v, err := strconv.ParseInt(strings.ReplaceAll(lit.Value, "_", ""), 0, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// stringValue evaluates a string field: a literal, a concatenation of literals,
|
||||
// or an identifier naming a string constant in the same package.
|
||||
func stringValue(sf *sourceFile, expr ast.Expr) (string, bool) {
|
||||
switch e := expr.(type) {
|
||||
case *ast.BasicLit:
|
||||
if e.Kind != token.STRING {
|
||||
return "", false
|
||||
}
|
||||
s, err := strconv.Unquote(e.Value)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
case *ast.BinaryExpr:
|
||||
if e.Op != token.ADD {
|
||||
return "", false
|
||||
}
|
||||
l, lok := stringValue(sf, e.X)
|
||||
r, rok := stringValue(sf, e.Y)
|
||||
if lok && rok {
|
||||
return l + r, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// embeddedTypes returns the types a struct embeds, as (import path, name).
|
||||
func embeddedTypes(sf *sourceFile, st *ast.StructType) [][2]string {
|
||||
var out [][2]string
|
||||
for _, f := range st.Fields.List {
|
||||
if len(f.Names) != 0 {
|
||||
continue // a named field, not an embed
|
||||
}
|
||||
if pkg, name, ok := resolveNamed(sf, f.Type); ok {
|
||||
out = append(out, [2]string{pkg, name})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tableNames maps struct name to the literal its TableName method returns.
|
||||
func tableNames(sf *sourceFile) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, decl := range sf.Syntax.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != "TableName" || fn.Recv == nil || len(fn.Recv.List) != 1 || fn.Body == nil {
|
||||
continue
|
||||
}
|
||||
recv := receiverName(fn.Recv.List[0].Type)
|
||||
if recv == "" {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
ret, ok := n.(*ast.ReturnStmt)
|
||||
if !ok || len(ret.Results) != 1 {
|
||||
return true
|
||||
}
|
||||
if s, ok := stringValue(sf, ret.Results[0]); ok && out[recv] == "" {
|
||||
out[recv] = s
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func receiverName(expr ast.Expr) string {
|
||||
switch t := expr.(type) {
|
||||
case *ast.Ident:
|
||||
return t.Name
|
||||
case *ast.StarExpr:
|
||||
return receiverName(t.X)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// structTypes maps struct name to its declaration.
|
||||
func structTypes(sf *sourceFile) map[string]*ast.StructType {
|
||||
out := map[string]*ast.StructType{}
|
||||
ast.Inspect(sf.Syntax, func(n ast.Node) bool {
|
||||
ts, ok := n.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if st, ok := ts.Type.(*ast.StructType); ok {
|
||||
out[ts.Name.Name] = st
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/token"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Check names. They appear in every message and in the CI log, so they are
|
||||
// what people will search for.
|
||||
const (
|
||||
checkModelTimeMix = "modeltime-mix"
|
||||
checkMenuSort = "menu-sort-overflow"
|
||||
checkMenuName = "menu-name-mismatch"
|
||||
checkConfigValue = "config-value-truncation"
|
||||
checkMenuIDConflict = "menu-id-collision"
|
||||
checkImportBoundary = "contract-import-boundary"
|
||||
checkShimAlias = "contract-shim-alias"
|
||||
)
|
||||
|
||||
// Package paths, relative to the module. Spelled once so a module rename
|
||||
// touches one place.
|
||||
const (
|
||||
pkgFrozenModels = "cmd/migrate/migration/models"
|
||||
pkgRuntimeModel = "common/models"
|
||||
pkgAdminModels = "app/admin/models"
|
||||
)
|
||||
|
||||
// options are the run-time knobs. Only the frontend directory is one: every
|
||||
// other check either applies or does not, with nothing to configure.
|
||||
type options struct {
|
||||
// UIDir is the go-admin-ui src directory. Empty disables checkMenuName,
|
||||
// which is the only check that needs a second repository.
|
||||
UIDir string
|
||||
}
|
||||
|
||||
// runChecks runs every check over one parse of the tree.
|
||||
func runChecks(s *snapshot, opt options) ([]Finding, error) {
|
||||
var out []Finding
|
||||
out = append(out, checkModelTimeMixing(s)...)
|
||||
out = append(out, checkMenuSortOverflow(s)...)
|
||||
out = append(out, checkConfigValueLength(s)...)
|
||||
out = append(out, checkMenuIDCollisions(s)...)
|
||||
out = append(out, checkContractImportBoundary(s)...)
|
||||
out = append(out, checkContractShimAlias(s)...)
|
||||
|
||||
if opt.UIDir != "" {
|
||||
fs, err := checkMenuNames(s, opt.UIDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, fs...)
|
||||
}
|
||||
|
||||
sortFindings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *snapshot) pkg(rel string) string { return s.ModulePath + "/" + rel }
|
||||
|
||||
func (s *snapshot) finding(sev Severity, check string, sf *sourceFile, pos posLike, format string, args ...interface{}) Finding {
|
||||
file, line, col := s.Pos(sf, pos.Pos())
|
||||
return Finding{
|
||||
Check: check,
|
||||
Severity: sev.String(),
|
||||
File: file,
|
||||
Line: line,
|
||||
Col: col,
|
||||
Message: fmt.Sprintf(format, args...),
|
||||
severity: sev,
|
||||
}
|
||||
}
|
||||
|
||||
type posLike interface{ Pos() token.Pos }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// check 1: the two ModelTime flavours
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// checkModelTimeMixing reports code that mixes the repository's two soft-delete
|
||||
// shapes.
|
||||
//
|
||||
// cmd/migrate/migration/models.ModelTime declares a nullable gorm.DeletedAt;
|
||||
// common/models.ModelTime declares the NOT NULL millisecond marker. Mixing them
|
||||
// on one table is not a compile error and not a run-time error either: gorm
|
||||
// scopes the nullable flavour as "WHERE deleted_at IS NULL" while live rows hold
|
||||
// 0, so every row of the table becomes invisible and the feature reading it
|
||||
// simply returns nothing. sys_columns and sys_tables sat in that state until
|
||||
// 1786700004000 - the code generator listed no tables at all and reported no
|
||||
// error.
|
||||
//
|
||||
// Two shapes are reported, and both are unambiguous:
|
||||
//
|
||||
// 1. a runtime model under app/ that embeds the frozen package's time struct -
|
||||
// always wrong, that package is the shape the columns had before the
|
||||
// conversion;
|
||||
// 2. a migration ordered after the conversion that imports the frozen package
|
||||
// - AGENTS.md states this rule, and the version/ directory already has a
|
||||
// test for it; this extends it to version-local/, where third-party and
|
||||
// downstream migrations live and where no test was watching.
|
||||
//
|
||||
// Not reported: that two model packages describe the same table with different
|
||||
// flavours. That is true of a dozen tables on purpose - the frozen package is
|
||||
// correct for the migrations that predate the conversion - so reporting it
|
||||
// would be reporting the design.
|
||||
func checkModelTimeMixing(s *snapshot) []Finding {
|
||||
var out []Finding
|
||||
frozen := s.pkg(pkgFrozenModels)
|
||||
|
||||
for _, sf := range s.Files {
|
||||
if sf.isTest() {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(sf.Path, "app/") && sf.Imports(frozen) {
|
||||
tables := tableNames(sf)
|
||||
for name, st := range structTypes(sf) {
|
||||
table, isModel := tables[name]
|
||||
if !isModel {
|
||||
continue
|
||||
}
|
||||
for _, emb := range embeddedTypes(sf, st) {
|
||||
if emb[0] != frozen {
|
||||
continue
|
||||
}
|
||||
out = append(out, s.finding(Error, checkModelTimeMix, sf, st,
|
||||
"runtime model %s (table %s) embeds %s.%s, whose DeletedAt is the nullable pre-conversion shape;\n"+
|
||||
" gorm will query this table with deleted_at IS NULL while live rows hold 0, and it will return nothing.\n"+
|
||||
" Embed %s.ModelTime instead.",
|
||||
name, table, pkgFrozenModels, emb[1], pkgRuntimeModel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
version, isMigration := migrationVersion(sf.Path)
|
||||
if !isMigration || version <= softDeleteConversion || !sf.Imports(frozen) {
|
||||
continue
|
||||
}
|
||||
spec := sf.ImportSpec(frozen)
|
||||
out = append(out, s.finding(Error, checkModelTimeMix, sf, spec,
|
||||
"migration %d is ordered after the soft-delete conversion (%d) but seeds through %s;\n"+
|
||||
" that package writes a nullable deleted_at into a NOT NULL column, and reads through it match no rows.\n"+
|
||||
" Use the runtime models under app/ instead.",
|
||||
version, softDeleteConversion, pkgFrozenModels))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// softDeleteConversion is the version at which deleted_at stopped being a
|
||||
// nullable timestamp and became the NOT NULL millisecond marker.
|
||||
const softDeleteConversion = 1786700003000
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// check 2: menu sort overflows a tinyint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// checkMenuSortOverflow reports a seeded menu sort outside a tinyint.
|
||||
//
|
||||
// sys_menu.sort is `gorm:"size:4"`, which MySQL builds as a tinyint holding
|
||||
// -128..127. sqlite ignores the width, so an overflowing value passes every
|
||||
// local test and fails on a real install - with Error 1264, partway through a
|
||||
// migration that is not transactional, leaving every later migration unapplied.
|
||||
// That is how a seeded Sort: 900 once stopped the run before the soft-delete
|
||||
// conversion and left nobody able to log in.
|
||||
func checkMenuSortOverflow(s *snapshot) []Finding {
|
||||
const (
|
||||
min = -128
|
||||
max = 127
|
||||
)
|
||||
var out []Finding
|
||||
for _, sf := range s.Files {
|
||||
if sf.isTest() {
|
||||
continue
|
||||
}
|
||||
forEachStructLiteral(sf, func(lit structLiteral) {
|
||||
if !s.isMenuModel(lit) {
|
||||
return
|
||||
}
|
||||
expr, ok := field(lit.Lit, "Sort")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, ok := intValue(sf, expr)
|
||||
if !ok || (v >= min && v <= max) {
|
||||
return
|
||||
}
|
||||
out = append(out, s.finding(Error, checkMenuSort, sf, expr,
|
||||
"menu sort %d does not fit a tinyint (%d..%d);\n"+
|
||||
" MySQL rejects it with Error 1264 and the migration stops there, leaving later migrations unapplied.",
|
||||
v, min, max))
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// check 4: sys_config value truncation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// checkConfigValueLength reports a seeded sys_config value longer than the
|
||||
// column.
|
||||
//
|
||||
// config_value is varchar(255). MySQL outside strict mode truncates rather than
|
||||
// refusing, so the migration succeeds, the row is written, and the setting is
|
||||
// silently half of what was intended.
|
||||
//
|
||||
// Counted in runes, not bytes, because varchar(255) counts characters - byte
|
||||
// counting would flag Chinese values that fit.
|
||||
func checkConfigValueLength(s *snapshot) []Finding {
|
||||
const limit = 255
|
||||
var out []Finding
|
||||
for _, sf := range s.Files {
|
||||
if sf.isTest() {
|
||||
continue
|
||||
}
|
||||
forEachStructLiteral(sf, func(lit structLiteral) {
|
||||
if lit.Name != "SysConfig" || !s.isModelPackage(lit.PkgPath) {
|
||||
return
|
||||
}
|
||||
expr, ok := field(lit.Lit, "ConfigValue")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, ok := stringValue(sf, expr)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if n := len([]rune(v)); n > limit {
|
||||
out = append(out, s.finding(Error, checkConfigValue, sf, expr,
|
||||
"sys_config.config_value is %d characters, over the varchar(%d) column;\n"+
|
||||
" MySQL outside strict mode truncates instead of failing, so the migration succeeds with half the value.",
|
||||
n, limit))
|
||||
}
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// check 5: hard-coded menu ids colliding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// checkMenuIDCollisions reports the same menu id seeded from more than one file.
|
||||
//
|
||||
// menu_id is the primary key and every seed is an upsert, so two modules that
|
||||
// pick the same id do not collide loudly - the second overwrites the first, and
|
||||
// which one wins depends on migration order. One module's menu quietly becomes
|
||||
// the other's.
|
||||
//
|
||||
// Only across files. Inside one file the same id appearing twice is the same
|
||||
// menu being written and then referenced, which is how the seeds are written
|
||||
// today and not a mistake.
|
||||
func checkMenuIDCollisions(s *snapshot) []Finding {
|
||||
type site struct {
|
||||
sf *sourceFile
|
||||
expr posLike
|
||||
file string
|
||||
line int
|
||||
}
|
||||
sites := map[int64][]site{}
|
||||
|
||||
for _, sf := range s.Files {
|
||||
if sf.isTest() {
|
||||
continue
|
||||
}
|
||||
forEachStructLiteral(sf, func(lit structLiteral) {
|
||||
if !s.isMenuModel(lit) {
|
||||
return
|
||||
}
|
||||
expr, ok := field(lit.Lit, "MenuId")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, ok := intValue(sf, expr)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
file, line, _ := s.Pos(sf, expr.Pos())
|
||||
sites[v] = append(sites[v], site{sf: sf, expr: expr, file: file, line: line})
|
||||
})
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(sites))
|
||||
for id := range sites {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
|
||||
var out []Finding
|
||||
for _, id := range ids {
|
||||
group := sites[id]
|
||||
files := map[string]bool{}
|
||||
for _, st := range group {
|
||||
files[st.file] = true
|
||||
}
|
||||
if len(files) < 2 {
|
||||
continue
|
||||
}
|
||||
sort.Slice(group, func(i, j int) bool {
|
||||
if group[i].file != group[j].file {
|
||||
return group[i].file < group[j].file
|
||||
}
|
||||
return group[i].line < group[j].line
|
||||
})
|
||||
related := make([]string, 0, len(group)-1)
|
||||
for _, st := range group[1:] {
|
||||
related = append(related, fmt.Sprintf("also at %s:%d", st.file, st.line))
|
||||
}
|
||||
f := s.finding(Error, checkMenuIDConflict, group[0].sf, group[0].expr,
|
||||
"menu id %d is seeded from %d files;\n"+
|
||||
" menu_id is the primary key and the seeds upsert, so whichever migration runs last overwrites the other's menu.",
|
||||
id, len(files))
|
||||
f.Related = related
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// check 6: the contract packages must not import app/
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// contractRoots are the trees that may not depend on a business module. core/
|
||||
// does not exist in this repository yet and is listed because the boundary is
|
||||
// declared for both in docs/contract.md; naming it here means the check is
|
||||
// already in place the day the directory appears.
|
||||
//
|
||||
// Which of them actually exist is reported by ScannedContractRoots, because a
|
||||
// root that is absent contributes nothing and a check that silently covers less
|
||||
// than it claims is worse than no check: it leaves people believing a boundary
|
||||
// is guarded when nothing is guarding it.
|
||||
var contractRoots = []string{"common/", "core/"}
|
||||
|
||||
// ScannedContractRoots splits contractRoots by whether the snapshot actually
|
||||
// holds files under them, so the summary can name what was covered.
|
||||
func ScannedContractRoots(s *snapshot) (scanned, absent []string) {
|
||||
for _, root := range contractRoots {
|
||||
found := false
|
||||
for _, sf := range s.Files {
|
||||
if strings.HasPrefix(sf.Path, root) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
scanned = append(scanned, strings.TrimSuffix(root, "/"))
|
||||
} else {
|
||||
absent = append(absent, strings.TrimSuffix(root, "/"))
|
||||
}
|
||||
}
|
||||
return scanned, absent
|
||||
}
|
||||
|
||||
// checkContractImportBoundary reports a contract package importing app/.
|
||||
//
|
||||
// docs/contract.md promises four packages under common/ as the surface an app
|
||||
// may build on. A promise like that stops being true the moment the surface
|
||||
// imports one particular app: a fork that replaces app/admin then cannot
|
||||
// compile common/middleware, and an app can no longer be built against the
|
||||
// contract alone. Nothing about it fails visibly - it fails when somebody tries
|
||||
// to take the framework apart, which is the whole point of the exercise.
|
||||
//
|
||||
// Test files count. A fork that drops app/admin should be able to run go test
|
||||
// ./... too.
|
||||
func checkContractImportBoundary(s *snapshot) []Finding {
|
||||
appPrefix := s.ModulePath + "/app/"
|
||||
var out []Finding
|
||||
for _, sf := range s.Files {
|
||||
inContract := false
|
||||
for _, root := range contractRoots {
|
||||
if strings.HasPrefix(sf.Path, root) {
|
||||
inContract = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inContract {
|
||||
continue
|
||||
}
|
||||
for _, spec := range sf.Syntax.Imports {
|
||||
path, err := strconv.Unquote(spec.Path.Value)
|
||||
if err != nil || !strings.HasPrefix(path, appPrefix) {
|
||||
continue
|
||||
}
|
||||
out = append(out, s.finding(Error, checkImportBoundary, sf, spec,
|
||||
"%s is a contract package and imports %s;\n"+
|
||||
" a fork that replaces or drops that app can then no longer compile the contract surface it was told to build on.\n"+
|
||||
" Move what is shared down into common/, or out of the contract package.",
|
||||
sf.Path, path))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// check 7: a shim of a core contract type must be an alias
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// coreModulePrefix and coreContractSegment together identify a package under
|
||||
// core's contract namespace. Matched as prefix plus segment rather than as one
|
||||
// literal path so that a major-version bump of core - which rewrites the
|
||||
// /v2 in every import - does not quietly turn this check off.
|
||||
const (
|
||||
coreModulePrefix = "github.com/go-admin-team/go-admin-core/"
|
||||
coreContractSegment = "/sdk/contract/"
|
||||
)
|
||||
|
||||
// isCoreContractPkg reports whether an import path names one of core's
|
||||
// contract packages.
|
||||
func isCoreContractPkg(path string) bool {
|
||||
return strings.HasPrefix(path, coreModulePrefix) && strings.Contains(path, coreContractSegment)
|
||||
}
|
||||
|
||||
// checkContractShimAlias reports a shim of a core contract type that was
|
||||
// written as a defined type instead of an alias.
|
||||
//
|
||||
// type ControlBy = models.ControlBy // alias: same type, same method set
|
||||
// type ControlBy models.ControlBy // defined type: methods are gone
|
||||
//
|
||||
// The two lines differ by one character and by everything else. A defined type
|
||||
// takes the underlying struct and none of the methods declared on it, so a
|
||||
// model embedding the second one no longer has SetCreateBy or SetUpdateBy and
|
||||
// no longer satisfies ActiveRecord - which is not a warning, it is a compile
|
||||
// error, but only in code that actually uses the method set.
|
||||
//
|
||||
// That is why the compiler is not enough on its own. This repository exercises
|
||||
// some of the contract types through interfaces and some not at all; the ones
|
||||
// it does not exercise compile perfectly well as defined types here and break
|
||||
// in a third-party application, or in a fork's own module, which is where
|
||||
// nobody is looking. The check costs one field of the AST - a type alias
|
||||
// records the position of its '=' - and covers the surface uniformly rather
|
||||
// than covering whatever app/demo happens to touch this month.
|
||||
//
|
||||
// The trigger is the right-hand side, not a list of names: any type declared
|
||||
// from a core contract package is one of these, whoever wrote it and whenever
|
||||
// it was added. A type declared from a local struct literal is not caught by
|
||||
// this - see ScannedShimAliases, which is what stops a run over a tree with no
|
||||
// shims in it from reading as a clean bill of health.
|
||||
func checkContractShimAlias(s *snapshot) []Finding {
|
||||
var out []Finding
|
||||
for _, sf := range s.Files {
|
||||
forEachTypeSpec(sf, func(ts *ast.TypeSpec) {
|
||||
qualifier, pkg, name, ok := qualifiedType(sf, ts.Type)
|
||||
if !ok || !isCoreContractPkg(pkg) {
|
||||
return
|
||||
}
|
||||
if ts.Assign.IsValid() {
|
||||
return // "type X = pkg.Y", which is what it must be
|
||||
}
|
||||
out = append(out, s.finding(Error, checkShimAlias, sf, ts,
|
||||
"%s is declared from %s.%s as a defined type, not an alias;\n"+
|
||||
" a defined type keeps the fields and drops the method set, so anything embedding it stops satisfying\n"+
|
||||
" the interfaces it satisfied before - here it may still compile, in a fork or a third-party app it does not.\n"+
|
||||
" Write it as: type %s = %s.%s",
|
||||
ts.Name.Name, qualifier, name, ts.Name.Name, qualifier, name))
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ScannedShimAliases counts the type aliases into core's contract packages the
|
||||
// snapshot holds, so the summary can say whether checkContractShimAlias found
|
||||
// anything to guard at all.
|
||||
//
|
||||
// Reported for the same reason ScannedContractRoots is: before the contract
|
||||
// packages are lowered into core there are no shims here, the check has
|
||||
// nothing to look at, and a run that printed nothing would look exactly like a
|
||||
// run over a tree that passed.
|
||||
func ScannedShimAliases(s *snapshot) int {
|
||||
n := 0
|
||||
for _, sf := range s.Files {
|
||||
forEachTypeSpec(sf, func(ts *ast.TypeSpec) {
|
||||
_, pkg, _, ok := qualifiedType(sf, ts.Type)
|
||||
if ok && isCoreContractPkg(pkg) && ts.Assign.IsValid() {
|
||||
n++
|
||||
}
|
||||
})
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// forEachTypeSpec visits every type declaration in the file, including the
|
||||
// ones inside a parenthesised type block.
|
||||
func forEachTypeSpec(sf *sourceFile, fn func(*ast.TypeSpec)) {
|
||||
for _, decl := range sf.Syntax.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
for _, spec := range gen.Specs {
|
||||
if ts, ok := spec.(*ast.TypeSpec); ok {
|
||||
fn(ts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// qualifiedType resolves a type expression that names a type in another
|
||||
// package, returning that package's import path and the type name. A bare
|
||||
// identifier, a struct literal or anything else reports false: this asks
|
||||
// specifically "is the right-hand side pkg.Name", which is the shape both a
|
||||
// correct shim and the mistake it guards against are written in.
|
||||
// The qualifier returned is the one written in this file, which is not
|
||||
// path.Base of the import path whenever the import is aliased - and the shim
|
||||
// files alias every one of them (contractmodels, contractdto). A message that
|
||||
// suggests a fix has to spell it the way the file already does, or the line it
|
||||
// tells the author to write does not compile.
|
||||
func qualifiedType(sf *sourceFile, typ ast.Expr) (qualifier, pkgPath, name string, ok bool) {
|
||||
sel, isSel := typ.(*ast.SelectorExpr)
|
||||
if !isSel {
|
||||
return "", "", "", false
|
||||
}
|
||||
ident, isIdent := sel.X.(*ast.Ident)
|
||||
if !isIdent {
|
||||
return "", "", "", false
|
||||
}
|
||||
p, found := sf.imports[ident.Name]
|
||||
if !found {
|
||||
return "", "", "", false
|
||||
}
|
||||
return ident.Name, p, sel.Sel.Name, true
|
||||
}
|
||||
|
||||
// migrationVersion reads the 13-digit timestamp a migration file name starts
|
||||
// with. Files outside the two migration directories are not migrations, however
|
||||
// they are named.
|
||||
func migrationVersion(rel string) (int64, bool) {
|
||||
dir := path.Dir(rel)
|
||||
if dir != "cmd/migrate/migration/version" && dir != "cmd/migrate/migration/version-local" {
|
||||
return 0, false
|
||||
}
|
||||
name := path.Base(rel)
|
||||
if len(name) < 13 {
|
||||
return 0, false
|
||||
}
|
||||
v, err := strconv.ParseInt(name[:13], 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// isMenuModel reports whether a literal describes a menu row, whichever of
|
||||
// the two shapes it is written in.
|
||||
//
|
||||
// A host module seeds a menu by building the SysMenu model directly. An
|
||||
// application installed from outside this repository cannot reach that type,
|
||||
// so it describes the same row as a seed.MenuSpec and hands it to the host's
|
||||
// Seeder. Both end up in sys_menu and both are subject to its column widths,
|
||||
// so a check that knew only the first shape would go quiet exactly when the
|
||||
// author is furthest from the schema it protects.
|
||||
//
|
||||
// That is not hypothetical: this repository's own reference application was
|
||||
// written with a Sort of 200 - past the tinyint sys_menu.sort is built as -
|
||||
// and this check passed it, because a MenuSpec is not a SysMenu.
|
||||
func (s *snapshot) isMenuModel(lit structLiteral) bool {
|
||||
if lit.Name == "MenuSpec" && isCoreContractPkg(lit.PkgPath) {
|
||||
return true
|
||||
}
|
||||
return lit.Name == "SysMenu" && s.isModelPackage(lit.PkgPath)
|
||||
}
|
||||
|
||||
func (s *snapshot) isModelPackage(path string) bool {
|
||||
return path == s.pkg(pkgFrozenModels) || path == s.pkg(pkgAdminModels)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user