Compare commits

...
69 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
wenjianzhang 5864058a81 Merge pull request #858 from go-admin-team/chore/bump-version-2.4.0
chore🔧: 版本号升至 2.4.0
2026-08-16 11:17:51 +08:00
zhangwenjian 45035a16e4 chore🔧: 版本号升至 2.4.0 2026-08-16 11:14:28 +08:00
wenjianzhang f4d0108d49 Merge pull request #855 from go-admin-team/fix/remove-refresh-token-endpoint
fix🐛: 移除 refresh_token 接口,修复 token 可无限续期问题
2026-08-16 11:08:41 +08:00
zhangwenjian b81611ba72 chore🔧: 清理 refresh_token 的残留权限数据
接口移除后,库中仍留有三类记录:sys_api 的接口登记、sys_menu_api_rule 的
菜单绑定、casbin_rule 的策略。留着会让「接口管理」列出一个不存在的端点,
角色配置里也仍可勾选。

- 新装:从 db.sql 与 db-sqlserver.sql 的种子数据中删除该接口
- 已有部署:新增迁移清理,按 path 匹配而非固定 id,因为执行过
  `server -a` 重新注册接口的库中 id 会与官方种子数据不同
2026-08-14 21:42:59 +08:00
zhangwenjian bb34108831 fix🐛: 移除 refresh_token 接口,修复 token 可无限续期问题
close #820

GET /api/v1/refresh_token 用业务 token 即可换取新 token,而续期上限
MaxRefresh 依据的 orig_iat 在每次续期时被一并重置,上限永远无法到达 ——
token 一旦泄露即等同于永久访问权,且无任何吊销手段。

该路由此前还位于 CasbinExclude 中,不受 Casbin 约束,任何角色的已登录用户
都可调用。

官方前端从未使用它:store 中虽有 refreshToken action,但全仓库无一处
dispatch,属死代码。移除不影响正常登录与鉴权流程。

破坏性变更:自行调用该端点实现续期的使用者需改为重新登录。正确的无感续期
应在 go-admin-core 中区分 access token 与 refresh token 后重新实现,不应
沿用此路由。
2026-08-14 21:42:52 +08:00
wenjianzhang b7fd92f39b Merge pull request #854 from go-admin-team/docs/agents-and-demo-module
feat✨: 新增 app/demo 参照模块与 AGENTS.md 规范文档
2026-08-14 21:37:57 +08:00
zhangwenjian 63b800a3ba docs📝: 补充 sqlite3 构建标签与迁移目录说明
driver 配置为 sqlite3 时不带 -tags sqlite3 会在 nil 函数上 panic,
报错不提及构建标签,容易误判为环境损坏;同时说明 version/ 与
version-local/ 的区别,后者已被 gitignore,提交到本仓库的迁移必须放 version/。
2026-08-14 21:17:37 +08:00
zhangwenjian ed9450a2d5 feat✨: 补充 demo 模块的菜单与权限种子数据
一个业务模块要在界面上可用,需要四类数据协同:

  sys_api           后端路由登记,Casbin 据此判定
  sys_menu          侧边栏菜单,含目录 M、菜单 C、按钮 F 三级
  sys_menu_api_rule 菜单与接口的关联,角色保存时据此生成策略
  casbin_rule       实际生效的权限策略

菜单的 menu_name 与前端组件 name 保持一致(DemoProduct),按钮的
permission 与前端 v-permisaction 标识一致(demo:product:add 等)。

策略写入 casbin_rule 而非 sys_casbin_rule:后者对应的 models.CasbinRule
是历史遗留,其 7 列 size:512 唯一索引在 MySQL 下会超出索引长度限制,实际
生效的是 adapter 创建的 casbin_rule 表。

所有写入均为存在则更新、不存在则插入,迁移可安全地在已有数据的库上执行。
实测:在含 67 条菜单、121 条接口的库上执行后各表数据正确;清除版本记录重
跑一次,各表行数不变,确认幂等。
2026-08-14 16:57:55 +08:00
zhangwenjian 1d551a10ab docs📝: 新增 AGENTS.md 与架构说明
AGENTS.md 是给 AI 编码工具与新贡献者的约定,只记录「不遵守就会出错」的
规则,技术栈版本与命令交由 go.mod 和 Makefile 表达,避免文档与代码脱节。
标准写法指向 app/demo/——那是可编译、有测试的参照物,文档与它冲突时以它
为准。

docs/architecture.md 承载不易从代码直接读出的语义:DataScope 五档的过滤
方式、定时任务的 JobExec 接口、多数据源约束、迁移目录的分工。

内容整理自此前未纳入版本控制的 CLAUDE.md,撰写时逐条对照代码核实,修正
了其中两处失效描述(构建工具已非 Vue CLI;JobExec 的方法是 Exec(interface{})
而非 Run(string))。CLAUDE.md 现改为指向 AGENTS.md 的软链,两者不再各自
漂移。
2026-08-14 16:49:48 +08:00
zhangwenjian 4d7c9e5a12 feat✨: 补充 demo 模块的建表迁移
放在 version/ 而非 version-local/:后者已被 .gitignore 忽略,是留给使用
者存放自身迁移脚本的位置,示例迁移需随框架一起分发。文件注释中说明了这
一区分。
2026-08-14 16:46:53 +08:00
zhangwenjian d1f5fe5681 feat✨: 新增 app/demo 标准 CRUD 参照模块
作为编码约定的可执行参照物:文档会滞后,而这个模块过时会导致构建或测试
失败,因此以它为准。

目录骨架与自动注册文件由项目自带的脚手架生成:

  go run main.go app -n demo

它同时产出 cmd/api/demo.go,其中的 init() 将路由追加进 AppRouters,
无需在任何中心文件手工登记。

模块本身演示了单表 CRUD 的推荐写法——直接使用 common/actions 提供的五个
通用 Action,因此只有 model、dto、router 三个业务文件,没有 apis 与
service。手写 Handler 的场景仅在业务超出单表 CRUD 时才需要。

DTO 中详情/删除入参内嵌 dto.ObjectById 以复用其 Bind 与 GetId,不重复
实现 uri 绑定与批量 ids 合并逻辑。

补充 8 项测试锁定通用 Action 的接口约束,其中最关键的是 Generate() 必须
返回副本——Action 在并发请求间复用实例,就地返回会串数据。反向验证:将
Generate 改为就地返回,测试立即失败。
2026-08-14 16:46:09 +08:00
zhangwenjian e8c2e0a966 chore🔧: 修正 .DS_Store 忽略规则
原规则 `*/.DS_Store` 只匹配子目录一层,仓库根目录下的 .DS_Store 不在其
中。改为 `.DS_Store`,匹配任意层级。
2026-08-14 16:45:55 +08:00
wenjianzhang cef0a19a9c Merge pull request #853 from go-admin-team/fix/community-pr-batch
fix🐛: 处理社区 PR 中仍然成立的四项修复
2026-08-14 15:46:43 +08:00
zhangwenjian c0e81363dc docs📝: 修正 Makefile 注释错别字
「实际决对路径」→「实际绝对路径」。

问题由 PR #847 指出。
2026-08-14 15:35:55 +08:00
zhangwenjian df2e4a2b48 fix🐛: 修正欢迎页 iframe 高度塌陷
页面通过 JS 计算并设置 iframe 高度,但 html 与 body 未声明高度,
百分比高度失去参照,iframe 在部分场景下塌陷为 0。

补充 html,body{height:100%} 与 iframe 的 height:100%,并为原先缺失的
overflow-y 声明补上分号。

问题由 PR #829 指出。
2026-08-14 15:35:55 +08:00
zhangwenjian 9088ebc2e1 refactor🎨: 修正文件名拼写 int_router.go → init_router.go
该文件内容为 init() 函数中的路由注册,原文件名少了一个字母。

问题由 PR #787 指出。
2026-08-14 15:35:55 +08:00
zhangwenjian 9f2dec3036 fix🐛: 修正 GeneralDelDto.GetIds 重复追加 Id
该方法先在开头追加了 Id,随后 else 分支中又追加一次:仅传 Id 时返回
[5 5],删除接口会对同一条记录执行两次 DELETE。

  if g.Id != 0 { ids = append(ids, g.Id) }
  if len(g.Ids) > 0 { ... } else {
      if g.Id > 0 { ids = append(ids, g.Id) }   // 重复
  }

去掉冗余分支,同时将首个判断由 != 0 收紧为 > 0,与 Ids 中逐个元素的
过滤条件保持一致(负数 Id 无意义)。

补充单元测试,覆盖仅 Id、仅 Ids、二者并存、含非正数、全空回退等场景。

问题由 PR #848 指出。
2026-08-14 15:35:55 +08:00
wenjianzhang ea049f9b06 Merge pull request #852 from go-admin-team/fix/ci-deploy-guard
fix🐛: 限制部署步骤仅在 master 收到 push 时执行
2026-08-12 15:41:26 +08:00
zhangwenjian 1f1349a685 docs📝: 更新在线体验地址
Element UI vue2 演示站已升级为 Element Plus + Vue 3,域名同步更换为
vue.go-admin.pro。

Arco Design vue3 演示站(vue3.go-admin.dev)已下线,移除对应条目。
2026-08-12 12:27:41 +08:00
zhangwenjian dcef2df38e fix🐛: 限制部署步骤仅在 master 收到 push 时执行
本工作流同时由 push 与 pull_request 触发,而推送镜像与重启服务两步没有
任何事件限制。其后果是:任何指向 master 的 PR 一经创建,就会把 PR 分支
构建出的镜像推送到镜像仓库,并 docker rm -f 掉线上容器、用该镜像重新启
动 API 服务——发生在代码被审查和合并之前。

同仓库分支发起的 PR 可以取到 secrets,因此该路径实际可达;历史运行记录
中已多次出现由 pull_request 事件触发的成功部署。

为两步加上 event_name 与 ref 双重判断。额外判断 ref 是考虑到日后若有人
向 on.push.branches 追加分支,部署不会随之扩散。

Tidy 与 Build 不受影响,PR 仍会执行编译校验。
2026-08-12 12:27:33 +08:00
158 changed files with 3191 additions and 599 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。
+74 -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 }}
@@ -29,7 +37,12 @@ jobs:
- name: Build
run: env CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -tags "sqlite3,json1" --ldflags "-extldflags -static" -o main .
# 以下推镜像与重启步骤仅在 master 收到 push 时执行。
# pull_request 事件同样会触发本工作流,若不加限制,任何指向 master 的
# PR 一经创建就会把 PR 分支的镜像推上仓库,并直接重启线上 API 服务,
# 且发生在合并之前。构建与编译校验不受影响,PR 仍会执行。
- name: Build the Docker image and push
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
run: |
docker login --username=${{ secrets.DOCKER_USERNAME }} registry.ap-northeast-1.aliyuncs.com --password=${{ secrets.DOCKER_PASSWORD }}
echo "************ docker login end"
@@ -43,6 +56,7 @@ jobs:
echo "************ docker push end"
- name: Restart server # 第五步,重启服务
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
env:
GITHUB_SHA_X: ${GITHUB_SHA}
@@ -51,7 +65,65 @@ jobs:
username: ${{ secrets.SSH_USERNAME }}
key: ${{ secrets.DEPLOY_KEY }}
# 重启的脚本,根据自身情况做相应改动,一般要做的是migrate数据库以及重启服务器
#
# 配置从宿主机挂载,不使用镜像里的那份:演示站连的是托管数据库,
# 而 config/settings.demo.yml 会随仓库公开、也会打进镜像,凭据不能写在那里。
# 镜像里那份保持 sqlite,供 clone 仓库的人开箱即用。
#
# 路径本身走 secret:它不是凭据,但本仓库公开,没有理由把服务器的
# 目录结构一并公布。DEMO_CONFIG_PATH 指向宿主机上那份配置。
#
# 顺序是有意的:迁移先跑,跑不过就保持现有版本不动;
# 旧容器改名保留而不是删除,新容器不健康时能原样恢复。
# 健康检查两条都要过——HTTP 活着不代表数据库通了。
script: |
sudo docker rm -f go-admin-api
set -u
CFG="${{ secrets.DEMO_CONFIG_PATH }}"
IMG="${{ env.IMAGE_NAME_TAG }}"
NAME=go-admin-api
PREV="$NAME-prev"
test -f "$CFG" || { echo "宿主机配置缺失,中止部署"; exit 1; }
sudo docker login --username=${{ secrets.DOCKER_USERNAME }} registry.ap-northeast-1.aliyuncs.com --password=${{ secrets.DOCKER_PASSWORD }}
sudo docker run -d -p 8000:8000 --name go-admin-api ${{ env.IMAGE_NAME_TAG }}
sudo docker pull "$IMG" || { echo "拉取镜像失败,中止部署"; exit 1; }
# 迁移用新镜像跑。失败时线上仍是旧版本配旧 schema,是自洽的;
# 硬切过去才会得到代码与表对不上的服务。
if ! sudo docker run --rm -v "$CFG":/config/settings.yml:ro "$IMG" \
/main migrate -c /config/settings.yml; then
echo "迁移失败,保持现有版本"; exit 1
fi
if sudo docker ps -a --format '{{.Names}}' | grep -qx "$NAME"; then
sudo docker rm -f "$PREV" >/dev/null 2>&1 || true
sudo docker rename "$NAME" "$PREV"
sudo docker stop "$PREV" >/dev/null
fi
sudo docker run -d -p 8000:8000 \
-v "$CFG":/config/settings.yml:ro \
--name "$NAME" "$IMG"
ok=0
for i in $(seq 1 20); do
sleep 3
code=$(curl -s -o /dev/null -w '%{http_code}' -m 5 http://127.0.0.1:8000/api/v1/captcha 2>/dev/null || true)
if [ "$code" = "200" ] && sudo docker logs "$NAME" 2>&1 | grep -q 'connect success'; then
ok=1; echo "健康检查通过(第 $i 次探测)"; break
fi
done
if [ "$ok" = "1" ]; then
sudo docker rm -f "$PREV" >/dev/null 2>&1 || true
else
echo "健康检查失败,回滚到上一版本"
sudo docker logs --tail 40 "$NAME" 2>&1 || true
sudo docker rm -f "$NAME" >/dev/null 2>&1 || true
if sudo docker ps -a --format '{{.Names}}' | grep -qx "$PREV"; then
sudo docker rename "$PREV" "$NAME"
sudo docker start "$NAME" >/dev/null
echo "已恢复"
fi
exit 1
fi
-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 }}
+8 -2
View File
@@ -1,6 +1,6 @@
.idea
.vscode
*/.DS_Store
.DS_Store
static/uploadfile
main.exe
*.exe
@@ -21,5 +21,11 @@ cmd/migrate/migration/version-local/*
config/settings.deva.yml
go-admin-server
CLAUDE.md
.claude/
# Everything under .claude is private by default. Skills meant for people using
# go-admin are re-included one directory at a time, so a personal one dropped in
# here is never committed by accident.
.claude/*
!.claude/skills/
.claude/skills/*
!.claude/skills/new-business-module/
config/settings.local.dev.yml
+209
View File
@@ -0,0 +1,209 @@
# AGENTS.md — go-admin 后端
> 给 AI 编码工具与新贡献者的约定。**只写"不遵守就会出错"的规则**;技术栈版本以
> `go.mod` 为准,命令以 `Makefile` 为准,此处不复述,避免与代码脱节。
>
> 标准 CRUD 模块的完整写法见 **`app/demo/`** —— 那是可编译、有测试、CI 会跑的参照物。
> 本文与它冲突时,以 `app/demo/` 为准。
## 分层
```
Router → Api → Service → Model
路由注册 参数绑定 业务逻辑 GORM 结构体
中间件链 调用 Service 操作数据库 TableName()
```
对应目录:`app/{模块}/router|apis|service|models`,DTO 位于 `service/dto`。
**不可跨层**:Api 不直接操作 `Orm`,Service 不接触 `gin.Context`。
## 优先使用通用 Action
单表 CRUD **不要手写 Handler 与 Service**。`common/actions` 提供的五个
Action 已覆盖参数绑定、数据权限过滤、操作人注入、分页与错误响应:
```go
r := v1.Group("/demo-product").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
m := &models.DemoProduct{}
r.GET("", actions.PermissionAction(), actions.IndexAction(m, new(dto.DemoProductSearch), func() interface{} {
list := make([]models.DemoProduct, 0); return &list
}))
r.GET("/:id", actions.PermissionAction(), actions.ViewAction(new(dto.DemoProductById), func() interface{} {
return &models.DemoProduct{}
}))
r.POST("", actions.CreateAction(new(dto.DemoProductControl)))
r.PUT("/:id", actions.PermissionAction(), actions.UpdateAction(new(dto.DemoProductControl)))
r.DELETE("", actions.PermissionAction(), actions.DeleteAction(new(dto.DemoProductById)))
}
```
这样一个模块只需 **model + dto + router** 三个文件,完整示例见 `app/demo/`。
使用通用 Action 的前提:
- Model 实现 `models.ActiveRecord`(`Generate` / `GetId` / `TableName`)
- 列表 DTO 实现 `dto.Index`,增改删 DTO 实现 `dto.Control`
- **所有 `Generate()` 必须返回副本** —— Action 在并发请求间复用实例,
就地返回会串数据(`app/demo` 的测试锁定了这一点)
- 详情/删除 DTO 内嵌 `dto.ObjectById` 即可继承 `Bind` 与 `GetId`,无需重写
仅当业务超出单表 CRUD(跨表事务、外部调用、复杂校验)时才自行编写 Handler
与 Service,写法见下。
## Api 层(仅在通用 Action 不适用时)
结构体嵌入 `api.Api`,链式初始化后**必须检查 `Errors`**:
```go
func (e SysPost) GetPage(c *gin.Context) {
s := service.SysPost{}
req := dto.SysPostPageReq{}
err := e.MakeContext(c).MakeOrm().Bind(&req, binding.Form).MakeService(&s.Service).Errors
if err != nil {
e.Logger.Error(err)
e.Error(500, err, err.Error())
return
}
// ... 调用 s.GetPage(...)
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
}
```
响应一律走 `e.OK` / `e.PageOK` / `e.Error`,不要自行 `c.JSON`。
## Service 层(仅在通用 Action 不适用时)
结构体嵌入 `service.Service`(持有 `Orm` 与 `Log`)。查询通过 Scopes 组合:
```go
err = e.Orm.Model(&data).Scopes(
cDto.MakeCondition(c.GetNeedSearch()), // 由 search tag 生成 WHERE
cDto.Paginate(c.GetPageSize(), c.GetPageIndex()),
actions.Permission(data.TableName(), p), // 数据权限,列表/详情必须带
).Find(list).Limit(-1).Offset(-1).Count(count).Error
```
**遗漏 `actions.Permission` 会使数据权限配置静默失效** —— 这是最容易出的错。
错误一律 `return err` 向上传递,日志用 `e.Log.Errorf`,不使用 `panic`。
## DTO
搜索条件由 tag 声明,`MakeCondition` 据此拼 SQL:
```go
type SysPostPageReq struct {
dto.Pagination `search:"-"`
PostName string `form:"postName" search:"type:contains;column:post_name;table:sys_post"`
}
func (m *SysPostPageReq) GetNeedSearch() interface{} { return *m }
```
`type` 可选:`exact` `iexact` `contains` `gt` `gte` `lt` `lte` `order` `left`(联表)。
## Model
```go
type SysPost struct {
PostId int `gorm:"primaryKey;autoIncrement" json:"postId"`
// ... 业务字段
models.ControlBy // CreateBy / UpdateBy
models.ModelTime // CreatedAt / UpdatedAt / DeletedAt
}
func (SysPost) TableName() string { return "sys_post" }
```
`TableName()` 必须显式声明(GORM 配置了 `SingularTable`,不会自动推导复数)。
## 路由注册
通过 `init()` 自注册,不在中心文件手工添加:
```go
func init() { routerCheckRole = append(routerCheckRole, registerSysPostRouter) }
func registerSysPostRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
api := apis.SysPost{}
r := v1.Group("/post").
Use(authMiddleware.MiddlewareFunc()).
Use(middleware.AuthCheckRole()). // Casbin 鉴权
Use(actions.PermissionAction()) // 注入数据权限
{ r.GET("", api.GetPage); r.POST("", api.Insert); /* ... */ }
}
```
新增路由文件后,需确认 `cmd/api/` 中已用 `_` 导入该包。
## 命名
| 对象 | 规则 | 示例 |
|---|---|---|
| 数据表 | `sys_` 前缀 + 下划线 | `sys_post` |
| API 路径 | `/api/v1/` + kebab-case | `/api/v1/sys-user` |
| DTO | `{Model}{Action}Req` | `SysPostPageReq` |
| 权限标识 | `模块:资源:操作` | `admin:sysPost:add` |
权限标识需与前端 `v-permisaction` 一致,并写入 `sys_menu` 种子数据——完整可运行的
参照见 `cmd/migrate/migration/version/1786700001000_demo_menu.go`(sys_api /
sys_menu / sys_menu_api_rule / casbin_rule 四张表如何配齐,用的是幂等 upsert,
可以直接照抄结构)。
## Swagger
Handler 必须带完整注解,`go generate` 会据此生成文档:
```go
// @Summary 岗位列表
// @Tags 岗位
// @Success 200 {object} response.Response
// @Router /api/v1/post [get]
// @Security Bearer
```
## 本地运行
**配置 `driver: sqlite3` 时必须带构建标签**,否则启动即 panic:
```bash
go run -tags sqlite3 . migrate -c config/settings.sqlite.yml
go run -tags sqlite3 . server -c config/settings.sqlite.yml
```
原因:`common/database/open.go` 带 `//go:build !sqlite3`,不加标签时编进的是
不含 sqlite3 的版本,`opens["sqlite3"]` 为 nil,调用时在 nil 函数上崩溃。
报错信息不会提到构建标签,容易误判成环境损坏。MySQL / PostgreSQL 无此问题。
对应 `Makefile` 的 `build-sqlite` 目标。
## 数据库迁移
文件名前 13 位为时间戳版本号。**已执行过的迁移文件不可修改** ——
`sys_migration` 表按版本号去重,改动不会重跑,只能新增一个迁移来修正。
放哪个目录取决于身份:
| 目录 | 用途 | 是否入库 |
|---|---|---|
| `version/` | 框架自带迁移,随仓库分发给所有使用者 | 是 |
| `version-local/` | 使用者自己项目的迁移 | 否(已在 `.gitignore`) |
**向本仓库提交迁移必须放 `version/`** —— 放进 `version-local/` 会被忽略掉,
`git status` 看不到,PR 里也不会出现。两个目录的包名分别是 `version` 与
`version_local`(后者与目录名不一致,因为标识符不能含连字符)。
## 提交规范
格式 `type+emoji: 描述`:
`feat✨` `fix🐛` `style💄` `docs📝` `perf👌` `test✅` `refactor🎨` `chore🔧`
一个提交只做一件事。改动跨越多个语义时拆分提交,不要混在一起。
## 红线
- 不使用全局 DB 变量,一律用 `e.Orm`(来自请求上下文,多租户依赖它)
- 不在 Service 中引用 `gin.Context`
- 生产部署前确认 `mode: prod` 且已修改 `jwt.secret`(dev 模式下 token 几乎不过期)
- 不提交 `config/settings.yml` 中的真实凭据
-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"]
+1 -1
View File
@@ -21,7 +21,7 @@ run:
# 进入到项目根目录 执行 make run 命令
@docker-compose up -d
# 启动方式二 docker run 这里注意-v挂载的宿主机的地址改为部署时的实际决对路径
# 启动方式二 docker run 这里注意-v挂载的宿主机的地址改为部署时的实际绝对路径
#@docker run --name=go-admin -p 8000:8000 -v /home/code/go/src/go-admin/go-admin/config:/go-admin-api/config -v /home/code/go/src/go-admin/go-admin-api/static:/go-admin/static -v /home/code/go/src/go-admin/go-admin/temp:/go-admin-api/temp -d --restart=always go-admin:latest
@echo "go-admin service is running..."
+7 -10
View File
@@ -19,13 +19,10 @@
## 🎬 在线体验
Element UI vue体验:[https://vue2.go-admin.dev](https://vue2.go-admin.dev/#/login)
Element Plus vue3 体验:[https://vue.go-admin.pro](https://vue.go-admin.pro/#/login)
> ⚠️⚠️⚠️ 账号 / 密码: admin / 123456
Arco Design vue3 demo:[https://vue3.go-admin.dev](https://vue3.go-admin.dev/#/login)
> ⚠️⚠️⚠️ 账号 / 密码: admin / 123456
antd体验:[https://antd.go-admin.pro](https://antd.go-admin.pro/)
antd 体验(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admin.pro/)
> ⚠️⚠️⚠️ 账号 / 密码: admin / 123456
## ✨ 特性
@@ -81,9 +78,9 @@ antd体验:[https://antd.go-admin.pro](https://antd.go-admin.pro/)
### 轻松实现go-admin写出第一个应用 - 文档教程
[步骤一 - 基础内容介绍](https://doc.zhangwj.com/guide/intro/tutorial01.html)
[步骤一 - 基础内容介绍](https://doc.go-admin.dev/guide/intro/tutorial01.html)
[步骤二 - 实际应用 - 编写增删改查](https://doc.zhangwj.com/guide/intro/tutorial02.html)
[步骤二 - 实际应用 - 编写增删改查](https://doc.go-admin.dev/guide/intro/tutorial02.html)
### 手把手教你从入门到放弃 - 视频教程
@@ -332,7 +329,7 @@ pnpm dev
6. [spf13/viper](https://github.com/spf13/viper)
7. [gorm](https://github.com/jinzhu/gorm)
8. [gin-swagger](https://github.com/swaggo/gin-swagger)
9. [jwt-go](https://github.com/dgrijalva/jwt-go)
9. [golang-jwt](https://github.com/golang-jwt/jwt)
10. [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
11. [ruoyi-vue](https://gitee.com/y_project/RuoYi-Vue)
12. [form-generator](https://github.com/JakHuang/form-generator)
@@ -346,10 +343,10 @@ pnpm dev
## 🤝 链接
[Go开发者成长线路图](http://www.golangroadmap.com/)
- [mss-boot-io](https://docs.mss-boot-io.top/)
## 🔑 License
[MIT](https://github.com/go-admin-team/go-admin/blob/master/LICENSE.md)
Copyright (c) 2024 wenjianzhang
Copyright (c) 2026 wenjianzhang
+8 -12
View File
@@ -20,14 +20,11 @@ The front-end and back-end separation authority management system based on Gin +
## 🎬 Online Demo
Element UI vue demo:[https://vue2.go-admin.dev](https://vue2.go-admin.dev/#/login)
> 账号 / 密码: admin / 123456
Element Plus vue3 demo:[https://vue.go-admin.pro](https://vue.go-admin.pro/#/login)
> Account / Password: admin / 123456
Arco Design vue3 demo:[https://vue3.go-admin.dev](https://vue3.go-admin.dev/#/login)
> 账号 / 密码: admin / 123456
antd demo:[https://antd.go-admin.pro](https://antd.go-admin.pro/)
> 账号 / 密码: admin / 123456
antd demo (go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admin.pro/)
> Account / Password: admin / 123456
>
## ✨ Feature
@@ -79,9 +76,9 @@ At the same time, a series of tutorials including videos and documents are provi
### Easily implement go-admin to write the first application-documentation tutorial
[Step 1 - basic content introduction](https://doc.zhangwj.com/guide/intro/tutorial01.html)
[Step 1 - basic content introduction](https://doc.go-admin.dev/guide/intro/tutorial01.html)
[Step 2 - Practical application - writing database operations](https://doc.zhangwj.com/guide/intro/tutorial02.html)
[Step 2 - Practical application - writing database operations](https://doc.go-admin.dev/guide/intro/tutorial02.html)
### Teach you from getting started to giving up-video tutorial
@@ -323,7 +320,7 @@ The `go-admin` project has always been developed in the GoLand integrated develo
2. [spf13/viper](https://github.com/spf13/viper)
2. [gorm](https://github.com/jinzhu/gorm)
2. [gin-swagger](https://github.com/swaggo/gin-swagger)
2. [jwt-go](https://github.com/dgrijalva/jwt-go)
2. [golang-jwt](https://github.com/golang-jwt/jwt)
2. [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
2. [ruoyi-vue](https://gitee.com/y_project/RuoYi-Vue)
2. [form-generator](https://github.com/JakHuang/form-generator)
@@ -335,11 +332,10 @@ The `go-admin` project has always been developed in the GoLand integrated develo
<img class="no-margin" src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/pay.png" height="200px" >
## 🤝 Link
- [Go developer growth roadmap](http://www.golangroadmap.com/)
- [mss-boot-io](https://docs.mss-boot-io.top/)
## 🔑 License
[MIT](https://github.com/go-admin-team/go-admin/blob/master/LICENSE.md)
Copyright (c) 2022 wenjianzhang
Copyright (c) 2026 wenjianzhang
+2 -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 {
+6 -5
View File
@@ -11,10 +11,11 @@ const INDEX = `
<meta charset="utf-8">
<title>GO-ADMIN欢迎您</title>
<style>
body{
margin:0;
padding:0;
overflow-y:hidden
html,body{
margin:0;
padding:0;
height:100%;
overflow-y:hidden;
}
</style>
<script src="https://libs.baidu.com/jquery/1.9.0/jquery.js"></script>
@@ -28,7 +29,7 @@ $(function(){
</script>
</head>
<body>
<iframe id="iframe" frameborder="0" src="https://www.go-admin.pro" style="width:100%;"></iframe>
<iframe id="iframe" frameborder="0" src="https://www.go-admin.pro" style="width:100%;height:100%;"></iframe>
</body>
</html>
`
+3 -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"
+12 -5
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"
@@ -69,8 +69,15 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
v1 := r.Group("/api/v1")
{
v1.POST("/login", authMiddleware.LoginHandler)
// Refresh time can be longer than token timeout
v1.GET("/refresh_token", authMiddleware.RefreshHandler)
// GET /api/v1/refresh_token 已移除,原因见 issue #820:
// 该接口用业务 token 即可换取新 token,而续期上限 MaxRefresh 依据的
// orig_iat 在每次续期时被一并重置,上限永远无法到达 —— token 一旦泄
// 露即等同于永久访问权。它此前还位于 CasbinExclude 中,任何角色的已
// 登录用户都能调用,不受权限约束。
//
// 官方前端从未调用该接口(store 中的 refreshToken action 无人 dispatch),
// 移除不影响正常使用。若确需无感续期,应在 go-admin-core 中区分
// access token 与 refresh token 后重新实现,而非沿用此路由。
}
registerBaseRouter(v1, authMiddleware)
}
+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"
+37
View File
@@ -0,0 +1,37 @@
package models
import (
"go-admin/common/models"
)
// DemoProduct 示例模型
//
// 内嵌 ControlBy 与 ModelTime 后,创建人/更新人与时间戳由框架自动维护;
// 数据权限(actions.Permission)正是按 create_by 过滤,缺少 ControlBy 会使其失效。
type DemoProduct struct {
models.Model
Name string `json:"name" gorm:"size:128;comment:名称"`
Code string `json:"code" gorm:"size:64;comment:编码"`
Price float64 `json:"price" gorm:"comment:单价"`
Status string `json:"status" gorm:"size:4;comment:状态"`
Remark string `json:"remark" gorm:"size:255;comment:备注"`
models.ControlBy
models.ModelTime
}
func (DemoProduct) TableName() string {
return "demo_product"
}
// Generate 返回副本,供通用 Action 使用。
// 必须返回新实例:Action 在并发请求间复用同一个模型指针,就地返回会串数据。
func (e *DemoProduct) Generate() models.ActiveRecord {
o := *e
return &o
}
func (e *DemoProduct) GetId() interface{} {
return e.Id
}
+49
View File
@@ -0,0 +1,49 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/demo/models"
"go-admin/app/demo/service/dto"
"go-admin/common/actions"
"go-admin/common/middleware"
)
// 路由通过 init 自注册,无需在任何中心文件登记。
// 新建应用时用 `go run main.go app -n <名称>` 生成骨架,
// 它会同时产出 cmd/api/<名称>.go 完成注册。
func init() {
routerCheckRole = append(routerCheckRole, registerDemoProductRouter)
}
// registerDemoProductRouter 标准 CRUD 的推荐写法。
//
// 五个通用 Action 覆盖了增删改查的全部样板逻辑——参数绑定、数据权限过滤、
// 操作人注入、分页、错误响应,因此本模块没有 apis 与 service 文件。
//
// 仅当业务逻辑超出单表 CRUD(如跨表事务、外部调用、复杂校验)时,才需要
// 自行编写 Handler 与 Service,写法参照 app/admin/apis/sys_post.go。
func registerDemoProductRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
r := v1.Group("/demo-product").
Use(authMiddleware.MiddlewareFunc()). // JWT 认证
Use(middleware.AuthCheckRole()) // Casbin 鉴权
{
m := &models.DemoProduct{}
// actions.PermissionAction() 注入数据权限上下文,
// 列表与详情缺少它会绕过 DataScope 过滤
r.GET("", actions.PermissionAction(), actions.IndexAction(m, new(dto.DemoProductSearch), func() interface{} {
list := make([]models.DemoProduct, 0)
return &list
}))
r.GET("/:id", actions.PermissionAction(), actions.ViewAction(new(dto.DemoProductById), func() interface{} {
return &models.DemoProduct{}
}))
r.POST("", actions.CreateAction(new(dto.DemoProductControl)))
r.PUT("/:id", actions.PermissionAction(), actions.UpdateAction(new(dto.DemoProductControl)))
r.DELETE("", actions.PermissionAction(), actions.DeleteAction(new(dto.DemoProductById)))
}
}
+74
View File
@@ -0,0 +1,74 @@
package router
import (
"github.com/gin-gonic/gin"
_ "github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
// "github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
common "go-admin/common/middleware"
"os"
)
var (
routerNoCheckRole = make([]func(*gin.RouterGroup), 0)
routerCheckRole = make([]func(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware), 0)
)
// InitRouter 路由初始化
func InitRouter() {
var r *gin.Engine
h := sdk.Runtime.GetEngine()
if h == nil {
h = gin.New()
sdk.Runtime.SetEngine(h)
}
switch h.(type) {
case *gin.Engine:
r = h.(*gin.Engine)
default:
log.Fatal("not support other engine")
os.Exit(-1)
}
// the jwt middleware
authMiddleware, err := common.AuthInit()
if err != nil {
log.Fatalf("JWT Init Error, %s", err.Error())
}
// 注册业务路由
InitBusinessRouter(r, authMiddleware)
}
func InitBusinessRouter(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) *gin.Engine {
// 无需认证的路由
noCheckRoleRouter(r)
// 需要认证的路由
checkRoleRouter(r, authMiddleware)
return r
}
// noCheckRoleRouter 无需认证的路由
func noCheckRoleRouter(r *gin.Engine) {
// 可根据业务需求来设置接口版本
v := r.Group("/api/v1")
for _, f := range routerNoCheckRole {
f(v)
}
}
// checkRoleRouter 需要认证的路由
func checkRoleRouter(r *gin.Engine, authMiddleware *jwtauth.GinJWTMiddleware) {
// 可根据业务需求来设置接口版本
v := r.Group("/api/v1")
for _, f := range routerCheckRole {
f(v, authMiddleware)
}
}
+96
View File
@@ -0,0 +1,96 @@
package dto
import (
"github.com/gin-gonic/gin"
"go-admin/app/demo/models"
"go-admin/common/dto"
common "go-admin/common/models"
)
// DemoProductSearch 列表查询条件
//
// search tag 决定 MakeCondition 拼出的 WHERE:
//
// exact 精确匹配 / icontains 忽略大小写模糊 / gte 大于等于 …
//
// 未打 search tag 的字段不参与查询,可避免无意间开放过滤维度。
type DemoProductSearch struct {
dto.Pagination `search:"-"`
Name string `form:"name" search:"type:icontains;column:name;table:demo_product"`
Code string `form:"code" search:"type:exact;column:code;table:demo_product"`
Status string `form:"status" search:"type:exact;column:status;table:demo_product"`
DemoProductOrder
}
// DemoProductOrder 排序字段单独成组,避免与查询字段混在一起
type DemoProductOrder struct {
CreatedAtOrder string `form:"createdAtOrder" search:"type:order;column:created_at;table:demo_product"`
}
func (m *DemoProductSearch) GetNeedSearch() interface{} { return *m }
func (m *DemoProductSearch) Bind(ctx *gin.Context) error {
return ctx.ShouldBind(m)
}
func (m *DemoProductSearch) Generate() dto.Index {
o := *m
return &o
}
// DemoProductControl 新增与修改共用的入参
//
// 通用 Action(Create / Update)通过 GenerateM 拿到落库对象,
// 因此这里不直接暴露 Model,字段校验用 validate tag 声明。
type DemoProductControl struct {
Id int `json:"id" comment:"主键"`
Name string `json:"name" comment:"名称" validate:"required"`
Code string `json:"code" comment:"编码" validate:"required"`
Price float64 `json:"price" comment:"单价" validate:"gte=0"`
Status string `json:"status" comment:"状态"`
Remark string `json:"remark" comment:"备注"`
}
func (s *DemoProductControl) Bind(ctx *gin.Context) error {
return ctx.ShouldBind(s)
}
func (s *DemoProductControl) Generate() dto.Control {
o := *s
return &o
}
func (s *DemoProductControl) GetId() interface{} { return s.Id }
// GenerateM 组装落库对象。CreateBy / UpdateBy 由通用 Action 在此之后注入,
// 此处不要手动赋值。
func (s *DemoProductControl) GenerateM() (common.ActiveRecord, error) {
return &models.DemoProduct{
Model: common.Model{Id: s.Id},
Name: s.Name,
Code: s.Code,
Price: s.Price,
Status: s.Status,
Remark: s.Remark,
}, nil
}
// DemoProductById 详情与删除共用,支持单个 id 与批量 ids
type DemoProductById struct {
dto.ObjectById
}
// Bind 与 GetId 由内嵌的 dto.ObjectById 提供:它已处理好 uri 绑定、
// DELETE 时的批量 ids 合并与参数校验,无需在此重复实现。
func (s *DemoProductById) Generate() dto.Control {
o := *s
return &o
}
func (s *DemoProductById) GenerateM() (common.ActiveRecord, error) {
return &models.DemoProduct{}, nil
}
+91
View File
@@ -0,0 +1,91 @@
package dto
import (
"testing"
"go-admin/app/demo/models"
"go-admin/common/dto"
common "go-admin/common/models"
)
// 通用 Action 依赖 DTO 与 Model 实现一组接口。这些约束在编译期无法完全覆盖
// (接口是在路由注册处才被要求的),因此用测试锁定,避免改动后在运行时才暴露。
func TestImplementsIndexInterface(t *testing.T) {
var _ dto.Index = (*DemoProductSearch)(nil)
}
func TestImplementsControlInterface(t *testing.T) {
var _ dto.Control = (*DemoProductControl)(nil)
var _ dto.Control = (*DemoProductById)(nil)
}
func TestModelImplementsActiveRecord(t *testing.T) {
var _ common.ActiveRecord = (*models.DemoProduct)(nil)
}
// Generate 必须返回副本:通用 Action 在并发请求间复用同一个实例,
// 就地返回会导致请求之间串数据。
func TestGenerateReturnsCopy(t *testing.T) {
src := &DemoProductControl{Id: 1, Name: "原始"}
got := src.Generate().(*DemoProductControl)
if got == src {
t.Fatal("Generate 返回了同一指针,应返回副本")
}
got.Name = "被修改"
if src.Name != "原始" {
t.Errorf("修改副本影响了原对象:src.Name = %q", src.Name)
}
}
func TestSearchGenerateReturnsCopy(t *testing.T) {
src := &DemoProductSearch{Name: "原始"}
got := src.Generate().(*DemoProductSearch)
if got == src {
t.Fatal("Generate 返回了同一指针,应返回副本")
}
got.Name = "被修改"
if src.Name != "原始" {
t.Errorf("修改副本影响了原对象:src.Name = %q", src.Name)
}
}
func TestModelGenerateReturnsCopy(t *testing.T) {
src := &models.DemoProduct{Name: "原始"}
got := src.Generate().(*models.DemoProduct)
if got == src {
t.Fatal("Generate 返回了同一指针,应返回副本")
}
got.Name = "被修改"
if src.Name != "原始" {
t.Errorf("修改副本影响了原对象:src.Name = %q", src.Name)
}
}
// GenerateM 组装落库对象,主键需正确传递,否则更新会退化成插入。
func TestGenerateMCarriesId(t *testing.T) {
c := &DemoProductControl{Id: 42, Name: "示例", Code: "P-42", Price: 9.9}
m, err := c.GenerateM()
if err != nil {
t.Fatalf("GenerateM 返回错误: %v", err)
}
p, ok := m.(*models.DemoProduct)
if !ok {
t.Fatalf("GenerateM 返回类型错误: %T", m)
}
if p.Id != 42 {
t.Errorf("主键未传递: got %d, want 42", p.Id)
}
if p.Name != "示例" || p.Code != "P-42" || p.Price != 9.9 {
t.Errorf("字段映射有误: %+v", p)
}
}
func TestTableName(t *testing.T) {
if got := (models.DemoProduct{}).TableName(); got != "demo_product" {
t.Errorf("TableName() = %q, want %q", got, "demo_product")
}
}
+4 -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)
@@ -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"
)
+8
View File
@@ -0,0 +1,8 @@
package api
import "go-admin/app/demo/router"
func init() {
//注册路由 fixme 其他应用的路由,在本目录新建文件放在init方法
AppRouters = append(AppRouters, router.InitRouter)
}
+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"
@@ -0,0 +1,42 @@
package version
import (
"runtime"
"gorm.io/gorm"
"go-admin/app/demo/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
// 示例模块的建表迁移。
//
// 迁移文件名前 13 位是版本号(Unix 毫秒时间戳),框架按文件名升序执行,
// 已执行的版本记录在 sys_migration 表中,不会重复运行。
//
// 本文件放在 version/ 是因为它随框架一起分发;**业务项目自己的迁移应放
// version-local/**,该目录已被 .gitignore 忽略,不会与上游冲突。
//
// 生成新的迁移骨架:go run main.go migrate -c config/settings.yml -g
//
// 注意:已执行过的迁移文件不可再修改——版本号已入表,改动不会重跑。
// 需要调整时新建一个迁移。
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700000000DemoProduct)
}
func _1786700000000DemoProduct(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
// 1. 建表
if err := tx.Migrator().AutoMigrate(new(models.DemoProduct)); err != nil {
return err
}
// 2. 初始化数据(可选)。此处留空,示例模块不写入业务数据。
// 3. 记录版本号——必须,否则该迁移每次启动都会重跑
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,138 @@
package version
import (
"runtime"
"strconv"
"gorm.io/gorm"
"go-admin/cmd/migrate/migration"
"go-admin/cmd/migrate/migration/models"
common "go-admin/common/models"
)
// 为 app/demo 模块写入菜单、接口与权限种子数据。
//
// 一个业务模块要在界面上可用,需要四类数据:
//
// 1. sys_api —— 后端路由的登记,Casbin 据此判定权限
// 2. sys_menu —— 侧边栏菜单(目录 M / 菜单 C / 按钮 F)
// 3. sys_menu_api_rule —— 菜单与接口的多对多关联,角色保存时据此生成策略
// 4. casbin_rule —— 实际生效的权限策略
//
// 注意 casbin_rule 才是 adapter 实际使用的表;models.CasbinRule 对应的
// sys_casbin_rule 是历史遗留,其 7 列 size:512 唯一索引在 MySQL 下会超出
// 索引长度限制,不要将其纳入 AutoMigrate。
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700001000DemoMenu)
}
const (
demoMenuId = 9000 // 目录:示例模块
demoProductId = 9001 // 菜单:商品管理
demoApiBaseId = 9000 // sys_api 起始 id
adminRoleKey = "admin"
)
func _1786700001000DemoMenu(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
// ---- 1. 接口登记 ----
apis := []models.SysApi{
{Id: demoApiBaseId + 1, Handle: "go-admin/common/actions.IndexAction.func1", Title: "示例商品列表", Path: "/api/v1/demo-product", Type: "SYS", Action: "GET"},
{Id: demoApiBaseId + 2, Handle: "go-admin/common/actions.ViewAction.func1", Title: "示例商品详情", Path: "/api/v1/demo-product/:id", Type: "SYS", Action: "GET"},
{Id: demoApiBaseId + 3, Handle: "go-admin/common/actions.CreateAction.func1", Title: "示例商品新增", Path: "/api/v1/demo-product", Type: "SYS", Action: "POST"},
{Id: demoApiBaseId + 4, Handle: "go-admin/common/actions.UpdateAction.func1", Title: "示例商品修改", Path: "/api/v1/demo-product/:id", Type: "SYS", Action: "PUT"},
{Id: demoApiBaseId + 5, Handle: "go-admin/common/actions.DeleteAction.func1", Title: "示例商品删除", Path: "/api/v1/demo-product", Type: "SYS", Action: "DELETE"},
}
for i := range apis {
if err := upsert(tx, &models.SysApi{}, "id = ?", apis[i].Id, &apis[i]); err != nil {
return err
}
}
// ---- 2. 菜单 ----
// 目录本身不对应页面,component 固定为 Layout
dir := models.SysMenu{
MenuId: demoMenuId, MenuName: "Demo", Title: "示例模块", Icon: "example",
Path: "/demo", Paths: "/0/9000", MenuType: "M", ParentId: 0,
// sort is `gorm:"size:4"`, which MySQL builds as a tinyint - anything
// over 127 is rejected outright. The seeded menus run to 100, so 110
// still puts this last.
Component: "Layout", Sort: 110, Visible: "0", IsFrame: "1",
}
if err := upsert(tx, &models.SysMenu{}, "menu_id = ?", dir.MenuId, &dir); err != nil {
return err
}
// 菜单的 menu_name 需与前端组件 name 一致,否则 keep-alive 缓存无法命中
page := models.SysMenu{
MenuId: demoProductId, MenuName: "DemoProduct", Title: "商品管理", Icon: "documentation",
Path: "/demo/product", Paths: "/0/9000/9001", MenuType: "C", ParentId: demoMenuId,
Component: "/demo/product/index", Sort: 1, Visible: "0", IsFrame: "1",
SysApi: apis,
}
if err := upsert(tx, &models.SysMenu{}, "menu_id = ?", page.MenuId, &page); err != nil {
return err
}
// 按钮级权限,permission 需与前端 v-permisaction 中的标识一致
buttons := []models.SysMenu{
{MenuId: 9002, MenuName: "DemoProductAdd", Title: "新增", MenuType: "F", ParentId: demoProductId, Permission: "demo:product:add", Sort: 1, Visible: "0", IsFrame: "1"},
{MenuId: 9003, MenuName: "DemoProductEdit", Title: "修改", MenuType: "F", ParentId: demoProductId, Permission: "demo:product:edit", Sort: 2, Visible: "0", IsFrame: "1"},
{MenuId: 9004, MenuName: "DemoProductDel", Title: "删除", MenuType: "F", ParentId: demoProductId, Permission: "demo:product:delete", Sort: 3, Visible: "0", IsFrame: "1"},
}
for i := range buttons {
buttons[i].Paths = "/0/9000/9001/" + strconv.Itoa(buttons[i].MenuId)
if err := upsert(tx, &models.SysMenu{}, "menu_id = ?", buttons[i].MenuId, &buttons[i]); err != nil {
return err
}
}
// ---- 3. 授权给 admin 角色 ----
var role models.SysRole
err := tx.Where("role_key = ?", adminRoleKey).First(&role).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
// 尚未初始化角色数据时跳过授权,不阻断迁移
return tx.Create(&common.Migration{Version: version}).Error
}
return err
}
for _, id := range []int{demoMenuId, demoProductId, 9002, 9003, 9004} {
if err = tx.Exec(
"INSERT INTO sys_role_menu (role_id, menu_id) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu WHERE role_id = ? AND menu_id = ?)",
role.RoleId, id, role.RoleId, id,
).Error; err != nil {
return err
}
}
// ---- 4. Casbin 策略 ----
// admin 角色在中间件中直接放行,此处仍写入策略以便复制该角色配置时可继承
for _, a := range apis {
if err = tx.Exec(
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) SELECT 'p', ?, ?, ?, '', '', '' WHERE NOT EXISTS (SELECT 1 FROM casbin_rule WHERE ptype='p' AND v0=? AND v1=? AND v2=?)",
role.RoleKey, a.Path, a.Action, role.RoleKey, a.Path, a.Action,
).Error; err != nil {
return err
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
// upsert 存在则更新、不存在则插入。迁移可能在已有数据的库上运行,
// 直接 Create 会因主键冲突失败。
func upsert(tx *gorm.DB, model interface{}, where string, id int, value interface{}) error {
var count int64
if err := tx.Model(model).Where(where, id).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return tx.Model(model).Where(where, id).Updates(value).Error
}
return tx.Create(value).Error
}
@@ -0,0 +1,61 @@
package version
import (
"runtime"
"gorm.io/gorm"
"go-admin/cmd/migrate/migration"
"go-admin/cmd/migrate/migration/models"
common "go-admin/common/models"
)
// 清理 GET /api/v1/refresh_token 的残留权限数据。
//
// 该路由已随 issue #820 的修复移除:它允许用业务 token 换取新 token,而续期
// 上限 MaxRefresh 依据的 orig_iat 每次续期都被重置,上限永远无法到达。
//
// 路由虽已删除(请求会返回 404),但已有部署的库中仍残留三类记录:接口登记、
// 菜单与接口的绑定、Casbin 策略。留着会让「接口管理」列出一个不存在的端点,
// 角色配置里也仍可勾选,造成误解。
//
// 按 path 匹配而非固定 id —— 使用者若执行过 `server -a` 重新注册接口,id 会
// 与官方种子数据不同。
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700002000RemoveRefreshTokenApi)
}
const refreshTokenPath = "/api/v1/refresh_token"
func _1786700002000RemoveRefreshTokenApi(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
var apiIds []int
if err := tx.Model(&models.SysApi{}).
Where("path = ? AND action = ?", refreshTokenPath, "GET").
Pluck("id", &apiIds).Error; err != nil {
return err
}
if len(apiIds) > 0 {
// 先断开绑定,再删接口本身,避免遗留悬空外键
// 连接表由 GORM many2many 生成,列名为 sys_api_id 而非 api_id
if err := tx.Exec("DELETE FROM sys_menu_api_rule WHERE sys_api_id IN ?", apiIds).Error; err != nil {
return err
}
if err := tx.Where("id IN ?", apiIds).Delete(&models.SysApi{}).Error; err != nil {
return err
}
}
// casbin_rule 才是 adapter 实际使用的表,策略按路径存储,与 sys_api 的
// id 无关,因此即使上面没匹配到接口也要清理
if err := tx.Exec(
"DELETE FROM casbin_rule WHERE ptype = 'p' AND v1 = ?", refreshTokenPath,
).Error; err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,287 @@
package version
import (
"fmt"
"runtime"
"time"
"gorm.io/gorm"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
// Convert deleted_at from a nullable timestamp to a non-null millisecond
// marker, then put a unique index on the three natural keys.
//
// Those keys had no unique index at all. Uniqueness was a SELECT COUNT
// followed by an INSERT, which two concurrent requests both pass.
//
// The index cannot be on the key alone: a soft-deleted row keeps occupying the
// name, so a deleted user's username could never be used again. It has to
// include the delete marker — and the marker has to be non-null, because two
// live rows are (name, NULL) and (name, NULL), and NULL is not equal to NULL.
// An index over a nullable marker permits both rows. It looks like a
// constraint and enforces nothing.
//
// DDL does not roll back on MySQL, so this is written to be re-runnable rather
// than transactional: every step asks whether it has already been taken.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700003000SoftDeleteMarker)
}
// Every table whose model embeds common.ModelTime.
var softDeleteTables = []string{
"sys_api", "sys_config", "sys_dept", "sys_dict_data", "sys_dict_type",
"sys_job", "sys_menu", "sys_post", "sys_role", "sys_user", "demo_product",
}
// The keys that gain a constraint, and the column that makes it possible.
var naturalKeys = []struct {
table, column, index string
}{
{"sys_user", "username", "uk_sys_user_username"},
{"sys_role", "role_key", "uk_sys_role_role_key"},
{"sys_dict_type", "dict_type", "uk_sys_dict_type_dict_type"},
}
const tempColumn = "deleted_at_ms"
func _1786700003000SoftDeleteMarker(db *gorm.DB, version string) error {
// Refused before anything is altered: creating the index on a table that
// already holds duplicates fails halfway through, and the operator is left
// guessing which rows to reconcile.
if err := refuseOnDuplicates(db); err != nil {
return err
}
for _, table := range softDeleteTables {
if err := convertDeletedAt(db, table); err != nil {
return fmt.Errorf("%s: %w", table, err)
}
}
for _, k := range naturalKeys {
if err := createUniqueIndex(db, k.table, k.column, k.index); err != nil {
return fmt.Errorf("%s.%s: %w", k.table, k.column, err)
}
}
return db.Create(&common.Migration{Version: version}).Error
}
// refuseOnDuplicates reports the values that would make the index impossible,
// rather than the index failing to build and saying only that it did.
func refuseOnDuplicates(db *gorm.DB) error {
for _, k := range naturalKeys {
if !db.Migrator().HasTable(k.table) {
continue
}
var dupes []string
q := fmt.Sprintf(
"SELECT %s FROM %s WHERE deleted_at IS NULL GROUP BY %s HAVING COUNT(*) > 1",
k.column, k.table, k.column,
)
if !db.Migrator().HasColumn(k.table, "deleted_at") {
// Already converted; live rows carry zero rather than null.
q = fmt.Sprintf(
"SELECT %s FROM %s WHERE deleted_at = 0 GROUP BY %s HAVING COUNT(*) > 1",
k.column, k.table, k.column,
)
}
if err := db.Raw(q).Scan(&dupes).Error; err != nil {
return fmt.Errorf("checking %s.%s for duplicates: %w", k.table, k.column, err)
}
if len(dupes) > 0 {
return fmt.Errorf(
"%s.%s already holds duplicates %v; reconcile them before this migration can add its unique index",
k.table, k.column, dupes,
)
}
}
return nil
}
func convertDeletedAt(db *gorm.DB, table string) error {
m := db.Migrator()
if !m.HasTable(table) {
return nil
}
converted, err := isConverted(db, table)
if err != nil {
return err
}
if converted {
return nil
}
if !m.HasColumn(table, tempColumn) {
if err := db.Exec(addBigIntColumn(db, table, tempColumn)).Error; err != nil {
return fmt.Errorf("adding %s: %w", tempColumn, err)
}
}
// Converted in Go rather than in SQL: turning a timestamp into epoch
// milliseconds is spelled differently by every dialect this supports, and
// the row counts here do not justify four versions of it.
if err := copyTimestamps(db, table); err != nil {
return err
}
// SQLite refuses to drop a column an index still refers to, where MySQL and
// PostgreSQL drop the dependent indexes along with it. Drop them first, on
// every dialect: the column is about to be replaced by one of the same name,
// and gorm recreates the index from the model's tag.
if err := dropIndexesOn(db, table, "deleted_at"); err != nil {
return err
}
if err := db.Exec(dropColumn(db, table, "deleted_at")).Error; err != nil {
return fmt.Errorf("dropping deleted_at: %w", err)
}
if err := db.Exec(renameColumn(db, table, tempColumn, "deleted_at")).Error; err != nil {
return fmt.Errorf("renaming %s: %w", tempColumn, err)
}
return nil
}
// isConverted reports whether deleted_at already holds the marker. A table
// mid-conversion still has both columns, and is finished rather than skipped.
func isConverted(db *gorm.DB, table string) (bool, error) {
types, err := db.Migrator().ColumnTypes(table)
if err != nil {
return false, err
}
for _, c := range types {
if c.Name() != "deleted_at" {
continue
}
if nullable, ok := c.Nullable(); ok && !nullable {
return !db.Migrator().HasColumn(table, tempColumn), nil
}
return false, nil
}
// No such column: nothing to convert.
return true, nil
}
func copyTimestamps(db *gorm.DB, table string) error {
type row struct {
Id int64
DeletedAt *time.Time
}
// The tables walked here do not agree on what the key is called: sys_dept
// keys on dept_id, sys_user on user_id, and only some on id.
key, err := primaryKeyOf(db, table)
if err != nil {
return err
}
var rows []row
q := fmt.Sprintf("SELECT %s AS id, deleted_at FROM %s WHERE deleted_at IS NOT NULL", key, table)
if err := db.Raw(q).Scan(&rows).Error; err != nil {
return fmt.Errorf("reading deleted rows: %w", err)
}
for _, r := range rows {
if r.DeletedAt == nil {
continue
}
u := fmt.Sprintf("UPDATE %s SET %s = ? WHERE %s = ?", table, tempColumn, key)
if err := db.Exec(u, r.DeletedAt.UnixMilli(), r.Id).Error; err != nil {
return fmt.Errorf("marking row %d deleted: %w", r.Id, err)
}
}
return nil
}
// primaryKeyOf asks the database which column is the primary key, rather than
// assuming the name.
func primaryKeyOf(db *gorm.DB, table string) (string, error) {
columns, err := db.Migrator().ColumnTypes(table)
if err != nil {
return "", fmt.Errorf("reading columns of %s: %w", table, err)
}
for _, c := range columns {
if isKey, ok := c.PrimaryKey(); ok && isKey {
return c.Name(), nil
}
}
return "", fmt.Errorf("no primary key on %s", table)
}
func createUniqueIndex(db *gorm.DB, table, column, name string) error {
if db.Migrator().HasIndex(table, name) {
return nil
}
return db.Exec(fmt.Sprintf(
"CREATE UNIQUE INDEX %s ON %s (%s, deleted_at)", name, table, column,
)).Error
}
// dropIndexesOn removes every index on table that mentions column, so the
// column can be dropped. Named indexes are asked of the migrator rather than
// guessed, because the names differ between a schema gorm created and one a
// hand-written migration did.
func dropIndexesOn(db *gorm.DB, table, column string) error {
names, err := indexNamesFor(db, table, column)
if err != nil {
return fmt.Errorf("listing indexes on %s.%s: %w", table, column, err)
}
m := db.Migrator()
for _, name := range names {
if !m.HasIndex(table, name) {
continue
}
if err := m.DropIndex(table, name); err != nil {
return fmt.Errorf("dropping index %s: %w", name, err)
}
}
return nil
}
// indexNamesFor asks the database which indexes cover column.
func indexNamesFor(db *gorm.DB, table, column string) ([]string, error) {
indexes, err := db.Migrator().GetIndexes(table)
if err != nil {
return nil, err
}
var names []string
for _, idx := range indexes {
for _, c := range idx.Columns() {
if c == column {
names = append(names, idx.Name())
break
}
}
}
return names, nil
}
// The three statements every dialect spells differently.
func addBigIntColumn(db *gorm.DB, table, column string) string {
if db.Dialector.Name() == "sqlserver" {
return fmt.Sprintf("ALTER TABLE %s ADD %s BIGINT NOT NULL DEFAULT 0", table, column)
}
return fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s BIGINT NOT NULL DEFAULT 0", table, column)
}
func dropColumn(db *gorm.DB, table, column string) string {
return fmt.Sprintf("ALTER TABLE %s DROP COLUMN %s", table, column)
}
func renameColumn(db *gorm.DB, table, from, to string) string {
switch db.Dialector.Name() {
case "sqlserver":
return fmt.Sprintf("EXEC sp_rename '%s.%s', '%s', 'COLUMN'", table, from, to)
case "mysql":
return fmt.Sprintf("ALTER TABLE %s CHANGE %s %s BIGINT NOT NULL DEFAULT 0", table, from, to)
default:
return fmt.Sprintf("ALTER TABLE %s RENAME COLUMN %s TO %s", table, from, to)
}
}
@@ -0,0 +1,185 @@
package version
import (
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
// oldUser is the shape of the table before this migration: deleted_at is a
// nullable timestamp, and nothing constrains the username.
//
// It mirrors the real sys_user in the two respects that matter here, both of
// which the migration got wrong against a live database while an id-keyed,
// unindexed table let it pass: the key is user_id rather than id, and
// deleted_at carries an index, which SQLite will not let a column be dropped
// out from under.
type oldUser struct {
UserId int64 `gorm:"column:user_id;primaryKey;autoIncrement"`
Username string
DeletedAt *time.Time `gorm:"index"`
}
func (oldUser) TableName() string { return "sys_user" }
func openWithOldSchema(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.AutoMigrate(&oldUser{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func TestConvertsDeletedAtAndKeepsWhoWasDeleted(t *testing.T) {
db := openWithOldSchema(t)
gone := time.Now().Add(-time.Hour)
rows := []oldUser{
{Username: "alice"},
{Username: "bob", DeletedAt: &gone},
}
for i := range rows {
if err := db.Create(&rows[i]).Error; err != nil {
t.Fatalf("seed: %v", err)
}
}
if err := convertDeletedAt(db, "sys_user"); err != nil {
t.Fatalf("convert: %v", err)
}
var marks []struct {
Username string
DeletedAt int64
}
if err := db.Raw("SELECT username, deleted_at FROM sys_user ORDER BY user_id").Scan(&marks).Error; err != nil {
t.Fatalf("read back: %v", err)
}
if len(marks) != 2 {
t.Fatalf("got %d rows, want 2", len(marks))
}
if marks[0].DeletedAt != 0 {
t.Errorf("a live row carries %d, want 0", marks[0].DeletedAt)
}
if want := gone.UnixMilli(); marks[1].DeletedAt != want {
t.Errorf("the deleted row carries %d, want %d: the timestamp was lost", marks[1].DeletedAt, want)
}
}
// Running it twice must be safe: DDL does not roll back on MySQL, so an
// operator whose first attempt failed halfway has nothing to do but run it
// again.
func TestConversionIsRepeatable(t *testing.T) {
db := openWithOldSchema(t)
if err := db.Create(&oldUser{Username: "alice"}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
for i := 0; i < 3; i++ {
if err := convertDeletedAt(db, "sys_user"); err != nil {
t.Fatalf("convert %d: %v", i, err)
}
}
}
// The point of the whole exercise: the constraint has to reject a second live
// row and accept one whose predecessor was deleted.
func TestUniqueIndexBindsLiveRowsOnly(t *testing.T) {
db := openWithOldSchema(t)
if err := db.Create(&oldUser{Username: "alice"}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
if err := convertDeletedAt(db, "sys_user"); err != nil {
t.Fatalf("convert: %v", err)
}
if err := createUniqueIndex(db, "sys_user", "username", "uk_sys_user_username"); err != nil {
t.Fatalf("index: %v", err)
}
t.Run("a second live row is rejected", func(t *testing.T) {
err := db.Exec("INSERT INTO sys_user (username, deleted_at) VALUES ('alice', 0)").Error
if err == nil {
t.Fatal("a duplicate username was accepted")
}
})
t.Run("the name is free once the row is deleted", func(t *testing.T) {
if err := db.Exec("UPDATE sys_user SET deleted_at = ? WHERE username = 'alice'", time.Now().UnixMilli()).Error; err != nil {
t.Fatalf("delete: %v", err)
}
if err := db.Exec("INSERT INTO sys_user (username, deleted_at) VALUES ('alice', 0)").Error; err != nil {
t.Errorf("the name stayed taken after its row was deleted: %v", err)
}
})
t.Run("the same name can be deleted more than once", func(t *testing.T) {
if err := db.Exec("UPDATE sys_user SET deleted_at = ? WHERE deleted_at = 0", time.Now().UnixMilli()+1).Error; err != nil {
t.Errorf("a second deletion collided with the first: %v", err)
}
})
}
// A table that already holds duplicates cannot take the index, and the
// migration says which values rather than letting the index fail.
func TestRefusesWhenDuplicatesAlreadyExist(t *testing.T) {
db := openWithOldSchema(t)
for i := 0; i < 2; i++ {
if err := db.Create(&oldUser{Username: "alice"}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
}
err := refuseOnDuplicates(db)
if err == nil {
t.Fatal("the migration accepted a table that already holds duplicates")
}
if !contains(err.Error(), "alice") {
t.Errorf("the error does not name the value: %v", err)
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
// Why the column has to change at all, demonstrated rather than argued: the
// same index over the column as it was accepts both live rows, because each
// carries NULL and NULL is not equal to NULL. The constraint exists and binds
// nothing — which is worse than its absence, because it reads as protection.
func TestTheIndexOverANullableMarkerEnforcesNothing(t *testing.T) {
db := openWithOldSchema(t)
if err := db.Exec("CREATE UNIQUE INDEX uk_nullable ON sys_user (username, deleted_at)").Error; err != nil {
t.Fatalf("index: %v", err)
}
for i := 0; i < 2; i++ {
if err := db.Create(&oldUser{Username: "alice"}).Error; err != nil {
t.Fatalf("the nullable marker rejected a duplicate after all, which would make this migration unnecessary: %v", err)
}
}
var live int64
if err := db.Raw("SELECT COUNT(*) FROM sys_user WHERE deleted_at IS NULL").Scan(&live).Error; err != nil {
t.Fatalf("count: %v", err)
}
if live != 2 {
t.Fatalf("got %d live rows, want 2", live)
}
}
@@ -0,0 +1,40 @@
package version
import (
"fmt"
"runtime"
"gorm.io/gorm"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
// Convert the two generator tables to the same delete marker every other table
// got in 1786700003000.
//
// They were left out of that list, and the mismatch is invisible until it is
// not: the tables are built from cmd/migrate/migration/models, whose ModelTime
// still carries a nullable gorm.DeletedAt, while the runtime models in
// app/other/models/tools embed common.ModelTime, which is the millisecond
// marker. GORM therefore queries them with deleted_at = 0 against a datetime
// column holding NULL, and every row is invisible - so the code generator
// listed no tables at all.
//
// tb_demo has the same shape and is deliberately not here: nothing reads it at
// runtime, so there is no mismatch to fix.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700004000GeneratorTablesMarker)
}
var generatorTables = []string{"sys_columns", "sys_tables"}
func _1786700004000GeneratorTablesMarker(db *gorm.DB, version string) error {
for _, table := range generatorTables {
if err := convertDeletedAt(db, table); err != nil {
return fmt.Errorf("%s: %w", table, err)
}
}
return db.Create(&common.Migration{Version: version}).Error
}
@@ -0,0 +1,82 @@
package version
import (
"go/ast"
"go/parser"
"go/token"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// Fields tagged gorm:"size:4" become a tinyint on MySQL, which holds -128..127.
// sqlite ignores the width, so a value that overflows passes every local test
// and fails on a real install - and because the migration is not transactional,
// it fails partway, leaving later migrations unapplied.
//
// That is what happened: a seeded menu with Sort: 900 stopped the run at
// 1786700001000, so the soft-delete conversion never ran, deleted_at stayed
// NULL, and nobody could log in.
var narrowColumns = map[string]struct{ min, max int64 }{
"Sort": {math.MinInt8, math.MaxInt8},
"Status": {math.MinInt8, math.MaxInt8},
}
func TestSeededValuesFitTheirColumns(t *testing.T) {
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
files, err := filepath.Glob(filepath.Join(dir, "*.go"))
if err != nil {
t.Fatal(err)
}
checked := 0
for _, path := range files {
if strings.HasSuffix(path, "_test.go") {
continue
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
ast.Inspect(f, func(n ast.Node) bool {
kv, ok := n.(*ast.KeyValueExpr)
if !ok {
return true
}
key, ok := kv.Key.(*ast.Ident)
if !ok {
return true
}
limits, watched := narrowColumns[key.Name]
if !watched {
return true
}
lit, ok := kv.Value.(*ast.BasicLit)
if !ok || lit.Kind != token.INT {
return true
}
v, err := strconv.ParseInt(lit.Value, 10, 64)
if err != nil {
return true
}
checked++
if v < limits.min || v > limits.max {
t.Errorf("%s:%d: %s: %d does not fit a tinyint (%d..%d);\n"+
" MySQL rejects it with Error 1264 and the migration stops there",
filepath.Base(path), fset.Position(lit.Pos()).Line, key.Name, v, limits.min, limits.max)
}
return true
})
}
if checked == 0 {
t.Fatal("no seeded values were examined; the scan is broken, not the code")
}
}
@@ -0,0 +1,173 @@
package version
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// The repository carries two ModelTime types. The one in
// cmd/migrate/migration/models still has a nullable gorm.DeletedAt and is what
// builds the tables; the one in common/models is the millisecond marker and is
// what queries them. A table whose runtime model embeds the second but which no
// migration converts is queried with deleted_at = 0 against a datetime column,
// and every row is invisible - silently, and only in production.
//
// sys_columns and sys_tables were in exactly that state: the code generator
// listed no tables at all.
func TestEveryRuntimeSoftDeleteTableIsConverted(t *testing.T) {
converted := map[string]bool{}
for _, name := range append(append([]string{}, softDeleteTables...), generatorTables...) {
converted[name] = true
}
// tb_demo is built with the marker-less model and has no runtime model at
// all, so nothing ever queries it with deleted_at = 0.
const noRuntimeModel = "tb_demo"
for table, file := range runtimeSoftDeleteTables(t) {
if table == noRuntimeModel {
continue
}
if !converted[table] {
t.Errorf("%s (%s) embeds common.ModelTime but no migration converts its deleted_at;\n"+
" it will be queried with deleted_at = 0 against a nullable datetime and return nothing",
table, file)
}
}
}
// runtimeSoftDeleteTables maps table name to the file declaring it, for every
// model under app/ that embeds the marker-carrying ModelTime.
func runtimeSoftDeleteTables(t *testing.T) map[string]string {
t.Helper()
root := repoRoot(t)
found := map[string]string{}
err := filepath.Walk(filepath.Join(root, "app"), func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return err
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil // not this test's business
}
// Only files importing the runtime models package can embed its ModelTime.
if !importsRuntimeModels(f) {
return nil
}
for name, table := range tablesWithModelTime(f) {
_ = name
found[table] = strings.TrimPrefix(path, root+"/")
}
return nil
})
if err != nil {
t.Fatalf("walk app/: %v", err)
}
if len(found) == 0 {
t.Fatal("found no runtime models at all; the scan is broken, not the code")
}
return found
}
func importsRuntimeModels(f *ast.File) bool {
for _, imp := range f.Imports {
p, err := strconv.Unquote(imp.Path.Value)
if err == nil && p == "go-admin/common/models" {
return true
}
}
return false
}
// tablesWithModelTime returns struct name -> table name for structs that embed
// ModelTime and declare a TableName.
func tablesWithModelTime(f *ast.File) map[string]string {
embeds := map[string]bool{}
ast.Inspect(f, func(n ast.Node) bool {
ts, ok := n.(*ast.TypeSpec)
if !ok {
return true
}
st, ok := ts.Type.(*ast.StructType)
if !ok {
return true
}
for _, field := range st.Fields.List {
if len(field.Names) != 0 {
continue // named field, not an embed
}
if sel, ok := field.Type.(*ast.SelectorExpr); ok && sel.Sel.Name == "ModelTime" {
embeds[ts.Name.Name] = true
}
}
return true
})
out := map[string]string{}
for name := range embeds {
if table := tableNameOf(f, name); table != "" {
out[name] = table
}
}
return out
}
// tableNameOf finds the string returned by func (T) TableName() string.
func tableNameOf(f *ast.File, structName string) string {
var table string
ast.Inspect(f, func(n ast.Node) bool {
fn, ok := n.(*ast.FuncDecl)
if !ok || fn.Name.Name != "TableName" || fn.Recv == nil || len(fn.Recv.List) != 1 {
return true
}
if receiverName(fn.Recv.List[0].Type) != structName {
return true
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
lit, ok := n.(*ast.BasicLit)
if ok && lit.Kind == token.STRING {
if s, err := strconv.Unquote(lit.Value); err == nil && table == "" {
table = s
}
}
return true
})
return true
})
return table
}
func receiverName(expr ast.Expr) string {
switch t := expr.(type) {
case *ast.Ident:
return t.Name
case *ast.StarExpr:
return receiverName(t.X)
}
return ""
}
func repoRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for i := 0; i < 8; i++ {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
dir = filepath.Dir(dir)
}
t.Fatal("go.mod not found above the test directory")
return ""
}
+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"

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