Compare commits

..
49 Commits
Author SHA1 Message Date
wenjianzhang 76c9d1211e Merge pull request #880 from go-admin-team/fix/dsn-in-logs
ci🔧: migrate before deploying, roll back on failure — and stop logging the database password
2026-08-27 17:31:52 +08:00
zhangwenjian f5273f5a58 ci🔧: migrate before deploying, and roll back when the new version does not come up
Closes #871.

The deploy did docker rm -f then docker run. Nothing ran migrations, so
new code met old tables, and nothing checked the result - a container
that exits immediately left the site down with a green deploy.

Now, in order: pull the image, run the migration with it, and only then
touch what is running. A failed migration stops there, leaving old code
with the old schema, which is at least self-consistent.

The running container is renamed rather than removed, so it can be
started again unchanged if the new one does not become healthy. Healthy
means both an HTTP response and a database connection in the log: the
captcha endpoint answers without touching the database, so it alone
would call a container healthy that cannot reach MySQL.
2026-08-27 16:19:03 +08:00
zhangwenjian 54ffaac9c5 chore🔧: say which migration is running, not a column of ones
An applied migration printed its count - a bare '1' - so a database with
seven of them wrote seven lines of '1' at every start, and a failure said
only which error, never which migration.

It now names each one as it applies, reports the total, and says so when
there is nothing to do.
2026-08-27 16:19:03 +08:00
zhangwenjian 484de2e698 fix🔒: stop writing the database password into the log
The startup line printed the DSN whole:

  * => goadmin:<password>@tcp(host:3306)/go-admin?...

So every deployment wrote its own database credential into its own logs,
where a log shipper, a support bundle or a screenshot of a terminal
carries it onward. Found while reading deploy output, which is exactly
how it leaks.

The host and username stay - they are what makes the line worth printing
- and only the password is replaced. Both DSN shapes this project accepts
are covered, a sqlite path is left alone, and anything unparseable is
withheld rather than echoed, since it may hold a credential too.
2026-08-27 16:17:12 +08:00
wenjianzhang d72ff76aad Merge pull request #879 from go-admin-team/ci/config-path-secret
ci🔧: keep the host config path out of a public repository
2026-08-27 15:41:54 +08:00
zhangwenjian 4a523bed92 ci🔧: keep the host config path out of a public repository
The path is not a credential, and the file it points at is 600 and owned
by root, so this is not what protects it. But the repository is public
and there is no reason to publish the server's directory layout next to
the deploy that uses it.

DEMO_CONFIG_PATH holds it instead. It has to be set before this merges,
or the deploy stops at the guard - which is the intended failure: better
that than falling back to the sqlite in the image.
2026-08-27 15:37:09 +08:00
wenjianzhang 55dc33b865 Merge pull request #878 from go-admin-team/ci/demo-on-mysql
ci🔧: run the demo on the managed database instead of a bundled sqlite file
2026-08-27 15:33:15 +08:00
zhangwenjian 3d13f5856a ci🔧: point the demo at the managed database
The demo ran on the sqlite file baked into the image, so every deploy
reset it and nothing there resembled how anyone actually runs this.

The config is mounted from the host rather than taken from the image.
config/settings.demo.yml ships in a public repository and is copied into
a public image, so the connection string cannot live there; that copy
stays on sqlite, which is what a fresh clone should get.

The deploy refuses to start if the host config is missing, rather than
falling back to the image's sqlite and looking like it worked.
2026-08-27 12:48:32 +08:00
wenjianzhang aa2976ba17 Merge pull request #877 from go-admin-team/fix/mysql-fresh-install
fix🐛: MySQL installs could not log in — the migration run stopped at a tinyint overflow
2026-08-27 12:18:04 +08:00
zhangwenjian 8e141ff8a0 fix🐛: the code generator listed no tables at all
sys_columns and sys_tables were left out of the soft-delete conversion in
1786700003000. Their runtime models embed common.ModelTime, which is the
millisecond marker, so GORM queries them with deleted_at = 0 - against a
nullable datetime column holding NULL. Every row was invisible.

The repository carries two ModelTime types: the one under
cmd/migrate/migration/models still has a nullable gorm.DeletedAt and is
what builds the tables, while common/models has the marker and is what
queries them. Nothing connected the two, so a table could be built one
way and read the other with no signal at all.

The test now walks app/ for models embedding the marker and requires a
migration to cover each. tb_demo is exempt and says why: nothing reads it
at runtime.
2026-08-27 12:12:13 +08:00
zhangwenjian 2628ab8e3e fix🐛: a seeded menu overflowed its column and stopped the migration run
sort is gorm:"size:4", which MySQL builds as a tinyint holding -128..127.
The demo menu seeded Sort: 900, so on MySQL the run stopped at
1786700001000 with Error 1264, and every migration after it - including
the soft-delete conversion - never ran.

deleted_at therefore stayed NULL while the code queries deleted_at = 0,
and the login returned 'incorrect Username or Password' on a database
whose password hash was correct all along.

sqlite ignores the declared width, so a fresh install there passed and
the fault only appeared on MySQL.
2026-08-27 12:12:00 +08:00
wenjianzhang 1b7dcd843c Merge pull request #876 from go-admin-team/perf/data-permission
perf👌: the data-permission lookup ran on every request, including when it was switched off
2026-08-24 16:11:47 +08:00
zhangwenjian f0d91fb763 perf👌: read the data scope from the token instead of joining for it
The scope is decided by the user id, the role id, the department and the
data_scope string. Three of the four were already in the token; deptid
was not, though core's user.GetDeptId has always read that claim. Adding
it removes a sys_user join from every list, detail, update and delete.

This goes no more stale than rolekey does, which Casbin has read from
the token since the beginning: both settle on the next login.

A token minted before this still works. Its claims are incomplete, and
the lookup runs for it as before.
2026-08-24 15:36:11 +08:00
zhangwenjian 7238c6a26d perf👌: stop looking up a data scope that is switched off
Permission() returns the query untouched when EnableDP is false, so the
lookup feeding it has nothing to feed. The lookup ran anyway: a sys_user
join against sys_role on every list, detail, update and delete, with the
result discarded.

enabledp is false in settings.full.yml, so this was the default.
2026-08-24 15:35:33 +08:00
wenjianzhang 0964cf98e2 Merge pull request #875 from go-admin-team/fix/file-store-nil-client
fix🐛: the upload endpoint panicked on source=2, and cloud storage was never wired up
2026-08-24 15:00:31 +08:00
zhangwenjian 04c6a081ae fix🐛: source=3 uploaded to aliyun, and neither provider was ever configured
thirdUpload dispatched on the source parameter and then built the same
zero-value ALiYunOSS in both branches, so source=3 could not have
reached qiniu even with credentials.

Neither branch had credentials to use. OXS.Setup is the initialisation
path and nothing in the repository called it, and no configuration field
existed to fill. The store is now taken from extend.fileStore, and a
provider that was not configured says so rather than producing the
provider's own complaint about an empty bucket name.

The two handlers passed errors.New("") to e.Error, discarding what
actually went wrong; they now pass the error.
2026-08-24 13:25:54 +08:00
zhangwenjian fcbd9ae02e fix🐛: an unconfigured object store reports it instead of panicking
Each implementation keeps its provider client in an interface{} field that
Setup assigns, so an unconfigured store holds nil - and asserting nil to
the provider's client type panics:

  panic: interface conversion: interface {} is nil, not *oss.Client

The upload endpoint reaches that path for any request naming a provider
the deployment never configured.

Three more things were wrong in the same files. OXS.Setup printed a
failure and returned the store anyway, handing back exactly the broken
object that panics. HuaWeiOBS.UpLoad printed the provider's error and
returned nil, so a failed upload reported success. Both it and
QiNiuKODO.UpLoad asserted the local path was a string without checking.

The tests asked the reader to paste their own credentials, so they failed
for everyone who did not. They now cover the guards and skip the part
that needs a provider unless credentials are in the environment.
2026-08-24 13:23:07 +08:00
wenjianzhang d34d30a197 Merge pull request #874 from go-admin-team/ci/serialize-deploys
ci🔧: run one deploy at a time
2026-08-23 14:15:21 +08:00
zhangwenjian ecfea845c2 ci🔧: run one deploy at a time
Two merges seconds apart raced. Both runs do docker rm -f then docker
run; the second removed the container the first had just created, and
the first's docker run failed on the name conflict:

  Conflict. The container name "/go-admin-api" is already in use

The deploy went red and the demo stayed on the older image, which is the
worse half: a failure that leaves the wrong version running.

Grouping by ref serialises pushes to master while leaving pull request
runs independent, since those carry their own ref.
2026-08-23 14:11:49 +08:00
wenjianzhang e05ff7c809 Merge pull request #873 from go-admin-team/chore/drop-dockerfilebak
chore🔧: delete Dockerfilebak
2026-08-23 14:03:34 +08:00
wenjianzhang 28a9626661 Merge pull request #872 from go-admin-team/chore/skill-new-business-module
docs📝: add the new-business-module skill, and keep the rest of .claude out
2026-08-23 14:03:28 +08:00
zhangwenjian 96b2cb3acf chore🔧: delete Dockerfilebak
Added in 2022 and never touched since. Nothing references it - not the
workflows, not the Makefile, not a script - and it could not build
anyway: it copies config/settings.yml out of the builder, and that file
is gitignored.

It is a leftover from when the image was built inside the container,
before that was replaced by copying a binary built on the runner. Its
MAINTAINER line was the last one in the repository; Docker deprecated
the instruction in favour of LABEL maintainer years ago.
2026-08-23 13:43:40 +08:00
wenjianzhang 87fe6b7d9b Merge pull request #864 from go-admin-team/chore/core-v2
chore🔧: move to go-admin-core v2
2026-08-23 13:43:19 +08:00
zhangwenjian 722de8ea65 chore🔧: move the generator templates to v2 as well
The code generator writes Go files, and its templates still spelled the
old import paths, so a module generated after this migration did not
compile: the router it emits declares InitBusinessRouter with the v1
*GinJWTMiddleware while common.AuthInit now returns the v2 type.

Two of the paths moved rather than gaining a /v2 segment - the jwtauth
and response shims under sdk/pkg are gone in v2 - so this is not the
same rewrite the Go files got.
2026-08-23 13:28:17 +08:00
zhangwenjian 8ffde94433 chore🔧: move to go-admin-core v2
Every import of the module changes, not only the seven packages that
moved out of sdk/pkg: Go requires the major version in the path from v2
on. Both happen in one pass —

    go run github.com/go-admin-team/go-admin-core/tools/coreupgrade@v2.0.0 -w -v2 .
    go mod tidy

— which is the command the release notes give, run here as a consumer
would run it. 210 imports across 95 files.

The compatibility shims this used are gone in v2, so the paths that
moved had to move: sdk/pkg/captcha, sdk/pkg/jwtauth and its user
package, sdk/pkg/response and sdk/pkg/casbin.

The count of unformatted files is unchanged at 34, none of them touched
by this: the tool reformats a file only if it was already gofmt clean,
so a migration cannot disappear into whitespace.
2026-08-23 13:26:46 +08:00
wenjianzhang 08eef12bca Merge pull request #865 from go-admin-team/fix/codegen-inverted-guards
fix🐛: the code generator's guards were all written backwards
2026-08-23 13:24:50 +08:00
zhangwenjian ab0e8e6056 docs📝: add the new-business-module skill, and keep the rest of .claude out
The skill walks a single-table CRUD module end to end: migration, the
Actions-mode model, dto and router, and the sys_menu / sys_api /
casbin_rule seed data without which the module builds but never appears.

.claude was ignored wholesale. Un-ignoring the skills directory would
have committed every skill put there, including personal ones, so the
skills that ship are re-included one directory at a time.

AGENTS.md now points at 1786700001000_demo_menu.go for the seed data,
which is the runnable version of what the skill describes.
2026-08-23 13:20:26 +08:00
zhangwenjian 2cef52d906 chore🔧: record the modules the code imports as direct
go mod tidy moves glebarez/sqlite and gorm.io/plugin/soft_delete out of
the indirect block: the tests import the first and common/models the
second. CI runs tidy before building, so the tree was dirty from the
first command.
2026-08-23 13:19:15 +08:00
zhangwenjian 95077b116b fix🐛: the candidate table query assumed one database
The exclusion list was a subquery against `$GenConfig.DBName`.sys_tables,
so it named the schema by hand. Generating from a schema that is not the
one holding sys_tables made the whole query fail, and because the
subquery read the table directly it also counted soft-deleted entries:
deleting a generator entry never handed its table back.

Read the registrations through the model on this connection instead. An
empty list skips the clause - NOT IN (NULL) is unknown for every row,
which would leave a fresh install with nothing to generate from.
2026-08-23 13:19:15 +08:00
zhangwenjian d4cf11d313 fix🐛: report an unknown database driver instead of panicking
opens is a map, so opens[c.Driver] on a driver this build does not carry
returns a nil function, and gorm.Open calls it. The operator saw a nil
dereference inside gorm with nothing naming the driver.

sqlite3 is the case that bites: it needs cgo and is only compiled in
under the sqlite3 build tag, so the same config file works on one binary
and dies on another. Resolve the driver first and say which ones this
build supports.
2026-08-23 13:19:15 +08:00
zhangwenjian f201792d8f fix🐛: the mysql-only guard never fired
pkg.Assert panics when its condition is false, so pkg.Assert(true,
"目前只支持mysql数据库") is a no-op. On postgres or sqlserver the code
generator did not report that it needs MySQL: DBTables returned an empty
list with a nil error, and DBColumns ran its query on the zero-value
*gorm.DB left over from the branch that never assigned, which is a nil
dereference rather than a message.

Assert the driver up front instead of asserting a constant in an else,
which also removes the placeholder *gorm.DB the fall-through relied on.
DBColumns.GetPage had no guard at all and gets the same one.
2026-08-23 13:19:15 +08:00
zhangwenjian d16f5e7180 fix🐛: db columns endpoint rejected exactly the valid requests
pkg.Assert panics when its condition is false, so
Assert(TableName == "", "table name cannot be empty") rejected every
request that carried a table name and let the empty one through. The
model layer repeated the inversion with if TableName != "" { return
error }, so either one alone was enough to break the endpoint.

Flip both, and hoist the model guard out of the mysql branch so it
matches GetList ten lines below, which had it right all along.
2026-08-23 13:19:15 +08:00
wenjianzhang f8f697af2b Merge pull request #868 from go-admin-team/ci/stop-gitee-mirror
ci🔧: stop mirroring the repository
2026-08-23 13:18:19 +08:00
wenjianzhang eb8da38a46 Merge pull request #870 from go-admin-team/fix/demo-db-soft-delete
fix🐛: the soft-delete migration could not run, and the demo database never got it
2026-08-23 13:01:17 +08:00
zhangwenjian f19568c69a chore🔧: bring the bundled demo database up to the current migrations
go-admin-db.db ships in the repository and the Dockerfile copies it into
the image, which then runs only the server. Its last recorded migration
was from 2022, so every row still carried a null deleted_at while the
code queries deleted_at = 0. Nothing matched: not the login, not the
sixty-seven menus, not the five departments.

Anyone starting from the bundled sqlite database met the same wall, and
the failure reads as an incorrect username or password.
2026-08-23 12:57:05 +08:00
zhangwenjian 91bf25e5fe fix🐛: the soft-delete migration could not run against the real schema
Two assumptions held on the test's table and on nothing else.

It dropped deleted_at while an index still referred to it. MySQL and
PostgreSQL drop dependent indexes along with the column; SQLite refuses,
and the migration stopped at the first table with such an index - which
is all thirteen of them.

It also read the rows through a column named id. sys_dept keys on
dept_id, sys_user on user_id, and only some tables on id, so the pass
that carries the deletion timestamps across never ran.

The test's table had an id key and no index on deleted_at, which is
exactly the shape that lets both through. It now matches sys_user.
2026-08-23 12:56:50 +08:00
wenjianzhang 85666f160f Merge pull request #867 from go-admin-team/docs/readme-refresh
docs📝: repoint the README links that stopped resolving
2026-08-22 23:12:49 +08:00
zhangwenjian b2baf48dc6 ci🔧: stop mirroring the repository
Every push mirrored to Gitee and GitLab. Neither mirror is wanted any
more, so the workflow goes rather than half of it.

The GITEE_KEY and GITLAB_KEY secrets are left in place; restoring the
mirror is a revert of this commit.
2026-08-22 17:34:50 +08:00
zhangwenjian c906e1d503 docs📝: repoint the links that stopped resolving
The two tutorial links pointed at doc.zhangwj.com, which no longer
answers; the same paths serve from doc.go-admin.dev. golangroadmap.com
returns 503. The jwt-go credit pointed at dgrijalva/jwt-go, archived
years ago - this project builds on golang-jwt/jwt.

Also: the copyright years said 2022 and 2024, the English README asked
for a password in Chinese, and the Chinese README's link section lost
its only entry, so it gets the one the English side already had.
2026-08-22 15:26:44 +08:00
wenjianzhang 3b93ac19f8 Merge pull request #863 from go-admin-team/fix/soft-delete-groundwork
fix🐛: a unique constraint the database can actually keep
2026-08-22 11:59:12 +08:00
zhangwenjian 9914373d45 test🧪: run the assertion through the function it is about
Review caught that this reissued getByRoleName's query instead of
calling it, so it passed whether or not the production line still said
what it was supposed to — a test named for a change it did not touch.

It calls getByRoleName now, and restoring the hand-written clause fails
it for exactly the reason this PR exists: with the marker non-null,
"deleted_at is null" matches nothing and the query returns an empty
list.
2026-08-22 11:40:38 +08:00
zhangwenjian 4911730012 fix🐛: give the natural keys a constraint the database can keep
sys_user.username, sys_role.role_key and sys_dict_type.dict_type had no
unique index. Uniqueness was a SELECT COUNT followed by an INSERT, which
two concurrent requests both pass — and login resolves a username with
First, so which of the two accounts answers is whichever the database
returns.

The index cannot be on the key alone, because a soft-deleted row keeps
occupying the name and a deleted user's username could never be used
again. It has to include the delete marker, and the marker has to be
non-null: two live rows are (alice, NULL) and (alice, NULL), and NULL is
not equal to NULL, so an index over a nullable marker admits both. That
is the worst of the three states — a constraint that reads as protection
and binds nothing — and there is a test that demonstrates it rather than
asserting it.

ModelTime.DeletedAt is milliseconds since the epoch now, zero while the
row is live. Sixteen tables carry it; the migration converts each one,
preserving when each deleted row was deleted, then adds the three
indexes.

Written to be re-runnable rather than transactional, because DDL does not
roll back on MySQL and an operator whose first attempt failed halfway
should have nothing to do but run it again. It refuses before altering
anything if a table already holds duplicates, naming them, rather than
letting the index fail and leaving the operator to guess.

The timestamp conversion happens in Go: turning a timestamp into epoch
milliseconds is spelled differently by every dialect this supports, and
these row counts do not justify four versions of it.
2026-08-22 11:27:22 +08:00
zhangwenjian 88bab51056 fix🐛: stop hand-writing the soft-delete condition, and check the count
Two things in front of the unique-index work, both safe on their own.

getSysMenuByRoleName carried "deleted_at is null" in its where clause.
GORM adds that condition itself for a model with a DeletedAt field, so
it was a duplicate — and one phrased as a column being null, which stops
being true the moment the column stops being nullable. A schema that
moves to a non-null delete marker would have turned this query into one
that matches nothing, silently, for admin users only.

SysDictType.Insert dropped the error from its duplicate check: a query
that failed left the count at zero and the insert went ahead as though
the name were free.

The test pins what the removed clause was there for. Its counter-proof
is Unscoped rather than deleting the field — taking ModelTime off the
model fails to compile, which proves nothing.
2026-08-22 11:11:03 +08:00
wenjianzhang 0fd4f68b6c Merge pull request #861 from go-admin-team/docs/fix-stale-queue-redis-sample
fix: correct the commented-out queue.redis sample in settings.yml
2026-08-20 11:01:03 +08:00
zhangwenjian c66cb5c6a8 fix: correct the commented-out queue.redis sample in settings.yml
The sample had producer/consumer nested keys (streamMaxLength,
approximateMaxLength, visibilityTimeout, bufferSize, concurrency,
blockingTimeout, reclaimInterval) that don't exist on config.RedisQueue —
checked against sdk/config/queue.go, which only reads addr, password, and the
embedded RedisOptions fields, plus group, key_prefix and max_attempts. Filling
in the old sample as written would compile and start fine, since it's YAML
under a key the struct doesn't declare, and every one of those settings would
be silently ignored.

Replaced with the fields the struct actually has. Still commented out —
redis stays opt-in, this only fixes what filling it in would produce.
2026-08-20 10:54:53 +08:00
wenjianzhang b16ec0af77 Merge pull request #860 from go-admin-team/chore/upgrade-core
chore🔧: upgrade go-admin-core and route the queue through configuration
2026-08-18 22:48:00 +08:00
zhangwenjian 82ea8539eb chore🔧: upgrade go-admin-core and route the queue through configuration
The pinned core dated from April, before sdk stopped being a separate module,
so the build resolved sdk packages from the old module and core packages from
the new one. Dropping the separate requirement is what makes the two agree
again.

Most of the diff is renames that came with that: the tenant accessors gained a
ByTenant suffix, GetDb now returns one database and GetAllDb the map, and
casbin moved to v3.

The change that matters is four call sites moving from GetMemoryQueue to
GetQueuePrefix. GetMemoryQueue returns a queue fixed at construction, so the
login log, the operate log and the api check ran in process no matter what the
settings file selected — a second instance saw none of it. GetQueuePrefix
returns whatever the configuration built, which is the point of being able to
configure a queue at all.

Verified against core at main: build and vet clean. The two file_store failures
are unchanged from before this branch; they need cloud credentials.
2026-08-18 22:14:08 +08:00
wenjianzhang d17d5c1206 Merge pull request #859 from go-admin-team/docs/clarify-demo-sites
docs📝: 标注 antd 演示站对应 go-admin-pro
2026-08-16 19:31:25 +08:00
zhangwenjian 041d22d0d2 docs📝: 标注 antd 演示站对应 go-admin-pro
README 中两个演示地址并排列出、格式与账号密码完全相同,看不出 antd 站对应
的是另一个产品。用户在该站遇到问题时会认为是本仓库的缺陷(见 #857:登录
返回的错误码在本仓库中并不存在)。

仅在链接文字中补充产品名,不改变呈现方式。
2026-08-16 12:04:26 +08:00
144 changed files with 2249 additions and 574 deletions
@@ -0,0 +1,90 @@
---
name: new-business-module
description: Scaffold a new single-table CRUD business module end to end — migration, Actions-mode model/dto/router, and the sys_menu/sys_api/casbin seed data that makes it show up in the UI with working permissions. Use when the user wants to add a new business table/module to go-admin, not for cross-table or non-CRUD business logic.
---
# 新增业务模块
给一张新的业务表配齐"能跑、能看见、能授权"的完整闭环:迁移 → 后端代码 → 菜单与权限种子数据。
只适用于单表增删改查;跨表事务、外部调用、复杂校验等超出这个范围(见下方"何时不适用")。
开始前先读 `AGENTS.md`(分层边界、通用 Action 使用前提、命名规则)和 `app/demo/` 下的全部文件——
这是可编译、有测试、CI 会跑的参照物,本文与它冲突时以它为准。
## 何时不适用
业务超出单表 CRUD(跨表事务、外部服务调用、复杂校验)时,不要用这个 skill 硬套——
改成手写 Handler + Service,参照 `app/admin/apis/sys_post.go` 及其 Service,遵守
`AGENTS.md` 的分层约束(Api 不碰 Orm,Service 不碰 `gin.Context`,一律用 `e.Orm`)。
## 步骤
### 1. 确认表结构
表结构需符合命名规范:`sys_`/业务前缀 + 下划线(如 `tb_article`)。核对字段是否已有
`created_at`/`updated_at`/`deleted_at` 这类约定字段。
### 2. 写数据库迁移
放在 `cmd/migrate/migration/version/` 目录(**不是** `version-local/` —— 后者在
`.gitignore` 中,提交时会被忽略,`git status` 也看不到)。
- 文件名前 13 位是时间戳版本号
- 已执行过的迁移文件不可修改;需要修正时新增一个迁移
- 包名为 `version`
### 3. 生成 model / dto / router 三个文件(Actions 模式)
不要手写 Api 与 Service。使用 `common/actions` 的通用 Action,一个模块只需
model、dto、router 三个文件,完整写法照抄 `app/demo/` 的结构。
**关键正确性要求**(这三条是实际出问题最多的地方):
- Model 实现 `models.ActiveRecord`(`Generate` / `GetId` / `TableName`),
`TableName()` 必须显式声明——GORM 配置了 `SingularTable`,不会自动推导
- **`Generate()` 必须返回副本,不要就地返回**——Action 在并发请求间复用实例,
就地返回会导致请求之间串数据;这个问题单人测试时几乎不出现,上线后才暴露
- 完成后确认 `cmd/api/` 中已用 `_` 导入新包,否则路由不会被注册
### 4. 写菜单、接口与权限种子数据
这一步最容易被漏掉——代码能编译、接口能测通,但界面上看不到菜单、点了按钮说
没权限,往往就是漏了这一步。**完整参照 `cmd/migrate/migration/version/1786700001000_demo_menu.go`**
——那是可运行、幂等(用 `upsert`,重复跑不会报错)的真实例子,逐字照抄结构,只换 ID 和业务字段。
一个模块要在界面上可用,需要四类数据,缺一样都不行:
| 表 | 作用 |
|---|---|
| `sys_api` | 后端路由登记,Casbin 据此判定权限 |
| `sys_menu` | 侧边栏菜单(目录用 `M`、菜单用 `C`、按钮用 `F`) |
| `sys_menu_api_rule` | 菜单与接口的多对多关联,角色保存时据此生成策略 |
| `casbin_rule` | 实际生效的权限策略(**不是** `sys_casbin_rule`,那张表的唯一索引在 MySQL 下会超长,不要迁移它) |
必须核对的两处一致性——**错了不会报错,只会在界面上表现为"看不到/点不动"**:
- `sys_menu.menu_name` 必须与前端组件的 `defineOptions({ name: 'XxxManage' })` 一致,
否则 `keep-alive` 缓存静默失效
- 按钮级 `sys_menu.permission`(格式 `模块:资源:操作`)必须与前端
`v-permisaction="['模块:资源:操作']"` 完全一致,否则按钮权限判断静默失效
### 5. 收尾检查
| 检查项 | 出错后果 |
| --- | --- |
| `Generate()` 是否返回副本 | 并发请求之间串数据 |
| 是否使用 `e.Orm` 而非全局 DB | 多租户下拿到错误的数据库连接 |
| `TableName()` 是否显式声明 | GORM 不会自动推导 |
| 迁移文件是否放在 `version/` | 放进 `version-local/` 会被忽略,别人拉代码看不到 |
| `sys_menu.menu_name` 是否与前端组件 `name` 一致 | keep-alive 缓存静默失效 |
| `sys_menu.permission` 是否与前端 `v-permisaction` 一致 | 按钮权限静默失效 |
跑一遍 `go run -tags sqlite3 . migrate -c config/settings.sqlite.yml` 验证迁移可执行,
再 `go run -tags sqlite3 . server -c config/settings.sqlite.yml` 启动服务,用 admin
账号登录确认新菜单和按钮权限都出现了。
如果前端页面还没生成,下一步用 go-admin-ui 仓库里的 `new-list-page` skill——两边靠
`sys_menu.permission` / `v-permisaction` 这个字符串对齐。
> 不要把 `config/settings.yml` 的真实内容贴给 AI 工具——`database.source` 含数据库
> 账号密码,`jwt.secret` 泄露后可被用来伪造任意用户的 token。
+68 -2
View File
@@ -6,6 +6,14 @@ on:
pull_request:
branches: [ master ]
# One deploy at a time. Two merges seconds apart raced here: both runs did
# docker rm -f then docker run, the second removed the container the first had
# just created, and the first's docker run then failed on a name conflict -
# leaving the demo on the older image with a red deploy.
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
env:
IMAGE_NAME: registry.ap-northeast-1.aliyuncs.com/go-admin/go-admin-api # 镜像名称
TAG: ${{ github.sha }}
@@ -57,7 +65,65 @@ jobs:
username: ${{ secrets.SSH_USERNAME }}
key: ${{ secrets.DEPLOY_KEY }}
# 重启的脚本,根据自身情况做相应改动,一般要做的是migrate数据库以及重启服务器
#
# 配置从宿主机挂载,不使用镜像里的那份:演示站连的是托管数据库,
# 而 config/settings.demo.yml 会随仓库公开、也会打进镜像,凭据不能写在那里。
# 镜像里那份保持 sqlite,供 clone 仓库的人开箱即用。
#
# 路径本身走 secret:它不是凭据,但本仓库公开,没有理由把服务器的
# 目录结构一并公布。DEMO_CONFIG_PATH 指向宿主机上那份配置。
#
# 顺序是有意的:迁移先跑,跑不过就保持现有版本不动;
# 旧容器改名保留而不是删除,新容器不健康时能原样恢复。
# 健康检查两条都要过——HTTP 活着不代表数据库通了。
script: |
sudo docker rm -f go-admin-api
set -u
CFG="${{ secrets.DEMO_CONFIG_PATH }}"
IMG="${{ env.IMAGE_NAME_TAG }}"
NAME=go-admin-api
PREV="$NAME-prev"
test -f "$CFG" || { echo "宿主机配置缺失,中止部署"; exit 1; }
sudo docker login --username=${{ secrets.DOCKER_USERNAME }} registry.ap-northeast-1.aliyuncs.com --password=${{ secrets.DOCKER_PASSWORD }}
sudo docker run -d -p 8000:8000 --name go-admin-api ${{ env.IMAGE_NAME_TAG }}
sudo docker pull "$IMG" || { echo "拉取镜像失败,中止部署"; exit 1; }
# 迁移用新镜像跑。失败时线上仍是旧版本配旧 schema,是自洽的;
# 硬切过去才会得到代码与表对不上的服务。
if ! sudo docker run --rm -v "$CFG":/config/settings.yml:ro "$IMG" \
/main migrate -c /config/settings.yml; then
echo "迁移失败,保持现有版本"; exit 1
fi
if sudo docker ps -a --format '{{.Names}}' | grep -qx "$NAME"; then
sudo docker rm -f "$PREV" >/dev/null 2>&1 || true
sudo docker rename "$NAME" "$PREV"
sudo docker stop "$PREV" >/dev/null
fi
sudo docker run -d -p 8000:8000 \
-v "$CFG":/config/settings.yml:ro \
--name "$NAME" "$IMG"
ok=0
for i in $(seq 1 20); do
sleep 3
code=$(curl -s -o /dev/null -w '%{http_code}' -m 5 http://127.0.0.1:8000/api/v1/captcha 2>/dev/null || true)
if [ "$code" = "200" ] && sudo docker logs "$NAME" 2>&1 | grep -q 'connect success'; then
ok=1; echo "健康检查通过(第 $i 次探测)"; break
fi
done
if [ "$ok" = "1" ]; then
sudo docker rm -f "$PREV" >/dev/null 2>&1 || true
else
echo "健康检查失败,回滚到上一版本"
sudo docker logs --tail 40 "$NAME" 2>&1 || true
sudo docker rm -f "$NAME" >/dev/null 2>&1 || true
if sudo docker ps -a --format '{{.Names}}' | grep -qx "$PREV"; then
sudo docker rename "$PREV" "$NAME"
sudo docker start "$NAME" >/dev/null
echo "已恢复"
fi
exit 1
fi
-33
View File
@@ -1,33 +0,0 @@
name: 'GitHub Actions Mirror'
on: [push, delete]
jobs:
mirror_to_gitee:
runs-on: ubuntu-latest
steps:
- name: 'Checkout'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: 'Mirror to gitee'
uses: pixta-dev/repository-mirroring-action@674e65a7d483ca28dafaacba0d07351bdcc8bd75 # v1
with:
target_repo_url:
git@gitee.com:go-admin-team/go-admin.git
ssh_private_key:
${{ secrets.GITEE_KEY }}
mirror_to_gitlab:
runs-on: ubuntu-latest
steps:
- name: 'Checkout'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: 'Mirror to gitlab'
uses: pixta-dev/repository-mirroring-action@674e65a7d483ca28dafaacba0d07351bdcc8bd75 # v1
with:
target_repo_url:
git@gitlab.com:go-admin-team/go-admin.git
ssh_private_key:
${{ secrets.GITLAB_KEY }}
+7 -1
View File
@@ -21,5 +21,11 @@ cmd/migrate/migration/version-local/*
config/settings.deva.yml
go-admin-server
CLAUDE.md
.claude/
# Everything under .claude is private by default. Skills meant for people using
# go-admin are re-included one directory at a time, so a personal one dropped in
# here is never committed by accident.
.claude/*
!.claude/skills/
.claude/skills/*
!.claude/skills/new-business-module/
config/settings.local.dev.yml
+4 -1
View File
@@ -145,7 +145,10 @@ func registerSysPostRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
| DTO | `{Model}{Action}Req` | `SysPostPageReq` |
| 权限标识 | `模块:资源:操作` | `admin:sysPost:add` |
权限标识需与前端 `v-permisaction` 一致,并写入 `sys_menu` 种子数据。
权限标识需与前端 `v-permisaction` 一致,并写入 `sys_menu` 种子数据——完整可运行的
参照见 `cmd/migrate/migration/version/1786700001000_demo_menu.go`(sys_api /
sys_menu / sys_menu_api_rule / casbin_rule 四张表如何配齐,用的是幂等 upsert,
可以直接照抄结构)。
## Swagger
-28
View File
@@ -1,28 +0,0 @@
FROM golang:alpine as builder
MAINTAINER lwnmengjing
ENV GOPROXY https://goproxy.cn/
WORKDIR /go/release
#RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
RUN apk update && apk add tzdata
COPY go.mod ./go.mod
RUN go mod tidy
COPY . .
RUN pwd && ls
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -a -installsuffix cgo -o go-admin .
FROM alpine
COPY --from=builder /go/release/go-admin /
COPY --from=builder /go/release/config/settings.yml /config/settings.yml
COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
EXPOSE 8000
CMD ["/go-admin","server","-c", "/config/settings.yml"]
+6 -6
View File
@@ -22,7 +22,7 @@
Element Plus vue3 体验:[https://vue.go-admin.pro](https://vue.go-admin.pro/#/login)
> ⚠️⚠️⚠️ 账号 / 密码: admin / 123456
antd体验:[https://antd.go-admin.pro](https://antd.go-admin.pro/)
antd 体验(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admin.pro/)
> ⚠️⚠️⚠️ 账号 / 密码: admin / 123456
## ✨ 特性
@@ -78,9 +78,9 @@ antd体验:[https://antd.go-admin.pro](https://antd.go-admin.pro/)
### 轻松实现go-admin写出第一个应用 - 文档教程
[步骤一 - 基础内容介绍](https://doc.zhangwj.com/guide/intro/tutorial01.html)
[步骤一 - 基础内容介绍](https://doc.go-admin.dev/guide/intro/tutorial01.html)
[步骤二 - 实际应用 - 编写增删改查](https://doc.zhangwj.com/guide/intro/tutorial02.html)
[步骤二 - 实际应用 - 编写增删改查](https://doc.go-admin.dev/guide/intro/tutorial02.html)
### 手把手教你从入门到放弃 - 视频教程
@@ -329,7 +329,7 @@ pnpm dev
6. [spf13/viper](https://github.com/spf13/viper)
7. [gorm](https://github.com/jinzhu/gorm)
8. [gin-swagger](https://github.com/swaggo/gin-swagger)
9. [jwt-go](https://github.com/dgrijalva/jwt-go)
9. [golang-jwt](https://github.com/golang-jwt/jwt)
10. [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
11. [ruoyi-vue](https://gitee.com/y_project/RuoYi-Vue)
12. [form-generator](https://github.com/JakHuang/form-generator)
@@ -343,10 +343,10 @@ pnpm dev
## 🤝 链接
[Go开发者成长线路图](http://www.golangroadmap.com/)
- [mss-boot-io](https://docs.mss-boot-io.top/)
## 🔑 License
[MIT](https://github.com/go-admin-team/go-admin/blob/master/LICENSE.md)
Copyright (c) 2024 wenjianzhang
Copyright (c) 2026 wenjianzhang
+7 -8
View File
@@ -21,10 +21,10 @@ The front-end and back-end separation authority management system based on Gin +
## 🎬 Online Demo
Element Plus vue3 demo:[https://vue.go-admin.pro](https://vue.go-admin.pro/#/login)
> 账号 / 密码: admin / 123456
> Account / Password: admin / 123456
antd demo:[https://antd.go-admin.pro](https://antd.go-admin.pro/)
> 账号 / 密码: admin / 123456
antd demo (go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admin.pro/)
> Account / Password: admin / 123456
>
## ✨ Feature
@@ -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.zhangwj.com/guide/intro/tutorial01.html)
[Step 1 - basic content introduction](https://doc.go-admin.dev/guide/intro/tutorial01.html)
[Step 2 - Practical application - writing database operations](https://doc.zhangwj.com/guide/intro/tutorial02.html)
[Step 2 - Practical application - writing database operations](https://doc.go-admin.dev/guide/intro/tutorial02.html)
### Teach you from getting started to giving up-video tutorial
@@ -320,7 +320,7 @@ The `go-admin` project has always been developed in the GoLand integrated develo
2. [spf13/viper](https://github.com/spf13/viper)
2. [gorm](https://github.com/jinzhu/gorm)
2. [gin-swagger](https://github.com/swaggo/gin-swagger)
2. [jwt-go](https://github.com/dgrijalva/jwt-go)
2. [golang-jwt](https://github.com/golang-jwt/jwt)
2. [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
2. [ruoyi-vue](https://gitee.com/y_project/RuoYi-Vue)
2. [form-generator](https://github.com/JakHuang/form-generator)
@@ -332,11 +332,10 @@ The `go-admin` project has always been developed in the GoLand integrated develo
<img class="no-margin" src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/pay.png" height="200px" >
## 🤝 Link
- [Go developer growth roadmap](http://www.golangroadmap.com/)
- [mss-boot-io](https://docs.mss-boot-io.top/)
## 🔑 License
[MIT](https://github.com/go-admin-team/go-admin/blob/master/LICENSE.md)
Copyright (c) 2022 wenjianzhang
Copyright (c) 2026 wenjianzhang
+2 -2
View File
@@ -2,8 +2,8 @@ package apis
import (
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/captcha"
"github.com/go-admin-team/go-admin-core/v2/captcha"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
)
type System struct {
+3 -3
View File
@@ -3,9 +3,9 @@ package apis
import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
+2 -2
View File
@@ -3,8 +3,8 @@ package apis
import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
+4 -4
View File
@@ -3,10 +3,10 @@ package apis
import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
+3 -3
View File
@@ -3,9 +3,9 @@ package apis
import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
+1 -1
View File
@@ -3,7 +3,7 @@ package apis
import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
+2 -2
View File
@@ -3,8 +3,8 @@ package apis
import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
"go-admin/app/admin/service/dto"
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
+7 -7
View File
@@ -6,13 +6,13 @@ import (
"net/http"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"go-admin/app/admin/models"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/admin/service"
"go-admin/app/admin/service/dto"
@@ -122,7 +122,7 @@ func (e SysRole) Insert(c *gin.Context) {
if req.Status == "" {
req.Status = "2"
}
cb := sdk.Runtime.GetCasbinKey(c.Request.Host)
cb := sdk.Runtime.GetCasbinByTenant(c.Request.Host)
err = s.Insert(&req, cb)
if err != nil {
e.Logger.Error(err)
@@ -161,7 +161,7 @@ func (e SysRole) Update(c *gin.Context) {
e.Error(500, err, err.Error())
return
}
cb := sdk.Runtime.GetCasbinKey(c.Request.Host)
cb := sdk.Runtime.GetCasbinByTenant(c.Request.Host)
req.SetUpdateBy(user.GetUserId(c))
@@ -203,7 +203,7 @@ func (e SysRole) Delete(c *gin.Context) {
return
}
cb := sdk.Runtime.GetCasbinKey(c.Request.Host)
cb := sdk.Runtime.GetCasbinByTenant(c.Request.Host)
err = s.Remove(&req, cb)
if err != nil {
e.Logger.Error(err)
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/google/uuid"
"go-admin/app/admin/service"
+3 -3
View File
@@ -2,11 +2,11 @@ package models
import (
"errors"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk/config"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
)
type DataPermission struct {
+4 -4
View File
@@ -9,9 +9,9 @@ import (
"strings"
"github.com/bitly/go-simplejson"
"github.com/go-admin-team/go-admin-core/sdk"
"github.com/go-admin-team/go-admin-core/sdk/runtime"
"github.com/go-admin-team/go-admin-core/storage"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
"github.com/go-admin-team/go-admin-core/v2/storage"
"go-admin/common/models"
)
@@ -54,7 +54,7 @@ func SaveSysApi(message storage.Messager) (err error) {
err = fmt.Errorf("json Unmarshal error, %s", err.Error())
return err
}
dbList := sdk.Runtime.GetDb()
dbList := sdk.Runtime.GetAllDb()
for _, d := range dbList {
for _, v := range l.List {
if v.HttpMethod != "HEAD" ||
+4 -4
View File
@@ -5,9 +5,9 @@ import (
"errors"
"time"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
"github.com/go-admin-team/go-admin-core/storage"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/storage"
"go-admin/common/models"
)
@@ -45,7 +45,7 @@ func (e *SysLoginLog) GetId() interface{} {
// SaveLoginLog 从队列中获取登录日志
func SaveLoginLog(message storage.Messager) (err error) {
//准备db
db := sdk.Runtime.GetDbByKey(message.GetPrefix())
db := sdk.Runtime.GetDbByTenant(message.GetPrefix())
if db == nil {
err = errors.New("db not exist")
log.Errorf("host[%s]'s %s", message.GetPrefix(), err.Error())
+4 -4
View File
@@ -5,9 +5,9 @@ import (
"errors"
"time"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
"github.com/go-admin-team/go-admin-core/storage"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/storage"
"go-admin/common/models"
)
@@ -53,7 +53,7 @@ func (e *SysOperaLog) GetId() interface{} {
// SaveOperaLog 从队列中获取操作日志
func SaveOperaLog(message storage.Messager) (err error) {
//准备db
db := sdk.Runtime.GetDbByKey(message.GetPrefix())
db := sdk.Runtime.GetDbByTenant(message.GetPrefix())
if db == nil {
err = errors.New("db not exist")
log.Errorf("host[%s]'s %s", message.GetPrefix(), err.Error())
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"os"
"github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
common "go-admin/common/middleware"
)
+2 -2
View File
@@ -3,8 +3,8 @@ package router
import (
"github.com/gin-gonic/gin"
_ "github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/go-admin-team/go-admin-core/v2/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
)
var (
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/middleware"
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"go-admin/common/middleware"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
)
func init() {
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/middleware"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/middleware"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/middleware"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/middleware"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/middleware"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/middleware"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/middleware"
+3 -3
View File
@@ -4,11 +4,11 @@ import (
"go-admin/app/admin/apis"
"mime"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/go-admin-team/go-admin-core/sdk/pkg/ws"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/ws"
ginSwagger "github.com/swaggo/gin-swagger"
swaggerfiles "github.com/swaggo/files"
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/actions"
"go-admin/common/middleware"
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"errors"
"fmt"
"github.com/go-admin-team/go-admin-core/sdk/runtime"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"go-admin/app/admin/models"
"go-admin/app/admin/service/dto"
"go-admin/common/actions"
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"go-admin/app/admin/service/dto"
cDto "go-admin/common/dto"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
)
type SysConfig struct {
+3 -3
View File
@@ -4,13 +4,13 @@ import (
"errors"
"go-admin/app/admin/models"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"go-admin/app/admin/service/dto"
cDto "go-admin/common/dto"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
)
type SysDept struct {
+1 -1
View File
@@ -3,7 +3,7 @@ package service
import (
"errors"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
"go-admin/app/admin/models"
+7 -2
View File
@@ -4,7 +4,7 @@ import (
"errors"
"fmt"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
"go-admin/app/admin/models"
@@ -59,7 +59,12 @@ func (e *SysDictType) Insert(c *dto.SysDictTypeInsertReq) error {
var data models.SysDictType
c.Generate(&data)
var count int64
e.Orm.Model(&data).Where("dict_type = ?", data.DictType).Count(&count)
// The error was dropped, so a query that failed left count at zero and the
// insert went ahead as though the name were free.
if err = e.Orm.Model(&data).Where("dict_type = ?", data.DictType).Count(&count).Error; err != nil {
e.Log.Errorf("db error: %s", err)
return err
}
if count > 0 {
return fmt.Errorf("当前字典类型[%s]已经存在!", data.DictType)
}
+1 -1
View File
@@ -3,7 +3,7 @@ package service
import (
"errors"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
"go-admin/app/admin/models"
+7 -3
View File
@@ -5,7 +5,7 @@ import (
"sort"
"strings"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"github.com/pkg/errors"
"gorm.io/gorm"
@@ -14,7 +14,7 @@ import (
cDto "go-admin/common/dto"
cModels "go-admin/common/models"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
)
type SysMenu struct {
@@ -395,7 +395,11 @@ func (e *SysMenu) getByRoleName(roleName string) ([]models.SysMenu, error) {
data := make([]models.SysMenu, 0)
if roleName == "admin" {
err = e.Orm.Where(" menu_type in ('M','C') and deleted_at is null").
// The soft-delete condition is GORM's to add: it appends one for the
// model's DeletedAt field on every query. Writing it by hand duplicates
// that and hard-codes what "deleted" looks like — a column that stops
// being nullable turns this clause into one that matches nothing.
err = e.Orm.Where("menu_type in ('M','C')").
Order("sort").
Find(&data).
Error
@@ -0,0 +1,55 @@
package service
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"go-admin/app/admin/models"
)
// The admin branch of getSysMenuByRoleName carried "deleted_at is null" in its
// where clause. GORM adds that condition itself for a model with a DeletedAt
// field, so the clause was a duplicate — and one written in terms of a column
// being null, which stops being true the moment the column stops being
// nullable. This pins the behaviour the clause was there for.
func TestSoftDeletedMenusAreNotReturned(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.AutoMigrate(&models.SysMenu{}); err != nil {
t.Fatalf("migrate: %v", err)
}
live := models.SysMenu{MenuName: "live", MenuType: "M"}
gone := models.SysMenu{MenuName: "gone", MenuType: "M"}
if err := db.Create(&live).Error; err != nil {
t.Fatalf("create: %v", err)
}
if err := db.Create(&gone).Error; err != nil {
t.Fatalf("create: %v", err)
}
if err := db.Delete(&gone).Error; err != nil {
t.Fatalf("delete: %v", err)
}
// Through getByRoleName rather than a copy of its query: a test that
// reissues the statement passes whether or not the production line still
// says what it is supposed to, which is what the first version of this
// test did.
e := &SysMenu{}
e.Orm = db
got, err := e.getByRoleName("admin")
if err != nil {
t.Fatalf("getByRoleName: %v", err)
}
if len(got) != 1 {
t.Fatalf("got %d rows, want 1", len(got))
}
if got[0].MenuName != "live" {
t.Errorf("got %q, want the row that was not deleted", got[0].MenuName)
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"go-admin/app/admin/service/dto"
cDto "go-admin/common/dto"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
)
+1 -1
View File
@@ -3,7 +3,7 @@ package service
import (
"errors"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
"go-admin/app/admin/models"
+3 -3
View File
@@ -3,12 +3,12 @@ package service
import (
"errors"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"gorm.io/gorm/clause"
"github.com/casbin/casbin/v2"
"github.com/casbin/casbin/v3"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
"go-admin/app/admin/models"
+1 -1
View File
@@ -1,7 +1,7 @@
package service
import (
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
)
// SysRoleMenu 即将弃用结构体
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"go-admin/app/admin/models"
"go-admin/app/admin/service/dto"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/service"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
"go-admin/common/actions"
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/demo/models"
"go-admin/app/demo/service/dto"
+4 -4
View File
@@ -3,11 +3,11 @@ package router
import (
"github.com/gin-gonic/gin"
_ "github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
// "github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/go-admin-team/go-admin-core/v2/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
common "go-admin/common/middleware"
"os"
)
+4 -4
View File
@@ -4,8 +4,8 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/jobs/service"
"go-admin/common/dto"
@@ -29,7 +29,7 @@ func (e SysJob) RemoveJobForService(c *gin.Context) {
return
}
s.Cron = sdk.Runtime.GetCrontabKey(c.Request.Host)
s.Cron = sdk.Runtime.GetCrontabByTenant(c.Request.Host)
err = s.RemoveJob(&v)
if err != nil {
e.Logger.Errorf("RemoveJob error, %s", err.Error())
@@ -58,7 +58,7 @@ func (e SysJob) StartJobForService(c *gin.Context) {
s := service.SysJob{}
s.Orm = db
s.Log = log
s.Cron = sdk.Runtime.GetCrontabKey(c.Request.Host)
s.Cron = sdk.Runtime.GetCrontabByTenant(c.Request.Host)
err = s.StartJob(&v)
if err != nil {
log.Errorf("GetCrontabKey error, %s", err.Error())
+6 -6
View File
@@ -2,16 +2,16 @@ package jobs
import (
"fmt"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
models2 "go-admin/app/jobs/models"
"gorm.io/gorm"
"time"
"github.com/robfig/cron/v3"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/cronjob"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/cronjob"
)
var timeFormat = "2006-01-02 15:04:05"
@@ -101,13 +101,13 @@ func Setup(dbs map[string]*gorm.DB) {
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore Starting...")
for k, db := range dbs {
sdk.Runtime.SetCrontab(k, cronjob.NewWithSeconds())
sdk.Runtime.SetCrontabByTenant(k, cronjob.NewWithSeconds())
setup(k, db)
}
}
func setup(key string, db *gorm.DB) {
crontab := sdk.Runtime.GetCrontabKey(key)
crontab := sdk.Runtime.GetCrontabByTenant(key)
sysJob := models2.SysJob{}
jobList := make([]models2.SysJob, 0)
err := sysJob.GetList(db, &jobList)
+3 -3
View File
@@ -1,12 +1,12 @@
package router
import (
//"github.com/go-admin-team/go-admin-core/sdk/pkg"
//"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"os"
"github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
common "go-admin/common/middleware"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
)
var (
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/jobs/apis"
models2 "go-admin/app/jobs/models"
dto2 "go-admin/app/jobs/service/dto"
+1 -1
View File
@@ -2,7 +2,7 @@ package dto
import (
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/jobs/models"
"go-admin/common/dto"
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"errors"
"time"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"github.com/robfig/cron/v3"
"go-admin/app/jobs"
+30 -15
View File
@@ -8,12 +8,13 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/utils"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/utils"
"github.com/google/uuid"
"go-admin/common/file_store"
"go-admin/config"
)
type FileResponse struct {
@@ -95,7 +96,7 @@ func (e File) baseImg(c *gin.Context, fileResponse FileResponse, urlPrefix strin
source, _ := c.GetPostForm("source")
if err := thirdUpload(source, fileName, base64File); err != nil {
e.Error(200, errors.New(""), "上传第三方失败")
e.Error(200, err, "上传第三方失败")
return fileResponse
}
@@ -126,7 +127,7 @@ func (e File) multipleFile(c *gin.Context, urlPrefix string) []FileResponse {
fileType, _ := utils.GetType(multipartFileName)
if err := thirdUpload(source, fileName, multipartFileName); err != nil {
e.Error(500, errors.New(""), "上传第三方失败")
e.Error(500, err, "上传第三方失败")
continue
}
@@ -176,22 +177,36 @@ func (e File) buildFileResponse(filePath, urlPrefix, fileName, fileType string)
}
}
// thirdUpload copies the file that was already stored locally to the object
// store the request asked for. source "1", and anything unrecognised, keeps the
// local copy only.
//
// Both branches used to construct a zero-value ALiYunOSS and call UpLoad on it,
// which panicked - and the qiniu branch constructed the aliyun client, so
// source=3 never reached qiniu even in principle.
func thirdUpload(source string, name string, path string) error {
switch source {
case "2":
return ossUpload("img/"+name, path)
return upload(file_store.AliYunOSS, config.ExtConfig.FileStore.AliYun, "img/"+name, path)
case "3":
return qiniuUpload("img/"+name, path)
return upload(file_store.QiNiuKodo, config.ExtConfig.FileStore.QiNiu, "img/"+name, path)
}
return nil
}
func ossUpload(name string, path string) error {
oss := file_store.ALiYunOSS{}
return oss.UpLoad(name, path)
}
func qiniuUpload(name string, path string) error {
oss := file_store.ALiYunOSS{}
return oss.UpLoad(name, path)
func upload(driver file_store.DriverType, store config.ObjectStore, name, path string) error {
if !store.Configured() {
return fmt.Errorf("file store %s is not configured; set it under extend.fileStore", driver)
}
oxs := file_store.OXS{
Endpoint: store.Endpoint,
AccessKeyID: store.AccessKeyID,
AccessKeySecret: store.AccessKeySecret,
BucketName: store.BucketName,
}
client, err := oxs.Setup(driver)
if err != nil {
return err
}
return client.UpLoad(name, path)
}
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/host"
+3 -3
View File
@@ -2,8 +2,8 @@ package tools
import (
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"go-admin/app/other/models/tools"
)
@@ -41,7 +41,7 @@ func (e Gen) GetDBColumnList(c *gin.Context) {
}
data.TableName = c.Request.FormValue("tableName")
pkg.Assert(data.TableName == "", "table name cannot be empty!", 500)
pkg.Assert(data.TableName != "", "table name cannot be empty!", 500)
result, count, err := data.GetPage(db, pageSize, pageIndex)
if err != nil {
log.Errorf("GetPage error, %s", err.Error())
+78
View File
@@ -0,0 +1,78 @@
package tools
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
"go-admin/common/middleware"
)
const emptyTableNameMsg = "table name cannot be empty!"
// bodyOf covers both the success and the CustomError shape: both carry msg.
type bodyOf struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
// newColumnListEngine wires the handler the way the router does, including the
// middleware that turns pkg.Assert's panic into a response.
func newColumnListEngine(t *testing.T) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
// The query targets MySQL's information_schema; the driver setting only has
// to select that branch, the statement itself is never expected to succeed.
previous := config.DatabaseConfig.Driver
config.DatabaseConfig.Driver = "mysql"
t.Cleanup(func() { config.DatabaseConfig.Driver = previous })
r := gin.New()
r.Use(middleware.CustomError)
r.GET("/db/columns/page", func(c *gin.Context) {
c.Set("db", db)
c.Set(pkg.LoggerKey, logger.NewHelper(logger.DefaultLogger))
Gen{}.GetDBColumnList(c)
})
return r
}
func columnListMsg(t *testing.T, r *gin.Engine, query string) bodyOf {
t.Helper()
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/db/columns/page"+query, nil))
var body bodyOf
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %q: %v", w.Body.String(), err)
}
return body
}
func TestGetDBColumnList_AcceptsATableName(t *testing.T) {
body := columnListMsg(t, newColumnListEngine(t), "?tableName=sys_user")
if body.Msg == emptyTableNameMsg {
t.Fatalf("request carried a table name and was still rejected as empty: %+v", body)
}
}
func TestGetDBColumnList_RejectsAMissingTableName(t *testing.T) {
body := columnListMsg(t, newColumnListEngine(t), "")
if body.Msg != emptyTableNameMsg {
t.Fatalf("missing table name should be rejected, got %+v", body)
}
}
+3 -3
View File
@@ -3,9 +3,9 @@ package tools
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"go-admin/app/other/models/tools"
)
+3 -3
View File
@@ -11,9 +11,9 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"go-admin/app/other/models/tools"
)
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"gorm.io/gorm"
"go-admin/app/other/models/tools"
+38
View File
@@ -0,0 +1,38 @@
package apis
import (
"strings"
"testing"
"go-admin/config"
)
// Both branches used to construct a zero-value ALiYunOSS and call UpLoad on it,
// which panicked; the qiniu branch built the aliyun client, so source=3 could
// not have reached qiniu even with credentials. Unconfigured now reports which
// store is missing.
func TestThirdUploadReportsAnUnconfiguredStore(t *testing.T) {
previous := config.ExtConfig.FileStore
config.ExtConfig.FileStore = config.FileStore{}
t.Cleanup(func() { config.ExtConfig.FileStore = previous })
for source, want := range map[string]string{"2": "AliYunOSS", "3": "QiNiuKodo"} {
err := thirdUpload(source, "x.png", "/tmp/x.png")
if err == nil {
t.Errorf("source=%s: no error from an unconfigured store", source)
continue
}
if !strings.Contains(err.Error(), want) {
t.Errorf("source=%s: error names %q, want it to mention %s", source, err, want)
}
}
}
// source 1 and anything unrecognised keep the local copy and do nothing else.
func TestThirdUploadIgnoresLocalAndUnknownSources(t *testing.T) {
for _, source := range []string{"", "1", "9"} {
if err := thirdUpload(source, "x.png", "/tmp/x.png"); err != nil {
t.Errorf("source=%q returned %v, want nil", source, err)
}
}
}
+16 -25
View File
@@ -3,8 +3,8 @@ package tools
import (
"errors"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
)
@@ -24,47 +24,38 @@ type DBColumns struct {
}
func (e *DBColumns) GetPage(tx *gorm.DB, pageSize int, pageIndex int) ([]DBColumns, int, error) {
pkg.Assert(config.DatabaseConfig.Driver == "mysql", "目前只支持mysql数据库", 500)
var doc []DBColumns
var count int64
table := new(gorm.DB)
if config.DatabaseConfig.Driver == "mysql" {
table = tx.Table("information_schema.`COLUMNS`")
table = table.Where("table_schema= ? ", config.GenConfig.DBName)
if e.TableName != "" {
return nil, 0, errors.New("table name cannot be empty!")
}
table = table.Where("TABLE_NAME = ?", e.TableName)
if e.TableName == "" {
return nil, 0, errors.New("table name cannot be empty!")
}
table := tx.Table("information_schema.`COLUMNS`")
table = table.Where("table_schema= ? ", config.GenConfig.DBName)
table = table.Where("TABLE_NAME = ?", e.TableName)
if err := table.Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&doc).Offset(-1).Limit(-1).Count(&count).Error; err != nil {
return nil, 0, err
}
//table.Count(&count)
return doc, int(count), nil
}
func (e *DBColumns) GetList(tx *gorm.DB) ([]DBColumns, error) {
var doc []DBColumns
table := new(gorm.DB)
pkg.Assert(config.DatabaseConfig.Driver == "mysql", "目前只支持mysql数据库", 500)
var doc []DBColumns
if e.TableName == "" {
return nil, errors.New("table name cannot be empty!")
}
if config.DatabaseConfig.Driver == "mysql" {
table = tx.Table("information_schema.columns")
table = table.Where("table_schema= ? ", config.GenConfig.DBName)
table = table.Where("TABLE_NAME = ?", e.TableName).Order("ORDINAL_POSITION asc")
} else {
pkg.Assert(true, "目前只支持mysql数据库", 500)
}
table := tx.Table("information_schema.columns")
table = table.Where("table_schema= ? ", config.GenConfig.DBName)
table = table.Where("TABLE_NAME = ?", e.TableName).Order("ORDINAL_POSITION asc")
if err := table.Find(&doc).Error; err != nil {
return doc, err
}
return doc, nil
}
}
+35 -29
View File
@@ -2,11 +2,11 @@ package tools
import (
"errors"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
config2 "github.com/go-admin-team/go-admin-core/sdk/config"
config2 "github.com/go-admin-team/go-admin-core/v2/sdk/config"
)
type DBTables struct {
@@ -20,43 +20,49 @@ type DBTables struct {
}
func (e *DBTables) GetPage(tx *gorm.DB, pageSize int, pageIndex int) ([]DBTables, int, error) {
pkg.Assert(config2.DatabaseConfig.Driver == "mysql", "目前只支持mysql数据库", 500)
var doc []DBTables
table := new(gorm.DB)
var count int64
if config2.DatabaseConfig.Driver == "mysql" {
table = tx.Table("information_schema.tables")
table = table.Where("TABLE_NAME not in (select table_name from `" + config2.GenConfig.DBName + "`.sys_tables) ")
table = table.Where("table_schema= ? ", config2.GenConfig.DBName)
if e.TableName != "" {
table = table.Where("TABLE_NAME = ?", e.TableName)
}
if err := table.Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&doc).Offset(-1).Limit(-1).Count(&count).Error; err != nil {
return nil, 0, err
}
} else {
pkg.Assert(true, "目前只支持mysql数据库", 500)
// Tables already registered with the generator are not candidates. Read them
// through the model on this connection: the subquery used to spell the
// schema out by hand, so it only resolved when sys_tables happened to live
// in the schema being generated from, and it counted soft-deleted rows.
var generated []string
if err := tx.Model(&SysTables{}).Pluck("table_name", &generated).Error; err != nil {
return nil, 0, err
}
//table.Count(&count)
table := tx.Table("information_schema.tables")
table = table.Where("table_schema= ? ", config2.GenConfig.DBName)
if len(generated) > 0 {
// NOT IN (NULL) is unknown for every row, so an empty list has to skip
// the clause instead of rendering it.
table = table.Where("TABLE_NAME not in (?)", generated)
}
if e.TableName != "" {
table = table.Where("TABLE_NAME = ?", e.TableName)
}
if err := table.Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&doc).Offset(-1).Limit(-1).Count(&count).Error; err != nil {
return nil, 0, err
}
return doc, int(count), nil
}
func (e *DBTables) Get(tx *gorm.DB) (DBTables, error) {
pkg.Assert(config2.DatabaseConfig.Driver == "mysql", "目前只支持mysql数据库", 500)
var doc DBTables
if config2.DatabaseConfig.Driver == "mysql" {
table := tx.Table("information_schema.tables")
table = table.Where("table_schema= ? ", config2.GenConfig.DBName)
if e.TableName == "" {
return doc, errors.New("table name cannot be empty!")
}
table = table.Where("TABLE_NAME = ?", e.TableName)
if err := table.First(&doc).Error; err != nil {
return doc, err
}
} else {
pkg.Assert(true, "目前只支持mysql数据库", 500)
if e.TableName == "" {
return doc, errors.New("table name cannot be empty!")
}
table := tx.Table("information_schema.tables")
table = table.Where("table_schema= ? ", config2.GenConfig.DBName)
table = table.Where("TABLE_NAME = ?", e.TableName)
if err := table.First(&doc).Error; err != nil {
return doc, err
}
return doc, nil
}
+116
View File
@@ -0,0 +1,116 @@
package tools
import (
"testing"
"github.com/glebarez/sqlite"
config2 "github.com/go-admin-team/go-admin-core/v2/sdk/config"
"gorm.io/gorm"
)
const generatorSchema = "go_admin_test"
// newCandidateDB stands in for MySQL: sqlite is given an attached database
// called information_schema so the same query runs, and sys_tables lives on the
// connection the way it does in production.
func newCandidateDB(t *testing.T, tables ...string) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.Exec(`ATTACH DATABASE ':memory:' AS information_schema`).Error; err != nil {
t.Fatalf("attach information_schema: %v", err)
}
if err := db.Exec("CREATE TABLE information_schema.`tables` (" +
"TABLE_NAME text, TABLE_SCHEMA text, `ENGINE` text, TABLE_ROWS text," +
"TABLE_COLLATION text, CREATE_TIME text, UPDATE_TIME text, TABLE_COMMENT text)").Error; err != nil {
t.Fatalf("create information_schema.tables: %v", err)
}
for _, name := range tables {
if err := db.Exec("INSERT INTO information_schema.`tables` (TABLE_NAME, TABLE_SCHEMA) VALUES (?, ?)",
name, generatorSchema).Error; err != nil {
t.Fatalf("insert %s: %v", name, err)
}
}
if err := db.AutoMigrate(new(SysTables)); err != nil {
t.Fatalf("migrate sys_tables: %v", err)
}
previousDriver := config2.DatabaseConfig.Driver
previousName := config2.GenConfig.DBName
config2.DatabaseConfig.Driver = "mysql"
config2.GenConfig.DBName = generatorSchema
t.Cleanup(func() {
config2.DatabaseConfig.Driver = previousDriver
config2.GenConfig.DBName = previousName
})
return db
}
func candidateNames(t *testing.T, db *gorm.DB) []string {
t.Helper()
found, _, err := new(DBTables).GetPage(db, 100, 1)
if err != nil {
t.Fatalf("GetPage: %v", err)
}
names := make([]string, 0, len(found))
for _, row := range found {
names = append(names, row.TableName)
}
return names
}
func contains(names []string, want string) bool {
for _, name := range names {
if name == want {
return true
}
}
return false
}
// An empty sys_tables must not filter everything out - that is what a bare
// NOT IN (empty set) does, and a fresh install is exactly the case where the
// list matters most.
func TestGetPageListsEveryTableWhenNoneAreRegistered(t *testing.T) {
db := newCandidateDB(t, "sys_user", "sys_role")
names := candidateNames(t, db)
if len(names) != 2 {
t.Fatalf("want both tables offered on a fresh install, got %v", names)
}
}
func TestGetPageSkipsAlreadyRegisteredTables(t *testing.T) {
db := newCandidateDB(t, "sys_user", "sys_role")
if err := db.Create(&SysTables{TBName: "sys_user"}).Error; err != nil {
t.Fatalf("register sys_user: %v", err)
}
names := candidateNames(t, db)
if contains(names, "sys_user") {
t.Errorf("sys_user is already registered and was offered again: %v", names)
}
if !contains(names, "sys_role") {
t.Errorf("sys_role is not registered and was withheld: %v", names)
}
}
// Deleting the generator entry has to hand the table back, which the raw
// subquery never did: it read the row whether or not it was soft-deleted.
func TestGetPageOffersTablesWhoseEntryWasDeleted(t *testing.T) {
db := newCandidateDB(t, "sys_user")
registered := SysTables{TBName: "sys_user"}
if err := db.Create(&registered).Error; err != nil {
t.Fatalf("register sys_user: %v", err)
}
if err := db.Delete(&registered).Error; err != nil {
t.Fatalf("delete the entry: %v", err)
}
if names := candidateNames(t, db); !contains(names, "sys_user") {
t.Errorf("the generator entry is deleted, sys_user should be a candidate again: %v", names)
}
}
@@ -0,0 +1,52 @@
package tools
import (
"strings"
"testing"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"gorm.io/gorm"
)
// The generator reads MySQL's information_schema and nothing else. Every entry
// point says so, but the guard used to be written pkg.Assert(true, ...), which
// never fires: pkg.Assert panics when its condition is false. A non-mysql
// deployment therefore fell through to a query built on a zero-value *gorm.DB.
//
// tx is nil on purpose - the assertion has to come before anything touches it.
func TestCodegenModelsRefuseNonMySQLDrivers(t *testing.T) {
previous := config.DatabaseConfig.Driver
config.DatabaseConfig.Driver = "postgres"
t.Cleanup(func() { config.DatabaseConfig.Driver = previous })
cases := map[string]func(*gorm.DB){
"DBTables.GetPage": func(tx *gorm.DB) {
_, _, _ = new(DBTables).GetPage(tx, 10, 1)
},
"DBTables.Get": func(tx *gorm.DB) {
_, _ = (&DBTables{TableName: "sys_user"}).Get(tx)
},
"DBColumns.GetPage": func(tx *gorm.DB) {
_, _, _ = (&DBColumns{TableName: "sys_user"}).GetPage(tx, 10, 1)
},
"DBColumns.GetList": func(tx *gorm.DB) {
_, _ = (&DBColumns{TableName: "sys_user"}).GetList(tx)
},
}
for name, call := range cases {
t.Run(name, func(t *testing.T) {
defer func() {
raised := recover()
if raised == nil {
t.Fatal("driver is not mysql and the call went through anyway")
}
msg, ok := raised.(string)
if !ok || !strings.Contains(msg, "目前只支持mysql数据库") {
t.Fatalf("want the mysql-only assertion, got %v", raised)
}
}()
call(nil)
})
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/other/apis"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/app/other/apis/tools"
)
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"os"
"github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
common "go-admin/common/middleware"
)
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/tools/transfer"
"github.com/go-admin-team/go-admin-core/v2/tools/transfer"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
)
var (
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/other/apis"
"go-admin/common/middleware"
)
+9 -9
View File
@@ -9,12 +9,12 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/config/source/file"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/config/source/file"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"github.com/pkg/errors"
"github.com/spf13/cobra"
@@ -66,7 +66,7 @@ func setup() {
storage.Setup,
)
//注册监听函数
queue := sdk.Runtime.GetMemoryQueue("")
queue := sdk.Runtime.GetQueuePrefix("")
queue.Register(global.LoginLog, models.SaveLoginLog)
queue.Register(global.OperateLog, models.SaveOperaLog)
queue.Register(global.ApiCheck, models.SaveSysApi)
@@ -95,13 +95,13 @@ func run() error {
go func() {
jobs.InitJob()
jobs.Setup(sdk.Runtime.GetDb())
jobs.Setup(sdk.Runtime.GetAllDb())
}()
if apiCheck {
var routers = sdk.Runtime.GetRouter()
q := sdk.Runtime.GetMemoryQueue("")
q := sdk.Runtime.GetQueuePrefix("")
mp := make(map[string]interface{})
mp["List"] = routers
message, err := sdk.Runtime.GetStreamMessage("", global.ApiCheck, mp)
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"bytes"
"errors"
"fmt"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/utils"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/utils"
"github.com/spf13/cobra"
"text/template"
)
+1 -1
View File
@@ -3,7 +3,7 @@ package cmd
import (
"errors"
"fmt"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"go-admin/cmd/app"
"go-admin/common/global"
"os"
+2 -2
View File
@@ -4,10 +4,10 @@ import (
"encoding/json"
"fmt"
"github.com/go-admin-team/go-admin-core/config/source/file"
"github.com/go-admin-team/go-admin-core/v2/config/source/file"
"github.com/spf13/cobra"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
)
var (
+12 -4
View File
@@ -43,20 +43,28 @@ func (e *Migration) Migrate() {
}
var err error
var count int64
applied := 0
for _, v := range versions {
err = e.db.Table("sys_migration").Where("version = ?", v).Count(&count).Error
if err != nil {
log.Fatalln(err)
}
if count > 0 {
log.Println(count)
// Already applied. This used to print the bare count, so a mature
// database wrote a screen of "1" at every start.
count = 0
continue
}
err = (e.version[v])(e.db.Debug(), v)
if err != nil {
log.Fatalln(err)
log.Printf("applying migration %s", v)
if err = (e.version[v])(e.db.Debug(), v); err != nil {
log.Fatalf("migration %s failed: %v", v, err)
}
applied++
}
if applied == 0 {
log.Println("no migrations to apply")
} else {
log.Printf("applied %d migration(s)", applied)
}
}
@@ -1,7 +1,7 @@
package version
import (
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"runtime"
"go-admin/cmd/migrate/migration"
@@ -56,7 +56,10 @@ func _1786700001000DemoMenu(db *gorm.DB, version string) error {
dir := models.SysMenu{
MenuId: demoMenuId, MenuName: "Demo", Title: "示例模块", Icon: "example",
Path: "/demo", Paths: "/0/9000", MenuType: "M", ParentId: 0,
Component: "Layout", Sort: 900, Visible: "0", IsFrame: "1",
// sort is `gorm:"size:4"`, which MySQL builds as a tinyint - anything
// over 127 is rejected outright. The seeded menus run to 100, so 110
// still puts this last.
Component: "Layout", Sort: 110, Visible: "0", IsFrame: "1",
}
if err := upsert(tx, &models.SysMenu{}, "menu_id = ?", dir.MenuId, &dir); err != nil {
return err
@@ -0,0 +1,287 @@
package version
import (
"fmt"
"runtime"
"time"
"gorm.io/gorm"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
// Convert deleted_at from a nullable timestamp to a non-null millisecond
// marker, then put a unique index on the three natural keys.
//
// Those keys had no unique index at all. Uniqueness was a SELECT COUNT
// followed by an INSERT, which two concurrent requests both pass.
//
// The index cannot be on the key alone: a soft-deleted row keeps occupying the
// name, so a deleted user's username could never be used again. It has to
// include the delete marker — and the marker has to be non-null, because two
// live rows are (name, NULL) and (name, NULL), and NULL is not equal to NULL.
// An index over a nullable marker permits both rows. It looks like a
// constraint and enforces nothing.
//
// DDL does not roll back on MySQL, so this is written to be re-runnable rather
// than transactional: every step asks whether it has already been taken.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700003000SoftDeleteMarker)
}
// Every table whose model embeds common.ModelTime.
var softDeleteTables = []string{
"sys_api", "sys_config", "sys_dept", "sys_dict_data", "sys_dict_type",
"sys_job", "sys_menu", "sys_post", "sys_role", "sys_user", "demo_product",
}
// The keys that gain a constraint, and the column that makes it possible.
var naturalKeys = []struct {
table, column, index string
}{
{"sys_user", "username", "uk_sys_user_username"},
{"sys_role", "role_key", "uk_sys_role_role_key"},
{"sys_dict_type", "dict_type", "uk_sys_dict_type_dict_type"},
}
const tempColumn = "deleted_at_ms"
func _1786700003000SoftDeleteMarker(db *gorm.DB, version string) error {
// Refused before anything is altered: creating the index on a table that
// already holds duplicates fails halfway through, and the operator is left
// guessing which rows to reconcile.
if err := refuseOnDuplicates(db); err != nil {
return err
}
for _, table := range softDeleteTables {
if err := convertDeletedAt(db, table); err != nil {
return fmt.Errorf("%s: %w", table, err)
}
}
for _, k := range naturalKeys {
if err := createUniqueIndex(db, k.table, k.column, k.index); err != nil {
return fmt.Errorf("%s.%s: %w", k.table, k.column, err)
}
}
return db.Create(&common.Migration{Version: version}).Error
}
// refuseOnDuplicates reports the values that would make the index impossible,
// rather than the index failing to build and saying only that it did.
func refuseOnDuplicates(db *gorm.DB) error {
for _, k := range naturalKeys {
if !db.Migrator().HasTable(k.table) {
continue
}
var dupes []string
q := fmt.Sprintf(
"SELECT %s FROM %s WHERE deleted_at IS NULL GROUP BY %s HAVING COUNT(*) > 1",
k.column, k.table, k.column,
)
if !db.Migrator().HasColumn(k.table, "deleted_at") {
// Already converted; live rows carry zero rather than null.
q = fmt.Sprintf(
"SELECT %s FROM %s WHERE deleted_at = 0 GROUP BY %s HAVING COUNT(*) > 1",
k.column, k.table, k.column,
)
}
if err := db.Raw(q).Scan(&dupes).Error; err != nil {
return fmt.Errorf("checking %s.%s for duplicates: %w", k.table, k.column, err)
}
if len(dupes) > 0 {
return fmt.Errorf(
"%s.%s already holds duplicates %v; reconcile them before this migration can add its unique index",
k.table, k.column, dupes,
)
}
}
return nil
}
func convertDeletedAt(db *gorm.DB, table string) error {
m := db.Migrator()
if !m.HasTable(table) {
return nil
}
converted, err := isConverted(db, table)
if err != nil {
return err
}
if converted {
return nil
}
if !m.HasColumn(table, tempColumn) {
if err := db.Exec(addBigIntColumn(db, table, tempColumn)).Error; err != nil {
return fmt.Errorf("adding %s: %w", tempColumn, err)
}
}
// Converted in Go rather than in SQL: turning a timestamp into epoch
// milliseconds is spelled differently by every dialect this supports, and
// the row counts here do not justify four versions of it.
if err := copyTimestamps(db, table); err != nil {
return err
}
// SQLite refuses to drop a column an index still refers to, where MySQL and
// PostgreSQL drop the dependent indexes along with it. Drop them first, on
// every dialect: the column is about to be replaced by one of the same name,
// and gorm recreates the index from the model's tag.
if err := dropIndexesOn(db, table, "deleted_at"); err != nil {
return err
}
if err := db.Exec(dropColumn(db, table, "deleted_at")).Error; err != nil {
return fmt.Errorf("dropping deleted_at: %w", err)
}
if err := db.Exec(renameColumn(db, table, tempColumn, "deleted_at")).Error; err != nil {
return fmt.Errorf("renaming %s: %w", tempColumn, err)
}
return nil
}
// isConverted reports whether deleted_at already holds the marker. A table
// mid-conversion still has both columns, and is finished rather than skipped.
func isConverted(db *gorm.DB, table string) (bool, error) {
types, err := db.Migrator().ColumnTypes(table)
if err != nil {
return false, err
}
for _, c := range types {
if c.Name() != "deleted_at" {
continue
}
if nullable, ok := c.Nullable(); ok && !nullable {
return !db.Migrator().HasColumn(table, tempColumn), nil
}
return false, nil
}
// No such column: nothing to convert.
return true, nil
}
func copyTimestamps(db *gorm.DB, table string) error {
type row struct {
Id int64
DeletedAt *time.Time
}
// The tables walked here do not agree on what the key is called: sys_dept
// keys on dept_id, sys_user on user_id, and only some on id.
key, err := primaryKeyOf(db, table)
if err != nil {
return err
}
var rows []row
q := fmt.Sprintf("SELECT %s AS id, deleted_at FROM %s WHERE deleted_at IS NOT NULL", key, table)
if err := db.Raw(q).Scan(&rows).Error; err != nil {
return fmt.Errorf("reading deleted rows: %w", err)
}
for _, r := range rows {
if r.DeletedAt == nil {
continue
}
u := fmt.Sprintf("UPDATE %s SET %s = ? WHERE %s = ?", table, tempColumn, key)
if err := db.Exec(u, r.DeletedAt.UnixMilli(), r.Id).Error; err != nil {
return fmt.Errorf("marking row %d deleted: %w", r.Id, err)
}
}
return nil
}
// primaryKeyOf asks the database which column is the primary key, rather than
// assuming the name.
func primaryKeyOf(db *gorm.DB, table string) (string, error) {
columns, err := db.Migrator().ColumnTypes(table)
if err != nil {
return "", fmt.Errorf("reading columns of %s: %w", table, err)
}
for _, c := range columns {
if isKey, ok := c.PrimaryKey(); ok && isKey {
return c.Name(), nil
}
}
return "", fmt.Errorf("no primary key on %s", table)
}
func createUniqueIndex(db *gorm.DB, table, column, name string) error {
if db.Migrator().HasIndex(table, name) {
return nil
}
return db.Exec(fmt.Sprintf(
"CREATE UNIQUE INDEX %s ON %s (%s, deleted_at)", name, table, column,
)).Error
}
// dropIndexesOn removes every index on table that mentions column, so the
// column can be dropped. Named indexes are asked of the migrator rather than
// guessed, because the names differ between a schema gorm created and one a
// hand-written migration did.
func dropIndexesOn(db *gorm.DB, table, column string) error {
names, err := indexNamesFor(db, table, column)
if err != nil {
return fmt.Errorf("listing indexes on %s.%s: %w", table, column, err)
}
m := db.Migrator()
for _, name := range names {
if !m.HasIndex(table, name) {
continue
}
if err := m.DropIndex(table, name); err != nil {
return fmt.Errorf("dropping index %s: %w", name, err)
}
}
return nil
}
// indexNamesFor asks the database which indexes cover column.
func indexNamesFor(db *gorm.DB, table, column string) ([]string, error) {
indexes, err := db.Migrator().GetIndexes(table)
if err != nil {
return nil, err
}
var names []string
for _, idx := range indexes {
for _, c := range idx.Columns() {
if c == column {
names = append(names, idx.Name())
break
}
}
}
return names, nil
}
// The three statements every dialect spells differently.
func addBigIntColumn(db *gorm.DB, table, column string) string {
if db.Dialector.Name() == "sqlserver" {
return fmt.Sprintf("ALTER TABLE %s ADD %s BIGINT NOT NULL DEFAULT 0", table, column)
}
return fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s BIGINT NOT NULL DEFAULT 0", table, column)
}
func dropColumn(db *gorm.DB, table, column string) string {
return fmt.Sprintf("ALTER TABLE %s DROP COLUMN %s", table, column)
}
func renameColumn(db *gorm.DB, table, from, to string) string {
switch db.Dialector.Name() {
case "sqlserver":
return fmt.Sprintf("EXEC sp_rename '%s.%s', '%s', 'COLUMN'", table, from, to)
case "mysql":
return fmt.Sprintf("ALTER TABLE %s CHANGE %s %s BIGINT NOT NULL DEFAULT 0", table, from, to)
default:
return fmt.Sprintf("ALTER TABLE %s RENAME COLUMN %s TO %s", table, from, to)
}
}
@@ -0,0 +1,185 @@
package version
import (
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
// oldUser is the shape of the table before this migration: deleted_at is a
// nullable timestamp, and nothing constrains the username.
//
// It mirrors the real sys_user in the two respects that matter here, both of
// which the migration got wrong against a live database while an id-keyed,
// unindexed table let it pass: the key is user_id rather than id, and
// deleted_at carries an index, which SQLite will not let a column be dropped
// out from under.
type oldUser struct {
UserId int64 `gorm:"column:user_id;primaryKey;autoIncrement"`
Username string
DeletedAt *time.Time `gorm:"index"`
}
func (oldUser) TableName() string { return "sys_user" }
func openWithOldSchema(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.AutoMigrate(&oldUser{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func TestConvertsDeletedAtAndKeepsWhoWasDeleted(t *testing.T) {
db := openWithOldSchema(t)
gone := time.Now().Add(-time.Hour)
rows := []oldUser{
{Username: "alice"},
{Username: "bob", DeletedAt: &gone},
}
for i := range rows {
if err := db.Create(&rows[i]).Error; err != nil {
t.Fatalf("seed: %v", err)
}
}
if err := convertDeletedAt(db, "sys_user"); err != nil {
t.Fatalf("convert: %v", err)
}
var marks []struct {
Username string
DeletedAt int64
}
if err := db.Raw("SELECT username, deleted_at FROM sys_user ORDER BY user_id").Scan(&marks).Error; err != nil {
t.Fatalf("read back: %v", err)
}
if len(marks) != 2 {
t.Fatalf("got %d rows, want 2", len(marks))
}
if marks[0].DeletedAt != 0 {
t.Errorf("a live row carries %d, want 0", marks[0].DeletedAt)
}
if want := gone.UnixMilli(); marks[1].DeletedAt != want {
t.Errorf("the deleted row carries %d, want %d: the timestamp was lost", marks[1].DeletedAt, want)
}
}
// Running it twice must be safe: DDL does not roll back on MySQL, so an
// operator whose first attempt failed halfway has nothing to do but run it
// again.
func TestConversionIsRepeatable(t *testing.T) {
db := openWithOldSchema(t)
if err := db.Create(&oldUser{Username: "alice"}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
for i := 0; i < 3; i++ {
if err := convertDeletedAt(db, "sys_user"); err != nil {
t.Fatalf("convert %d: %v", i, err)
}
}
}
// The point of the whole exercise: the constraint has to reject a second live
// row and accept one whose predecessor was deleted.
func TestUniqueIndexBindsLiveRowsOnly(t *testing.T) {
db := openWithOldSchema(t)
if err := db.Create(&oldUser{Username: "alice"}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
if err := convertDeletedAt(db, "sys_user"); err != nil {
t.Fatalf("convert: %v", err)
}
if err := createUniqueIndex(db, "sys_user", "username", "uk_sys_user_username"); err != nil {
t.Fatalf("index: %v", err)
}
t.Run("a second live row is rejected", func(t *testing.T) {
err := db.Exec("INSERT INTO sys_user (username, deleted_at) VALUES ('alice', 0)").Error
if err == nil {
t.Fatal("a duplicate username was accepted")
}
})
t.Run("the name is free once the row is deleted", func(t *testing.T) {
if err := db.Exec("UPDATE sys_user SET deleted_at = ? WHERE username = 'alice'", time.Now().UnixMilli()).Error; err != nil {
t.Fatalf("delete: %v", err)
}
if err := db.Exec("INSERT INTO sys_user (username, deleted_at) VALUES ('alice', 0)").Error; err != nil {
t.Errorf("the name stayed taken after its row was deleted: %v", err)
}
})
t.Run("the same name can be deleted more than once", func(t *testing.T) {
if err := db.Exec("UPDATE sys_user SET deleted_at = ? WHERE deleted_at = 0", time.Now().UnixMilli()+1).Error; err != nil {
t.Errorf("a second deletion collided with the first: %v", err)
}
})
}
// A table that already holds duplicates cannot take the index, and the
// migration says which values rather than letting the index fail.
func TestRefusesWhenDuplicatesAlreadyExist(t *testing.T) {
db := openWithOldSchema(t)
for i := 0; i < 2; i++ {
if err := db.Create(&oldUser{Username: "alice"}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
}
err := refuseOnDuplicates(db)
if err == nil {
t.Fatal("the migration accepted a table that already holds duplicates")
}
if !contains(err.Error(), "alice") {
t.Errorf("the error does not name the value: %v", err)
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
// Why the column has to change at all, demonstrated rather than argued: the
// same index over the column as it was accepts both live rows, because each
// carries NULL and NULL is not equal to NULL. The constraint exists and binds
// nothing — which is worse than its absence, because it reads as protection.
func TestTheIndexOverANullableMarkerEnforcesNothing(t *testing.T) {
db := openWithOldSchema(t)
if err := db.Exec("CREATE UNIQUE INDEX uk_nullable ON sys_user (username, deleted_at)").Error; err != nil {
t.Fatalf("index: %v", err)
}
for i := 0; i < 2; i++ {
if err := db.Create(&oldUser{Username: "alice"}).Error; err != nil {
t.Fatalf("the nullable marker rejected a duplicate after all, which would make this migration unnecessary: %v", err)
}
}
var live int64
if err := db.Raw("SELECT COUNT(*) FROM sys_user WHERE deleted_at IS NULL").Scan(&live).Error; err != nil {
t.Fatalf("count: %v", err)
}
if live != 2 {
t.Fatalf("got %d live rows, want 2", live)
}
}
@@ -0,0 +1,40 @@
package version
import (
"fmt"
"runtime"
"gorm.io/gorm"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
// Convert the two generator tables to the same delete marker every other table
// got in 1786700003000.
//
// They were left out of that list, and the mismatch is invisible until it is
// not: the tables are built from cmd/migrate/migration/models, whose ModelTime
// still carries a nullable gorm.DeletedAt, while the runtime models in
// app/other/models/tools embed common.ModelTime, which is the millisecond
// marker. GORM therefore queries them with deleted_at = 0 against a datetime
// column holding NULL, and every row is invisible - so the code generator
// listed no tables at all.
//
// tb_demo has the same shape and is deliberately not here: nothing reads it at
// runtime, so there is no mismatch to fix.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700004000GeneratorTablesMarker)
}
var generatorTables = []string{"sys_columns", "sys_tables"}
func _1786700004000GeneratorTablesMarker(db *gorm.DB, version string) error {
for _, table := range generatorTables {
if err := convertDeletedAt(db, table); err != nil {
return fmt.Errorf("%s: %w", table, err)
}
}
return db.Create(&common.Migration{Version: version}).Error
}
@@ -0,0 +1,82 @@
package version
import (
"go/ast"
"go/parser"
"go/token"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// Fields tagged gorm:"size:4" become a tinyint on MySQL, which holds -128..127.
// sqlite ignores the width, so a value that overflows passes every local test
// and fails on a real install - and because the migration is not transactional,
// it fails partway, leaving later migrations unapplied.
//
// That is what happened: a seeded menu with Sort: 900 stopped the run at
// 1786700001000, so the soft-delete conversion never ran, deleted_at stayed
// NULL, and nobody could log in.
var narrowColumns = map[string]struct{ min, max int64 }{
"Sort": {math.MinInt8, math.MaxInt8},
"Status": {math.MinInt8, math.MaxInt8},
}
func TestSeededValuesFitTheirColumns(t *testing.T) {
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
files, err := filepath.Glob(filepath.Join(dir, "*.go"))
if err != nil {
t.Fatal(err)
}
checked := 0
for _, path := range files {
if strings.HasSuffix(path, "_test.go") {
continue
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
ast.Inspect(f, func(n ast.Node) bool {
kv, ok := n.(*ast.KeyValueExpr)
if !ok {
return true
}
key, ok := kv.Key.(*ast.Ident)
if !ok {
return true
}
limits, watched := narrowColumns[key.Name]
if !watched {
return true
}
lit, ok := kv.Value.(*ast.BasicLit)
if !ok || lit.Kind != token.INT {
return true
}
v, err := strconv.ParseInt(lit.Value, 10, 64)
if err != nil {
return true
}
checked++
if v < limits.min || v > limits.max {
t.Errorf("%s:%d: %s: %d does not fit a tinyint (%d..%d);\n"+
" MySQL rejects it with Error 1264 and the migration stops there",
filepath.Base(path), fset.Position(lit.Pos()).Line, key.Name, v, limits.min, limits.max)
}
return true
})
}
if checked == 0 {
t.Fatal("no seeded values were examined; the scan is broken, not the code")
}
}
@@ -0,0 +1,173 @@
package version
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// The repository carries two ModelTime types. The one in
// cmd/migrate/migration/models still has a nullable gorm.DeletedAt and is what
// builds the tables; the one in common/models is the millisecond marker and is
// what queries them. A table whose runtime model embeds the second but which no
// migration converts is queried with deleted_at = 0 against a datetime column,
// and every row is invisible - silently, and only in production.
//
// sys_columns and sys_tables were in exactly that state: the code generator
// listed no tables at all.
func TestEveryRuntimeSoftDeleteTableIsConverted(t *testing.T) {
converted := map[string]bool{}
for _, name := range append(append([]string{}, softDeleteTables...), generatorTables...) {
converted[name] = true
}
// tb_demo is built with the marker-less model and has no runtime model at
// all, so nothing ever queries it with deleted_at = 0.
const noRuntimeModel = "tb_demo"
for table, file := range runtimeSoftDeleteTables(t) {
if table == noRuntimeModel {
continue
}
if !converted[table] {
t.Errorf("%s (%s) embeds common.ModelTime but no migration converts its deleted_at;\n"+
" it will be queried with deleted_at = 0 against a nullable datetime and return nothing",
table, file)
}
}
}
// runtimeSoftDeleteTables maps table name to the file declaring it, for every
// model under app/ that embeds the marker-carrying ModelTime.
func runtimeSoftDeleteTables(t *testing.T) map[string]string {
t.Helper()
root := repoRoot(t)
found := map[string]string{}
err := filepath.Walk(filepath.Join(root, "app"), func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return err
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil // not this test's business
}
// Only files importing the runtime models package can embed its ModelTime.
if !importsRuntimeModels(f) {
return nil
}
for name, table := range tablesWithModelTime(f) {
_ = name
found[table] = strings.TrimPrefix(path, root+"/")
}
return nil
})
if err != nil {
t.Fatalf("walk app/: %v", err)
}
if len(found) == 0 {
t.Fatal("found no runtime models at all; the scan is broken, not the code")
}
return found
}
func importsRuntimeModels(f *ast.File) bool {
for _, imp := range f.Imports {
p, err := strconv.Unquote(imp.Path.Value)
if err == nil && p == "go-admin/common/models" {
return true
}
}
return false
}
// tablesWithModelTime returns struct name -> table name for structs that embed
// ModelTime and declare a TableName.
func tablesWithModelTime(f *ast.File) map[string]string {
embeds := map[string]bool{}
ast.Inspect(f, func(n ast.Node) bool {
ts, ok := n.(*ast.TypeSpec)
if !ok {
return true
}
st, ok := ts.Type.(*ast.StructType)
if !ok {
return true
}
for _, field := range st.Fields.List {
if len(field.Names) != 0 {
continue // named field, not an embed
}
if sel, ok := field.Type.(*ast.SelectorExpr); ok && sel.Sel.Name == "ModelTime" {
embeds[ts.Name.Name] = true
}
}
return true
})
out := map[string]string{}
for name := range embeds {
if table := tableNameOf(f, name); table != "" {
out[name] = table
}
}
return out
}
// tableNameOf finds the string returned by func (T) TableName() string.
func tableNameOf(f *ast.File, structName string) string {
var table string
ast.Inspect(f, func(n ast.Node) bool {
fn, ok := n.(*ast.FuncDecl)
if !ok || fn.Name.Name != "TableName" || fn.Recv == nil || len(fn.Recv.List) != 1 {
return true
}
if receiverName(fn.Recv.List[0].Type) != structName {
return true
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
lit, ok := n.(*ast.BasicLit)
if ok && lit.Kind == token.STRING {
if s, err := strconv.Unquote(lit.Value); err == nil && table == "" {
table = s
}
}
return true
})
return true
})
return table
}
func receiverName(expr ast.Expr) string {
switch t := expr.(type) {
case *ast.Ident:
return t.Name
case *ast.StarExpr:
return receiverName(t.X)
}
return ""
}
func repoRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for i := 0; i < 8; i++ {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
dir = filepath.Dir(dir)
}
t.Fatal("go.mod not found above the test directory")
return ""
}
+7 -7
View File
@@ -3,16 +3,16 @@ package migrate
import (
"bytes"
"fmt"
"github.com/go-admin-team/go-admin-core/sdk"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"strconv"
"text/template"
"time"
"github.com/go-admin-team/go-admin-core/config/source/file"
"github.com/go-admin-team/go-admin-core/v2/config/source/file"
"github.com/spf13/cobra"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"go-admin/cmd/migrate/migration"
_ "go-admin/cmd/migrate/migration/version"
_ "go-admin/cmd/migrate/migration/version-local"
@@ -62,10 +62,10 @@ func migrateModel() error {
if host == "" {
host = "*"
}
db := sdk.Runtime.GetDbByKey(host)
db := sdk.Runtime.GetDbByTenant(host)
if db == nil {
if len(sdk.Runtime.GetDb()) == 1 && host == "*" {
for k, v := range sdk.Runtime.GetDb() {
if len(sdk.Runtime.GetAllDb()) == 1 && host == "*" {
for k, v := range sdk.Runtime.GetAllDb() {
db = v
host = k
break
+4 -4
View File
@@ -4,10 +4,10 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
"github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"go-admin/common/dto"
"go-admin/common/models"
+4 -4
View File
@@ -4,10 +4,10 @@ import (
"net/http"
"github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"go-admin/common/dto"
"go-admin/common/models"
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"net/http"
"github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
"go-admin/common/dto"
+57 -15
View File
@@ -4,11 +4,11 @@ import (
"errors"
"github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
)
@@ -21,28 +21,70 @@ type DataPermission struct {
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)
var p = new(DataPermission)
if userId := user.GetUserIdStr(c); userId != "" {
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
}
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{}
+81
View File
@@ -0,0 +1,81 @@
package actions
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/config"
)
// 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)
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) {
previous := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = true
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
p, ok := runPermission(t, jwt.MapClaims{
"identity": float64(7),
"roleid": float64(3),
"deptid": float64(5),
"datascope": "4",
})
if !ok {
t.Fatal("the token carried the scope and a database was still needed")
}
if p.DataScope != "4" || p.UserId != 7 || p.DeptId != 5 || p.RoleId != 3 {
t.Fatalf("scope read as %+v", p)
}
}
// 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) {
previous := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = true
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
if _, ok := runPermission(t, jwt.MapClaims{
"identity": float64(7),
"roleid": float64(3),
"datascope": "4",
}); ok {
t.Fatal("an old token was served from claims it does not have")
}
}
+4 -4
View File
@@ -4,10 +4,10 @@ import (
"net/http"
"github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"go-admin/common/dto"
"go-admin/common/models"
+3 -3
View File
@@ -2,12 +2,12 @@ package actions
import (
"errors"
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/response"
"net/http"
"github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
"go-admin/common/dto"
+4 -4
View File
@@ -6,10 +6,10 @@ import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
"go-admin/common/service"
+34
View File
@@ -0,0 +1,34 @@
package database
import (
"fmt"
"sort"
"strings"
"gorm.io/gorm"
)
// openerFor resolves the gorm dialector registered for driver.
//
// Which drivers exist depends on how the binary was built: sqlite3 needs cgo
// and is only compiled in under the sqlite3 build tag. A configuration naming a
// driver this build does not carry used to reach gorm.Open with a nil function,
// which is a nil dereference deep in the stack rather than an answer.
func openerFor(driver string) (func(string) gorm.Dialector, error) {
open, ok := opens[driver]
if !ok {
return nil, fmt.Errorf("unsupported database driver %q, this build supports %s",
driver, strings.Join(supportedDrivers(), ", "))
}
return open, nil
}
// supportedDrivers lists what this build can open, in a stable order.
func supportedDrivers() []string {
names := make([]string, 0, len(opens))
for name := range opens {
names = append(names, name)
}
sort.Strings(names)
return names
}
+32
View File
@@ -0,0 +1,32 @@
package database
import (
"strings"
"testing"
)
func TestOpenerForRejectsADriverThisBuildDoesNotCarry(t *testing.T) {
open, err := openerFor("sqlite")
if err == nil {
t.Fatalf("misspelled driver was accepted, opener is %v", open != nil)
}
// The message has to name the alternatives: the reader is looking at a
// config file and needs to know what to put there.
for _, want := range supportedDrivers() {
if !strings.Contains(err.Error(), want) {
t.Errorf("error does not mention the %s driver: %s", want, err)
}
}
}
func TestOpenerForResolvesTheBuiltInDrivers(t *testing.T) {
for _, driver := range []string{"mysql", "postgres", "sqlserver"} {
open, err := openerFor(driver)
if err != nil {
t.Fatalf("%s must be available in every build: %s", driver, err)
}
if open == nil {
t.Fatalf("%s resolved to a nil opener", driver)
}
}
}

Some files were not shown because too many files have changed in this diff Show More