Compare commits

...
Author SHA1 Message Date
zhangwenjian 39ea1f6aef fix🐛: normalize a NULL data scope too, not just an unrecognized one
`NULL NOT IN (...)` evaluates to NULL rather than true, so the previous
condition left a NULL data_scope exactly as it found it - and NULL is the one
value that most needs the repair: it scans into a Go string as "", which is
what the fail-closed default now refuses.

The column has no NOT NULL constraint, so the value is reachable from any
writer that is not the admin UI.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 19:10:53 +08:00
zhangwenjian b2053f507a refactor🎨: drop the unused second data permission implementation
app/admin/models/datascope.go carried a second copy of the scope logic with no
callers. Its department-tree pattern was written as "%" + id + "%" instead of
"%/" + id + "/%", so dept_id 1 also matched /11/, /21/ and /100/ - visibility
into unrelated subtrees.

It sat where someone looking for a data permission example would find it. The
copy that is actually wired up stays in common/actions.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:24:55 +08:00
zhangwenjian 9520117914 feat✨: normalize invalid data scopes on existing installs
The seed fix only reaches new installations. An install that imported the old
db.sql already has an administrator with an empty data_scope, and after the
fail-closed change that account sees nothing.

The migration rewrites any value outside "1".."5" to "1", which is the
behaviour those rows had before. Plain SQL rather than the frozen migration
models, per the rule that migrations after 1786700003000 must not use them.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:24:55 +08:00
zhangwenjian 7a5fc7d440 fix🐛: give the seeded admin role an explicit data scope
The shipped seed data left data_scope empty for the built-in administrator.
That was harmless while an unrecognized scope meant "see everything"; with the
previous commits it means the opposite, so a fresh install with data permission
enabled would have blinded its own default account on every list endpoint.

The admin short-circuit does not help here: role_key == "admin" bypasses Casbin,
not the data permission scopes, which never look at role_key.

The value is "1" - all data - which is the behaviour the empty string used to
produce, so this restores the intent rather than tightening it. A test reads
both seed files back so the pair cannot drift apart again.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:24:41 +08:00
zhangwenjian bd5e83d464 fix🐛: validate data scope on the role DTOs
Nothing checked what went into sys_role.data_scope, so creating a role without
a dataScope stored an empty string - the value that used to be indistinguishable
from "see everything".

All three DTOs that write the column are validated, not just the insert path:
they target the same column, and guarding one entrance while leaving two open
would not be a guard.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:24:41 +08:00
zhangwenjian 63dd40a8d7 test✅: cover the data scope failure directions
A table over all five scopes plus the ones that are not scopes, asserting the
generated SQL rather than a boolean, because the defect was that two different
intentions produced the same query.

The rows that matter are the negative ones: an unrecognized value, a zero
value, and a department scope with a non-positive id. Each was verified to go
red with its own fix reverted and the others in place, so a regression names
the defect it belongs to.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:24:41 +08:00
zhangwenjian 32bd88504d fix🐛: fail closed when the department id is not positive
dept_path is built as "/0/" + id + "/..." for every department, so a DeptId of
0 turns the department-tree pattern into '%/0/%' - which matches every row in
sys_dept. The scope meant to narrow visibility to one subtree returned the
whole organisation instead.

Both department scopes now refuse a non-positive id rather than building a
pattern from it. The admin DTO validates deptId, but seed scripts, SSO and
third-party registration paths do not, and after the contract move the caller
is no longer ours to control.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:24:26 +08:00
zhangwenjian d70818a9db fix🐛: fail closed on an unrecognized data scope
The switch ended in `default: return db`, which is the same answer as "this
role may see everything". That made a legitimate scope indistinguishable from a
broken one: "1" (all data) had no case of its own and fell into default too, so
"1", "", "6" and a zero value all produced byte-identical SQL.

Three changes, in this order, because reversing them would break "1":

  - the five scope values become named constants, so a reader can tell which
    string means what without consulting the seed data
  - "1" gets an explicit case, which is what frees default to mean "not a
    scope I recognise"
  - default now matches nothing rather than everything

SysRole DTOs accept the scope unvalidated, so an empty string reaches this
switch from ordinary use, not just from a corrupted row.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:24:26 +08:00
zhangwenjian 9d4a425fc0 fix🐛: abort the request when the ORM is unavailable
PermissionAction logged the error and returned. Gin treats a plain return as
"carry on", so the request reached the business handler with PermissionKey
never set - and a zero DataPermission means Permission() adds no WHERE clause
at all. A database hiccup turned into full visibility, silently.

The neighbouring newDataPermission branch already aborts. This one now does the
same.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:24:12 +08:00
wenjianzhang 34773a0a81 Merge pull request #894 from go-admin-team/chore/core-v2.4.1
Bump go-admin-core to v2.4.1
2026-09-01 20:47:11 +08:00
zhangwenjian 36a018400b chore🔧(deps): bump go-admin-core to v2.4.1
Documentation wording only; no code change between the two.
2026-09-01 20:42:36 +08:00
wenjianzhang 7bb02c5f1d Merge pull request #893 from go-admin-team/docs/contract-wording
Describe the rules rather than who follows them
2026-09-01 20:38:34 +08:00
zhangwenjian 15fb128236 docs📝: describe the rules rather than who follows them
The warning on Authorizator matters to anyone keeping a copy of that file, not
to one particular consumer, and it reads better addressed to all of them: check
what reads those context keys before taking this change.
2026-09-01 19:56:27 +08:00
wenjianzhang 3581e060ec Merge pull request #891 from go-admin-team/feat/003-app-prep
Groundwork for installable applications
2026-09-01 19:38:23 +08:00
zhangwenjian 0604a29596 feat✨(server): run the startup hooks through core
The package-level AppRouters slice keeps working and keeps running first, so
a fork that only ever appended to it sees no change. What is new is that the
core registry runs too, and that before callbacks run at all - this server
never had a loop for them.

Both go through core RunAppRouters / RunBefore, which brings the panic guard
and the seal with them.
2026-09-01 18:18:38 +08:00
zhangwenjian d8a2958797 chore🔧(deps): bump go-admin-core to v2.4.0 2026-09-01 18:18:38 +08:00
zhangwenjian ab28fa7bed docs📝: write down what a third-party app may depend on 2026-09-01 17:45:41 +08:00
zhangwenjian e88d751039 chore🔧: run the silent-failure checks in CI 2026-09-01 17:45:41 +08:00
zhangwenjian b836945eea feat✨: add checksilent, for the failures that do not report themselves
Six checks, five at ERROR and one - the cross-repository menu-name comparison -
at WARN, because it can only match by regular expression across two modules and
a false positive that fails CI teaches people to silence the tool.

The summary names which contract roots were actually scanned: core/ is a
separate module with no directory here, and a check that quietly covers less
than it claims is worse than no check.
2026-09-01 17:45:41 +08:00
zhangwenjian d7a8e66753 feat✨: add migrate status, --dry-run and --app
--app rejects a code nothing was registered under, on all three paths. It used
to take a typo as "nothing matched" and report success: migrate said the app
was unknown and still exited 0, while --dry-run and status printed the same
words an up-to-date database produces.
2026-09-01 17:45:40 +08:00
zhangwenjian 68780a845c feat✨: register migrations under an app code with ForApp 2026-09-01 17:45:40 +08:00
zhangwenjian 487dc94a2e feat✨: record on sys_migration which app a migration belongs to 2026-09-01 17:45:40 +08:00
zhangwenjian 016e977776 refactor🎨: drop Authorizator assertions that never matched
The map Authorizator receives is built by IdentityHandler in the same file and
carries IdentityKey / UserName / RoleKey / UserId / RoleIds / DataScope - not
user and role. Both assertions failed on every request, and because the ok
result was discarded the five c.Set calls stored zero values and the function
returned true anyway. Nothing in this repository or in core reads those keys.

go-admin-pro has its own copy of this file and does read them; this change
must not be carried over there verbatim.
2026-09-01 17:45:40 +08:00
zhangwenjian dcfe512204 refactor🎨: move the operation log status constants out of app/admin
common/middleware imported app/admin/service/dto for two string constants,
which made a package apps are told to build on depend on one particular app.
2026-09-01 17:45:40 +08:00
zhangwenjian fe6ebfd47c chore🔧: ignore the Go workspace files and the checksilent binary
go.work points this module at a local checkout of go-admin-core while the
two are developed together. It is a local tool and must never be committed:
CI resolves core from go.mod.

checksilent is where `go build ./tools/checksilent` drops its binary - four
megabytes beside the server one, which was already ignored by name.
2026-09-01 17:45:40 +08:00
wenjianzhang eba5fba3da Merge pull request #889 from go-admin-team/fix/password-hook-and-body-buffering
fix: a password hook that could destroy credentials, and a body copy on every request
2026-09-01 14:22:13 +08:00
zhangwenjian b7e9a79225 fix🐛: refuse an out-of-scope API update with the permission message
The previous commit added a data-permission scope to SysApi.Update and
returned early on db.Error, which left the RowsAffected check below it
unreachable: First reports a row the scope excluded as ErrRecordNotFound,
so the caller got "record not found" where the code meant to say
"无权更新该数据".

Map that one error to the permission message and drop the check it made
dead. The two cases - the row does not exist, and the row exists but is
not yours - have to look the same from outside, and now do.

Found by Copilot's review of #889.
2026-09-01 14:17:20 +08:00
wenjianzhang deffb19fd8 Merge pull request #888 from go-admin-team/ci/run-tests
Run the test suite in CI
2026-09-01 11:38:10 +08:00
zhangwenjian c858b322bd fix🐛: apply the data permission when updating an API
SysApi.Update took a DataPermission and never used it, so with data
permission enabled the update reached rows the caller could not read
through GetPage, Get or Remove, which all scope the query. It also
reported "无权更新该数据" for a row that simply did not exist, a message
that only becomes true once the scope is applied.

Drops the Debug() left on the query, which logged the statement for
every call.
2026-09-01 11:35:51 +08:00
zhangwenjian 1b5b52f0f1 perf👌: only read the request body when the operation log will store it
LoggerToFile is registered on the engine, so every POST, PUT, GET and
DELETE had its body copied into memory - through a bytes.Buffer, a
ReadAll and a string conversion - before any handler ran. The only
consumer is operParam on the operation-log row, which is written when
logger.enableddb is on, and that is off in the shipped configuration.

There was no size limit either, and a file upload is a POST like any
other: a 1MB request allocated 4.3MB here and a 16MB upload allocated
about 67MB, to build a value nobody stored.

The body is now read only when the operation log will use it, and at
most 32KB of it. The handler still receives the whole request: it reads
the copied part from memory and the rest from the connection, so what
this holds is bounded however large the request is. 32KB also keeps the
value inside the TEXT column it is written to.

The bufio.Writer this replaces was never flushed. Nothing was truncated
only because bytes.Buffer implements io.ReaderFrom, so io.Copy bypassed
the buffer entirely - a different destination would have dropped the
tail of every request body.
2026-09-01 11:35:45 +08:00
zhangwenjian ecb31a158b fix🐛: stop re-hashing a password that is already hashed
BeforeCreate and BeforeUpdate run Encrypt on whatever is in the struct,
and a user read from the database carries the stored hash in Password.
Hashing it again produces a hash of a hash: the password that user knows
stops matching, they cannot log in, and nothing reports an error.

Only the Omit("password") on SysUser.Update stood between that and the
stored credential. Any other write to this model - a profile update
written the way every other model here is written - destroys the
password, permanently and silently.

Encrypt now returns early when Password already parses as a bcrypt hash.
That also removes the round SysUser.Update was paying and discarding:
306ns where it was 54.7ms, on a route reachable without the permission
check, since PUT /api/v1/sys-user is in CasbinExclude.

The cost of deciding from the value is that a password which is itself a
well-formed bcrypt hash would be stored unchanged. That is a
60-character string beginning "$2a$", and it grants whoever set it no
access they did not already have.
2026-09-01 11:35:33 +08:00
zhangwenjian 1aecc140dc ci🔧: run the test suite on every push and pull request
The repository has 19 test files and nothing was running any of them. Both
workflows build with go build, which does not compile _test.go, the Makefile's
test target was commented out, and there is no pre-commit hook. Every test in
the tree, including the schema guards that exist precisely to catch a silent
breakage, only ran when someone remembered to type go test.

Enables the commented-out target and calls it from go.yml, the one workflow
that fires on every push and pull request. build.yml is left alone: it skips
documentation-only changes and deploys on master, so it is the wrong place for
a gate that should never be skipped.

Runs with -race. common/actions reuses model instances across concurrent
requests, so a Generate() that returns in place rather than a copy leaks data
between them, which a single-threaded run cannot see.

Verified locally: the suite passes under CGO_ENABLED=0 and under -race, and
make test exits non-zero when a test fails, so the step actually gates.

Claude-Session: https://claude.ai/code/session_01DJhM6LvhkNPej35wy9F7Aq
2026-09-01 11:33:08 +08:00
wenjianzhang e464a4aedd Merge pull request #887 from go-admin-team/fix/migration-model-soft-delete-drift
Guard new migrations against the frozen seed models
2026-08-31 15:06:05 +08:00
wenjianzhang 595c4a6be5 Merge pull request #885 from go-admin-team/fix/casbin-tenant-and-pattern-cache
fix: key the casbin enforcer by tenant, and stop recompiling patterns in the exclusion scan
2026-08-31 15:01:42 +08:00
zhangwenjian d115c5299c docs📝: say which models package a new migration may seed through
The hazard had no signal at its point of contact. Someone adding a business
module is told to copy 1786700001000_demo_menu.go, which imports the frozen
seed models - correct for that file, wrong for anything ordered after the
soft-delete conversion. The frozen ModelTime carried no comment at all, so
opening it taught the reader nothing.

Documents the boundary in three places the author actually passes through:
the frozen type itself, the contributor guide, and the module-scaffolding
skill, which previously said to copy the reference file verbatim and now
says to copy its structure but not its imports.

Claude-Session: https://claude.ai/code/session_01DJhM6LvhkNPej35wy9F7Aq
2026-08-31 15:01:26 +08:00
zhangwenjian 9bd542bb59 test✅: guard post-conversion migrations against the frozen seed models
Migrations ordered after 1786700003000 must not seed through
cmd/migrate/migration/models. That package's ModelTime declares a nullable
gorm.DeletedAt, which is the shape the columns had until that migration
converted deleted_at to a NOT NULL millisecond marker.

Afterwards it breaks in both directions. Writes put NULL into a NOT NULL
column and fail on the first insert. Reads are scoped "WHERE deleted_at IS
NULL" while live rows hold 0, so they match nothing - and 1786700001000
looks the admin role up that way and treats ErrRecordNotFound as "roles are
not seeded yet, skip authorisation", which would leave a module seeded with
no permissions and the migration still recorded as applied.

A fresh database does not surface either one: every migration using that
package today is ordered before the conversion, so it runs while the column
is still nullable. Only a migration added afterwards hits it.

Also pulls the import scan out of importsRuntimeModels so both checks share
one implementation, and derives the version through migration.GetFilename
rather than a second filename-parsing rule.

Verified by adding a violating migration and confirming the test fails with
an actionable message, then removing it and confirming the suite passes.

Claude-Session: https://claude.ai/code/session_01DJhM6LvhkNPej35wy9F7Aq
2026-08-31 15:01:11 +08:00
zhangwenjian 0fa015b6d0 perf👌: stop recompiling patterns when scanning the casbin exclusion list
AuthCheckRole walks CasbinExclude for every non-admin request, and used
casbin's util.KeyMatch2 to test each entry. That delegates to
util.RegexMatch, which is regexp.MatchString - it compiles its pattern on
every call - so a 32-entry list cost about 2,566 allocations per request
before the request reached Enforce.

Test the method first, which rules out most entries with a string
compare, and take the path test from go-admin-core, whose KeyMatch2
answers the same thing without recompiling. The scan drops to 52ns and no
allocations.

The loop moves out of AuthCheckRole so the tests exercise the code a
request runs rather than a copy of it, and an allocation budget fails if
the uncached matcher comes back.
2026-08-31 14:01:32 +08:00
zhangwenjian ec7d838ebd fix🐛: key the casbin enforcer by tenant host
setupSimpleDatabase runs once per configured database - one per host in
the multi-tenant configuration - and passed the same empty key to
mycasbin.Setup every time. Setup caches per key, so every host after the
first was handed the enforcer built from the first host's database and
was authorized against a casbin_rule table that was not its own.

Takes effect with the go-admin-core release that keys the cache; before
it, Setup ignored the argument entirely.
2026-08-30 10:03:26 +08:00
wenjianzhang 26e116c16c Merge pull request #884 from go-admin-team/ci/skip-deploy-for-docs
ci🔧: skip the deploy workflow for documentation-only changes
2026-08-29 14:49:39 +08:00
zhangwenjian 90d98893f5 ci🔧: skip the deploy workflow for documentation-only changes
A push to master here does not just build: it pushes an image, runs the
migrations and restarts the demo container, so the site takes a short outage
each time. The last two merges were markdown only and both paid for it.

Beyond the waste, a deploy can fail for reasons unrelated to the change that
triggered it - a container that will not come up, a database that is briefly
unreachable - and a README edit should not be able to turn the demo red.

Only build.yml is filtered. go.yml still builds on every push and pull request,
so nothing loses its compile check, and the badge keeps reporting the same
workflow it reports today.
2026-08-29 14:29:05 +08:00
wenjianzhang 10f162bf5d Merge pull request #883 from go-admin-team/fix/readme-vitepress-syntax
docs📝: drop VitePress container syntax GitHub cannot render
2026-08-29 14:28:11 +08:00
zhangwenjian 19909746f5 docs📝: drop VitePress container syntax GitHub cannot render
`:::tip` and its closing `:::` are VitePress custom containers. GitHub has no
such syntax, so both markers rendered as literal text: a paragraph beginning
":::tip" and a stray ":::" sitting alone above the next heading.

The Chinese README carries the same warning as a plain paragraph, which GitHub
renders correctly, so the English one now matches it. Verified through GitHub's
own markdown API: the literal marker no longer appears in the output and the
warning survives as ordinary text.

Only README.md was affected; the other three never had it.
2026-08-28 20:36:24 +08:00
wenjianzhang 205febdb8a Merge pull request #882 from go-admin-team/docs/readme-links-and-languages
docs📝: fix the badges and links, add Traditional Chinese and Japanese READMEs
2026-08-28 20:32:13 +08:00
zhangwenjian 5aec4ba32b docs📝: add Traditional Chinese and Japanese READMEs
The project had English and Simplified Chinese. These two follow the same
structure - same sections, same code blocks, same contributor list - so a
reader in any of the four sees the same document.

The Traditional Chinese is a translation rather than a character conversion of
the Simplified: the terminology differs (設定檔, 資料庫, 選單, 程式碼產生,
排程任務, 相依套件), and a converted file would read as machine output to
anyone who actually uses it.

Language navigation across all four is unified in the same commit, since a link
to a file that does not exist yet would be worse than no link.
2026-08-28 20:19:50 +08:00
zhangwenjian 8f1ea50dfe docs📝: point the badges and documentation links at the right places
The build badge rendered "build - failing" on both READMEs while CI was green.
It referenced the workflow under the old personal repository path, where the
status has been stale for years - so the first thing anyone saw on opening the
project was a failed build. It now points at the current repository, names the
workflow file explicitly, and pins the branch, so it reports master rather than
whatever happens to be the default branch later.

The workflow it reports on is go.yml, which is what the old badge referenced
by workflow name and is the right one to show: build.yml also deploys the demo
site, so a server-side problem there would turn the badge red while the code
is fine.

The licence badge read from mashape/apistatus, the example repository from
shields.io's own documentation. It happened to show MIT, the same licence this
project uses, so nobody noticed - but it reports someone else's licence.

Documentation links were spread across three hosts: doc.go-admin.dev redirects
to www.go-admin.pro, www.go-admin.dev serves byte-identical content, and only
the Chinese README linked the canonical host at all. All of them now point at
www.go-admin.pro directly rather than relying on a redirect outliving the
domain that issues it.

Two smaller ones: the gorm link pointed at the archived v1 repository while the
project builds on gorm.io v2, and the English introduction listed two UI kits
where the Chinese listed three, with an Ant Design demo linked directly below.
2026-08-28 20:19:50 +08:00
wenjianzhang 1483ca401d Merge pull request #881 from go-admin-team/fix/production-defaults
fix🐛: the settings a deployment needs, and the ones that were leaking
2026-08-28 19:50:18 +08:00
zhangwenjian ed74623a73 test✅: add an end-to-end load test harness
Skipped unless GOADMIN_BENCH_ADDR points at a running server, so `go test
./...` is unaffected.

Reports latency percentiles rather than an average, which is what capacity
planning needs, and a status-code distribution - that last part is how the rate
limiter's 200-on-rejection was found, since throughput alone looked excellent
while nothing reached a handler.

Includes a routing-floor control case. When a business endpoint matches it, the
measurement has stopped describing the endpoint and started describing the
transport, or the load generator when both share a machine.
2026-08-28 19:42:38 +08:00
zhangwenjian 1bc2e22833 fix🐛: give the config templates the defaults a deployment actually needs
Two settings that decide whether a deployment survives load, neither of which
appeared in any template.

The connection pool. Left unset, Go's defaults apply, and MaxIdleConns is 2:
under load almost every request opens a TCP connection and closes it again,
local ports run out, and the process answers "can't assign requested address"
to everything. Not slower - unavailable. A sweep against MySQL collapsed to
zero successful responses at 64 concurrent requests without these, and served
13,846 req/s with no errors once they were set.

The queue buffer. poolSize is the point at which messages start being dropped,
not a tuning knob: a full queue discards the message and returns an error
rather than blocking, and each stream has one consumer goroutine writing to the
database. At the previous default of 100 a load test lost over 60% of them; at
1000, none. Login and operation logs travel this queue, so what gets lost is
audit data - though only when logger.enableddb is on.

Both carry the reasoning in the file, because the failure mode of each is
invisible until it happens in production.
2026-08-28 19:42:38 +08:00
zhangwenjian cd8edfa5d4 fix🐛: reject rate-limited requests with 429 and make the threshold configurable
A rejected request answered 200 with the failure only in the body, so every
layer that reads the status line counted it as served: load balancers, metrics,
client-side retry. A load test against this reported the limiter's own
rejections as successful traffic and overstated throughput more than tenfold.

The threshold was a constant in the middleware, which made 200 QPS the ceiling
of every deployment with nothing in the configuration to reveal it. It now
reads extend.rateLimit.inboundQPS; an absent value keeps 200, so an upgrade
changes nothing, and zero disables the limiter for a deployment behind its own
gateway.

Also drops Strategy: system.BBR. Reading sentinel's source, the adaptive
strategy is consulted only for Load and CpuUsage - for InboundQPS the trigger
count is compared directly - so it read as if the limit adapted to the machine
when it never did.
2026-08-28 19:42:21 +08:00
zhangwenjian dcc2c8e175 fix🔒: stop logging the captcha answer
The answer was written at info level on every captcha request, so a currently
valid answer sat in the application log. Anyone able to read the log - an
operator, a log aggregator, anything that ships logs off the host - could
bypass the check the captcha exists to enforce.

The default log level records it, so this was not limited to debug builds.
2026-08-28 19:42:21 +08:00
zhangwenjian d991a285ba chore🔧: upgrade go-admin-core to v2.2.0
Carries four concurrency fixes and a bounded in-memory cache. The two that
reach this repository are the search resolver, which no longer panics on an
unexported field in a DTO and skips tag parsing for zero-valued ones, and the
captcha driver, which is built once rather than per request.

The cache bound does not apply here: config.CacheConfig.Setup() returns the
older Memory implementation, which core leaves unbounded.
2026-08-28 19:42:19 +08:00
64 changed files with 6020 additions and 209 deletions
+12 -2
View File
@@ -49,8 +49,18 @@ model、dto、router 三个文件,完整写法照抄 `app/demo/` 的结构。
### 4. 写菜单、接口与权限种子数据
这一步最容易被漏掉——代码能编译、接口能测通,但界面上看不到菜单、点了按钮说
没权限,往往就是漏了这一步。**完整参照 `cmd/migrate/migration/version/1786700001000_demo_menu.go`**
——那是可运行、幂等(用 `upsert`,重复跑不会报错)的真实例子,逐字照抄结构,只换 ID 和业务字段。
没权限,往往就是漏了这一步。结构参照 `cmd/migrate/migration/version/1786700001000_demo_menu.go`
——它是可运行、幂等(用 `upsert`,重复跑不会报错)的真实例子。
:::danger
**但不要照抄它的 import。** 那个文件用的是 `cmd/migrate/migration/models`,
只因为它的版本号排在软删除转换(`1786700003000`)之前才是安全的。
**你新写的迁移版本号在转换之后,必须改用 `app/` 下的运行时模型**
(`app/admin/models.SysApi`、`SysMenu`),否则第一条 insert 就会
`NOT NULL constraint failed: sys_api.deleted_at`。
`TestPostConversionMigrationsAvoidFrozenSeedModels` 会拦住这个错误。
:::
一个模块要在界面上可用,需要四类数据,缺一样都不行:
+17
View File
@@ -1,10 +1,27 @@
name: Build
# Documentation-only changes skip this workflow entirely.
#
# A push to master here does not just build - it pushes an image, runs the
# migrations and restarts the demo container, so the site takes a short outage.
# Paying that for a README edit is waste at best; at worst a deploy fails for a
# reason unrelated to anything in the change. Code coverage is unaffected,
# because go.yml still builds every push and pull request.
on:
push:
branches: [ master ]
paths-ignore:
- '**.md'
- 'docs/**'
- 'LICENSE*'
- '.github/ISSUE_TEMPLATE/**'
pull_request:
branches: [ master ]
paths-ignore:
- '**.md'
- 'docs/**'
- 'LICENSE*'
- '.github/ISSUE_TEMPLATE/**'
# One deploy at a time. Two merges seconds apart raced here: both runs did
# docker rm -f then docker run, the second removed the container the first had
+14
View File
@@ -28,9 +28,23 @@ jobs:
- name: Get dependencies
run: go mod tidy
# go build does not compile _test.go, so building alone never ran a single
# test. This is the only workflow that fires on every push and pull request,
# which makes it the one place a test gate belongs.
- name: Test
run: make test
- name: Build
run: make build
# Fails the build on the silent-failure classes listed in
# tools/checksilent, one of which is the contract boundary: nothing under
# common/ may import app/. A boundary that is only written down erodes; this
# is what keeps it true.
- name: Silent-failure checks
run: make checksilent
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
if: startsWith(github.ref, 'refs/tags/')
+11
View File
@@ -6,6 +6,10 @@ main.exe
*.exe
go-admin
go-admin.exe
# `go build ./tools/checksilent` drops the binary here, next to the one for the
# server. Anchored with a leading slash: unanchored, the same pattern matches
# tools/checksilent/ as well and the tool's own source never gets committed.
/checksilent
temp/
!temp
vendor
@@ -29,3 +33,10 @@ CLAUDE.md
.claude/skills/*
!.claude/skills/new-business-module/
config/settings.local.dev.yml
# Go workspace files. They exist to point this module at a local checkout of
# go-admin-core while the two are developed together, which is a private
# arrangement between one machine's directories - committing one would break
# the build for everyone else.
go.work
go.work.sum
+51
View File
@@ -117,6 +117,18 @@ func (SysPost) TableName() string { return "sys_post" }
`TableName()` 必须显式声明(GORM 配置了 `SingularTable`,不会自动推导复数)。
## 公共契约面
第三方应用(`app/` 下的业务模块)可以稳定依赖哪些包、路由与迁移怎么注册、
哪些约束是硬的,见 `docs/contract.md`。
两条与主仓贡献者直接相关的:
- **`common/`、`core/` 不得 import `app/`** —— `make checksilent` 在 CI 里守着,违反即红。
- **注册类 API(`AppRouters` / `sdk.Runtime.SetAppRouters` / `migration.ForApp`)
只允许在 `init()` 中调用** —— 注册期靠 Go 的包初始化顺序保证无并发写,
core 侧的 setter 没有加锁。
## 路由注册
通过 `init()` 自注册,不在中心文件手工添加:
@@ -193,6 +205,45 @@ go run -tags sqlite3 . server -c config/settings.sqlite.yml
`git status` 看不到,PR 里也不会出现。两个目录的包名分别是 `version` 与
`version_local`(后者与目录名不一致,因为标识符不能含连字符)。
### 写种子数据用哪个 models 包
`1786700003000` 之后新增的迁移,**种子数据要用 `app/` 下的运行时模型**
(如 `app/admin/models.SysApi`、`SysMenu`),**不要用 `cmd/migrate/migration/models`**。
后者的 `ModelTime` 声明的是可空的 `gorm.DeletedAt`,这对它之前的迁移是对的(那正是
当时列的形状),转换之后就不再成立,两个方向都会出问题:
- **写**:往 NOT NULL 列里塞 NULL,第一条 insert 就 `NOT NULL constraint failed`
- **读**:GORM 拼 `WHERE deleted_at IS NULL`,而活跃行存的是 `0`,静默查不到——
照抄 `demo_menu.go` 的授权段落会因此跳过授权,菜单建好、权限没授、迁移仍记为成功
干净库跑不出这个问题,今天所有用该包的迁移都排在转换之前。完整推导见
`schema_coverage_test.go` 里 `TestPostConversionMigrationsAvoidFrozenSeedModels`
的注释,那个测试也守着这条边界。
## 静默失败校验
`make checksilent` 检查六类**不报错、不记日志、行为悄悄变得不对**的问题,
CI 会跑,命中 ERROR 即失败:
| 检查 | 级别 | 静默后果 |
|---|---|---|
| `modeltime-mix` | ERROR | 两个 `ModelTime` 混用,整张表查不到数据 |
| `menu-sort-overflow` | ERROR | 菜单 `sort` 超 127,MySQL tinyint 拒绝写入,迁移中断 |
| `config-value-truncation` | ERROR | `sys_config.config_value` 超 255 字符被静默截断 |
| `menu-id-collision` | ERROR | 两个模块硬编码同一菜单 ID,互相覆盖 |
| `contract-import-boundary` | ERROR | 契约包 import `app/`,应用无法独立编译 |
| `menu-name-mismatch` | WARN | 菜单名与前端组件 `name` 不一致,keep-alive 缓存静默失效 |
最后一条要跨仓库比对,只能做正则启发式,因此是 WARN,**不影响退出码**,
且默认跳过;要跑它得指定前端目录:
```bash
make checksilent UI_DIR=../go-admin-ui/src
```
升级门槛:连续 2 个发版周期零误报后转为 ERROR。
## 提交规范
格式 `type+emoji: 描述`:
+21 -3
View File
@@ -37,9 +37,27 @@ stop:
#@echo "go-admin stop success"
#.PHONY: test
#test:
# go test -v ./... -cover
# -race is worth the extra minute here: common/actions reuses model instances
# across concurrent requests, so a Generate() that returns in place instead of
# a copy leaks data between them - and that is invisible to a single-threaded
# test run.
.PHONY: test
test:
go test -race -cover ./...
# Reports the failures that do not announce themselves - see
# tools/checksilent. Exits non-zero on an ERROR; the one WARN-level check
# prints and does not fail the build.
#
# Pass UI_DIR to enable the cross-repository menu-name check, which is skipped
# without it: make checksilent UI_DIR=../go-admin-ui/src
.PHONY: checksilent
checksilent:
ifdef UI_DIR
go run ./tools/checksilent -ui-dir $(UI_DIR)
else
go run ./tools/checksilent
endif
#.PHONY: docker
#docker:
+7 -7
View File
@@ -3,11 +3,11 @@
<img align="right" width="320" src="https://doc-image.zhangwj.com/img/go-admin.svg">
[![Build Status](https://github.com/wenjianzhang/go-admin/workflows/build/badge.svg)](https://github.com/go-admin-team/go-admin)
[![Build Status](https://github.com/go-admin-team/go-admin/actions/workflows/go.yml/badge.svg?branch=master)](https://github.com/go-admin-team/go-admin)
[![Release](https://img.shields.io/github/release/go-admin-team/go-admin.svg?style=flat-square)](https://github.com/go-admin-team/go-admin/releases)
[![License](https://img.shields.io/github/license/mashape/apistatus.svg)](https://github.com/go-admin-team/go-admin)
[![License](https://img.shields.io/github/license/go-admin-team/go-admin.svg)](https://github.com/go-admin-team/go-admin)
[English](https://github.com/go-admin-team/go-admin/blob/master/README.md) | 简体中文
[English](https://github.com/go-admin-team/go-admin/blob/master/README.md) | 简体中文 | [繁體中文](https://github.com/go-admin-team/go-admin/blob/master/README.zh-TW.md) | [日本語](https://github.com/go-admin-team/go-admin/blob/master/README.ja-JP.md)
基于Gin + Vue + Element UI OR Arco Design OR Ant Design的前后端分离权限管理系统,系统初始化极度简单,只需要配置文件中,修改数据库连接,系统支持多指令操作,迁移指令可以让初始化数据库信息变得更简单,服务指令可以很简单的启动api服务
@@ -78,9 +78,9 @@ antd 体验(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admi
### 轻松实现go-admin写出第一个应用 - 文档教程
[步骤一 - 基础内容介绍](https://doc.go-admin.dev/guide/intro/tutorial01.html)
[步骤一 - 基础内容介绍](https://www.go-admin.pro/guide/intro/tutorial01.html)
[步骤二 - 实际应用 - 编写增删改查](https://doc.go-admin.dev/guide/intro/tutorial02.html)
[步骤二 - 实际应用 - 编写增删改查](https://www.go-admin.pro/guide/intro/tutorial02.html)
### 手把手教你从入门到放弃 - 视频教程
@@ -173,7 +173,7 @@ D:\Code\go-admin>go build
cgo: exec gcc: exec: "gcc": executable file not found in %PATH%
```
[解决cgo问题进入](https://doc.go-admin.dev/zh-CN/guide/faq#cgo-%E7%9A%84%E9%97%AE%E9%A2%98)
[解决cgo问题进入](https://www.go-admin.pro/zh-CN/guide/faq#cgo-%E7%9A%84%E9%97%AE%E9%A2%98)
#### 初始化数据库,以及服务启动
@@ -327,7 +327,7 @@ pnpm dev
4. [gin](https://github.com/gin-gonic/gin)
5. [casbin](https://github.com/casbin/casbin)
6. [spf13/viper](https://github.com/spf13/viper)
7. [gorm](https://github.com/jinzhu/gorm)
7. [gorm](https://github.com/go-gorm/gorm)
8. [gin-swagger](https://github.com/swaggo/gin-swagger)
9. [golang-jwt](https://github.com/golang-jwt/jwt)
10. [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
+350
View File
@@ -0,0 +1,350 @@
# go-admin
<img align="right" width="320" src="https://doc-image.zhangwj.com/img/go-admin.svg">
[![Build Status](https://github.com/go-admin-team/go-admin/actions/workflows/go.yml/badge.svg?branch=master)](https://github.com/go-admin-team/go-admin)
[![Release](https://img.shields.io/github/release/go-admin-team/go-admin.svg?style=flat-square)](https://github.com/go-admin-team/go-admin/releases)
[![License](https://img.shields.io/github/license/go-admin-team/go-admin.svg)](https://github.com/go-admin-team/go-admin)
[English](https://github.com/go-admin-team/go-admin/blob/master/README.md) | [简体中文](https://github.com/go-admin-team/go-admin/blob/master/README.Zh-cn.md) | [繁體中文](https://github.com/go-admin-team/go-admin/blob/master/README.zh-TW.md) | 日本語
Gin + Vue + Element UI / Arco Design / Ant Design による、フロントエンドとバックエンドを分離した権限管理システムです。初期化は非常に簡単で、設定ファイルのデータベース接続情報を変更するだけで動作します。複数のコマンドに対応しており、マイグレーションコマンドでデータベースの初期化が容易になり、サーバーコマンドで API を手軽に起動できます。
[オンラインドキュメント](https://www.go-admin.pro)
[フロントエンドプロジェクト](https://github.com/go-admin-team/go-admin-ui)
[動画チュートリアル](https://space.bilibili.com/565616721/channel/detail?cid=125737)
## 🎬 オンラインデモ
Element Plus vue3 デモ:[https://vue.go-admin.pro](https://vue.go-admin.pro/#/login)
> ⚠️⚠️⚠️ アカウント / パスワード: admin / 123456
antd デモ(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admin.pro/)
> ⚠️⚠️⚠️ アカウント / パスワード: admin / 123456
## ✨ 特徴
- RESTful API の設計規約に準拠
- GIN WEB API フレームワークをベースに、豊富なミドルウェアを提供(ユーザー認証、CORS、アクセスログ、トレース ID など)
- Casbin による RBAC アクセス制御モデル
- JWT 認証
- Swagger ドキュメントに対応(swaggo ベース)
- GORM によるデータベース永続化、複数種類のデータベースに拡張可能
- 設定ファイルからモデルへの単純なマッピングで、必要な設定をすぐに取得
- コード生成ツール
- フォームビルダー
- マルチコマンド方式
- マルチテナント対応
- TODO: ユニットテスト
## 🎁 標準機能
1. マルチテナント:デフォルトで対応。データベース単位で分離し、1 データベースにつき 1 テナント。
1. ユーザー管理:システムの操作者であるユーザーの設定を行います。
2. 部門管理:組織構造(会社・部門・グループ)を設定します。ツリー構造で表示し、データ権限に対応します。
3. 役職管理:ユーザーが担当する職務を設定します。
4. メニュー管理:メニュー、操作権限、ボタン権限識別子、API 権限などを設定します。
5. ロール管理:ロールへのメニュー権限の割り当て、および組織単位でのデータ範囲権限の設定を行います。
6. 辞書管理:システム内で頻繁に使う固定的なデータを管理します。
7. パラメータ管理:よく使うパラメータを動的に設定します。
8. 操作ログ:正常系の操作ログと異常情報のログを記録・検索します。
9. ログインログ:ログイン履歴を記録・検索します。ログイン異常も含みます。
1. API ドキュメント:業務コードから API ドキュメントを自動生成します。
1. コード生成:テーブル定義から CRUD 業務を生成します。すべて画面上で操作でき、基本的な業務をコードなしで実現できます。
1. フォームビルダー:ページのスタイルをカスタマイズし、ドラッグ&ドロップでレイアウトを作成します。
1. サービス監視:サーバーの基本情報を確認します。
1. コンテンツ管理:デモ機能。カテゴリ管理とコンテンツ管理を含み、入門用の参考実装として利用できます。
1. スケジュールタスク:自動実行タスク。現在は API 呼び出しと関数呼び出しに対応しています。
## 事前準備
ローカルに [go] [gin] [node](http://nodejs.org/) と [git](https://git-scm.com/) をインストールしてください。
ダウンロードから使いこなすまでを解説した動画とドキュメントのチュートリアルを用意しています。本プロジェクトを試す前に、まずこれらに目を通すことを強くおすすめします。
### go-admin で最初のアプリケーションを作る - ドキュメント
[ステップ 1 - 基礎の紹介](https://www.go-admin.pro/guide/intro/tutorial01.html)
[ステップ 2 - 実践 - CRUD を書く](https://www.go-admin.pro/guide/intro/tutorial02.html)
### 動画チュートリアル
[go-admin の起動方法](https://www.bilibili.com/video/BV1z5411x7JG)
[生成ツールで業務を手軽に実装する](https://www.bilibili.com/video/BV1Dg4y1i79D)
[v1.1.0 のコード生成ツール](https://www.bilibili.com/video/BV1N54y1i71P) [応用]
[マルチコマンドでの起動方法と IDE 設定](https://www.bilibili.com/video/BV1Fg4y1q7ph)
[go-admin のメニュー設定](https://www.bilibili.com/video/BV1Wp4y1D715) [必見]
[メニュー情報と API 情報の設定方法](https://www.bilibili.com/video/BV1zv411B7nG) [必見]
[go-admin の権限設定](https://www.bilibili.com/video/BV1rt4y197d3) [必見]
[go-admin のデータ権限](https://www.bilibili.com/video/BV1LK4y1s71e) [必見]
**不明点はまず上記のドキュメントと記事をご確認ください。解決しない場合は issue や pr をお寄せください。動画とドキュメントは継続的に更新しています**
## 📦 ローカル開発
### 動作要件
go 1.26.5
node バージョン: v22 以上(v24 LTS 推奨)
パッケージマネージャー: pnpm v9 以上(UI プロジェクトは pnpm を使用)
### 開発ディレクトリの作成
```bash
# 開発ディレクトリを作成
mkdir goadmin
cd goadmin
```
### コードの取得
> 重要:2 つのプロジェクトは同じディレクトリに配置してください。
```bash
# バックエンドのコードを取得
git clone https://github.com/go-admin-team/go-admin.git
# フロントエンドのコードを取得
git clone https://github.com/go-admin-team/go-admin-ui.git
```
### 起動方法
#### サーバーの起動
```bash
# go-admin バックエンドプロジェクトへ移動
cd ./go-admin
# 依存関係を整理
go mod tidy
# ビルド
go build
# 設定を変更
# ファイルパス go-admin/config/settings.yml
vi ./config/settings.yml
# 1. 設定ファイル内のデータベース情報を変更
# 注意: settings.database 配下の設定項目
# 2. log のパスを確認
```
⚠️注意 Windows 環境で CGO が未導入の場合、次のエラーが発生します。
```bash
E:\go-admin>go build
# github.com/mattn/go-sqlite3
cgo: exec /missing-cc: exec: "/missing-cc": file does not exist
```
or
```bash
D:\Code\go-admin>go build
# github.com/mattn/go-sqlite3
cgo: exec gcc: exec: "gcc": executable file not found in %PATH%
```
[cgo の問題の解決方法はこちら](https://www.go-admin.pro/zh-CN/guide/faq#cgo-%E7%9A%84%E9%97%AE%E9%A2%98)
#### データベースの初期化とサービス起動
``` bash
# 初回はデータベースのリソース情報を初期化する必要があります
# macOS または linux の場合
$ ./go-admin migrate -c config/settings.dev.yml
# ⚠️注意: windows の場合
$ go-admin.exe migrate -c config/settings.dev.yml
# プロジェクトを起動します。IDE からデバッグ実行することもできます
# macOS または linux の場合
$ ./go-admin server -c config/settings.yml
# ⚠️注意: windows の場合
$ go-admin.exe server -c config/settings.yml
```
#### sys_api テーブルへのデータ追加方法
起動時に `-a true` を付けると、不足している API データが自動的に追加されます。
```bash
./go-admin server -c config/settings.yml -a true
```
#### docker でのビルドと起動
```shell
# イメージをビルド
docker build -t go-admin .
# コンテナを起動します。1 つ目の go-admin はコンテナ名、2 つ目はイメージ名です
# -v は設定ファイルのマウント ローカルパス:コンテナ内パス
docker run --name go-admin -p 8000:8000 -v /config/settings.yml:/config/settings.yml -d go-admin-server
```
#### ドキュメント生成
```bash
go generate
```
#### クロスコンパイル
```bash
# windows
env GOOS=windows GOARCH=amd64 go build main.go
# or
# linux
env GOOS=linux GOARCH=amd64 go build main.go
```
### UI 側の起動方法
```bash
# pnpm をインストール(未導入の場合)
npm install -g pnpm
# 依存関係をインストール
pnpm install
# 中国本土のネットワークではミラーを指定すると高速化できます
pnpm install --registry=https://registry.npmmirror.com
# 開発サーバーを起動
pnpm dev
```
## 📨 コミュニティ
<table>
<tr>
<td><img src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/wx.png" width="180px"></td>
<td><img src="https://doc-image.zhangwj.com/img/qrcode_for_gh_b798dc7db30c_258.jpg" width="180px"></td>
<td><img src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/qq2.png" width="200px"></td>
<td><a href="https://space.bilibili.com/565616721">wenjianzhang</a></td>
</tr>
<tr>
<td>微信</td>
<td>公众号🔥🔥🔥</td>
<td><a target="_blank" href="https://shang.qq.com/wpa/qunwpa?idkey=0f2bf59f5f2edec6a4550c364242c0641f870aa328e468c4ee4b7dbfb392627b"><img border="0" src="https://pub.idqqimg.com/wpa/images/group.png" alt="go-admin技术交流乙号" title="go-admin技术交流乙号"></a></td>
<td>哔哩哔哩🔥🔥🔥</td>
</tr>
</table>
## 💎 コントリビューター
<span style="margin: 0 5px;" ><a href="https://github.com/wenjianzhang" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/3890175?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/G-Akiraka" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45746659?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/lwnmengjing" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/12806223?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/bing127" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31166183?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/chengxiao" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/1379545?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/NightFire0307" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/19854086?v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/appleboy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/21979?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/misakichan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16569274?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/zhuxuyang" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/19301024?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/mss-boot" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/109259065?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/AuroraV" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/37330199?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Vingurzhou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/57127283?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/haimait" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/40926384?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/zyd" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/3446278?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/43291304?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/DemoLiang
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/23476007?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/jfcg
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/1410597?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Nicole0724
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/10487328?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
## JetBrains のオープンソースライセンス支援
`go-admin` は一貫して JetBrains 社の GoLand 統合開発環境で開発されています。**free JetBrains Open Source license(s)** による正規の無償ライセンス提供に、この場を借りて感謝を申し上げます。
<a href="https://www.jetbrains.com/?from=kubeadm-ha" target="_blank"><img src="https://raw.githubusercontent.com/panjf2000/illustrations/master/jetbrains/jetbrains-variant-4.png" width="250" align="middle"/></a>
## 🤝 謝辞
1. [ant-design](https://github.com/ant-design/ant-design)
2. [ant-design-pro](https://github.com/ant-design/ant-design-pro)
2. [arco-design](https://github.com/arco-design/arco-design)
2. [arco-design-pro](https://github.com/arco-design/arco-design-pro)
4. [gin](https://github.com/gin-gonic/gin)
5. [casbin](https://github.com/casbin/casbin)
6. [spf13/viper](https://github.com/spf13/viper)
7. [gorm](https://github.com/go-gorm/gorm)
8. [gin-swagger](https://github.com/swaggo/gin-swagger)
9. [golang-jwt](https://github.com/golang-jwt/jwt)
10. [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
11. [ruoyi-vue](https://gitee.com/y_project/RuoYi-Vue)
12. [form-generator](https://github.com/JakHuang/form-generator)
## 🤟 支援
> このプロジェクトがお役に立ちましたら、作者にジュースを一杯おごる形で応援いただけます :tropical_drink:
<img class="no-margin" src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/pay.png" height="200px" >
## 🤝 関連リンク
- [mss-boot-io](https://docs.mss-boot-io.top/)
## 🔑 License
[MIT](https://github.com/go-admin-team/go-admin/blob/master/LICENSE.md)
Copyright (c) 2026 wenjianzhang
+10 -12
View File
@@ -4,15 +4,15 @@
<img align="right" width="320" src="https://raw.githubusercontent.com/wenjianzhang/image/203c5930b9ed08d5cf2fcb4516b85e412f8e0e60/img/go-admin.svg">
[![Build Status](https://github.com/wenjianzhang/go-admin/workflows/build/badge.svg)](https://github.com/go-admin-team/go-admin)
[![Build Status](https://github.com/go-admin-team/go-admin/actions/workflows/go.yml/badge.svg?branch=master)](https://github.com/go-admin-team/go-admin)
[![Release](https://img.shields.io/github/release/go-admin-team/go-admin.svg?style=flat-square)](https://github.com/go-admin-team/go-admin/releases)
[![License](https://img.shields.io/github/license/mashape/apistatus.svg)](https://github.com/go-admin-team/go-admin)
[![License](https://img.shields.io/github/license/go-admin-team/go-admin.svg)](https://github.com/go-admin-team/go-admin)
English | [简体中文](https://github.com/go-admin-team/go-admin/blob/master/README.Zh-cn.md)
English | [简体中文](https://github.com/go-admin-team/go-admin/blob/master/README.Zh-cn.md) | [繁體中文](https://github.com/go-admin-team/go-admin/blob/master/README.zh-TW.md) | [日本語](https://github.com/go-admin-team/go-admin/blob/master/README.ja-JP.md)
The front-end and back-end separation authority management system based on Gin + Vue + Element UI OR Arco Design is extremely simple to initialize the system. You only need to modify the database connection in the configuration file. The system supports multi-instruction operations. Migration instructions can make it easier to initialize database information. Service instructions It's easy to start the api service.
The front-end and back-end separation authority management system based on Gin + Vue + Element UI OR Arco Design OR Ant Design is extremely simple to initialize the system. You only need to modify the database connection in the configuration file. The system supports multi-instruction operations. Migration instructions can make it easier to initialize database information. Service instructions It's easy to start the api service.
[documentation](https://www.go-admin.dev)
[documentation](https://www.go-admin.pro)
[Front-end project](https://github.com/go-admin-team/go-admin-ui)
@@ -76,9 +76,9 @@ At the same time, a series of tutorials including videos and documents are provi
### Easily implement go-admin to write the first application-documentation tutorial
[Step 1 - basic content introduction](https://doc.go-admin.dev/guide/intro/tutorial01.html)
[Step 1 - basic content introduction](https://www.go-admin.pro/guide/intro/tutorial01.html)
[Step 2 - Practical application - writing database operations](https://doc.go-admin.dev/guide/intro/tutorial02.html)
[Step 2 - Practical application - writing database operations](https://www.go-admin.pro/guide/intro/tutorial02.html)
### Teach you from getting started to giving up-video tutorial
@@ -155,7 +155,7 @@ vi ./config/settings.yml
# 2. Confirm the log path
```
:::tip ⚠️Note that this problem will occur if CGO is not installed in the windows10+ environment;
⚠️ Note that this problem will occur if CGO is not installed in the windows10+ environment;
```bash
E:\go-admin>go build
@@ -171,9 +171,7 @@ D:\Code\go-admin>go build
cgo: exec gcc: exec: "gcc": executable file not found in %PATH%
```
[Solve the cgo problem and enter](https://doc.go-admin.dev/guide/faq#cgo-%E7%9A%84%E9%97%AE%E9%A2%98)
:::
[Solve the cgo problem and enter](https://www.go-admin.pro/guide/faq#cgo-%E7%9A%84%E9%97%AE%E9%A2%98)
#### Initialize the database, and start the service
@@ -318,7 +316,7 @@ The `go-admin` project has always been developed in the GoLand integrated develo
2. [gin](https://github.com/gin-gonic/gin)
2. [casbin](https://github.com/casbin/casbin)
2. [spf13/viper](https://github.com/spf13/viper)
2. [gorm](https://github.com/jinzhu/gorm)
2. [gorm](https://github.com/go-gorm/gorm)
2. [gin-swagger](https://github.com/swaggo/gin-swagger)
2. [golang-jwt](https://github.com/golang-jwt/jwt)
2. [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
+350
View File
@@ -0,0 +1,350 @@
# go-admin
<img align="right" width="320" src="https://doc-image.zhangwj.com/img/go-admin.svg">
[![Build Status](https://github.com/go-admin-team/go-admin/actions/workflows/go.yml/badge.svg?branch=master)](https://github.com/go-admin-team/go-admin)
[![Release](https://img.shields.io/github/release/go-admin-team/go-admin.svg?style=flat-square)](https://github.com/go-admin-team/go-admin/releases)
[![License](https://img.shields.io/github/license/go-admin-team/go-admin.svg)](https://github.com/go-admin-team/go-admin)
[English](https://github.com/go-admin-team/go-admin/blob/master/README.md) | [简体中文](https://github.com/go-admin-team/go-admin/blob/master/README.Zh-cn.md) | 繁體中文 | [日本語](https://github.com/go-admin-team/go-admin/blob/master/README.ja-JP.md)
基於 Gin + Vue + Element UI OR Arco Design OR Ant Design 的前後端分離權限管理系統。系統初始化極為簡單,只需在設定檔中修改資料庫連線資訊即可。系統支援多指令操作:遷移指令讓資料庫初始化變得更簡單,服務指令則能輕鬆啟動 API 服務。
[線上文件](https://www.go-admin.pro)
[前端專案](https://github.com/go-admin-team/go-admin-ui)
[影片教學](https://space.bilibili.com/565616721/channel/detail?cid=125737)
## 🎬 線上體驗
Element Plus vue3 體驗:[https://vue.go-admin.pro](https://vue.go-admin.pro/#/login)
> ⚠️⚠️⚠️ 帳號 / 密碼: admin / 123456
antd 體驗(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admin.pro/)
> ⚠️⚠️⚠️ 帳號 / 密碼: admin / 123456
## ✨ 特性
- 遵循 RESTful API 設計規範
- 基於 GIN WEB API 框架,提供豐富的中介軟體支援(使用者認證、跨域、存取日誌、追蹤 ID 等)
- 基於 Casbin 的 RBAC 存取控制模型
- JWT 認證
- 支援 Swagger 文件(基於 swaggo)
- 基於 GORM 的資料庫儲存,可擴充多種類型資料庫
- 設定檔簡單的模型映射,快速取得所需設定
- 程式碼產生工具
- 表單建構工具
- 多指令模式
- 多租戶的支援
- TODO: 單元測試
## 🎁 內建
1. 多租戶:系統預設支援多租戶,按資料庫分離,一個資料庫一個租戶。
1. 使用者管理:使用者是系統操作者,該功能主要完成系統使用者設定。
2. 部門管理:設定系統組織架構(公司、部門、小組),以樹狀結構呈現並支援資料權限。
3. 職位管理:設定系統使用者所擔任的職務。
4. 選單管理:設定系統選單、操作權限、按鈕權限標識、介面權限等。
5. 角色管理:角色選單權限分配、設定角色按機構進行資料範圍權限劃分。
6. 字典管理:對系統中經常使用且較為固定的資料進行維護。
7. 參數管理:對系統動態設定常用參數。
8. 操作日誌:系統正常操作的日誌記錄與查詢;系統異常資訊的日誌記錄與查詢。
9. 登入日誌:系統登入日誌記錄查詢,包含登入異常。
1. 介面文件:根據業務程式碼自動產生相關的 API 介面文件。
1. 程式碼產生:根據資料表結構產生對應的增刪改查業務,全程視覺化操作,讓基本業務可以零程式碼實現。
1. 表單建構:自訂頁面樣式,拖拉放實現頁面佈局。
1. 服務監控:檢視伺服器的基本資訊。
1. 內容管理:demo 功能,下設分類管理、內容管理,可參考使用以快速入門。
1. 排程任務:自動化任務,目前支援介面呼叫與函式呼叫。
## 準備工作
你需要在本機安裝 [go] [gin] [node](http://nodejs.org/) 和 [git](https://git-scm.com/)
同時配套了系列教學(含影片與文件),說明如何從下載到熟練使用。強烈建議先看完這些教學再來實作本專案!!!
### 輕鬆用 go-admin 寫出第一個應用 - 文件教學
[步驟一 - 基礎內容介紹](https://www.go-admin.pro/guide/intro/tutorial01.html)
[步驟二 - 實際應用 - 撰寫增刪改查](https://www.go-admin.pro/guide/intro/tutorial02.html)
### 手把手教你從入門到放棄 - 影片教學
[如何啟動 go-admin](https://www.bilibili.com/video/BV1z5411x7JG)
[使用產生工具輕鬆實現業務](https://www.bilibili.com/video/BV1Dg4y1i79D)
[v1.1.0 版本程式碼產生工具 - 釋放雙手](https://www.bilibili.com/video/BV1N54y1i71P) [進階]
[多指令啟動方式講解以及 IDE 設定](https://www.bilibili.com/video/BV1Fg4y1q7ph)
[go-admin 選單的設定說明](https://www.bilibili.com/video/BV1Wp4y1D715) [必看]
[如何設定選單資訊以及介面資訊](https://www.bilibili.com/video/BV1zv411B7nG) [必看]
[go-admin 權限設定使用說明](https://www.bilibili.com/video/BV1rt4y197d3) [必看]
[go-admin 資料權限使用說明](https://www.bilibili.com/video/BV1LK4y1s71e) [必看]
**如有問題請先參閱上述文件與文章,若仍無法解決,歡迎提出 issue 與 pr。影片教學與文件持續更新中**
## 📦 本機開發
### 環境需求
go 1.26.5
node 版本: v22+(建議 v24 LTS)
套件管理器: pnpm v9+(UI 專案使用 pnpm)
### 建立開發目錄
```bash
# 建立開發目錄
mkdir goadmin
cd goadmin
```
### 取得程式碼
> 重點注意:兩個專案必須放在同一資料夾下;
```bash
# 取得後端程式碼
git clone https://github.com/go-admin-team/go-admin.git
# 取得前端程式碼
git clone https://github.com/go-admin-team/go-admin-ui.git
```
### 啟動說明
#### 伺服器端啟動說明
```bash
# 進入 go-admin 後端專案
cd ./go-admin
# 更新整理相依套件
go mod tidy
# 編譯專案
go build
# 修改設定
# 檔案路徑 go-admin/config/settings.yml
vi ./config/settings.yml
# 1. 在設定檔中修改資料庫資訊
# 注意: settings.database 下對應的設定資料
# 2. 確認 log 路徑
```
⚠️注意 在 Windows 環境若未安裝 CGO,會出現這個問題;
```bash
E:\go-admin>go build
# github.com/mattn/go-sqlite3
cgo: exec /missing-cc: exec: "/missing-cc": file does not exist
```
or
```bash
D:\Code\go-admin>go build
# github.com/mattn/go-sqlite3
cgo: exec gcc: exec: "gcc": executable file not found in %PATH%
```
[解決 cgo 問題請進入](https://www.go-admin.pro/zh-CN/guide/faq#cgo-%E7%9A%84%E9%97%AE%E9%A2%98)
#### 初始化資料庫,以及服務啟動
``` bash
# 首次設定需要初始化資料庫資源資訊
# macOS or linux 下使用
$ ./go-admin migrate -c config/settings.dev.yml
# ⚠️注意:windows 下使用
$ go-admin.exe migrate -c config/settings.dev.yml
# 啟動專案,也可以用 IDE 進行除錯
# macOS or linux 下使用
$ ./go-admin server -c config/settings.yml
# ⚠️注意:windows 下使用
$ go-admin.exe server -c config/settings.yml
```
#### sys_api 表的資料如何新增
在專案啟動時,使用 `-a true` 系統會自動新增缺少的介面資料
```bash
./go-admin server -c config/settings.yml -a true
```
#### 使用 docker 編譯啟動
```shell
# 編譯映像檔
docker build -t go-admin .
# 啟動容器,第一個 go-admin 是容器名稱,第二個 go-admin 是映像檔名稱
# -v 映射設定檔 本機路徑:容器路徑
docker run --name go-admin -p 8000:8000 -v /config/settings.yml:/config/settings.yml -d go-admin-server
```
#### 文件產生
```bash
go generate
```
#### 交叉編譯
```bash
# windows
env GOOS=windows GOARCH=amd64 go build main.go
# or
# linux
env GOOS=linux GOARCH=amd64 go build main.go
```
### UI 互動端啟動說明
```bash
# 安裝 pnpm(若未安裝)
npm install -g pnpm
# 安裝相依套件
pnpm install
# 中國大陸網路可指定鏡像來源加速
pnpm install --registry=https://registry.npmmirror.com
# 啟動服務
pnpm dev
```
## 📨 互動
<table>
<tr>
<td><img src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/wx.png" width="180px"></td>
<td><img src="https://doc-image.zhangwj.com/img/qrcode_for_gh_b798dc7db30c_258.jpg" width="180px"></td>
<td><img src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/qq2.png" width="200px"></td>
<td><a href="https://space.bilibili.com/565616721">wenjianzhang</a></td>
</tr>
<tr>
<td>微信</td>
<td>公众号🔥🔥🔥</td>
<td><a target="_blank" href="https://shang.qq.com/wpa/qunwpa?idkey=0f2bf59f5f2edec6a4550c364242c0641f870aa328e468c4ee4b7dbfb392627b"><img border="0" src="https://pub.idqqimg.com/wpa/images/group.png" alt="go-admin技术交流乙号" title="go-admin技术交流乙号"></a></td>
<td>哔哩哔哩🔥🔥🔥</td>
</tr>
</table>
## 💎 貢獻者
<span style="margin: 0 5px;" ><a href="https://github.com/wenjianzhang" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/3890175?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/G-Akiraka" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45746659?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/lwnmengjing" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/12806223?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/bing127" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31166183?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/chengxiao" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/1379545?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/NightFire0307" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/19854086?v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/appleboy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/21979?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/misakichan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16569274?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/zhuxuyang" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/19301024?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/mss-boot" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/109259065?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/AuroraV" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/37330199?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Vingurzhou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/57127283?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/haimait" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/40926384?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/zyd" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/3446278?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/43291304?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/DemoLiang
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/23476007?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/jfcg
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/1410597?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
<span style="margin: 0 5px;" ><a href="https://github.com/Nicole0724
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/10487328?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
## JetBrains 開源證書支援
`go-admin` 專案一直以來都是在 JetBrains 公司旗下的 GoLand 整合開發環境中進行開發,基於 **free JetBrains Open Source license(s)** 正版免費授權,在此表達我的謝意。
<a href="https://www.jetbrains.com/?from=kubeadm-ha" target="_blank"><img src="https://raw.githubusercontent.com/panjf2000/illustrations/master/jetbrains/jetbrains-variant-4.png" width="250" align="middle"/></a>
## 🤝 特別感謝
1. [ant-design](https://github.com/ant-design/ant-design)
2. [ant-design-pro](https://github.com/ant-design/ant-design-pro)
2. [arco-design](https://github.com/arco-design/arco-design)
2. [arco-design-pro](https://github.com/arco-design/arco-design-pro)
4. [gin](https://github.com/gin-gonic/gin)
5. [casbin](https://github.com/casbin/casbin)
6. [spf13/viper](https://github.com/spf13/viper)
7. [gorm](https://github.com/go-gorm/gorm)
8. [gin-swagger](https://github.com/swaggo/gin-swagger)
9. [golang-jwt](https://github.com/golang-jwt/jwt)
10. [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
11. [ruoyi-vue](https://gitee.com/y_project/RuoYi-Vue)
12. [form-generator](https://github.com/JakHuang/form-generator)
## 🤟 贊助
> 如果你覺得這個專案幫助到了你,可以幫作者買一杯果汁表示鼓勵 :tropical_drink:
<img class="no-margin" src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/pay.png" height="200px" >
## 🤝 連結
- [mss-boot-io](https://docs.mss-boot-io.top/)
## 🔑 License
[MIT](https://github.com/go-admin-team/go-admin/blob/master/LICENSE.md)
Copyright (c) 2026 wenjianzhang
+5 -2
View File
@@ -21,13 +21,16 @@ func (e System) GenerateCaptchaHandler(c *gin.Context) {
e.Error(500, err, "服务初始化失败!")
return
}
id, b64s, answer, err := captcha.DriverDigitFunc()
// The answer is deliberately discarded rather than logged. It used to be
// written at info level, which put a currently valid captcha answer in the
// application log - anyone able to read the log could bypass the check the
// captcha exists to enforce.
id, b64s, _, err := captcha.DriverDigitFunc()
if err != nil {
e.Logger.Errorf("DriverDigitFunc error, %s", err.Error())
e.Error(500, err, "验证码获取失败")
return
}
e.Logger.Infof("DriverDigitFunc answer: %s", answer)
e.Custom(gin.H{
"code": 200,
"data": b64s,
-81
View File
@@ -1,81 +0,0 @@
package models
import (
"errors"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
)
type DataPermission struct {
DataScope string
UserId int
DeptId int
RoleId int
}
func (e *DataPermission) GetDataScope(tableName string, db *gorm.DB) (*gorm.DB, error) {
if !config.ApplicationConfig.EnableDP {
usageStr := `数据权限已经为您` + pkg.Green(`关闭`) + `,如需开启请参考配置文件字段说明`
log.Debug("%s\n", usageStr)
return db, nil
}
user := new(SysUser)
role := new(SysRole)
err := db.Find(user, e.UserId).Error
if err != nil {
return nil, errors.New("获取用户数据出错 msg:" + err.Error())
}
err = db.Find(role, user.RoleId).Error
if err != nil {
return nil, errors.New("获取用户数据出错 msg:" + err.Error())
}
if role.DataScope == "2" {
db = db.Where(tableName+".create_by in (select sys_user.user_id from sys_role_dept left join sys_user on sys_user.dept_id=sys_role_dept.dept_id where sys_role_dept.role_id = ?)", user.RoleId)
}
if role.DataScope == "3" {
db = db.Where(tableName+".create_by in (SELECT user_id from sys_user where dept_id = ? )", user.DeptId)
}
if role.DataScope == "4" {
db = db.Where(tableName+".create_by in (SELECT user_id from sys_user where sys_user.dept_id in(select dept_id from sys_dept where dept_path like ? ))", "%"+pkg.IntToString(user.DeptId)+"%")
}
if role.DataScope == "5" || role.DataScope == "" {
db = db.Where(tableName+".create_by = ?", e.UserId)
}
return db, nil
}
//func DataScopes(tableName string, userId int) func(db *gorm.DB) *gorm.DB {
// return func(db *gorm.DB) *gorm.DB {
// user := new(SysUser)
// role := new(SysRole)
// user.UserId = userId
// err := db.Find(user, userId).Error
// if err != nil {
// db.Error = errors.New("获取用户数据出错 msg:" + err.Error())
// return db
// }
// err = db.Find(role, user.RoleId).Error
// if err != nil {
// db.Error = errors.New("获取用户数据出错 msg:" + err.Error())
// return db
// }
// if role.DataScope == "2" {
// return db.Where(tableName+".create_by in (select sys_user.user_id from sys_role_dept left join sys_user on sys_user.dept_id=sys_role_dept.dept_id where sys_role_dept.role_id = ?)", user.RoleId)
// }
// if role.DataScope == "3" {
// return db.Where(tableName+".create_by in (SELECT user_id from sys_user where dept_id = ? )", user.DeptId)
// }
// if role.DataScope == "4" {
// return db.Where(tableName+".create_by in (SELECT user_id from sys_user where sys_user.dept_id in(select dept_id from sys_dept where dept_path like ? ))", "%"+pkg.IntToString(user.DeptId)+"%")
// }
// if role.DataScope == "5" || role.DataScope == "" {
// return db.Where(tableName+".create_by = ?", userId)
// }
// return db
// }
//}
+26 -14
View File
@@ -42,19 +42,35 @@ func (e *SysUser) GetId() interface{} {
return e.UserId
}
// Encrypt 加密
func (e *SysUser) Encrypt() (err error) {
// Encrypt hashes Password, unless it already holds a hash.
//
// The hooks below run on whatever is in the struct, and a user read from the
// database carries the stored hash in that field. Hashing it again produces a
// hash of a hash, and the password that user knows no longer matches anything:
// they cannot log in, and nothing reports an error. The only thing preventing
// that today is an Omit("password") on the one update that loads a user first,
// which makes every other write to this model one line away from destroying
// credentials.
//
// bcrypt.Cost parses a hash and fails on anything else, so it distinguishes
// the two cases without the call site having to say which it is. The cost is
// that a password which is itself a well-formed bcrypt hash would be stored
// unchanged - a 60-character string beginning "$2a$", not something a person
// types, and it grants whoever set it no access they did not already have.
func (e *SysUser) Encrypt() error {
if e.Password == "" {
return
return nil
}
if _, err := bcrypt.Cost([]byte(e.Password)); err == nil {
return nil
}
var hash []byte
if hash, err = bcrypt.GenerateFromPassword([]byte(e.Password), bcrypt.DefaultCost); err != nil {
return
} else {
e.Password = string(hash)
return
hash, err := bcrypt.GenerateFromPassword([]byte(e.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
e.Password = string(hash)
return nil
}
func (e *SysUser) BeforeCreate(_ *gorm.DB) error {
@@ -62,11 +78,7 @@ func (e *SysUser) BeforeCreate(_ *gorm.DB) error {
}
func (e *SysUser) BeforeUpdate(_ *gorm.DB) error {
var err error
if e.Password != "" {
err = e.Encrypt()
}
return err
return e.Encrypt()
}
func (e *SysUser) AfterFind(_ *gorm.DB) error {
+106
View File
@@ -0,0 +1,106 @@
package models
import (
"testing"
"golang.org/x/crypto/bcrypt"
)
const knownPassword = "correct-horse-battery-staple"
// A user loaded from the database carries the stored hash in Password, and the
// hooks run on whatever is in the struct. Hashing it a second time produces a
// hash of a hash: the password the user knows stops matching, they cannot log
// in, and nothing reports an error.
//
// Only an Omit("password") on one call site stood between this and every write
// to the model. This is the test that removes the need for it.
func TestEncryptLeavesAnAlreadyHashedPasswordAlone(t *testing.T) {
fresh := SysUser{Password: knownPassword}
if err := fresh.Encrypt(); err != nil {
t.Fatalf("Encrypt: %v", err)
}
stored := fresh.Password
if err := bcrypt.CompareHashAndPassword([]byte(stored), []byte(knownPassword)); err != nil {
t.Fatalf("setup failed: the password was not hashed: %v", err)
}
// What a query puts in the struct, and what an update then hands the hook.
loaded := SysUser{Password: stored}
if err := loaded.Encrypt(); err != nil {
t.Fatalf("Encrypt on a loaded user: %v", err)
}
if loaded.Password != stored {
t.Error("Encrypt re-hashed a stored hash; the user can no longer log in")
}
if err := bcrypt.CompareHashAndPassword([]byte(loaded.Password), []byte(knownPassword)); err != nil {
t.Errorf("the user can no longer log in with their password: %v", err)
}
}
// The other half: a password that is not a hash still gets hashed, on create
// and on update alike.
func TestEncryptHashesAPlaintextPassword(t *testing.T) {
for _, c := range []struct {
name string
hook func(*SysUser) error
}{
{"BeforeCreate", func(u *SysUser) error { return u.BeforeCreate(nil) }},
{"BeforeUpdate", func(u *SysUser) error { return u.BeforeUpdate(nil) }},
} {
t.Run(c.name, func(t *testing.T) {
u := SysUser{Password: knownPassword}
if err := c.hook(&u); err != nil {
t.Fatal(err)
}
if u.Password == knownPassword {
t.Fatal("the password was stored as it was typed")
}
if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(knownPassword)); err != nil {
t.Errorf("the stored value does not verify the password: %v", err)
}
})
}
}
// An empty Password means "not being set", and must not become a hash of "".
func TestEncryptIgnoresAnEmptyPassword(t *testing.T) {
u := SysUser{}
if err := u.Encrypt(); err != nil {
t.Fatal(err)
}
if u.Password != "" {
t.Errorf("an unset password became %q", u.Password)
}
}
// Encrypt runs on every update of this model, including the ones that change
// something else entirely. What it costs when there is nothing to do is the
// difference between a profile update and a bcrypt round; the correctness test
// above is what catches a regression, this reports the size of it.
func BenchmarkEncrypt(b *testing.B) {
fresh := SysUser{Password: knownPassword}
if err := fresh.Encrypt(); err != nil {
b.Fatal(err)
}
b.Run("already hashed", func(b *testing.B) {
u := SysUser{Password: fresh.Password}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if err := u.Encrypt(); err != nil {
b.Fatal(err)
}
}
})
b.Run("plaintext", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
u := SysUser{Password: knownPassword}
if err := u.Encrypt(); err != nil {
b.Fatal(err)
}
}
})
}
+7 -2
View File
@@ -5,12 +5,17 @@ import (
"go-admin/app/admin/models"
"go-admin/common/dto"
"go-admin/common/global"
common "go-admin/common/models"
)
// Deprecated: use global.OperaStatusEnabled / global.OperaStatusDisabled.
// These two names are kept - misspelling and all - because forks import them;
// the values moved to common/global so common/middleware no longer has to
// import this package. See docs/contract.md.
const (
OperaStatusEnabel = "1" // 状态-正常
OperaStatusDisable = "2" // 状态-关闭
OperaStatusEnabel = global.OperaStatusEnabled // 状态-正常
OperaStatusDisable = global.OperaStatusDisabled // 状态-关闭
)
type SysOperaLogGetPageReq struct {
@@ -0,0 +1,24 @@
package dto
import (
"testing"
"go-admin/common/global"
)
// The values moved to common/global so common/middleware would stop importing
// this package; these two names stayed behind as aliases, misspelling and all,
// because forks import them.
//
// If they ever drift apart, rows written through the two spellings land in
// different buckets and the operation-log filter silently misses half of them.
func TestDeprecatedStatusAliasesStillMatch(t *testing.T) {
if OperaStatusEnabel != global.OperaStatusEnabled {
t.Errorf("OperaStatusEnabel = %q, global.OperaStatusEnabled = %q",
OperaStatusEnabel, global.OperaStatusEnabled)
}
if OperaStatusDisable != global.OperaStatusDisabled {
t.Errorf("OperaStatusDisable = %q, global.OperaStatusDisabled = %q",
OperaStatusDisable, global.OperaStatusDisabled)
}
}
+3 -3
View File
@@ -42,7 +42,7 @@ type SysRoleInsertReq struct {
Flag string `form:"flag" comment:"标记"` // 标记
Remark string `form:"remark" comment:"备注"` // 备注
Admin bool `form:"admin" comment:"是否管理员"`
DataScope string `form:"dataScope"`
DataScope string `form:"dataScope" vd:"$=='1'||$=='2'||$=='3'||$=='4'||$=='5'"` // must be one of actions.DataScope{All,Custom,Dept,DeptTree,Self}; PRD 006 F14/H2
SysMenu []models.SysMenu `form:"sysMenu"`
MenuIds []int `form:"menuIds"`
SysDept []models.SysDept `form:"sysDept"`
@@ -79,7 +79,7 @@ type SysRoleUpdateReq struct {
Flag string `form:"flag" comment:"标记"` // 标记
Remark string `form:"remark" comment:"备注"` // 备注
Admin bool `form:"admin" comment:"是否管理员"`
DataScope string `form:"dataScope"`
DataScope string `form:"dataScope" vd:"$=='1'||$=='2'||$=='3'||$=='4'||$=='5'"` // must be one of actions.DataScope{All,Custom,Dept,DeptTree,Self}; PRD 006 F14/H2
SysMenu []models.SysMenu `form:"sysMenu"`
MenuIds []int `form:"menuIds"`
SysDept []models.SysDept `form:"sysDept"`
@@ -147,7 +147,7 @@ func (s *SysRoleDeleteReq) GetId() interface{} {
// RoleDataScopeReq 角色数据权限修改
type RoleDataScopeReq struct {
RoleId int `json:"roleId" binding:"required"`
DataScope string `json:"dataScope" binding:"required"`
DataScope string `json:"dataScope" binding:"required" vd:"$=='1'||$=='2'||$=='3'||$=='4'||$=='5'"` // must be one of actions.DataScope{All,Custom,Dept,DeptTree,Self}; PRD 006 F14/H2
DeptIds []int `json:"deptIds"`
}
@@ -0,0 +1,64 @@
package dto
import (
"testing"
vd "github.com/bytedance/go-tagexpr/v2/validator"
)
// api.Bind calls vd.Validate unconditionally on every request, regardless of
// which binding stage ran, so a vd tag on DataScope is enough to reject
// anything actions.Permission's fail-closed default would otherwise have to
// deal with. PRD 006 F14/H2 named this the real trigger for the default
// branch: SysRoleInsertReq.DataScope had no validation at all, so leaving
// dataScope out of a create-role request wrote an empty string straight to
// sys_role.
func TestDataScopeRejectsWhatPermissionCannotRecognize(t *testing.T) {
invalid := []string{"", "0", "6", "all", " 1", "1 "}
valid := []string{"1", "2", "3", "4", "5"}
t.Run("SysRoleInsertReq", func(t *testing.T) {
for _, s := range invalid {
req := SysRoleInsertReq{RoleName: "r", RoleKey: "r", DataScope: s}
if err := vd.Validate(&req); err == nil {
t.Errorf("DataScope %q was accepted, want rejected", s)
}
}
for _, s := range valid {
req := SysRoleInsertReq{RoleName: "r", RoleKey: "r", DataScope: s}
if err := vd.Validate(&req); err != nil {
t.Errorf("DataScope %q was rejected: %v", s, err)
}
}
})
t.Run("SysRoleUpdateReq", func(t *testing.T) {
for _, s := range invalid {
req := SysRoleUpdateReq{RoleName: "r", RoleKey: "r", DataScope: s}
if err := vd.Validate(&req); err == nil {
t.Errorf("DataScope %q was accepted, want rejected", s)
}
}
for _, s := range valid {
req := SysRoleUpdateReq{RoleName: "r", RoleKey: "r", DataScope: s}
if err := vd.Validate(&req); err != nil {
t.Errorf("DataScope %q was rejected: %v", s, err)
}
}
})
t.Run("RoleDataScopeReq", func(t *testing.T) {
for _, s := range invalid {
req := RoleDataScopeReq{RoleId: 1, DataScope: s}
if err := vd.Validate(&req); err == nil {
t.Errorf("DataScope %q was accepted, want rejected", s)
}
}
for _, s := range valid {
req := RoleDataScopeReq{RoleId: 1, DataScope: s}
if err := vd.Validate(&req); err != nil {
t.Errorf("DataScope %q was rejected: %v", s, err)
}
}
})
}
+14 -3
View File
@@ -6,6 +6,8 @@ import (
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
"go-admin/app/admin/models"
"go-admin/app/admin/service/dto"
"go-admin/common/actions"
@@ -74,9 +76,18 @@ func (e *SysApi) Get(d *dto.SysApiGetReq, p *actions.DataPermission, model *mode
// Update 修改SysApi对象
func (e *SysApi) Update(c *dto.SysApiUpdateReq, p *actions.DataPermission) error {
var model = models.SysApi{}
db := e.Orm.Debug().First(&model, c.GetId())
if db.RowsAffected == 0 {
return errors.New("无权更新该数据")
db := e.Orm.Scopes(
actions.Permission(model.TableName(), p),
).First(&model, c.GetId())
if err := db.Error; err != nil {
// First reports a row the data permission excluded exactly as it
// reports one that does not exist, and the caller should not be able
// to tell those apart either.
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("无权更新该数据")
}
e.Log.Errorf("Service UpdateSysApi error:%s", err)
return err
}
c.Generate(&model)
db = e.Orm.Save(&model)
@@ -0,0 +1,70 @@
package service
import (
"strings"
"testing"
"github.com/glebarez/sqlite"
"github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
"go-admin/app/admin/models"
"go-admin/app/admin/service/dto"
"go-admin/common/actions"
)
// An update the data permission excludes has to be refused, and refused in a
// way that does not tell the caller whether the row exists. First reports both
// cases the same way - no rows - so the message has to come from there rather
// than from a RowsAffected check the error return has already skipped past.
func TestSysApiUpdateRefusesARowOutsideTheDataPermission(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:sysapi-perm?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Skipf("sqlite unavailable: %v", err)
}
if err := db.AutoMigrate(&models.SysApi{}); err != nil {
t.Skipf("automigrate: %v", err)
}
prev := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = true
t.Cleanup(func() { config.ApplicationConfig.EnableDP = prev })
// Owned by user 1.
row := models.SysApi{Handle: "h", Title: "t", Path: "/api/v1/probe", Type: "BUS", Action: "GET"}
row.CreateBy = 1
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
e := &SysApi{Service: service.Service{Orm: db, Log: logger.NewHelper(logger.DefaultLogger)}}
req := &dto.SysApiUpdateReq{Id: row.Id, Title: "changed"}
// User 2, scope 5: only rows they created.
outsider := &actions.DataPermission{DataScope: "5", UserId: 2, DeptId: 1, RoleId: 2}
err = e.Update(req, outsider)
if err == nil {
t.Fatal("the update was allowed on a row the data permission excludes")
}
if !strings.Contains(err.Error(), "无权更新该数据") {
t.Errorf("refused with %q, want the permission message; a raw database error tells the "+
"caller the row exists", err)
}
var after models.SysApi
if err := db.First(&after, row.Id).Error; err != nil {
t.Fatal(err)
}
if after.Title != "t" {
t.Errorf("the row was modified: title is now %q", after.Title)
}
// The owner still gets through, so the scope is refusing rather than
// everything failing.
owner := &actions.DataPermission{DataScope: "5", UserId: 1, DeptId: 1, RoleId: 1}
if err := e.Update(&dto.SysApiUpdateReq{Id: row.Id, Title: "by owner"}, owner); err != nil {
t.Fatalf("the owner could not update their own row: %v", err)
}
}
+23 -3
View File
@@ -82,9 +82,7 @@ func run() error {
}
initRouter()
for _, f := range AppRouters {
f()
}
runStartupHooks()
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
@@ -154,6 +152,28 @@ func run() error {
return nil
}
// runStartupHooks runs the router registries and then the before callbacks.
//
// The package-level slice runs first and in its existing order, so a fork that
// only ever appended to AppRouters sees no change at all. The core registry
// runs second, through RunAppRouters: a module can register through
// sdk.Runtime.SetAppRouters and no longer has to import this command package -
// which is a main package's plumbing - just to be routed.
//
// The loop over the core registry now lives in core, which is what brings the
// panic guard and the registration seal with it. RunBefore closes a gap rather
// than moving one: the open-source edition never executed the before callbacks
// at all, so SetBefore was accepted and silently ignored. It has to stay ahead
// of ListenAndServe, because a callback registered WithFatal exits the process
// and that must not happen to one that is already serving.
func runStartupHooks() {
for _, f := range AppRouters {
f()
}
sdk.Runtime.RunAppRouters()
sdk.Runtime.RunBefore()
}
//var Router runtime.Router
func tip() {
+116
View File
@@ -0,0 +1,116 @@
package api
import (
"strings"
"testing"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
)
// freshRuntime hands the test its own Runtime and puts the old one back.
//
// Both registries close permanently the first time they are run, and
// sdk.Runtime is a process-wide singleton, so a test that runs the startup
// hooks would otherwise leave every later test in this binary registering into
// a closed registry - which is only an ERROR log, not a failure. The symptom
// is a test that passes alone and loses its routes when run with the others.
func freshRuntime(t *testing.T) {
t.Helper()
previous := sdk.Runtime
t.Cleanup(func() { sdk.Runtime = previous })
sdk.Runtime = runtime.NewConfig()
}
// Acceptance 1 and 2 together: the package-level slice a fork appends to and
// the core registry a module registers through both run, package-level first,
// registration order preserved inside each.
//
// The order matters beyond neatness. A module that appends to AppRouters has to
// import go-admin/cmd/api, which is why every module used to need a seven-line
// file in the command package; SetAppRouters is the way out of that. Running
// the old registry first is what makes the change invisible to anyone who never
// takes it.
func TestRunStartupHooksRunsBothRegistriesInOrder(t *testing.T) {
freshRuntime(t)
savedPackage := AppRouters
t.Cleanup(func() { AppRouters = savedPackage })
var order []string
AppRouters = []func(){
func() { order = append(order, "package-1") },
func() { order = append(order, "package-2") },
}
sdk.Runtime.SetAppRouters(func() { order = append(order, "runtime-1") })
sdk.Runtime.SetAppRouters(func() { order = append(order, "runtime-2") })
runStartupHooks()
const want = "package-1,package-2,runtime-1,runtime-2"
if got := strings.Join(order, ","); got != want {
t.Errorf("ran %q, want %q", got, want)
}
}
// Acceptance 17: a before callback registered through core actually runs.
//
// It did not, ever: core stored the callbacks and nothing executed them, so
// SetBefore was accepted and silently did nothing. The gap survived because
// core offered the registry without ever running it, leaving each consumer to
// write - or forget - its own loop.
func TestBeforeCallbacksRun(t *testing.T) {
freshRuntime(t)
savedPackage := AppRouters
t.Cleanup(func() { AppRouters = savedPackage })
AppRouters = nil
var order []string
sdk.Runtime.SetBefore(func() { order = append(order, "before-1") })
sdk.Runtime.SetBefore(func() { order = append(order, "before-2") })
sdk.Runtime.SetAppRouters(func() { order = append(order, "router") })
runStartupHooks()
// Routers first, then before: both happen ahead of ListenAndServe, and a
// router callback is what puts the engine in place for anything that comes
// after it.
const want = "router,before-1,before-2"
if got := strings.Join(order, ","); got != want {
t.Errorf("ran %q, want %q", got, want)
}
}
// A panicking module must not take the server down with it. The guard lives in
// core; this asserts that go-admin actually goes through it rather than around
// it with a loop of its own.
func TestAPanickingRouterDoesNotStopStartup(t *testing.T) {
freshRuntime(t)
savedPackage := AppRouters
t.Cleanup(func() { AppRouters = savedPackage })
AppRouters = nil
var order []string
sdk.Runtime.SetAppRouters(func() { order = append(order, "first") })
sdk.Runtime.SetAppRouters(func() { panic("a third-party module blew up") })
sdk.Runtime.SetAppRouters(func() { order = append(order, "third") })
sdk.Runtime.SetBefore(func() { order = append(order, "before") })
runStartupHooks()
const want = "first,third,before"
if got := strings.Join(order, ","); got != want {
t.Errorf("ran %q, want %q", got, want)
}
}
// The default AppRouters must keep the admin routes on it. Emptying the slice
// would not fail to compile anywhere - it would just serve a server with no
// admin API and no error.
func TestAdminRouterIsRegisteredOnThePackageSlice(t *testing.T) {
if len(AppRouters) == 0 {
t.Fatal("AppRouters is empty; the admin router is registered in init()")
}
}
+49
View File
@@ -0,0 +1,49 @@
package migrate
import (
"strings"
"testing"
"go-admin/cmd/migrate/migration"
)
// A mistyped --app used to be indistinguishable from an up-to-date database on
// all three paths: `migrate` printed that the app was unknown and exited 0,
// while `--dry-run` and `status` printed "nothing to apply" and "none
// recorded" - the same words a database with nothing pending produces. An
// operator scripting `migrate --app crmm && deploy` therefore deployed against
// a database the migrations never touched.
func TestAppRegistrationErrorRejectsAnUnknownCode(t *testing.T) {
restore := appCode
t.Cleanup(func() { appCode = restore })
appCode = "doesnotexist"
err := appRegistrationError()
if err == nil {
t.Fatal("an unregistered app code must be an error, not an empty run")
}
if !strings.Contains(err.Error(), `"doesnotexist"`) {
t.Errorf("the message must quote what was typed; got %q", err)
}
// Listing what is registered is what turns the error into a fix: the typo
// is usually one letter away from something in this list.
if !strings.Contains(err.Error(), migration.FrameworkAppCode) {
t.Errorf("the message must list the registered codes; got %q", err)
}
}
func TestAppRegistrationErrorAcceptsWhatIsRegistered(t *testing.T) {
restore := appCode
t.Cleanup(func() { appCode = restore })
for _, code := range []string{
"", // no --app at all: every migration runs
migration.FrameworkAppCode, // "core", the framework's own
strings.ToUpper(migration.FrameworkAppCode), // codes normalize to lower case
} {
appCode = code
if err := appRegistrationError(); err != nil {
t.Errorf("appCode %q must be accepted; got %v", code, err)
}
}
}
+259 -12
View File
@@ -1,21 +1,37 @@
package migration
import (
"fmt"
"log"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"gorm.io/gorm"
common "go-admin/common/models"
)
var Migrate = &Migration{
version: make(map[string]func(db *gorm.DB, version string) error),
var Migrate = newMigration()
func newMigration() *Migration {
return &Migration{version: make(map[string]versionEntry)}
}
// versionEntry is one registered migration plus the app it belongs to. The
// empty app code means the framework itself, which is also what the
// sys_migration.app_code column defaults to, so history written before this
// field existed reads back correctly with no backfill.
type versionEntry struct {
appCode string
fn func(db *gorm.DB, version string) error
}
type Migration struct {
db *gorm.DB
version map[string]func(db *gorm.DB, version string) error
version map[string]versionEntry
mutex sync.Mutex
}
@@ -27,20 +43,247 @@ func (e *Migration) SetDb(db *gorm.DB) {
e.db = db
}
// SetVersion registers a migration owned by the framework. Signature and
// behaviour are unchanged: every existing call site in version/*.go keeps
// compiling and keeps writing common.Migration{Version: version} with no app
// code, which is the correct meaning of "framework".
func (e *Migration) SetVersion(k string, f func(db *gorm.DB, version string) error) {
e.mutex.Lock()
defer e.mutex.Unlock()
e.version[k] = f
e.setVersion(k, "", f)
}
func (e *Migration) Migrate() {
versions := make([]string, 0)
for k := range e.version {
func (e *Migration) setVersion(k, appCode string, f func(db *gorm.DB, version string) error) {
e.mutex.Lock()
defer e.mutex.Unlock()
e.version[k] = versionEntry{appCode: appCode, fn: f}
}
// AppMigrationFunc is the signature of a migration registered through ForApp.
//
// It receives appCode explicitly because the migration - not the framework -
// writes its own completion row, normally as the last statement inside its own
// transaction. That is what makes "the schema change and the record of it
// commit together" true, and the framework cannot insert the row on the
// migration's behalf without giving that up. Handing the code to the function
// is what stops an app's migrations from silently recording themselves as the
// framework's.
type AppMigrationFunc func(db *gorm.DB, version, appCode string) error
// AppRegistrar is a per-app view over a registry.
type AppRegistrar struct {
m *Migration
appCode string
}
// FrameworkAppCode is the name migrate status prints for migrations that belong
// to the framework rather than to an app, and the name --app accepts to select
// them. The stored app code for those is the empty string; this is only the
// spelling humans use. It is reserved - ForApp rejects it - so that every group
// heading status prints is also a value --app understands.
const FrameworkAppCode = "core"
// ForApp returns a registrar that records migrations under code.
//
// The code is lower-cased: sys_migration.version sorts as ASCII, so mixed case
// would order MyApp before crm for no reason a reader could guess, and the two
// spellings would group as two different apps in migrate status.
//
// An empty or reserved code panics rather than falling back to the framework.
// Registration happens in init(), so this fires the first time the binary runs
// anywhere, which is the point: an app whose migrations quietly file themselves
// under the framework is exactly the class of silent failure this work is meant
// to remove. Framework migrations call Migrate.SetVersion directly.
func ForApp(code string) *AppRegistrar { return Migrate.ForApp(code) }
// ForApp is the same on an explicit registry, which is what tests use.
func (e *Migration) ForApp(code string) *AppRegistrar {
code = NormalizeAppCode(code)
switch code {
case "":
panic("migration.ForApp: empty app code; framework migrations use Migrate.SetVersion")
case FrameworkAppCode:
panic("migration.ForApp: app code " + FrameworkAppCode + " is reserved for the framework")
}
return &AppRegistrar{m: e, appCode: code}
}
// AppCode reports the code this registrar files migrations under, after
// normalisation.
func (r *AppRegistrar) AppCode() string { return r.appCode }
// SetVersion registers an app-owned migration under k, which is the bare
// timestamp taken from the file name exactly as framework migrations do.
//
// What reaches sys_migration.version is the namespaced form; the version string
// handed to f is that same namespaced string, so a migration that writes
// common.Migration{Version: version, AppCode: appCode} records the key the
// registry will look for next time.
func (r *AppRegistrar) SetVersion(k string, f AppMigrationFunc) {
key := namespacedKey(r.appCode, k)
r.m.setVersion(key, r.appCode, func(db *gorm.DB, version string) error {
return f(db, version, r.appCode)
})
}
// namespacedKey scopes k to appCode so two apps cannot collide on the
// sys_migration.version primary key by minting the same millisecond timestamp.
// Framework migrations (appCode == "") stay bare, matching every version string
// already in production.
func namespacedKey(appCode, k string) string {
if appCode == "" {
return k
}
return appCode + "-" + k
}
// StatusEntry is one row of migrate status.
type StatusEntry struct {
AppCode string
Version string
Registered bool
Applied bool
ApplyTime *time.Time
}
// Status merges the in-process registry with sys_migration, so it reports all
// three shapes at once: registered but not applied, registered and applied, and
// applied while nothing registers it any more - a row left behind by a
// migration file that was deleted, or by an app that was uninstalled.
//
// It only reads. Nothing here creates or alters a table, which is what lets
// both `status` and `--dry-run` run against a database without touching it.
func (e *Migration) Status() ([]StatusEntry, error) {
if e.db == nil {
return nil, fmt.Errorf("migration: no database configured")
}
e.mutex.Lock()
registered := make(map[string]string, len(e.version))
for k, v := range e.version {
registered[k] = v.appCode
}
e.mutex.Unlock()
applied := make(map[string]common.Migration)
// A database that has never been migrated has no sys_migration table.
// Reporting everything as pending is the honest answer there; erroring out
// would make status useless in exactly the case it is most wanted.
if e.db.Migrator().HasTable(&common.Migration{}) {
var rows []common.Migration
if err := e.db.Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
applied[r.Version] = r
}
}
versions := make(map[string]struct{}, len(registered)+len(applied))
for k := range registered {
versions[k] = struct{}{}
}
for k := range applied {
versions[k] = struct{}{}
}
list := make([]string, 0, len(versions))
for k := range versions {
list = append(list, k)
}
sort.Strings(list)
out := make([]StatusEntry, 0, len(list))
for _, v := range list {
entry := StatusEntry{Version: v}
if code, ok := registered[v]; ok {
entry.Registered = true
entry.AppCode = code
}
if row, ok := applied[v]; ok {
entry.Applied = true
t := row.ApplyTime
entry.ApplyTime = &t
if !entry.Registered {
// Nothing registers this version any more, so the database is
// the only source left for what it belonged to.
entry.AppCode = row.AppCode
}
}
out = append(out, entry)
}
return out, nil
}
// Migrate applies every registered migration that has not been applied yet,
// across all apps. Existing callers are unaffected.
func (e *Migration) Migrate() { e.run(allApps) }
// MigrateApp applies only the migrations registered under appCode. Pass
// FrameworkAppCode for the framework's own migrations.
func (e *Migration) MigrateApp(appCode string) { e.run(AppFilter(appCode)) }
// NormalizeAppCode applies the same rule ForApp does, so a code typed on the
// command line matches one written in an init().
func NormalizeAppCode(code string) string {
return strings.ToLower(strings.TrimSpace(code))
}
// AppFilter turns a code as typed into the code stored in the registry, so
// "core" selects the framework's migrations, whose stored code is empty.
func AppFilter(code string) string {
code = NormalizeAppCode(code)
if code == FrameworkAppCode {
return ""
}
return code
}
// DisplayAppCode is the inverse: what to print for a stored code.
func DisplayAppCode(code string) string {
if code == "" {
return FrameworkAppCode
}
return code
}
// AppCodes lists the app codes with at least one registered migration, framework
// included under its display name, sorted.
func (e *Migration) AppCodes() []string {
e.mutex.Lock()
seen := map[string]struct{}{}
for _, v := range e.version {
seen[DisplayAppCode(v.appCode)] = struct{}{}
}
e.mutex.Unlock()
out := make([]string, 0, len(seen))
for code := range seen {
out = append(out, code)
}
sort.Strings(out)
return out
}
func (e *Migration) run(appCode string) {
e.mutex.Lock()
versions := make([]string, 0, len(e.version))
entries := make(map[string]versionEntry, len(e.version))
for k, v := range e.version {
if appCode != allApps && v.appCode != appCode {
continue
}
versions = append(versions, k)
entries[k] = v
}
if !sort.StringsAreSorted(versions) {
sort.Strings(versions)
e.mutex.Unlock()
sort.Strings(versions)
// A mistyped --app would otherwise select nothing and report "no
// migrations to apply", which reads exactly like "already up to date".
if appCode != allApps && len(versions) == 0 {
log.Printf("no migrations are registered for app %q; registered: %s",
DisplayAppCode(appCode), strings.Join(e.AppCodes(), ", "))
return
}
var err error
var count int64
applied := 0
@@ -56,7 +299,7 @@ func (e *Migration) Migrate() {
continue
}
log.Printf("applying migration %s", v)
if err = (e.version[v])(e.db.Debug(), v); err != nil {
if err = entries[v].fn(e.db.Debug(), v); err != nil {
log.Fatalf("migration %s failed: %v", v, err)
}
applied++
@@ -68,6 +311,10 @@ func (e *Migration) Migrate() {
}
}
// allApps is the sentinel run() takes to mean "do not filter". It is distinct
// from the empty app code, which selects the framework's own migrations.
const allApps = "\x00all"
func GetFilename(s string) string {
s = filepath.Base(s)
return s[:13]
+390
View File
@@ -0,0 +1,390 @@
package migration
import (
"bytes"
"log"
"os"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
common "go-admin/common/models"
)
func newTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err = db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
return db
}
// recordFor is what an app's migration is expected to do: write its own
// completion row, with the version it was handed and the app code it was told
// it belongs to.
func recordFor(db *gorm.DB, version, appCode string) error {
return db.Create(&common.Migration{Version: version, AppCode: appCode}).Error
}
func rowsByVersion(t *testing.T, db *gorm.DB) map[string]common.Migration {
t.Helper()
var rows []common.Migration
if err := db.Find(&rows).Error; err != nil {
t.Fatalf("read sys_migration: %v", err)
}
out := make(map[string]common.Migration, len(rows))
for _, r := range rows {
out[r.Version] = r
}
return out
}
// Acceptance 9: a migration registered through ForApp("x") lands in
// sys_migration with app_code "x".
//
// The registry cannot write that row for the migration, because the row is the
// migration's own last statement inside its own transaction. So the only thing
// that can make this true is handing the code to the function - which is why
// AppMigrationFunc takes three parameters.
func TestForAppRecordsItsAppCode(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
m.ForApp("x").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
return recordFor(db, version, appCode)
})
m.Migrate()
rows := rowsByVersion(t, db)
row, ok := rows["x-1786800001000"]
if !ok {
t.Fatalf("no row for x-1786800001000; got %v", rows)
}
if row.AppCode != "x" {
t.Errorf("app_code = %q, want %q", row.AppCode, "x")
}
}
// The framework path is untouched: same signature, and an empty app code, which
// is what the column defaults to and what every row written before this field
// existed reads back as.
func TestSetVersionStillRecordsTheFrameworkAsEmpty(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
return db.Create(&common.Migration{Version: version}).Error
})
m.Migrate()
rows := rowsByVersion(t, db)
row, ok := rows["1786700009000"]
if !ok {
t.Fatalf("no row for 1786700009000; got %v", rows)
}
if row.AppCode != "" {
t.Errorf("app_code = %q, want empty (framework)", row.AppCode)
}
}
// Acceptance 12: --app x runs x's migrations and touches nothing else.
func TestMigrateAppRunsOnlyThatApp(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
ran := map[string]bool{}
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
ran["core"] = true
return db.Create(&common.Migration{Version: version}).Error
})
m.ForApp("x").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
ran["x"] = true
return recordFor(db, version, appCode)
})
m.ForApp("y").SetVersion("1786800002000", func(db *gorm.DB, version, appCode string) error {
ran["y"] = true
return recordFor(db, version, appCode)
})
m.MigrateApp("x")
if !ran["x"] {
t.Error("x did not run")
}
if ran["y"] || ran["core"] {
t.Errorf("MigrateApp(x) also ran %v", ran)
}
rows := rowsByVersion(t, db)
if len(rows) != 1 {
t.Fatalf("sys_migration has %d rows, want 1: %v", len(rows), rows)
}
}
// "core" is what status prints for the framework, so --app core has to select
// it. The stored code is the empty string; AppFilter is the translation.
func TestMigrateAppCoreSelectsTheFramework(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
ran := map[string]bool{}
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
ran["core"] = true
return db.Create(&common.Migration{Version: version}).Error
})
m.ForApp("x").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
ran["x"] = true
return recordFor(db, version, appCode)
})
m.MigrateApp(FrameworkAppCode)
if !ran["core"] {
t.Error("framework migration did not run")
}
if ran["x"] {
t.Error("--app core also ran x")
}
}
// Zero-argument Migrate keeps meaning "everything", which is what every
// existing caller relies on.
func TestMigrateRunsEveryApp(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
var order []string
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
order = append(order, version)
return db.Create(&common.Migration{Version: version}).Error
})
m.ForApp("bbb").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
order = append(order, version)
return recordFor(db, version, appCode)
})
m.ForApp("aaa").SetVersion("1786800002000", func(db *gorm.DB, version, appCode string) error {
order = append(order, version)
return recordFor(db, version, appCode)
})
m.Migrate()
// Namespacing puts every framework migration - bare digits - ahead of every
// app migration, and orders apps by code rather than by whose timestamp
// happened to be smaller. aaa's file is the newer of the two and still runs
// first. Cross-app order is not promised, but this is the order, and it is
// the one to notice changed.
want := []string{"1786700009000", "aaa-1786800002000", "bbb-1786800001000"}
if len(order) != len(want) {
t.Fatalf("ran %v, want %v", order, want)
}
for i := range want {
if order[i] != want[i] {
t.Fatalf("ran %v, want %v", order, want)
}
}
}
// Two apps minting the same millisecond timestamp used to mean one of them was
// read as already applied and silently skipped. The namespace prefix is what
// makes that impossible without changing the primary key.
func TestNamespacingKeepsTwoAppsWithTheSameTimestampApart(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
const sameTimestamp = "1786800001000"
ran := 0
for _, app := range []string{"crm", "oms"} {
m.ForApp(app).SetVersion(sameTimestamp, func(db *gorm.DB, version, appCode string) error {
ran++
return recordFor(db, version, appCode)
})
}
m.Migrate()
if ran != 2 {
t.Errorf("ran %d migrations, want 2", ran)
}
rows := rowsByVersion(t, db)
for _, want := range []string{"crm-" + sameTimestamp, "oms-" + sameTimestamp} {
if _, ok := rows[want]; !ok {
t.Errorf("missing %s; got %v", want, rows)
}
}
}
func TestNamespacedKeyLeavesFrameworkVersionsBare(t *testing.T) {
if got := namespacedKey("", "1786700009000"); got != "1786700009000" {
t.Errorf("framework version was rewritten to %q", got)
}
if got := namespacedKey("crm", "1786800001000"); got != "crm-1786800001000" {
t.Errorf("namespacedKey = %q", got)
}
}
// An app code differing only in case would group as two apps in status and sort
// before every lower-case one, for no reason a reader could guess.
func TestForAppNormalisesTheCode(t *testing.T) {
m := newMigration()
if got := m.ForApp(" CRM ").AppCode(); got != "crm" {
t.Errorf("AppCode = %q, want crm", got)
}
}
func TestForAppRejectsReservedCodes(t *testing.T) {
for _, code := range []string{"", " ", FrameworkAppCode, "CORE"} {
t.Run("code="+code, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Errorf("ForApp(%q) did not panic", code)
}
}()
newMigration().ForApp(code)
})
}
}
func TestStatusReportsPendingAppliedAndOrphaned(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
applied := time.Date(2026, 8, 25, 14, 3, 11, 0, time.UTC)
if err := db.Create(&common.Migration{Version: "1786700009000", ApplyTime: applied}).Error; err != nil {
t.Fatal(err)
}
// Recorded, but nothing registers it any more.
if err := db.Create(&common.Migration{Version: "gone-1786800000000", ApplyTime: applied, AppCode: "gone"}).Error; err != nil {
t.Fatal(err)
}
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error { return nil })
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error { return nil })
entries, err := m.Status()
if err != nil {
t.Fatal(err)
}
byVersion := map[string]StatusEntry{}
for _, e := range entries {
byVersion[e.Version] = e
}
if e := byVersion["1786700009000"]; !e.Applied || !e.Registered || e.AppCode != "" {
t.Errorf("framework entry = %+v", e)
} else if e.ApplyTime == nil || !e.ApplyTime.Equal(applied) {
t.Errorf("framework apply time = %v, want %v", e.ApplyTime, applied)
}
if e := byVersion["crm-1786800001000"]; e.Applied || !e.Registered || e.AppCode != "crm" {
t.Errorf("crm entry = %+v", e)
}
if e := byVersion["gone-1786800000000"]; !e.Applied || e.Registered || e.AppCode != "gone" {
t.Errorf("orphaned entry = %+v", e)
}
}
// Acceptance 11 rests on this: status and --dry-run both go through Status, and
// Status must not create the table it reads.
func TestStatusDoesNotCreateItsTable(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
m := newMigration()
m.SetDb(db)
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error { return nil })
entries, err := m.Status()
if err != nil {
t.Fatalf("Status on a database with no sys_migration: %v", err)
}
if len(entries) != 1 || entries[0].Applied {
t.Errorf("entries = %+v, want one pending", entries)
}
if db.Migrator().HasTable(&common.Migration{}) {
t.Error("Status created sys_migration; it must only read")
}
}
// The completion row is the migration's own last statement, inside its own
// transaction. A migration that fails must leave no record of having run, or
// the next run skips it and the schema stays half-changed with nothing to say
// so.
func TestFailedMigrationLeavesNoRecord(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := recordFor(tx, version, appCode); err != nil {
return err
}
return errTestMigrationFailed
})
})
// run() calls log.Fatal on failure, which would take the test binary with
// it, so drive the registered function directly - the point here is the
// transaction boundary, not the scheduler.
entry := m.version["crm-1786800001000"]
if err := entry.fn(db, "crm-1786800001000"); err == nil {
t.Fatal("migration reported success")
}
if rows := rowsByVersion(t, db); len(rows) != 0 {
t.Errorf("sys_migration has %v after a failed migration", rows)
}
}
var errTestMigrationFailed = &testError{"boom"}
type testError struct{ s string }
func (e *testError) Error() string { return e.s }
// A mistyped --app used to select nothing and print "no migrations to apply",
// which reads as "already up to date" - the command reports success and does
// nothing, which is the failure mode this whole batch exists to remove.
func TestMigrateAppOnAnUnknownCodeSaysSo(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
return db.Create(&common.Migration{Version: version}).Error
})
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
return recordFor(db, version, appCode)
})
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
m.MigrateApp("crmm")
if !strings.Contains(buf.String(), `no migrations are registered for app "crmm"`) {
t.Errorf("output = %q", buf.String())
}
if !strings.Contains(buf.String(), "registered: core, crm") {
t.Errorf("the message must list what is registered; got %q", buf.String())
}
if rows := rowsByVersion(t, db); len(rows) != 0 {
t.Errorf("a typo ran %v", rows)
}
}
+8
View File
@@ -15,6 +15,14 @@ type Model struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement;comment:主键编码"`
}
// ModelTime is frozen at the schema shape these tables had before
// 1786700003000 converted deleted_at to a NOT NULL millisecond marker. That is
// correct for the migrations ordered before the conversion, and wrong for any
// added after it: writes put NULL into a NOT NULL column, and reads are scoped
// "WHERE deleted_at IS NULL" and match nothing.
//
// Migrations after that version seed through the runtime models in app/.
// TestPostConversionMigrationsAvoidFrozenSeedModels enforces this.
type ModelTime struct {
CreatedAt time.Time `json:"createdAt" gorm:"comment:创建时间"`
UpdatedAt time.Time `json:"updatedAt" gorm:"comment:最后更新时间"`
@@ -0,0 +1,54 @@
package version
import (
"runtime"
"gorm.io/gorm"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
// Normalize sys_role.data_scope to one of the five values
// actions.Permission recognizes, ahead of PRD 006 F14/H2 making its
// unrecognized-scope branch fail closed instead of fail open.
//
// Before that change, an empty or unrecognized data_scope fell into
// Permission's default branch, which returned the query untouched - exactly
// the same SQL as data_scope "1" (全部数据权限). The seed data shipped
// precisely that: config/db.sql's built-in admin role (role_id 1) carries an
// empty data_scope rather than "1". Once the default starts matching no
// rows instead, that role would silently lose all visibility everywhere
// actions.Permission is used, the moment a deployment turns EnableDP on.
//
// Rewriting every value outside {1,2,3,4,5} to "1" keeps each such role's
// effective visibility exactly what it already was - a role that intended a
// tighter scope was never getting it under the old fail-open default either,
// so this does not tighten anything a deployment was relying on. Whether to
// tighten it further is left to whoever owns that role.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700005000NormalizeRoleDataScope)
}
func _1786700005000NormalizeRoleDataScope(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := normalizeRoleDataScope(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
// normalizeRoleDataScope is split out so tests can run it against a database
// that only has sys_role, without also standing up sys_migration.
//
// The explicit "IS NULL OR" matters: sys_role.data_scope has no NOT NULL
// constraint, and SQL's three-valued logic makes `NULL NOT IN (...)`
// evaluate to NULL rather than TRUE, so a bare NOT IN clause silently skips
// NULL rows instead of normalizing them.
func normalizeRoleDataScope(tx *gorm.DB) error {
return tx.Exec(
"UPDATE sys_role SET data_scope = '1' WHERE data_scope IS NULL OR data_scope NOT IN ('1', '2', '3', '4', '5')",
).Error
}
@@ -0,0 +1,146 @@
package version
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
type roleDataScopeRow struct {
RoleId int `gorm:"column:role_id;primaryKey;autoIncrement"`
DataScope string `gorm:"column:data_scope"`
}
func (roleDataScopeRow) TableName() string { return "sys_role" }
func openRoleTable(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.AutoMigrate(&roleDataScopeRow{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
// The migration exists because the shipped admin role is exactly this case:
// config/db.sql's role_id 1 carries an empty data_scope. Reproduces the seed
// data literally rather than a made-up example.
func TestNormalizesTheEmptyDataScopeTheSeedDataShips(t *testing.T) {
db := openRoleTable(t)
if err := db.Create(&roleDataScopeRow{RoleId: 1, DataScope: ""}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
if err := normalizeRoleDataScope(db); err != nil {
t.Fatalf("migrate: %v", err)
}
var row roleDataScopeRow
if err := db.First(&row, 1).Error; err != nil {
t.Fatalf("read back: %v", err)
}
if row.DataScope != "1" {
t.Fatalf("data_scope = %q, want %q", row.DataScope, "1")
}
}
// The five recognized values must survive untouched - this migration
// normalizes what Permission cannot make sense of, not what it already can.
func TestLeavesRecognizedScopesAlone(t *testing.T) {
db := openRoleTable(t)
valid := []string{"1", "2", "3", "4", "5"}
for i, scope := range valid {
if err := db.Create(&roleDataScopeRow{RoleId: i + 1, DataScope: scope}).Error; err != nil {
t.Fatalf("seed %d: %v", i, err)
}
}
if err := normalizeRoleDataScope(db); err != nil {
t.Fatalf("migrate: %v", err)
}
var rows []roleDataScopeRow
if err := db.Order("role_id").Find(&rows).Error; err != nil {
t.Fatalf("read back: %v", err)
}
for i, row := range rows {
if row.DataScope != valid[i] {
t.Errorf("role %d: data_scope = %q, want %q (untouched)", row.RoleId, row.DataScope, valid[i])
}
}
}
// A garbage value (not just empty) must be normalized the same way as empty -
// both are "not one of the five", and the migration's WHERE clause has to
// catch both.
func TestNormalizesGarbageScopesToo(t *testing.T) {
db := openRoleTable(t)
if err := db.Create(&roleDataScopeRow{RoleId: 1, DataScope: "6"}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
if err := normalizeRoleDataScope(db); err != nil {
t.Fatalf("migrate: %v", err)
}
var row roleDataScopeRow
if err := db.First(&row, 1).Error; err != nil {
t.Fatalf("read back: %v", err)
}
if row.DataScope != "1" {
t.Fatalf("data_scope = %q, want %q", row.DataScope, "1")
}
}
// A NULL data_scope must be normalized too. sys_role.data_scope has no NOT
// NULL constraint, and `NULL NOT IN (...)` evaluates to NULL rather than
// TRUE under SQL's three-valued logic, so a bare NOT IN clause would leave
// this row untouched - the exact gap that let a NULL-scoped role go blind
// once Permission's default branch starts fail-closing.
func TestNormalizesNullDataScope(t *testing.T) {
db := openRoleTable(t)
if err := db.Exec("INSERT INTO sys_role (role_id, data_scope) VALUES (1, NULL)").Error; err != nil {
t.Fatalf("seed: %v", err)
}
if err := normalizeRoleDataScope(db); err != nil {
t.Fatalf("migrate: %v", err)
}
var row roleDataScopeRow
if err := db.First(&row, 1).Error; err != nil {
t.Fatalf("read back: %v", err)
}
if row.DataScope != "1" {
t.Fatalf("data_scope = %q, want %q", row.DataScope, "1")
}
}
// Running it twice must be safe: it is a plain UPDATE, not DDL, but
// sys_migration only records success once, and an operator who reruns
// `migrate` on a partially-applied database has to be able to trust that.
func TestNormalizeRoleDataScopeIsRepeatable(t *testing.T) {
db := openRoleTable(t)
if err := db.Create(&roleDataScopeRow{RoleId: 1, DataScope: ""}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
for i := 0; i < 3; i++ {
if err := normalizeRoleDataScope(db); err != nil {
t.Fatalf("migrate %d: %v", i, err)
}
}
var row roleDataScopeRow
if err := db.First(&row, 1).Error; err != nil {
t.Fatalf("read back: %v", err)
}
if row.DataScope != "1" {
t.Fatalf("data_scope = %q, want %q", row.DataScope, "1")
}
}
@@ -9,6 +9,8 @@ import (
"strconv"
"strings"
"testing"
"go-admin/cmd/migrate/migration"
)
// The repository carries two ModelTime types. The one in
@@ -79,9 +81,13 @@ func runtimeSoftDeleteTables(t *testing.T) map[string]string {
}
func importsRuntimeModels(f *ast.File) bool {
return importsPackage(f, "go-admin/common/models")
}
func importsPackage(f *ast.File, pkg string) bool {
for _, imp := range f.Imports {
p, err := strconv.Unquote(imp.Path.Value)
if err == nil && p == "go-admin/common/models" {
if err == nil && p == pkg {
return true
}
}
@@ -171,3 +177,89 @@ func repoRoot(t *testing.T) string {
t.Fatal("go.mod not found above the test directory")
return ""
}
// softDeleteConversion is the version at which sys_api, sys_menu and the rest
// stop storing deleted_at as a nullable timestamp and start storing the NOT
// NULL millisecond marker.
const softDeleteConversion = 1786700003000
// versionPrefixLen is the width migration.GetFilename slices off a filename.
const versionPrefixLen = 13
// Migrations ordered after the conversion must not seed rows through
// cmd/migrate/migration/models.
//
// That package's ModelTime still declares a nullable gorm.DeletedAt, which is
// correct for the migrations that predate the conversion - it is the shape the
// column had when they ran. Reusing it afterwards writes NULL into a NOT NULL
// column and the migration fails on its first insert:
//
// NOT NULL constraint failed: sys_api.deleted_at
//
// A fresh database never catches this, because every migration using that
// package today is ordered before the conversion and so runs while the column
// is still nullable. Only a migration added afterwards hits it, which in
// practice means the next person adding a business module - the reference
// they copy, 1786700001000_demo_menu.go, is itself one of the safe ones.
//
// Reads through that package are worse than writes, which is why the whole
// import is banned rather than just the inserts. gorm scopes a nullable
// DeletedAt as "WHERE deleted_at IS NULL", and after the conversion live rows
// hold 0, so the row is simply not there:
//
// frozen SysRole -> record not found
// runtime SysRole -> roleId=1
//
// 1786700001000_demo_menu.go looks the admin role up that way and treats
// ErrRecordNotFound as "roles are not seeded yet, skip authorisation". A
// post-conversion copy that switched its inserts to the runtime models but
// kept this lookup would seed the menu, grant nothing, and still record the
// migration as applied - the menu appears, its buttons do nothing, and no
// error is reported anywhere.
func TestPostConversionMigrationsAvoidFrozenSeedModels(t *testing.T) {
const frozenModels = "go-admin/cmd/migrate/migration/models"
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
checked := 0
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
// GetFilename is what every migration uses to derive its own version,
// so the two stay in step if the filename convention ever changes.
if len(name) < versionPrefixLen {
continue
}
version, err := strconv.ParseInt(migration.GetFilename(name), 10, 64)
if err != nil || version <= softDeleteConversion {
continue // not a versioned migration, or one that predates the change
}
checked++
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, parser.ImportsOnly)
if err != nil {
t.Fatalf("parse %s: %v", name, err)
}
if importsPackage(f, frozenModels) {
t.Errorf("%s is ordered after the soft-delete conversion but seeds through %s;\n"+
" that package writes a nullable deleted_at and will fail with\n"+
" \"NOT NULL constraint failed\" on its first insert.\n"+
" Use the runtime models under app/ instead - they carry the marker.",
name, frozenModels)
}
}
if checked == 0 {
t.Fatal("no post-conversion migrations found; the scan is broken, not the code")
}
}
+141 -8
View File
@@ -3,12 +3,16 @@ package migrate
import (
"bytes"
"fmt"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"os"
"strconv"
"strings"
"text/template"
"time"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
"github.com/go-admin-team/go-admin-core/v2/config/source/file"
"github.com/spf13/cobra"
@@ -25,6 +29,8 @@ var (
generate bool
goAdmin bool
host string
appCode string
dryRun bool
StartCmd = &cobra.Command{
Use: "migrate",
Short: "Initialize the database",
@@ -33,14 +39,31 @@ var (
run()
},
}
statusCmd = &cobra.Command{
Use: "status",
Short: "List applied and pending migrations, grouped by app",
Example: "go-admin migrate status -c config/settings.yml",
Run: func(cmd *cobra.Command, args []string) {
runStatus()
},
}
)
// fixme 在您看不见代码的时候运行迁移,我觉得是不安全的,所以编译后最好不要去执行迁移
func init() {
StartCmd.PersistentFlags().StringVarP(&configYml, "config", "c", "config/settings.yml", "Start server with provided configuration file")
StartCmd.PersistentFlags().BoolVarP(&generate, "generate", "g", false, "generate migration file")
StartCmd.PersistentFlags().BoolVarP(&goAdmin, "goAdmin", "a", false, "generate go-admin migration file")
StartCmd.PersistentFlags().BoolVarP(&goAdmin, "goAdmin", "a", false, "with -g, write the generated file to version/ instead of version-local/ (does not affect which migrations run)")
StartCmd.PersistentFlags().StringVarP(&host, "domain", "d", "*", "select tenant host")
// --app is deliberately long-only. -a already means "generate into
// version/ rather than version-local/", which is about writing a template
// file, not about which migrations run; giving the two the same letter
// would be a trap.
StartCmd.PersistentFlags().StringVar(&appCode, "app", "", "limit to the migrations of one app (\""+migration.FrameworkAppCode+"\" for the framework's own)")
StartCmd.Flags().BoolVar(&dryRun, "dry-run", false, "list what would be applied, in order, and write nothing")
StartCmd.AddCommand(statusCmd)
}
func run() {
@@ -58,7 +81,12 @@ func run() {
}
}
func migrateModel() error {
// resolveDB picks the tenant database and hands it to the registry.
//
// It creates and alters nothing, which is what lets status and --dry-run share
// it: those two must be able to run against a production database without
// leaving a trace.
func resolveDB() (*gorm.DB, error) {
if host == "" {
host = "*"
}
@@ -73,29 +101,134 @@ func migrateModel() error {
}
}
if db == nil {
return fmt.Errorf("未找到数据库配置")
return nil, fmt.Errorf("未找到数据库配置")
}
if config.DatabasesConfig[host].Driver == "mysql" {
//初始化数据库时候用
db.Set("gorm:table_options", "ENGINE=InnoDB CHARSET=utf8mb4")
}
err := db.Debug().AutoMigrate(&models.Migration{})
return db, nil
}
// exitUnlessAppRegistered ends the command when --app names something no
// migration was registered under.
//
// Every path took a typo as "nothing matched" and reported success: `migrate`
// printed that the app was unknown and still exited 0, while `--dry-run` and
// `status` said "nothing to apply" and "none recorded" - which is what an
// up-to-date database says too, so the output does not even hint at the typo.
// An operator running `go-admin migrate --app crmm && deploy` gets the deploy.
//
// Checked against the registry, which init() has already filled, so this runs
// before any database work and costs nothing. It lives in the command layer
// because the exit code does: the migration package stays callable from a test
// without taking the process down with it.
func exitUnlessAppRegistered() {
if err := appRegistrationError(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// appRegistrationError carries the decision on its own so it can be tested;
// exitUnlessAppRegistered is only the os.Exit around it. Nil means --app was
// either empty or names a registered app.
func appRegistrationError() error {
if appCode == "" {
return nil
}
want := migration.DisplayAppCode(migration.AppFilter(appCode))
registered := migration.Migrate.AppCodes()
for _, c := range registered {
if c == want {
return nil
}
}
return fmt.Errorf("no migrations are registered for app %q; registered: %s",
want, strings.Join(registered, ", "))
}
func migrateModel() error {
db, err := resolveDB()
if err != nil {
return err
}
// sys_migration is the one table that never goes through a versioned
// migration - it is the table that records them. AutoMigrate realigns it
// on every run, which is how the app_code column reaches an existing
// database without anyone writing a migration for it.
if err = db.Debug().AutoMigrate(&models.Migration{}); err != nil {
return err
}
migration.Migrate.SetDb(db.Debug())
if appCode != "" {
migration.Migrate.MigrateApp(appCode)
return nil
}
migration.Migrate.Migrate()
return err
return nil
}
func initDB() {
// Before the database is touched, so a typo cannot get as far as looking
// like a successful no-op on either path below.
exitUnlessAppRegistered()
//3. 初始化数据库链接
database.Setup()
if dryRun {
db, err := resolveDB()
if err != nil {
fmt.Println(err)
return
}
migration.Migrate.SetDb(db)
entries, err := migration.Migrate.Status()
if err != nil {
fmt.Println(err)
return
}
if err = printPending(os.Stdout, entries, appCode); err != nil {
fmt.Println(err)
}
return
}
//4. 数据库迁移
fmt.Println("数据库迁移开始")
_ = migrateModel()
if err := migrateModel(); err != nil {
fmt.Println(err)
return
}
fmt.Println(`数据库基础数据初始化成功`)
}
func runStatus() {
config.Setup(
file.NewSource(file.WithPath(configYml)),
func() {
exitUnlessAppRegistered()
database.Setup()
db, err := resolveDB()
if err != nil {
fmt.Println(err)
return
}
migration.Migrate.SetDb(db)
entries, err := migration.Migrate.Status()
if err != nil {
fmt.Println(err)
return
}
if err = printStatus(os.Stdout, entries, appCode); err != nil {
fmt.Println(err)
}
},
)
}
func genFile() error {
t1, err := template.ParseFiles("template/migrate.template")
if err != nil {
+160
View File
@@ -0,0 +1,160 @@
package migrate
import (
"fmt"
"io"
"sort"
"strings"
"time"
"go-admin/cmd/migrate/migration"
)
const applyTimeLayout = "2006-01-02 15:04:05"
// printStatus lists every migration this binary knows about together with every
// row already in sys_migration, grouped by app.
//
// filter is an app code as typed on the command line; empty means every app.
func printStatus(w io.Writer, entries []migration.StatusEntry, filter string) error {
entries = filterByApp(entries, filter)
groups, order := groupByApp(entries)
if len(order) == 0 {
_, err := fmt.Fprintln(w, "no migrations registered and none recorded")
return err
}
// One width for the whole listing rather than one per group: the versions
// of two apps line up, so a long list can be read down the column.
width := versionWidth(entries)
var applied, pending, orphaned int
for i, app := range order {
if i > 0 {
fmt.Fprintln(w)
}
fmt.Fprintf(w, "[%s]\n", app)
for _, e := range groups[app] {
state := "pending"
switch {
case e.Applied && !e.Registered:
state = "orphaned"
orphaned++
case e.Applied:
state = "applied"
applied++
default:
pending++
}
fmt.Fprintln(w, strings.TrimRight(
fmt.Sprintf(" %-*s%-*s%s", stateWidth, state, width, e.Version, formatApplyTime(e.ApplyTime)), " "))
}
}
fmt.Fprintf(w, "\n%d applied, %d pending across %d app(s)\n", applied, pending, len(order))
if orphaned > 0 {
fmt.Fprintf(w, "%d orphaned: recorded in sys_migration, but nothing in this binary registers them.\n"+
"Expected after a migration file is removed or an app is uninstalled; they will not run again.\n", orphaned)
}
return nil
}
// printPending is --dry-run: the same data as status, narrowed to what an
// actual run would do and printed in the order it would do it.
//
// It reads and prints. Every write path - AutoMigrate on sys_migration
// included - is on the other branch in initDB, so a dry run leaves the database
// byte for byte as it found it.
func printPending(w io.Writer, entries []migration.StatusEntry, filter string) error {
entries = filterByApp(entries, filter)
fmt.Fprintln(w, "dry-run: nothing will be written")
pending := make([]migration.StatusEntry, 0, len(entries))
for _, e := range entries {
// An orphaned row is recorded and unregistered; a real run cannot
// apply it, so a dry run must not offer to.
if !e.Applied && e.Registered {
pending = append(pending, e)
}
}
if len(pending) == 0 {
_, err := fmt.Fprintln(w, "nothing to apply")
return err
}
appWidth := 0
for _, e := range pending {
if n := len(migration.DisplayAppCode(e.AppCode)) + 2; n > appWidth {
appWidth = n
}
}
fmt.Fprintln(w, "would apply, in this order:")
for _, e := range pending {
fmt.Fprintf(w, " %-*s%s\n", appWidth+2, "["+migration.DisplayAppCode(e.AppCode)+"]", e.Version)
}
fmt.Fprintf(w, "\n%d migration(s) pending\n", len(pending))
return nil
}
// stateWidth is the width of the applied/pending/orphaned column, sized to the
// longest of the three plus a gap.
const stateWidth = len("orphaned") + 2
func versionWidth(entries []migration.StatusEntry) int {
width := 0
for _, e := range entries {
if n := len(e.Version) + 2; n > width {
width = n
}
}
return width
}
// filterByApp keeps the entries of one app. The filter is matched after the
// same normalisation ForApp applies, so --app CRM finds crm.
func filterByApp(entries []migration.StatusEntry, filter string) []migration.StatusEntry {
if filter == "" {
return entries
}
want := migration.AppFilter(filter)
out := make([]migration.StatusEntry, 0, len(entries))
for _, e := range entries {
if e.AppCode == want {
out = append(out, e)
}
}
return out
}
// groupByApp buckets entries by display name and returns the buckets plus the
// order to print them in: the framework first, then apps alphabetically. That
// is also the order a full run executes them in, because version strings sort
// as ASCII and the framework's are bare digits.
func groupByApp(entries []migration.StatusEntry) (map[string][]migration.StatusEntry, []string) {
groups := make(map[string][]migration.StatusEntry)
for _, e := range entries {
app := migration.DisplayAppCode(e.AppCode)
groups[app] = append(groups[app], e)
}
order := make([]string, 0, len(groups))
for app := range groups {
order = append(order, app)
}
sort.Slice(order, func(i, j int) bool {
if (order[i] == migration.FrameworkAppCode) != (order[j] == migration.FrameworkAppCode) {
return order[i] == migration.FrameworkAppCode
}
return order[i] < order[j]
})
return groups, order
}
func formatApplyTime(t *time.Time) string {
if t == nil {
return ""
}
return t.Format(applyTimeLayout)
}
+170
View File
@@ -0,0 +1,170 @@
package migrate
import (
"bytes"
"strings"
"testing"
"time"
"go-admin/cmd/migrate/migration"
)
func at(s string) *time.Time {
t, err := time.Parse(applyTimeLayout, s)
if err != nil {
panic(err)
}
return &t
}
// The order Status returns: version strings sorted as ASCII.
func sampleEntries() []migration.StatusEntry {
return []migration.StatusEntry{
{Version: "1786700001000", AppCode: "", Registered: true, Applied: true, ApplyTime: at("2026-08-20 10:00:00")},
{Version: "1786700005000", AppCode: "", Registered: true},
{Version: "crm-1786800001000", AppCode: "crm", Registered: true, Applied: true, ApplyTime: at("2026-08-25 14:03:11")},
{Version: "crm-1786800002000", AppCode: "crm", Registered: true},
}
}
func TestPrintStatusGroupsByApp(t *testing.T) {
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), ""); err != nil {
t.Fatal(err)
}
got := buf.String()
for _, want := range []string{
"[core]",
"[crm]",
"applied 1786700001000 2026-08-20 10:00:00",
"pending 1786700005000",
"applied crm-1786800001000 2026-08-25 14:03:11",
"pending crm-1786800002000",
"2 applied, 2 pending across 2 app(s)",
} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q:\n%s", want, got)
}
}
// The framework heads the list, because that is the order a full run
// executes in.
if strings.Index(got, "[core]") > strings.Index(got, "[crm]") {
t.Errorf("core is not listed first:\n%s", got)
}
}
// A row nobody registers any more is neither applied-and-current nor pending.
// Calling it applied would say the migration is in this binary, which is what
// sends someone looking for a file that was deleted.
func TestPrintStatusMarksOrphanedRows(t *testing.T) {
entries := append(sampleEntries(), migration.StatusEntry{
Version: "gone-1786800000000", AppCode: "gone", Applied: true, ApplyTime: at("2026-08-01 09:00:00"),
})
var buf bytes.Buffer
if err := printStatus(&buf, entries, ""); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.Contains(got, "orphaned gone-1786800000000") {
t.Errorf("orphaned row not marked:\n%s", got)
}
if !strings.Contains(got, "nothing in this binary registers them") {
t.Errorf("orphaned rows need an explanation:\n%s", got)
}
if !strings.Contains(got, "2 applied, 2 pending") {
t.Errorf("orphaned rows must not be counted as applied:\n%s", got)
}
}
func TestPrintStatusFiltersByApp(t *testing.T) {
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), "crm"); err != nil {
t.Fatal(err)
}
got := buf.String()
if strings.Contains(got, "[core]") {
t.Errorf("--app crm listed the framework:\n%s", got)
}
if !strings.Contains(got, "across 1 app(s)") {
t.Errorf("output = %s", got)
}
}
// status prints [core]; --app core has to mean the same thing.
func TestPrintStatusAppCoreSelectsTheFramework(t *testing.T) {
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), migration.FrameworkAppCode); err != nil {
t.Fatal(err)
}
got := buf.String()
if strings.Contains(got, "[crm]") {
t.Errorf("--app core listed crm:\n%s", got)
}
if !strings.Contains(got, "[core]") {
t.Errorf("--app core listed nothing:\n%s", got)
}
}
func TestPrintStatusOnAnEmptyRegistry(t *testing.T) {
var buf bytes.Buffer
if err := printStatus(&buf, nil, ""); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "no migrations registered and none recorded") {
t.Errorf("output = %s", buf.String())
}
}
func TestPrintPendingListsOnlyPendingInOrder(t *testing.T) {
var buf bytes.Buffer
if err := printPending(&buf, sampleEntries(), ""); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.Contains(got, "dry-run: nothing will be written") {
t.Errorf("dry-run must say it writes nothing:\n%s", got)
}
if strings.Contains(got, "1786700001000\n") || strings.Contains(got, "crm-1786800001000") {
t.Errorf("dry-run listed already applied migrations:\n%s", got)
}
if !strings.Contains(got, "[core] 1786700005000") || !strings.Contains(got, "[crm] crm-1786800002000") {
t.Errorf("dry-run is missing pending migrations:\n%s", got)
}
if !strings.Contains(got, "2 migration(s) pending") {
t.Errorf("output = %s", got)
}
if strings.Index(got, "1786700005000") > strings.Index(got, "crm-1786800002000") {
t.Errorf("dry-run order does not match run order:\n%s", got)
}
}
// An orphaned row is applied and unregistered; a dry run must not offer to
// apply it, because a real run cannot.
func TestPrintPendingSkipsOrphanedRows(t *testing.T) {
entries := []migration.StatusEntry{
{Version: "gone-1786800000000", AppCode: "gone", Applied: true, ApplyTime: at("2026-08-01 09:00:00")},
}
var buf bytes.Buffer
if err := printPending(&buf, entries, ""); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "nothing to apply") {
t.Errorf("output = %s", buf.String())
}
}
func TestPrintPendingFiltersByApp(t *testing.T) {
var buf bytes.Buffer
if err := printPending(&buf, sampleEntries(), "CRM"); err != nil {
t.Fatal(err)
}
got := buf.String()
if strings.Contains(got, "[core]") {
t.Errorf("--app CRM listed the framework:\n%s", got)
}
if !strings.Contains(got, "1 migration(s) pending") {
t.Errorf("output = %s", got)
}
}
+68 -5
View File
@@ -50,6 +50,14 @@ func PermissionAction() gin.HandlerFunc {
db, err := pkg.GetOrm(c)
if err != nil {
log.Error(err)
// Same fix as the newDataPermission branch below: without Abort,
// gin's "return means continue" semantics send the request on to
// the business handler with PermissionKey never set. The caller
// then reads a zero-value DataPermission, which used to fall
// into Permission()'s fail-open default - a database hiccup
// silently turning into "see everything". PRD 006 F14/H1.
response.Error(c, 500, err, "权限范围鉴定错误")
c.Abort()
return
}
msgID := pkg.GenerateMsgIDFromContext(c)
@@ -101,22 +109,77 @@ func newDataPermission(tx *gorm.DB, userId interface{}) (*DataPermission, error)
return p, nil
}
// The five values sys_role.data_scope can hold. Front end's role editor
// calls them by the same names (go-admin-ui's sys-role/index.vue): "1" is
// 全部数据权限, "2" 自定义数据权限, "3" 本部门数据权限, "4" 本部门及以下数据权限,
// "5" 仅本人数据权限.
//
// DataScopeAll has to be a named, explicit case in Permission below rather
// than falling into default: it is a real, intentional configuration, not an
// absence of one, and default's job after PRD 006 F14/H2 is to catch values
// that are neither. Folding the two together is what made an unset or
// corrupted data_scope indistinguishable from "show everything" in the first
// place.
const (
DataScopeAll = "1"
DataScopeCustom = "2"
DataScopeDept = "3"
DataScopeDeptTree = "4"
DataScopeSelf = "5"
)
// IsValidDataScope reports whether s is one of the five values Permission
// recognizes. Anything else lands in Permission's fail-closed default, so
// code that persists data_scope (sys_role writes) should reject it before it
// reaches the database rather than let a typo or an empty string surface
// there silently.
func IsValidDataScope(s string) bool {
switch s {
case DataScopeAll, DataScopeCustom, DataScopeDept, DataScopeDeptTree, DataScopeSelf:
return true
default:
return false
}
}
func Permission(tableName string, p *DataPermission) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if !config.ApplicationConfig.EnableDP {
return db
}
switch p.DataScope {
case "2":
case DataScopeAll:
return db
case DataScopeCustom:
return db.Where(tableName+".create_by in (select sys_user.user_id from sys_role_dept left join sys_user on sys_user.dept_id=sys_role_dept.dept_id where sys_role_dept.role_id = ?)", p.RoleId)
case "3":
case DataScopeDept:
if p.DeptId <= 0 {
// A department id of 0 identifies no real department (see
// sys_dept.go: dept_path always starts with "/0/", the
// reserved root). Matching it literally would mean "every
// user whose dept_id happens to be unset", not "no one" -
// fail closed instead. PRD 006 F14/H3.
return db.Where("1 = 0")
}
return db.Where(tableName+".create_by in (SELECT user_id from sys_user where dept_id = ? )", p.DeptId)
case "4":
case DataScopeDeptTree:
if p.DeptId <= 0 {
// dept_path is built as "/0/" + id + "/..." for every
// department (sys_dept.go), so a DeptId of 0 turns the LIKE
// pattern below into '%/0/%', which matches every row in
// sys_dept - full visibility instead of none. PRD 006
// F14/H3.
return db.Where("1 = 0")
}
return db.Where(tableName+".create_by in (SELECT user_id from sys_user where sys_user.dept_id in(select dept_id from sys_dept where dept_path like ? ))", "%/"+pkg.IntToString(p.DeptId)+"/%")
case "5":
case DataScopeSelf:
return db.Where(tableName+".create_by = ?", p.UserId)
default:
return db
// Unrecognized scope: never configured, corrupted data, or a
// value a future version adds and this one does not know yet.
// Fail closed - match nothing - instead of silently falling
// back to "see everything". PRD 006 F14/H2.
return db.Where("1 = 0")
}
}
}
+125
View File
@@ -6,8 +6,10 @@ import (
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"gorm.io/gorm"
)
// No database is placed in the context on purpose. The middleware needs one
@@ -79,3 +81,126 @@ func TestATokenWithoutDeptIdFallsBackToTheQuery(t *testing.T) {
t.Fatal("an old token was served from claims it does not have")
}
}
// PRD 006 F14/H1. A token without deptid/datascope forces the fallback
// query, which needs pkg.GetOrm(c) - and no "db" key is set in this
// context, so GetOrm fails exactly as it would if a tenant's database were
// unreachable. Before the fix, that error was logged and the handler ran
// anyway with no data permission filter at all.
func TestPermissionActionAbortsWhenDBIsUnavailable(t *testing.T) {
previous := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = true
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
gin.SetMode(gin.TestMode)
r := gin.New()
handlerReached := false
r.Use(func(c *gin.Context) {
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"identity": float64(7)})
})
r.Use(PermissionAction())
r.GET("/", func(c *gin.Context) {
handlerReached = true
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if handlerReached {
t.Fatal("the business handler ran with no database and no data permission filter set")
}
}
// PRD 006 F14/H2 and H3. Table-driven over gorm DryRun so the exact SQL
// Permission produces for each scope is pinned down, not just "some WHERE
// clause got added".
func TestPermissionScopes(t *testing.T) {
previous := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = true
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DryRun: true})
if err != nil {
t.Fatalf("open: %v", err)
}
const noRows = "SELECT * FROM `t` WHERE 1 = 0"
cases := []struct {
name string
p *DataPermission
want string
vars []interface{}
}{
{
name: "all",
p: &DataPermission{DataScope: DataScopeAll},
want: "SELECT * FROM `t`",
},
{
name: "custom",
p: &DataPermission{DataScope: DataScopeCustom, RoleId: 3},
want: "SELECT * FROM `t` WHERE t.create_by in (select sys_user.user_id from sys_role_dept left join sys_user on sys_user.dept_id=sys_role_dept.dept_id where sys_role_dept.role_id = ?)",
vars: []interface{}{3},
},
{
name: "dept",
p: &DataPermission{DataScope: DataScopeDept, DeptId: 5},
want: "SELECT * FROM `t` WHERE t.create_by in (SELECT user_id from sys_user where dept_id = ? )",
vars: []interface{}{5},
},
{
name: "dept-tree",
p: &DataPermission{DataScope: DataScopeDeptTree, DeptId: 5},
want: "SELECT * FROM `t` WHERE t.create_by in (SELECT user_id from sys_user where sys_user.dept_id in(select dept_id from sys_dept where dept_path like ? ))",
vars: []interface{}{"%/5/%"},
},
{
name: "self",
p: &DataPermission{DataScope: DataScopeSelf, UserId: 7},
want: "SELECT * FROM `t` WHERE t.create_by = ?",
vars: []interface{}{7},
},
// H2: an unrecognized scope must not read like "all data" any more.
{name: "unrecognized value", p: &DataPermission{DataScope: "6"}, want: noRows},
// H2/H1: the zero-value DataPermission is what getPermissionFromContext
// and the two "give up and continue" branches in PermissionAction hand
// out when nothing else is available.
{name: "zero value (no scope at all)", p: &DataPermission{}, want: noRows},
// H3: dept_path always starts with "/0/" (sys_dept.go), so DeptId 0
// must not be allowed to build a pattern that matches every row.
{name: "dept with DeptId 0", p: &DataPermission{DataScope: DataScopeDept, DeptId: 0}, want: noRows},
{name: "dept-tree with DeptId 0", p: &DataPermission{DataScope: DataScopeDeptTree, DeptId: 0}, want: noRows},
{name: "dept-tree with negative DeptId", p: &DataPermission{DataScope: DataScopeDeptTree, DeptId: -1}, want: noRows},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
stmt := db.Session(&gorm.Session{DryRun: true}).
Table("t").
Scopes(Permission("t", tc.p)).
Find(&[]map[string]interface{}{}).
Statement
if stmt.SQL.String() != tc.want {
t.Errorf("SQL = %q, want %q", stmt.SQL.String(), tc.want)
}
if tc.vars == nil {
if len(stmt.Vars) != 0 {
t.Errorf("vars = %v, want none", stmt.Vars)
}
return
}
if len(stmt.Vars) != len(tc.vars) {
t.Fatalf("vars = %v, want %v", stmt.Vars, tc.vars)
}
for i := range tc.vars {
if stmt.Vars[i] != tc.vars[i] {
t.Errorf("vars[%d] = %v, want %v", i, stmt.Vars[i], tc.vars[i])
}
}
})
}
}
+5 -1
View File
@@ -63,7 +63,11 @@ func setupSimpleDatabase(host string, c *toolsConfig.Database) {
log.Info(pkg.Green(c.Driver + " connect success !"))
}
e := mycasbin.Setup(db, "")
// Keyed by host, matching the database this enforcer reads from. Passing
// the same key for every host would hand each one the enforcer built from
// whichever database was configured first, and the rest would be decided
// by a casbin_rule table that is not theirs.
e := mycasbin.Setup(db, host)
sdk.Runtime.SetDbByTenant(host, db)
sdk.Runtime.SetCasbinByTenant(host, e)
+14
View File
@@ -0,0 +1,14 @@
package global
// Status values written to sys_opera_log.status.
//
// They live here rather than in app/admin/service/dto because
// common/middleware/logger.go writes the operation-log message and needs them.
// A package promised as a stable contract must not compile-depend on a
// business module: a fork that replaces or drops app/admin would otherwise
// stop compiling common/middleware, which is not something a contract package
// is allowed to do. See docs/contract.md.
const (
OperaStatusEnabled = "1"
OperaStatusDisabled = "2"
)
+24 -13
View File
@@ -1,7 +1,6 @@
package handler
import (
"go-admin/app/admin/models"
"go-admin/common"
"net/http"
@@ -163,19 +162,31 @@ func LogOut(c *gin.Context) {
}
// Authorizator decides whether a parsed identity may proceed. It authorizes
// every identity IdentityHandler was able to build, which is what it has always
// done.
//
// It used to also assert data["user"] and data["role"] into app/admin/models
// types and copy five fields onto the context. Those two keys are not in the
// map: IdentityHandler builds it from the token claims and puts in
// IdentityKey / UserName / RoleKey / UserId / RoleIds / DataScope. Both
// assertions therefore failed on every request, and because the ok result was
// discarded, the five c.Set calls stored zero values and the function returned
// true regardless.
//
// Nothing in this repository or in go-admin-core reads role / roleIds /
// userId / userName / dataScope off the context - the open-source data
// permission path reads the JWT claims through
// common/actions.Permission -> user.GetUserIdStr(c). Dropping the block
// therefore removes five zero values nobody read, and with them the last
// import of app/admin from a contract package.
//
// Anything maintaining its own copy of this file must check its own consumers
// before taking this change: a codebase that does read those keys off the
// context needs Authorizator to keep setting them.
func Authorizator(data interface{}, c *gin.Context) bool {
if v, ok := data.(map[string]interface{}); ok {
u, _ := v["user"].(models.SysUser)
r, _ := v["role"].(models.SysRole)
c.Set("role", r.RoleName)
c.Set("roleIds", r.RoleId)
c.Set("userId", u.UserId)
c.Set("userName", u.Username)
c.Set("dataScope", r.DataScope)
return true
}
return false
_, ok := data.(map[string]interface{})
return ok
}
func Unauthorized(c *gin.Context, code int, message string) {
+67 -20
View File
@@ -1,19 +1,18 @@
package middleware
import (
"bufio"
"bytes"
"encoding/json"
"go-admin/app/admin/service/dto"
"errors"
"go-admin/common"
"io"
"io/ioutil"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
"github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
@@ -28,19 +27,14 @@ func LoggerToFile() gin.HandlerFunc {
// 开始时间
startTime := time.Now()
// 处理请求
//
// The body is only read when it has a destination. operParam below is
// the only consumer, and it is written when logger.enableddb is on -
// off in the shipped configuration, where reading the body was a copy
// of every request made and discarded.
var body string
switch c.Request.Method {
case http.MethodPost, http.MethodPut, http.MethodGet, http.MethodDelete:
bf := bytes.NewBuffer(nil)
wt := bufio.NewWriter(bf)
_, err := io.Copy(wt, c.Request.Body)
if err != nil {
log.Warnf("copy body error, %s", err.Error())
err = nil
}
rb, _ := ioutil.ReadAll(bf)
c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(rb))
body = string(rb)
if config.LoggerConfig.EnabledDB {
body = readOperParam(c, log)
}
c.Next()
@@ -100,10 +94,55 @@ func LoggerToFile() gin.HandlerFunc {
}
}
// SetDBOperLog 写入操作日志表 fixme 该方法后续即将弃用
func SetDBOperLog(c *gin.Context, clientIP string, statusCode int, reqUri string, reqMethod string, latencyTime time.Duration, body string, result string, status int) {
// operParamLimit caps what is copied out of a request body for the operation
// log. A file upload is a POST like any other and reaches this middleware
// before any handler, so without a limit the whole upload is held in memory to
// write a log row - a 16MB upload allocated about 67MB. The limit also keeps
// the value inside the column, which is TEXT.
const operParamLimit = 32 << 10
log := api.GetRequestLogger(c)
// readOperParam copies the start of the request body for the operation log and
// leaves the request readable by the handler.
//
// The body is not buffered whole: the handler reads the part copied here from
// memory and the rest straight from the connection, so what this holds is
// bounded by operParamLimit however large the request is.
func readOperParam(c *gin.Context, log *logger.Helper) string {
switch c.Request.Method {
case http.MethodPost, http.MethodPut, http.MethodGet, http.MethodDelete:
default:
return ""
}
if c.Request.Body == nil {
return ""
}
rest := c.Request.Body
head := make([]byte, operParamLimit)
n, err := io.ReadFull(rest, head)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
log.Warnf("read body for the operation log: %s", err)
}
head = head[:n]
c.Request.Body = readCloser{
Reader: io.MultiReader(bytes.NewReader(head), rest),
Closer: rest,
}
return string(head)
}
type readCloser struct {
io.Reader
io.Closer
}
// operaLogFields builds the message written to the operation log queue.
//
// Split out of SetDBOperLog so the field set can be asserted in a test: the
// consumer on the other end of the queue reads these keys by name, so a
// dropped or renamed key costs a column in sys_opera_log and reports nothing.
func operaLogFields(c *gin.Context, clientIP string, statusCode int, reqUri string, reqMethod string, latencyTime time.Duration, body string, result string, status int) map[string]interface{} {
l := make(map[string]interface{})
l["_fullPath"] = c.FullPath()
l["operUrl"] = reqUri
@@ -120,10 +159,18 @@ func SetDBOperLog(c *gin.Context, clientIP string, statusCode int, reqUri string
l["createBy"] = user.GetUserId(c)
l["updateBy"] = user.GetUserId(c)
if status == http.StatusOK {
l["status"] = dto.OperaStatusEnabel
l["status"] = global.OperaStatusEnabled
} else {
l["status"] = dto.OperaStatusDisable
l["status"] = global.OperaStatusDisabled
}
return l
}
// SetDBOperLog 写入操作日志表 fixme 该方法后续即将弃用
func SetDBOperLog(c *gin.Context, clientIP string, statusCode int, reqUri string, reqMethod string, latencyTime time.Duration, body string, result string, status int) {
log := api.GetRequestLogger(c)
l := operaLogFields(c, clientIP, statusCode, reqUri, reqMethod, latencyTime, body, result, status)
q := sdk.Runtime.GetQueuePrefix(c.Request.Host)
message, err := sdk.Runtime.GetStreamMessage("", global.OperateLog, l)
if err != nil {
+124
View File
@@ -0,0 +1,124 @@
package middleware
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
)
// serveWithLogger runs one request through the logger middleware and returns
// what the handler saw, with logger.enableddb set as given.
func serveWithLogger(t testing.TB, enabledDB bool, method, body string) string {
t.Helper()
prev := config.LoggerConfig.EnabledDB
config.LoggerConfig.EnabledDB = enabledDB
t.Cleanup(func() { config.LoggerConfig.EnabledDB = prev })
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(LoggerToFile())
var seen string
handler := func(c *gin.Context) {
b, err := io.ReadAll(c.Request.Body)
if err != nil {
t.Errorf("handler could not read the body: %v", err)
}
seen = string(b)
c.Status(http.StatusOK)
}
r.Handle(method, "/probe", handler)
req := httptest.NewRequest(method, "/probe", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(httptest.NewRecorder(), req)
return seen
}
// The middleware rewrites Request.Body so it can log the parameters. Whatever
// else it does, the handler has to receive the request the client sent - all
// of it, whether or not the operation log is on, and whether or not the body
// is longer than what gets logged.
func TestHandlerStillSeesTheWholeBody(t *testing.T) {
cases := []struct {
name string
enabledDB bool
body string
}{
{"log off, short body", false, `{"username":"admin"}`},
{"log on, short body", true, `{"username":"admin"}`},
{"log off, empty body", false, ""},
{"log on, empty body", true, ""},
// Longer than operParamLimit: the logged copy is truncated, the body is not.
{"log on, body past the limit", true, strings.Repeat("x", operParamLimit+4096)},
{"log off, body past the limit", false, strings.Repeat("y", operParamLimit+4096)},
// Exactly at the boundary, where a fencepost error would show.
{"log on, body exactly at the limit", true, strings.Repeat("z", operParamLimit)},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
if got := serveWithLogger(t, c.enabledDB, method, c.body); got != c.body {
t.Errorf("%s: handler saw %d bytes, the client sent %d",
method, len(got), len(c.body))
}
}
})
}
}
// The body is read for one reason - operParam on the operation log row - and
// that row is only written when logger.enableddb is on. With it off, reading
// the body is a copy of every request made and thrown away, and a file upload
// is a POST like any other: 16MB of upload allocated about 67MB here.
//
// Allocation counts are deterministic across machines; wall-clock is not.
func TestBodyIsNotCopiedWhenTheOperationLogIsOff(t *testing.T) {
const size = 1 << 20
body := strings.Repeat("x", size)
prev := config.LoggerConfig.EnabledDB
config.LoggerConfig.EnabledDB = false
t.Cleanup(func() { config.LoggerConfig.EnabledDB = prev })
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(LoggerToFile())
r.POST("/probe", func(c *gin.Context) { c.Status(http.StatusOK) })
payload := []byte(body)
run := func() {
req := httptest.NewRequest(http.MethodPost, "/probe", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(httptest.NewRecorder(), req)
}
var before, after uint64
before = heapAllocs()
run()
after = heapAllocs()
// The handler never reads the body, so a request that does not copy it
// should allocate far less than the body's size. The old middleware
// allocated about four times the body.
if grew := after - before; grew > size/2 {
t.Errorf("a %d-byte request allocated %d bytes with the operation log off; "+
"the body should not be read when nothing consumes it", size, grew)
}
}
func heapAllocs() uint64 {
var m runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&m)
return m.TotalAlloc
}
+73
View File
@@ -0,0 +1,73 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"go-admin/common/global"
)
// The operation-log consumer reads these keys by name off the queue message.
// Losing one costs a column in sys_opera_log and reports nothing - the request
// still succeeds, the log row is just wrong.
//
// This locks the set down across the move of the status constants out of
// app/admin/service/dto, which touched every request path.
var operaLogKeys = []string{
"_fullPath", "operUrl", "operIp", "operLocation", "operName",
"requestMethod", "operParam", "operTime", "jsonResult", "latencyTime",
"statusCode", "userAgent", "createBy", "updateBy", "status",
}
func TestOperaLogFieldsAreComplete(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/sys-user", nil)
c.Request.Header.Set("User-Agent", "go-test")
l := operaLogFields(c, "127.0.0.1", http.StatusOK, "/api/v1/sys-user", http.MethodPost,
12*time.Millisecond, `{"a":1}`, `{"code":200}`, http.StatusOK)
for _, k := range operaLogKeys {
if _, ok := l[k]; !ok {
t.Errorf("operation log is missing %q", k)
}
}
if len(l) != len(operaLogKeys) {
t.Errorf("operation log has %d fields, expected %d; update operaLogKeys deliberately, not to make this pass",
len(l), len(operaLogKeys))
}
if got := l["operUrl"]; got != "/api/v1/sys-user" {
t.Errorf("operUrl = %v", got)
}
if got := l["userAgent"]; got != "go-test" {
t.Errorf("userAgent = %v", got)
}
}
// status is what tells a failed request from a successful one in the log table.
// It is a string, and it is the one field whose source package changed.
func TestOperaLogStatusMapping(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name string
status int
want string
}{
{"ok", http.StatusOK, global.OperaStatusEnabled},
{"error", http.StatusInternalServerError, global.OperaStatusDisabled},
} {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
l := operaLogFields(c, "127.0.0.1", tc.status, "/", http.MethodGet, 0, "", "", tc.status)
if l["status"] != tc.want {
t.Fatalf("status = %v, want %v", l["status"], tc.want)
}
})
}
}
+34 -6
View File
@@ -1,10 +1,11 @@
package middleware
import (
"github.com/casbin/casbin/v3/util"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
mycasbin "github.com/go-admin-team/go-admin-core/v2/casbin"
"github.com/go-admin-team/go-admin-core/v2/jwtauth"
"github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk"
@@ -26,11 +27,9 @@ func AuthCheckRole() gin.HandlerFunc {
c.Next()
return
}
for _, i := range CasbinExclude {
if util.KeyMatch2(c.Request.URL.Path, i.Url) && c.Request.Method == i.Method {
casbinExclude = true
break
}
casbinExclude, err = excludedFromCasbin(c.Request.Method, c.Request.URL.Path)
if err != nil {
log.Errorf("AuthCheckRole: %s", err)
}
if casbinExclude {
log.Infof("Casbin exclusion, no validation method:%s path:%s", c.Request.Method, c.Request.URL.Path)
@@ -59,3 +58,32 @@ func AuthCheckRole() gin.HandlerFunc {
}
}
// excludedFromCasbin reports whether the route skips the permission check.
//
// It runs for every non-admin request, so the order matters: the method rules
// out most entries with a string compare, where the path test costs a pattern
// match. mycasbin.KeyMatch2 answers what casbin's util.KeyMatch2 answers
// without recompiling the pattern every time, which is what made this loop
// expensive - about 2,500 allocations per request against a 32-entry list.
//
// A pattern that will not compile is a bug in CasbinExclude rather than in the
// request, so the entry is skipped and the scan continues; the error comes
// back for the caller to log.
func excludedFromCasbin(method, path string) (bool, error) {
var bad error
for _, i := range CasbinExclude {
if method != i.Method {
continue
}
ok, err := mycasbin.KeyMatch2(path, i.Url)
if err != nil {
bad = fmt.Errorf("CasbinExclude entry %q is not a valid pattern: %w", i.Url, err)
continue
}
if ok {
return true, bad
}
}
return false, bad
}
+79
View File
@@ -0,0 +1,79 @@
package middleware
import "testing"
// excluded is excludedFromCasbin with the error dropped: these tests are about
// the answer and its cost, and CasbinExclude has no malformed entry to report.
func excluded(t testing.TB, path, method string) bool {
t.Helper()
ok, err := excludedFromCasbin(method, path)
if err != nil {
t.Fatalf("CasbinExclude holds a pattern that will not compile: %s", err)
}
return ok
}
// TestCasbinExcludeScanMatches pins the behaviour the scan has to keep: an
// excluded route is recognised, a protected one is not, and the method has to
// agree.
func TestCasbinExcludeScanMatches(t *testing.T) {
cases := []struct {
path, method string
want bool
}{
{"/api/v1/health", "GET", true},
{"/api/v1/login", "POST", true},
{"/api/v1/roleMenuTreeselect/12", "GET", true},
{"/api/v1/dept", "GET", false},
{"/api/v1/sys-user", "GET", false},
// Same path, wrong method: sys-user is excluded for PUT only.
{"/api/v1/sys-user", "PUT", true},
{"/api/v1/health", "POST", false},
}
for _, c := range cases {
if got := excluded(t, c.path, c.method); got != c.want {
t.Errorf("excludedFromCasbin(%s %s) = %v, want %v", c.method, c.path, got, c.want)
}
}
}
// TestCasbinExcludeScanAllocationBudget is what keeps the scan cheap.
//
// The list is walked per request with a pattern match per entry, and
// casbin's util.KeyMatch2 compiles a regexp on every call - the whole scan
// cost about 2,566 allocations that way. Going back to it fails this test.
//
// Allocation counts are deterministic across machines; wall-clock is not.
func TestCasbinExcludeScanAllocationBudget(t *testing.T) {
// A protected route, so the scan runs to the end without an early match -
// the case every authenticated business request hits.
const path, method = "/api/v1/dept", "GET"
if excluded(t, path, method) {
t.Fatalf("setup failed: %s is in the exclusion list", path)
}
// The budget covers the GET entries that carry a path parameter, which
// still need a match. Measured at 0 for the cached matcher; the headroom
// is for entries being added to the list.
const budget = 64
got := testing.AllocsPerRun(100, func() {
_, _ = excludedFromCasbin(method, path)
})
if got > budget {
t.Errorf("scanning CasbinExclude allocates %.0f times, budget is %d\n"+
"casbin's util.KeyMatch2 costs about 2566 here; use mycasbin.KeyMatch2",
got, budget)
}
}
// BenchmarkCasbinExcludeScan reports what the scan adds to a request.
func BenchmarkCasbinExcludeScan(b *testing.B) {
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_, _ = excludedFromCasbin("GET", "/api/v1/dept")
}
})
}
+29 -4
View File
@@ -1,29 +1,54 @@
package middleware
import (
"net/http"
"github.com/alibaba/sentinel-golang/core/system"
sentinel "github.com/alibaba/sentinel-golang/pkg/adapters/gin"
"github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"go-admin/config"
)
// Sentinel 限流
//
// The threshold comes from extend.ratelimit.inboundqps; see config.RateLimit
// for the values it accepts.
func Sentinel() gin.HandlerFunc {
qps := config.ExtConfig.RateLimit.Threshold()
if qps <= 0 {
log.Info("rate limit disabled by extend.ratelimit.inboundqps")
return func(c *gin.Context) { c.Next() }
}
if _, err := system.LoadRules([]*system.Rule{
{
MetricType: system.InboundQPS,
TriggerCount: 200,
Strategy: system.BBR,
TriggerCount: qps,
// InboundQPS is compared against TriggerCount directly - the
// adaptive strategy is only consulted for Load and CpuUsage. BBR
// stood here and read as if the limit adapted to the machine, which
// it never did.
Strategy: system.NoAdaptive,
},
}); err != nil {
log.Fatalf("Unexpected error: %+v", err)
}
log.Infof("rate limit: %.0f inbound req/s", qps)
return sentinel.SentinelMiddleware(
sentinel.WithBlockFallback(func(ctx *gin.Context) {
ctx.AbortWithStatusJSON(200, map[string]interface{}{
// 429, not 200. Everything that reads the status line rather than
// the body counts a 200 as served: load balancers, metrics,
// client-side retry, and load tests - a benchmark against the old
// behaviour reported the limiter's own rejections as successful
// traffic and overstated throughput by more than tenfold.
ctx.AbortWithStatusJSON(http.StatusTooManyRequests, map[string]interface{}{
"msg": "too many request; the quota used up!",
"code": 500,
"code": http.StatusTooManyRequests,
})
}),
)
+107
View File
@@ -0,0 +1,107 @@
package middleware
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/alibaba/sentinel-golang/core/system"
"github.com/gin-gonic/gin"
"go-admin/config"
)
// serve builds a router with the limiter in front of a handler that always
// succeeds, so any non-200 comes from the limiter.
func serve(t *testing.T) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(Sentinel())
r.GET("/ping", func(c *gin.Context) { c.Status(http.StatusOK) })
return r
}
func get(t *testing.T, r *gin.Engine) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ping", nil))
return w
}
// TestSentinelRejectsWithTooManyRequests pins the status code. A rejected
// request used to answer 200 with the failure only in the body, so every layer
// that reads the status line - load balancers, metrics, client retry, load
// tests - counted it as served.
func TestSentinelRejectsWithTooManyRequests(t *testing.T) {
one := 1.0
config.ExtConfig.RateLimit = config.RateLimit{InboundQPS: &one}
t.Cleanup(func() {
config.ExtConfig.RateLimit = config.RateLimit{}
_ = system.ClearRules()
})
r := serve(t)
var rejected *httptest.ResponseRecorder
for i := 0; i < 20; i++ {
if w := get(t, r); w.Code != http.StatusOK {
rejected = w
break
}
}
if rejected == nil {
t.Fatal("a limit of 1 req/s let 20 requests through; the limiter is not engaged")
}
if rejected.Code != http.StatusTooManyRequests {
t.Errorf("rejected with %d, want %d", rejected.Code, http.StatusTooManyRequests)
}
// The body's code must agree with the status line; they disagreed before.
var body struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(rejected.Body.Bytes(), &body); err != nil {
t.Fatalf("rejection body is not json: %v", err)
}
if body.Code != http.StatusTooManyRequests {
t.Errorf("body code = %d, want %d", body.Code, http.StatusTooManyRequests)
}
if body.Msg == "" {
t.Error("rejection carries no message")
}
}
// TestSentinelDisabledByZero covers the escape hatch: a deployment behind its
// own gateway has no use for a second limiter.
//
// It asserts on the loaded rules rather than on traffic. Sentinel measures QPS
// over a sliding window, so a burst issued inside one bucket is not counted
// before the bucket closes - a few hundred requests sail past a threshold of
// 200 in a test, and "no request was rejected" would pass whether or not the
// limiter is disabled. Whether a rule was installed at all does not depend on
// timing.
func TestSentinelDisabledByZero(t *testing.T) {
if err := system.ClearRules(); err != nil {
t.Fatal(err)
}
zero := 0.0
config.ExtConfig.RateLimit = config.RateLimit{InboundQPS: &zero}
t.Cleanup(func() {
config.ExtConfig.RateLimit = config.RateLimit{}
_ = system.ClearRules()
})
r := serve(t)
if rules := system.GetRules(); len(rules) != 0 {
t.Errorf("limiter disabled but %d rule(s) were loaded: %+v", len(rules), rules)
}
for i := 0; i < 500; i++ {
if w := get(t, r); w.Code != http.StatusOK {
t.Fatalf("request %d got %d with the limiter disabled", i, w.Code)
}
}
}
+11
View File
@@ -5,6 +5,17 @@ import "time"
type Migration struct {
Version string `gorm:"primaryKey"`
ApplyTime time.Time `gorm:"autoCreateTime"`
// AppCode identifies which app registered this migration. The empty string
// means the framework itself.
//
// NOT NULL DEFAULT '' rather than a nullable column, and the difference is
// not cosmetic: on a nullable column the rows that already exist when
// AutoMigrate adds it hold NULL, and the first SELECT scanning one into
// this string field fails with "converting NULL to string is unsupported".
// The default is what makes "existing history belongs to the framework"
// true without a backfill script anyone could forget to run.
AppCode string `gorm:"type:varchar(64);not null;default:'';index:idx_sys_migration_app_code;comment:AppCode"`
}
func (Migration) TableName() string {
+1 -1
View File
@@ -336,6 +336,6 @@ INSERT INTO sys_post (post_id, post_name, post_code, sort, status, remark, creat
(2, '首席技术执行官', 'CTO', 2, '2','首席技术执行官', 1, 1,'2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL),
(3, '首席运营官', 'COO', 3, '2','测试工程师', 1, 1,'2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
INSERT INTO sys_role (role_id, role_name, status, role_key, role_sort, flag, remark, admin, data_scope, create_by, update_by, created_at, updated_at, deleted_at)VALUES
(1, '系统管理员', '2', 'admin', 1, '', '', 1, '', 1, 1, '2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
(1, '系统管理员', '2', 'admin', 1, '', '', 1, '1', 1, 1, '2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
INSERT INTO sys_user VALUES (1, 'admin', '$2a$10$/Glr4g9Svr6O0kvjsRJCXu3f0W8/dsP3XZyVNi1019ratWpSPMyw.', 'zhangwj', '13818888888', 1, '', '', '1', '1@qq.com', 1, 1, '', '2', 1, 1, '2021-05-13 19:56:37.914', '2021-05-13 19:56:40.205', NULL);
-- 数据完成 ;
+1 -1
View File
@@ -318,6 +318,6 @@ INSERT INTO sys_menu_api_rule VALUES (46, 156);
INSERT INTO sys_post VALUES (1, '首席执行官', 'CEO', 0, '2','首席执行官', 1, 1, '2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
INSERT INTO sys_post VALUES (2, '首席技术执行官', 'CTO', 2, '2','首席技术执行官', 1, 1,'2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
INSERT INTO sys_post VALUES (3, '首席运营官', 'COO', 3, '2','测试工程师', 1, 1,'2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
INSERT INTO sys_role VALUES (1, '系统管理员', '2', 'admin', 1, '', '', true, '', 1, 1, '2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
INSERT INTO sys_role VALUES (1, '系统管理员', '2', 'admin', 1, '', '', true, '1', 1, 1, '2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
INSERT INTO sys_user VALUES (1, 'admin', '$2a$10$/Glr4g9Svr6O0kvjsRJCXu3f0W8/dsP3XZyVNi1019ratWpSPMyw.', 'zhangwj', '13818888888', 1, '', '', '1', '1@qq.com', 1, 1, '', '2', 1, 1, '2021-05-13 19:56:37.914', '2021-05-13 19:56:40.205', NULL);
-- 数据完成 ;
+33
View File
@@ -12,6 +12,39 @@ var ExtConfig Extend
type Extend struct {
AMap AMap // 这里配置对应配置文件的结构即可
FileStore FileStore
RateLimit RateLimit
}
// DefaultInboundQPS is the limit applied when nothing is configured. It is the
// value that used to be hard-coded in the middleware, so an existing deployment
// that adds nothing to settings.yml keeps the behaviour it already had.
const DefaultInboundQPS = 200
// RateLimit 全局入站限流。
//
// extend:
// ratelimit:
// inboundqps: 200 # 每秒入站请求上限;填 0 关闭限流
//
// The threshold used to live in common/middleware/sentinel.go as a constant,
// which made 200 QPS the ceiling of every deployment with nothing in the
// configuration to reveal it.
type RateLimit struct {
// InboundQPS caps inbound requests per second across the process.
//
// Absent means DefaultInboundQPS, zero disables the limiter, and a positive
// value is the threshold. The pointer is what separates "not configured"
// from "configured to zero" - the two need different answers and a plain
// float64 cannot tell them apart.
InboundQPS *float64
}
// Threshold reports the limit to apply. Zero means no limiting.
func (r RateLimit) Threshold() float64 {
if r.InboundQPS == nil {
return DefaultInboundQPS
}
return *r.InboundQPS
}
type AMap struct {
+18
View File
@@ -14,3 +14,21 @@ func TestObjectStoreConfigured(t *testing.T) {
t.Fatal("partial store reported as configured")
}
}
func TestRateLimitThreshold(t *testing.T) {
// Absent is the case an existing settings.yml hits after an upgrade: it has
// no ratelimit section, and must keep the limit it always had.
if got := (RateLimit{}).Threshold(); got != DefaultInboundQPS {
t.Errorf("unconfigured limit = %v, want the default %v", got, DefaultInboundQPS)
}
zero := 0.0
if got := (RateLimit{InboundQPS: &zero}).Threshold(); got != 0 {
t.Errorf("explicit zero = %v, want 0 so the limiter can be turned off", got)
}
custom := 1500.0
if got := (RateLimit{InboundQPS: &custom}).Threshold(); got != custom {
t.Errorf("configured limit = %v, want %v", got, custom)
}
}
+38
View File
@@ -0,0 +1,38 @@
package config
import (
"os"
"regexp"
"testing"
)
// The seed files write the built-in admin role's data_scope inline in a SQL
// INSERT, not through Go code, so nothing else in the test suite exercises
// this value. It has to be one of the five scopes actions.Permission
// recognizes: PRD 006 F14/H2 made every other value match no rows, and an
// empty string - which is what these files shipped before that fix - is one
// such value. Without this the shipped admin account would silently lose
// all visibility the moment a deployment turns EnableDP on.
func TestSeedAdminRoleHasAValidDataScope(t *testing.T) {
cases := map[string]*regexp.Regexp{
"db.sql": regexp.MustCompile(
`INSERT INTO sys_role VALUES \(1, '系统管理员', '2', 'admin', 1, '', '', true, '([^']*)'`),
"db-sqlserver.sql": regexp.MustCompile(
`\(1, '系统管理员', '2', 'admin', 1, '', '', 1, '([^']*)'`),
}
valid := map[string]bool{"1": true, "2": true, "3": true, "4": true, "5": true}
for file, pattern := range cases {
data, err := os.ReadFile(file)
if err != nil {
t.Fatalf("%s: %v", file, err)
}
m := pattern.FindSubmatch(data)
if m == nil {
t.Fatalf("%s: admin role INSERT not found; the regex may be out of date", file)
}
if scope := string(m[1]); !valid[scope] {
t.Errorf("%s: admin role data_scope = %q, want one of 1-5", file, scope)
}
}
}
+27 -1
View File
@@ -42,6 +42,17 @@ settings:
source: user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8&parseTime=True&loc=Local&timeout=1000ms
# source: sqlite3.db
# source: host=myhost port=myport user=gorm dbname=gorm password=mypassword
# 连接池。不配置这几项时走 Go 的默认值,其中 MaxIdleConns 默认只有 2:
# 高并发下几乎每个请求都要新建 TCP 连接、用完立刻关闭,本机端口很快耗尽,
# 表现为 "can't assign requested address" 且请求全部失败——不是变慢,是不可用。
#
# maxOpenConns 是单个实例的连接上限,多实例部署时总连接数是它乘以实例数,
# 需要小于数据库的 max_connections(MySQL 默认 151)。
# connMaxLifeTime 单位为秒,应小于数据库的 wait_timeout(MySQL 默认 28800),
# 否则会复用到已被服务端关闭的连接。
maxIdleConns: 20
maxOpenConns: 100
connMaxLifeTime: 3600
registers:
- sources:
- user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8&parseTime=True&loc=Local&timeout=1000ms
@@ -52,10 +63,25 @@ settings:
frontpath: ../go-admin-ui/src
queue:
memory:
poolSize: 100
# poolSize 是队列的缓冲长度,不是并发度。队列满时 Append 会丢弃该消息并
# 返回错误,而不是阻塞等待,所以这个值实际是「开始丢消息的临界点」。
#
# 每个 stream 只有一个消费 goroutine,而登录日志、操作日志的消费要写数据库,
# 吞吐受限于单条写入耗时。突发流量高于消费速度时,缓冲区是唯一的缓解手段。
# 压测中默认的 100 丢弃率超过 60%,1000 为 0。
#
# 仅在 logger.enableddb 为 true 时才会真正入队。
poolSize: 1000
extend: # 扩展项使用说明
demo:
name: data
# rateLimit 全局入站限流。不配置时为 200 QPS,与此前写死在
# common/middleware/sentinel.go 里的值一致,升级不会改变行为。
# 填 0 关闭限流——部署在自带限流的网关后面时用得上。
# 超出阈值的请求返回 HTTP 429(旧版本返回 200,只在 body 里写 code:500,
# 会被负载均衡、监控和压测统计成成功)。
rateLimit:
inboundQPS: 200
# fileStore 对象存储。上传接口的 source 参数决定走哪一家:
# source=1 只存本地,source=2 阿里云 OSS,source=3 七牛 Kodo
# 没有填的那一家在被请求时会返回明确错误,不会静默存到别处。
+27 -1
View File
@@ -32,6 +32,17 @@ settings:
driver: mysql
# 数据库连接字符串 mysql 缺省信息 charset=utf8&parseTime=True&loc=Local&timeout=1000ms
source: user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8&parseTime=True&loc=Local&timeout=1000ms
# 连接池。不配置这几项时走 Go 的默认值,其中 MaxIdleConns 默认只有 2:
# 高并发下几乎每个请求都要新建 TCP 连接、用完立刻关闭,本机端口很快耗尽,
# 表现为 "can't assign requested address" 且请求全部失败——不是变慢,是不可用。
#
# maxOpenConns 是单个实例的连接上限,多实例部署时总连接数是它乘以实例数,
# 需要小于数据库的 max_connections(MySQL 默认 151)。
# connMaxLifeTime 单位为秒,应小于数据库的 wait_timeout(MySQL 默认 28800),
# 否则会复用到已被服务端关闭的连接。
maxIdleConns: 20
maxOpenConns: 100
connMaxLifeTime: 3600
# databases:
# 'locaohost:8000':
# driver: mysql
@@ -48,6 +59,13 @@ settings:
extend: # 扩展项使用说明
demo:
name: data
# rateLimit 全局入站限流。不配置时为 200 QPS,与此前写死在
# common/middleware/sentinel.go 里的值一致,升级不会改变行为。
# 填 0 关闭限流——部署在自带限流的网关后面时用得上。
# 超出阈值的请求返回 HTTP 429(旧版本返回 200,只在 body 里写 code:500,
# 会被负载均衡、监控和压测统计成成功)。
rateLimit:
inboundQPS: 200
cache:
# redis:
# addr: 127.0.0.1:6379
@@ -57,7 +75,15 @@ settings:
memory: ''
queue:
memory:
poolSize: 100
# poolSize 是队列的缓冲长度,不是并发度。队列满时 Append 会丢弃该消息并
# 返回错误,而不是阻塞等待,所以这个值实际是「开始丢消息的临界点」。
#
# 每个 stream 只有一个消费 goroutine,而登录日志、操作日志的消费要写数据库,
# 吞吐受限于单条写入耗时。突发流量高于消费速度时,缓冲区是唯一的缓解手段。
# 压测中默认的 100 丢弃率超过 60%,1000 为 0。
#
# 仅在 logger.enableddb 为 true 时才会真正入队。
poolSize: 1000
# redis:
# addr: 127.0.0.1:6379
# password: xxxxxx
+199
View File
@@ -0,0 +1,199 @@
# 公共契约面
> 本文写给**第三方应用作者**:你写一个装进 go-admin 的业务模块,可以依赖什么、
> 怎么注册进来、哪些东西随时可能变。
>
> 主仓贡献者的编码约定见根目录 `AGENTS.md`,设计取舍见 `docs/architecture.md`。
---
## 承诺稳定的包
| 包 | 用途 |
|---|---|
| `common/actions` | 通用 CRUD Action(Index / View / Create / Update / Delete / Permission) |
| `common/dto` | 分页、`search` tag 解析、`Control` / `Index` 接口 |
| `common/models` | `ActiveRecord`、`ControlBy`、`ModelTime`、`Model` |
| `common/middleware` | `AuthCheckRole`、`InitMiddleware` 等 |
**依据不是拍脑袋列的**:`app/demo` 是一个可编译、有测试、CI 会跑的标准 CRUD 模块,
把它的 `go-admin/` 前缀 import 全部去重之后,恰好就是这四个包 —— 它代表
"写一个标准模块所需要的最小依赖面"。你的模块如果需要第五个包,先在 issue 里说一声,
那多半意味着契约面缺了什么。
"稳定"的含义:**在 `2.x` 内不做破坏性变更**。新增导出符号不算破坏;改签名、
改语义、删除导出符号算,会走 major 版本并在 release note 里单列。
### 没有已知例外
这四个包**不 import `app/` 下的任何东西**,2026-08-31 起由 CI 强制
(见下方「边界由 CI 守着」)。在此之前有两处反向依赖,都已根治:
| 原位置 | 反向依赖 | 处理 |
|---|---|---|
| `common/middleware/logger.go` | `app/admin/service/dto` 的两个操作日志状态常量 | 常量下沉到 `common/global`,`dto` 侧保留同名常量作为 deprecated 别名,fork 不受影响 |
| `common/middleware/handler/auth.go` | `app/admin/models` 的 `SysUser` / `SysRole` | 该段断言恒失败、设的是零值且开源版无人读取,属死代码,已删除 |
之所以不把它们记成"已知例外":这份文档的作用就是告诉你哪些包可以依赖,
如果第一条下面就挂着例外脚注,后来人会照着例外抄,边界从第一天起就是脏的。
---
## 其余包不保证稳定
`common/` 下没有出现在上表里的包(`common/global`、`common/storage`、
`common/database`、`common/file_store`、`common/response`、`common/service`、
`common/apis`、`common/middleware/handler`、根 `common` 包……)以及
`app/admin` 的内部实现,**均不承诺稳定**。
其中 `common/global`、`common/middleware/handler`、根 `common` 包是
`common/middleware` 的编译期依赖 —— 它们会被一起拉进你的依赖图,但这不代表
它们的 API 稳定。**不要因为"都在 `common/` 目录下"就认为是契约面。**
规划中的 001(模块路径改名)会把非契约包移进 `internal/`,由编译器强制这条边界。
届时上表之外的包对外部模块直接不可见 —— 现在就照上表写,那次改动对你零成本。
---
## 注册路由
一个应用模块要注册自己的路由,写一个 `func()` 签名的 `InitRouter`
(照抄 `app/demo/router/router.go`),然后二选一接进来:
```go
// 方式一(历史写法,仍然有效):在主仓 cmd/api/<name>.go 里
AppRouters = append(AppRouters, router.InitRouter)
// 方式二(推荐):不需要 import go-admin/cmd/api
sdk.Runtime.SetAppRouters(router.InitRouter)
```
方式二是本次新接上的。差别只有一个但很关键:方式一要求你的模块
`import "go-admin/cmd/api"` —— 那是主程序的命令包,让业务模块依赖它很别扭,
也正是"主仓要为每个模块加一个七行文件"的根源。
**执行顺序**:先跑完包级 `AppRouters`,再由 core 的 `sdk.Runtime.RunAppRouters()`
跑它自己的注册表,各自内部保持注册顺序。别依赖跨来源的相对顺序,各模块的
`RouterGroup` 前缀互不相同,本来就不该有顺序依赖。
走方式二还多拿到两样东西,都在 core 那边实现(见
[core 的 `docs/contract.md`](https://github.com/go-admin-team/go-admin-core/blob/main/docs/contract.md)):
**panic 护栏**——你的 `InitRouter` panic 了,其余模块照常注册、进程不退出,日志里会写明
是哪一行注册的;**失败分级**——`sdk.Runtime.SetAppRoutersWith(f, runtime.WithFatal())`
声明「我起不来就别启动」。方式一(包级 `AppRouters`)没有护栏,panic 直接掀桌。
`InitRouter()` 内部的约定:自己拿 `sdk.Runtime.GetEngine()`,按需建
`gin.RouterGroup`,通过 `init()` 自注册到你自己包内的
`routerCheckRole` / `routerNoCheckRole` 列表,不在任何中心文件手工列举
(与 `AGENTS.md`「路由注册」一节一致)。
---
## 注册数据库迁移
框架自身的迁移不变:
```go
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700001000DemoMenu)
```
应用的迁移走 `ForApp`:
```go
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.ForApp("crm").SetVersion(migration.GetFilename(fileName), initCrmTables)
}
func initCrmTables(db *gorm.DB, version, appCode string) error {
return db.Transaction(func(tx *gorm.DB) error {
// ... schema / data changes ...
return tx.Create(&common.Migration{Version: version, AppCode: appCode}).Error
})
}
```
四条必须知道的规则:
1. **完成记录由迁移函数自己写**,而且要写在自己的事务里。框架的调度循环只做
"这个 version 在 `sys_migration` 里有没有" 的判断,从不代你插入 —— 这样
"数据改完了"和"标记成已完成"才是同一个事务,不会出现改了一半却被记成成功。
2. **`AppCode` 必须写进去**。签名多带一个 `appCode` 参数就是为此 —— 忘了写,
schema 上那一列等于白加,你的迁移会被记成框架的。
3. **落库的 `version` 是加了前缀的**。`ForApp("crm")` 注册 `1786800001000`,
实际写进 `sys_migration.version` 的是 `crm-1786800001000`,函数收到的
`version` 参数已经是这个带前缀的值,照抄进 `common.Migration{Version: version}`
即可。前缀的意义是:两个来源不同的应用哪怕碰巧生成同一个毫秒时间戳,也不会撞主键、
不会有一方被误判为"已应用"。
4. **应用 code 一律小写**,`ForApp` 会自己 `strings.ToLower` 一遍。`core` 是保留字
(`migrate status` 用它表示框架自身,`--app core` 选中框架),`ForApp("core")`
会 panic。
顺序保证:**同一应用内按版本号严格有序**。跨应用顺序不做承诺 —— 由于前缀的存在,
今天的实际顺序是"先跑完全部框架迁移,再按 appCode 字母序逐个应用跑完",
但这是实现细节,不要依赖它。跨应用依赖(应用 A 的迁移要求应用 B 先跑完)
需要依赖拓扑排序,属于后续阶段。
看当前状态、看这次会跑什么,不用猜:
```bash
go-admin migrate status -c config/settings.yml # 按应用分组列出已应用 / 待应用
go-admin migrate --dry-run -c config/settings.yml # 列出会执行什么、什么顺序,不写库
go-admin migrate --app crm -c config/settings.yml # 只跑 crm 的迁移
```
`status` 与 `--dry-run` 是纯只读的,不建表、不改表结构,可以直接对生产库执行。
---
## 硬约束:注册要赶在启动钩子之前
三个注册入口——`AppRouters`、`sdk.Runtime.SetAppRouters`、`migration.ForApp`——
都必须在 `cmd/api/server.go` 的 `runStartupHooks()` 执行之前调用完。
`init()` 是最省事的位置:Go 规范保证包级变量初始化与 `init()` 在 `main()` 之前
**单 goroutine 顺序执行**,注册期天然没有并发写。但它不是唯一合法位置——
在 `run()` 之类早于启动钩子的地方注册同样成立。这条规则约束的是**顺序**,
不是你写在哪个函数里。
`sdk.Runtime.SetAppRouters` 的准确语义以 core 为准:
> [go-admin-core `docs/contract.md`](https://github.com/go-admin-team/go-admin-core/blob/main/docs/contract.md)
那份文档写明了注册类与资源类的划分、封闭时刻、护栏边界(**只覆盖同步 panic,
你自己 `go func()` 出去的 panic 框架够不着**)、以及配置热更新会在运行期
重新执行 setup 回调这件事。
主仓这边只补三条它管不着的:
1. **`RunAppRouters()` 跑过之后,core 的注册表就封闭了**,再调
`sdk.Runtime.SetAppRouters` 会被丢弃并记一条 ERROR 日志。包级 `AppRouters`
没有这个机制——它就是一个普通 slice,什么时候 append 都"成功",
但 `runStartupHooks()` 之后 append 的那些永远不会被执行,且不出声。
这是继续推荐方式二的理由之一。
2. **封闭是黏性的,而 `sdk.Runtime` 是包级单例。** 写测试时若会触发启动钩子,
必须换掉它再还原,否则同一个测试二进制里后面的测试会静默丢注册:
```go
previous := sdk.Runtime
t.Cleanup(func() { sdk.Runtime = previous })
sdk.Runtime = runtime.NewConfig()
```
`cmd/api/server_test.go` 里的 `freshRuntime` 就是这个。
3. **`migration.ForApp` 是主仓的东西**,core 不认识它,上面那份文档不覆盖它。
它的约束仍然是"注册要在迁移调度循环跑起来之前",实践上就是 `init()`。
---
## 边界由 CI 守着
`common/`、`core/` 不得 import `app/`,这条由 `tools/checksilent` 的
`contract-import-boundary` 检查固化,`make checksilent` 在 CI 里跑,违反即失败
(测试文件同样算 —— 一个删掉 `app/admin` 的 fork 也应该能跑 `go test ./...`)。
靠人工评审列契约面会漏。上面那两处反向依赖里,第二处就是评审没发现、
靠机器全量扫描才找出来的。
`tools/checksilent` 还检查另外五类"不出声的失败",写模块时值得先看一眼
`go run ./tools/checksilent -h`。
+1 -1
View File
@@ -11,7 +11,7 @@ require (
github.com/casbin/casbin/v3 v3.8.1
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/go-admin-team/go-admin-core/v2 v2.1.0
github.com/go-admin-team/go-admin-core/v2 v2.4.1
github.com/google/uuid v1.6.0
github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible
github.com/mssola/user_agent v0.6.0
+2 -2
View File
@@ -145,8 +145,8 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-admin-team/go-admin-core/v2 v2.1.0 h1:v1RQkRT/sg0YvmS3m11mbtbijj6F3jUKs8EUaK64/+8=
github.com/go-admin-team/go-admin-core/v2 v2.1.0/go.mod h1:YiJr2+vqC9qV5AoGeL+1W55h3XZ99CB5xWnP3Wo8c5g=
github.com/go-admin-team/go-admin-core/v2 v2.4.1 h1:69QprBVMcQzjVP0UksCwi//A0qh8gwcIHcwHmABPp2U=
github.com/go-admin-team/go-admin-core/v2 v2.4.1/go.mod h1:YiJr2+vqC9qV5AoGeL+1W55h3XZ99CB5xWnP3Wo8c5g=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
+367
View File
@@ -0,0 +1,367 @@
// Package loadtest measures what one go-admin process sustains over HTTP.
//
// It is skipped unless GOADMIN_BENCH_ADDR points at a running server, so
// `go test ./...` is unaffected. Start a server and run:
//
// GOADMIN_BENCH_ADDR=http://127.0.0.1:8000 go test ./test/loadtest/ -v -run TestLoadProfile
//
// Unlike a Go benchmark this reports latency percentiles, which is what
// capacity planning needs: an average hides the tail that users actually feel.
//
// Two caveats when reading the numbers. The load generator runs on the same
// machine as the server unless GOADMIN_BENCH_ADDR is remote, so both compete
// for the same cores - a split deployment measures higher. And the figures
// describe the configured backend: sqlite and MySQL differ by more than the
// framework does.
package loadtest
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
const (
addrEnv = "GOADMIN_BENCH_ADDR"
tokenEnv = "GOADMIN_BENCH_TOKEN"
userEnv = "GOADMIN_BENCH_USER"
passEnv = "GOADMIN_BENCH_PASS"
// Each concurrency level runs for this long. Long enough to get past
// connection setup and let the scheduler settle, short enough that the
// whole sweep stays interactive.
levelDuration = 3 * time.Second
)
// concurrencyLevels sweeps from a single client to well past core count, so
// the point where added concurrency stops buying throughput is visible rather
// than assumed. Peak throughput and peak concurrency are not the same number:
// past the peak a server takes more work than it can finish and both
// throughput and latency get worse, so the sweep has to bracket the turn
// rather than stop at the top.
//
// GOADMIN_BENCH_LEVELS overrides it, comma separated.
var concurrencyLevels = parseLevels(os.Getenv("GOADMIN_BENCH_LEVELS"), []int{1, 2, 4, 8, 16, 32, 64, 128, 256, 512})
func parseLevels(spec string, fallback []int) []int {
if spec == "" {
return fallback
}
out := make([]int, 0, 8)
for _, f := range strings.Split(spec, ",") {
n, err := strconv.Atoi(strings.TrimSpace(f))
if err != nil || n <= 0 {
continue
}
out = append(out, n)
}
if len(out) == 0 {
return fallback
}
return out
}
func addr(t testing.TB) string {
t.Helper()
a := os.Getenv(addrEnv)
if a == "" {
t.Skipf("%s not set; skipping load test", addrEnv)
}
return a
}
// newClient returns a client whose pool is large enough that the generator
// does not become the bottleneck it is trying to measure.
func newClient(maxConns int) *http.Client {
return &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: maxConns * 2,
MaxIdleConnsPerHost: maxConns * 2,
MaxConnsPerHost: maxConns * 2,
IdleConnTimeout: 90 * time.Second,
DisableCompression: true,
},
}
}
// result is one completed request.
type result struct {
latency time.Duration
err bool
status int
}
// report is the summary of one concurrency level.
type report struct {
concurrency int
total int64
failed int64
elapsed time.Duration
p50, p95, p99, max time.Duration
statuses map[int]int64
}
func (r report) qps() float64 {
if r.elapsed == 0 {
return 0
}
return float64(r.total) / r.elapsed.Seconds()
}
func (r report) String() string {
codes := make([]int, 0, len(r.statuses))
for c := range r.statuses {
codes = append(codes, c)
}
sort.Ints(codes)
dist := make([]string, 0, len(codes))
for _, c := range codes {
dist = append(dist, fmt.Sprintf("%d:%d", c, r.statuses[c]))
}
return fmt.Sprintf("c=%-4d %9.0f req/s p50=%-9s p95=%-9s p99=%-9s max=%-9s failed=%-7d %s",
r.concurrency, r.qps(),
r.p50.Round(time.Microsecond), r.p95.Round(time.Microsecond),
r.p99.Round(time.Microsecond), r.max.Round(time.Microsecond), r.failed,
strings.Join(dist, " "))
}
// drive runs `concurrency` workers against req for levelDuration and collects
// every latency. Bodies are drained and closed - skipping that silently caps
// throughput at the point connections stop being reused.
func drive(t testing.TB, concurrency int, want int, mk func() *http.Request) report {
t.Helper()
client := newClient(concurrency)
defer client.CloseIdleConnections()
ctx, cancel := context.WithTimeout(context.Background(), levelDuration)
defer cancel()
var (
mu sync.Mutex
samples []time.Duration
statuses = map[int]int64{}
failed atomic.Int64
total atomic.Int64
wg sync.WaitGroup
)
start := time.Now()
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
local := make([]time.Duration, 0, 1024)
localStatus := map[int]int64{}
for ctx.Err() == nil {
req := mk()
t0 := time.Now()
resp, err := client.Do(req.WithContext(ctx))
d := time.Since(t0)
if err != nil {
if ctx.Err() != nil {
break
}
failed.Add(1)
total.Add(1)
continue
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
localStatus[resp.StatusCode]++
if resp.StatusCode != want {
failed.Add(1)
}
total.Add(1)
local = append(local, d)
}
mu.Lock()
samples = append(samples, local...)
for code, n := range localStatus {
statuses[code] += n
}
mu.Unlock()
}()
}
wg.Wait()
elapsed := time.Since(start)
sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] })
r := report{
concurrency: concurrency,
total: total.Load(),
failed: failed.Load(),
elapsed: elapsed,
statuses: statuses,
}
if n := len(samples); n > 0 {
r.p50 = samples[n*50/100]
r.p95 = samples[min(n*95/100, n-1)]
r.p99 = samples[min(n*99/100, n-1)]
r.max = samples[n-1]
}
return r
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// login obtains a token. GOADMIN_BENCH_TOKEN short-circuits it, which is how a
// server in prod mode is reached - there the login endpoint demands a captcha.
func login(t testing.TB, base string) string {
t.Helper()
if tok := os.Getenv(tokenEnv); tok != "" {
return tok
}
user, pass := os.Getenv(userEnv), os.Getenv(passEnv)
if user == "" {
user, pass = "admin", "123456"
}
body, _ := json.Marshal(map[string]string{
"username": user,
"password": pass,
"code": "0",
"uuid": "0",
})
resp, err := http.Post(base+"/api/v1/login", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("login request failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("login returned %d: %s\n(a server in prod mode requires a captcha; set %s instead)",
resp.StatusCode, raw, tokenEnv)
}
var out struct {
Token string `json:"token"`
}
if err := json.Unmarshal(raw, &out); err != nil || out.Token == "" {
t.Fatalf("no token in login response: %s", raw)
}
return out.Token
}
// TestLoadProfile sweeps concurrency against three endpoints chosen for what
// they isolate:
//
// - captcha: no auth, no business query. The routing and image-generation
// floor.
// - dept list: the full authenticated path - JWT parse, casbin check, data
// permission scope, database read. This is what a real page costs.
// - login: bcrypt. Deliberately slow, and the one endpoint whose ceiling is
// set by design rather than by the framework.
func TestLoadProfile(t *testing.T) {
base := addr(t)
token := login(t, base)
cases := []struct {
name string
want int
mk func() *http.Request
}{
{
// The control. An unrouted path exercises the HTTP stack, gin's
// tree lookup and nothing else, so it bounds every other row here.
// When a business endpoint reaches this number, the measurement has
// stopped describing the endpoint and started describing the
// transport - or the load generator, when both share a machine.
name: "404 (http+routing floor)",
want: 404,
mk: func() *http.Request {
req, _ := http.NewRequest(http.MethodGet, base+"/api/v1/__no_such_route__", nil)
return req
},
},
{
// The framework on its own: global middleware chain, route lookup,
// and a handler that only sets a status. No database, no cache.
// Against the 404 row this isolates what the chain costs; against
// the rows below it, what the business path adds.
//
// Numbers from any endpoint that touches a database describe the
// database, the driver and the pool as much as the framework - the
// MySQL sweeps here moved from collapsing at c=64 to 19k req/s at
// c=512 on a pool setting alone, with the framework untouched.
name: "health (framework only, no db)",
want: 200,
mk: func() *http.Request {
req, _ := http.NewRequest(http.MethodGet, base+"/api/v1/health", nil)
return req
},
},
{
name: "captcha (no auth)",
want: 200,
mk: func() *http.Request {
req, _ := http.NewRequest(http.MethodGet, base+"/api/v1/captcha", nil)
return req
},
},
{
name: "dept list (jwt+casbin+db)",
want: 200,
mk: func() *http.Request {
req, _ := http.NewRequest(http.MethodGet, base+"/api/v1/dept", nil)
req.Header.Set("Authorization", "Bearer "+token)
return req
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
for _, c := range concurrencyLevels {
t.Log(drive(t, c, tc.want, tc.mk))
}
})
}
}
// TestLoginThroughput is separated because bcrypt saturates the CPU: running
// it alongside the others would distort them. It also writes a login-log row
// per attempt when logger.enableddb is on, so the number moves with that
// setting.
func TestLoginThroughput(t *testing.T) {
base := addr(t)
if os.Getenv(tokenEnv) != "" {
t.Skip("token supplied; login endpoint presumably needs a captcha")
}
user, pass := os.Getenv(userEnv), os.Getenv(passEnv)
if user == "" {
user, pass = "admin", "123456"
}
body, _ := json.Marshal(map[string]string{
"username": user, "password": pass, "code": "0", "uuid": "0",
})
mk := func() *http.Request {
req, _ := http.NewRequest(http.MethodPost, base+"/api/v1/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
return req
}
for _, c := range []int{1, 4, 8, 16, 32, 64, 128} {
t.Log(drive(t, c, http.StatusOK, mk))
}
}
+257
View File
@@ -0,0 +1,257 @@
package main
import (
"go/ast"
"go/token"
"strconv"
"strings"
)
// structLiteral is a composite literal whose type resolved to a named struct.
type structLiteral struct {
PkgPath string
Name string
Lit *ast.CompositeLit
}
// forEachStructLiteral visits every composite literal in the file whose type
// resolves to a named type, including the ones written with the type elided.
//
// The elided form is the one that matters: seed data is written as
// []models.SysMenu{{MenuId: 9000}, {MenuId: 9001}}, and the inner literals carry
// no type of their own. A walker that only looked at CompositeLit.Type would
// silently skip every seed in the repository and report nothing, which for a
// tool about silent failure would be its own punchline.
func forEachStructLiteral(sf *sourceFile, fn func(structLiteral)) {
// The type each type-less literal inherits from the literal containing it.
elided := map[*ast.CompositeLit]ast.Expr{}
var propagate func(lit *ast.CompositeLit, typ ast.Expr)
propagate = func(lit *ast.CompositeLit, typ ast.Expr) {
child := elementType(typ)
if child == nil {
return
}
for _, elt := range lit.Elts {
v := elt
if kv, ok := elt.(*ast.KeyValueExpr); ok {
v = kv.Value
}
if cl, ok := v.(*ast.CompositeLit); ok && cl.Type == nil {
elided[cl] = child
propagate(cl, child)
}
}
}
// ast.Inspect visits a node before its children, so every typed literal
// fills in its descendants before the reporting pass reaches them.
ast.Inspect(sf.Syntax, func(n ast.Node) bool {
cl, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
if typ := litType(cl, elided); typ != nil {
propagate(cl, typ)
}
return true
})
ast.Inspect(sf.Syntax, func(n ast.Node) bool {
cl, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
typ := litType(cl, elided)
if typ == nil {
return true
}
if pkg, name, ok := resolveNamed(sf, typ); ok {
fn(structLiteral{PkgPath: pkg, Name: name, Lit: cl})
}
return true
})
}
func litType(cl *ast.CompositeLit, elided map[*ast.CompositeLit]ast.Expr) ast.Expr {
if cl.Type != nil {
return cl.Type
}
return elided[cl]
}
// resolveNamed maps a type expression to (import path, type name). A bare
// identifier means a type declared in this file's own package.
func resolveNamed(sf *sourceFile, typ ast.Expr) (string, string, bool) {
switch t := typ.(type) {
case *ast.StarExpr:
return resolveNamed(sf, t.X)
case *ast.Ident:
return sf.Pkg, t.Name, true
case *ast.SelectorExpr:
pkgIdent, ok := t.X.(*ast.Ident)
if !ok {
return "", "", false
}
path, ok := sf.imports[pkgIdent.Name]
if !ok {
return "", "", false
}
return path, t.Sel.Name, true
}
return "", "", false
}
// elementType is the type the children of a composite literal take when they
// leave theirs out.
func elementType(typ ast.Expr) ast.Expr {
switch t := typ.(type) {
case *ast.ArrayType:
return t.Elt
case *ast.MapType:
return t.Value
case *ast.StarExpr:
return elementType(t.X)
}
return nil
}
// field returns the value written for a named field of a struct literal.
func field(lit *ast.CompositeLit, name string) (ast.Expr, bool) {
for _, elt := range lit.Elts {
kv, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
if key, ok := kv.Key.(*ast.Ident); ok && key.Name == name {
return kv.Value, true
}
}
return nil, false
}
// intValue evaluates an integer field: a literal, a negated literal, or an
// identifier naming a constant in the same package.
//
// Anything computed at run time is skipped rather than guessed at. That is the
// one thing these checks miss, and missing is the right way to be wrong here -
// a false positive teaches people to add ignore comments, and then the tool is
// finished.
func intValue(sf *sourceFile, expr ast.Expr) (int64, bool) {
switch e := expr.(type) {
case *ast.Ident:
v, ok := sf.consts[e.Name]
return v, ok
case *ast.UnaryExpr:
if e.Op == token.SUB {
if v, ok := intValue(sf, e.X); ok {
return -v, true
}
}
}
return intLiteral(expr)
}
func intLiteral(expr ast.Expr) (int64, bool) {
lit, ok := expr.(*ast.BasicLit)
if !ok || lit.Kind != token.INT {
return 0, false
}
v, err := strconv.ParseInt(strings.ReplaceAll(lit.Value, "_", ""), 0, 64)
if err != nil {
return 0, false
}
return v, true
}
// stringValue evaluates a string field: a literal, a concatenation of literals,
// or an identifier naming a string constant in the same package.
func stringValue(sf *sourceFile, expr ast.Expr) (string, bool) {
switch e := expr.(type) {
case *ast.BasicLit:
if e.Kind != token.STRING {
return "", false
}
s, err := strconv.Unquote(e.Value)
if err != nil {
return "", false
}
return s, true
case *ast.BinaryExpr:
if e.Op != token.ADD {
return "", false
}
l, lok := stringValue(sf, e.X)
r, rok := stringValue(sf, e.Y)
if lok && rok {
return l + r, true
}
}
return "", false
}
// embeddedTypes returns the types a struct embeds, as (import path, name).
func embeddedTypes(sf *sourceFile, st *ast.StructType) [][2]string {
var out [][2]string
for _, f := range st.Fields.List {
if len(f.Names) != 0 {
continue // a named field, not an embed
}
if pkg, name, ok := resolveNamed(sf, f.Type); ok {
out = append(out, [2]string{pkg, name})
}
}
return out
}
// tableNames maps struct name to the literal its TableName method returns.
func tableNames(sf *sourceFile) map[string]string {
out := map[string]string{}
for _, decl := range sf.Syntax.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Name.Name != "TableName" || fn.Recv == nil || len(fn.Recv.List) != 1 || fn.Body == nil {
continue
}
recv := receiverName(fn.Recv.List[0].Type)
if recv == "" {
continue
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
ret, ok := n.(*ast.ReturnStmt)
if !ok || len(ret.Results) != 1 {
return true
}
if s, ok := stringValue(sf, ret.Results[0]); ok && out[recv] == "" {
out[recv] = s
}
return true
})
}
return out
}
func receiverName(expr ast.Expr) string {
switch t := expr.(type) {
case *ast.Ident:
return t.Name
case *ast.StarExpr:
return receiverName(t.X)
}
return ""
}
// structTypes maps struct name to its declaration.
func structTypes(sf *sourceFile) map[string]*ast.StructType {
out := map[string]*ast.StructType{}
ast.Inspect(sf.Syntax, func(n ast.Node) bool {
ts, ok := n.(*ast.TypeSpec)
if !ok {
return true
}
if st, ok := ts.Type.(*ast.StructType); ok {
out[ts.Name.Name] = st
}
return true
})
return out
}
+409
View File
@@ -0,0 +1,409 @@
package main
import (
"fmt"
"go/token"
"path"
"sort"
"strconv"
"strings"
)
// Check names. They appear in every message and in the CI log, so they are
// what people will search for.
const (
checkModelTimeMix = "modeltime-mix"
checkMenuSort = "menu-sort-overflow"
checkMenuName = "menu-name-mismatch"
checkConfigValue = "config-value-truncation"
checkMenuIDConflict = "menu-id-collision"
checkImportBoundary = "contract-import-boundary"
)
// Package paths, relative to the module. Spelled once so a module rename
// touches one place.
const (
pkgFrozenModels = "cmd/migrate/migration/models"
pkgRuntimeModel = "common/models"
pkgAdminModels = "app/admin/models"
)
// options are the run-time knobs. Only the frontend directory is one: every
// other check either applies or does not, with nothing to configure.
type options struct {
// UIDir is the go-admin-ui src directory. Empty disables checkMenuName,
// which is the only check that needs a second repository.
UIDir string
}
// runChecks runs every check over one parse of the tree.
func runChecks(s *snapshot, opt options) ([]Finding, error) {
var out []Finding
out = append(out, checkModelTimeMixing(s)...)
out = append(out, checkMenuSortOverflow(s)...)
out = append(out, checkConfigValueLength(s)...)
out = append(out, checkMenuIDCollisions(s)...)
out = append(out, checkContractImportBoundary(s)...)
if opt.UIDir != "" {
fs, err := checkMenuNames(s, opt.UIDir)
if err != nil {
return nil, err
}
out = append(out, fs...)
}
sortFindings(out)
return out, nil
}
func (s *snapshot) pkg(rel string) string { return s.ModulePath + "/" + rel }
func (s *snapshot) finding(sev Severity, check string, sf *sourceFile, pos posLike, format string, args ...interface{}) Finding {
file, line, col := s.Pos(sf, pos.Pos())
return Finding{
Check: check,
Severity: sev.String(),
File: file,
Line: line,
Col: col,
Message: fmt.Sprintf(format, args...),
severity: sev,
}
}
type posLike interface{ Pos() token.Pos }
// ---------------------------------------------------------------------------
// check 1: the two ModelTime flavours
// ---------------------------------------------------------------------------
// checkModelTimeMixing reports code that mixes the repository's two soft-delete
// shapes.
//
// cmd/migrate/migration/models.ModelTime declares a nullable gorm.DeletedAt;
// common/models.ModelTime declares the NOT NULL millisecond marker. Mixing them
// on one table is not a compile error and not a run-time error either: gorm
// scopes the nullable flavour as "WHERE deleted_at IS NULL" while live rows hold
// 0, so every row of the table becomes invisible and the feature reading it
// simply returns nothing. sys_columns and sys_tables sat in that state until
// 1786700004000 - the code generator listed no tables at all and reported no
// error.
//
// Two shapes are reported, and both are unambiguous:
//
// 1. a runtime model under app/ that embeds the frozen package's time struct -
// always wrong, that package is the shape the columns had before the
// conversion;
// 2. a migration ordered after the conversion that imports the frozen package
// - AGENTS.md states this rule, and the version/ directory already has a
// test for it; this extends it to version-local/, where third-party and
// downstream migrations live and where no test was watching.
//
// Not reported: that two model packages describe the same table with different
// flavours. That is true of a dozen tables on purpose - the frozen package is
// correct for the migrations that predate the conversion - so reporting it
// would be reporting the design.
func checkModelTimeMixing(s *snapshot) []Finding {
var out []Finding
frozen := s.pkg(pkgFrozenModels)
for _, sf := range s.Files {
if strings.HasPrefix(sf.Path, "app/") && sf.Imports(frozen) {
tables := tableNames(sf)
for name, st := range structTypes(sf) {
table, isModel := tables[name]
if !isModel {
continue
}
for _, emb := range embeddedTypes(sf, st) {
if emb[0] != frozen {
continue
}
out = append(out, s.finding(Error, checkModelTimeMix, sf, st,
"runtime model %s (table %s) embeds %s.%s, whose DeletedAt is the nullable pre-conversion shape;\n"+
" gorm will query this table with deleted_at IS NULL while live rows hold 0, and it will return nothing.\n"+
" Embed %s.ModelTime instead.",
name, table, pkgFrozenModels, emb[1], pkgRuntimeModel))
}
}
}
version, isMigration := migrationVersion(sf.Path)
if !isMigration || version <= softDeleteConversion || !sf.Imports(frozen) {
continue
}
spec := sf.ImportSpec(frozen)
out = append(out, s.finding(Error, checkModelTimeMix, sf, spec,
"migration %d is ordered after the soft-delete conversion (%d) but seeds through %s;\n"+
" that package writes a nullable deleted_at into a NOT NULL column, and reads through it match no rows.\n"+
" Use the runtime models under app/ instead.",
version, softDeleteConversion, pkgFrozenModels))
}
return out
}
// softDeleteConversion is the version at which deleted_at stopped being a
// nullable timestamp and became the NOT NULL millisecond marker.
const softDeleteConversion = 1786700003000
// ---------------------------------------------------------------------------
// check 2: menu sort overflows a tinyint
// ---------------------------------------------------------------------------
// checkMenuSortOverflow reports a seeded menu sort outside a tinyint.
//
// sys_menu.sort is `gorm:"size:4"`, which MySQL builds as a tinyint holding
// -128..127. sqlite ignores the width, so an overflowing value passes every
// local test and fails on a real install - with Error 1264, partway through a
// migration that is not transactional, leaving every later migration unapplied.
// That is how a seeded Sort: 900 once stopped the run before the soft-delete
// conversion and left nobody able to log in.
func checkMenuSortOverflow(s *snapshot) []Finding {
const (
min = -128
max = 127
)
var out []Finding
for _, sf := range s.Files {
forEachStructLiteral(sf, func(lit structLiteral) {
if !s.isMenuModel(lit) {
return
}
expr, ok := field(lit.Lit, "Sort")
if !ok {
return
}
v, ok := intValue(sf, expr)
if !ok || (v >= min && v <= max) {
return
}
out = append(out, s.finding(Error, checkMenuSort, sf, expr,
"menu sort %d does not fit a tinyint (%d..%d);\n"+
" MySQL rejects it with Error 1264 and the migration stops there, leaving later migrations unapplied.",
v, min, max))
})
}
return out
}
// ---------------------------------------------------------------------------
// check 4: sys_config value truncation
// ---------------------------------------------------------------------------
// checkConfigValueLength reports a seeded sys_config value longer than the
// column.
//
// config_value is varchar(255). MySQL outside strict mode truncates rather than
// refusing, so the migration succeeds, the row is written, and the setting is
// silently half of what was intended.
//
// Counted in runes, not bytes, because varchar(255) counts characters - byte
// counting would flag Chinese values that fit.
func checkConfigValueLength(s *snapshot) []Finding {
const limit = 255
var out []Finding
for _, sf := range s.Files {
forEachStructLiteral(sf, func(lit structLiteral) {
if lit.Name != "SysConfig" || !s.isModelPackage(lit.PkgPath) {
return
}
expr, ok := field(lit.Lit, "ConfigValue")
if !ok {
return
}
v, ok := stringValue(sf, expr)
if !ok {
return
}
if n := len([]rune(v)); n > limit {
out = append(out, s.finding(Error, checkConfigValue, sf, expr,
"sys_config.config_value is %d characters, over the varchar(%d) column;\n"+
" MySQL outside strict mode truncates instead of failing, so the migration succeeds with half the value.",
n, limit))
}
})
}
return out
}
// ---------------------------------------------------------------------------
// check 5: hard-coded menu ids colliding
// ---------------------------------------------------------------------------
// checkMenuIDCollisions reports the same menu id seeded from more than one file.
//
// menu_id is the primary key and every seed is an upsert, so two modules that
// pick the same id do not collide loudly - the second overwrites the first, and
// which one wins depends on migration order. One module's menu quietly becomes
// the other's.
//
// Only across files. Inside one file the same id appearing twice is the same
// menu being written and then referenced, which is how the seeds are written
// today and not a mistake.
func checkMenuIDCollisions(s *snapshot) []Finding {
type site struct {
sf *sourceFile
expr posLike
file string
line int
}
sites := map[int64][]site{}
for _, sf := range s.Files {
forEachStructLiteral(sf, func(lit structLiteral) {
if !s.isMenuModel(lit) {
return
}
expr, ok := field(lit.Lit, "MenuId")
if !ok {
return
}
v, ok := intValue(sf, expr)
if !ok {
return
}
file, line, _ := s.Pos(sf, expr.Pos())
sites[v] = append(sites[v], site{sf: sf, expr: expr, file: file, line: line})
})
}
ids := make([]int64, 0, len(sites))
for id := range sites {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
var out []Finding
for _, id := range ids {
group := sites[id]
files := map[string]bool{}
for _, st := range group {
files[st.file] = true
}
if len(files) < 2 {
continue
}
sort.Slice(group, func(i, j int) bool {
if group[i].file != group[j].file {
return group[i].file < group[j].file
}
return group[i].line < group[j].line
})
related := make([]string, 0, len(group)-1)
for _, st := range group[1:] {
related = append(related, fmt.Sprintf("also at %s:%d", st.file, st.line))
}
f := s.finding(Error, checkMenuIDConflict, group[0].sf, group[0].expr,
"menu id %d is seeded from %d files;\n"+
" menu_id is the primary key and the seeds upsert, so whichever migration runs last overwrites the other's menu.",
id, len(files))
f.Related = related
out = append(out, f)
}
return out
}
// ---------------------------------------------------------------------------
// check 6: the contract packages must not import app/
// ---------------------------------------------------------------------------
// contractRoots are the trees that may not depend on a business module. core/
// does not exist in this repository yet and is listed because the boundary is
// declared for both in docs/contract.md; naming it here means the check is
// already in place the day the directory appears.
//
// Which of them actually exist is reported by ScannedContractRoots, because a
// root that is absent contributes nothing and a check that silently covers less
// than it claims is worse than no check: it leaves people believing a boundary
// is guarded when nothing is guarding it.
var contractRoots = []string{"common/", "core/"}
// ScannedContractRoots splits contractRoots by whether the snapshot actually
// holds files under them, so the summary can name what was covered.
func ScannedContractRoots(s *snapshot) (scanned, absent []string) {
for _, root := range contractRoots {
found := false
for _, sf := range s.Files {
if strings.HasPrefix(sf.Path, root) {
found = true
break
}
}
if found {
scanned = append(scanned, strings.TrimSuffix(root, "/"))
} else {
absent = append(absent, strings.TrimSuffix(root, "/"))
}
}
return scanned, absent
}
// checkContractImportBoundary reports a contract package importing app/.
//
// docs/contract.md promises four packages under common/ as the surface an app
// may build on. A promise like that stops being true the moment the surface
// imports one particular app: a fork that replaces app/admin then cannot
// compile common/middleware, and an app can no longer be built against the
// contract alone. Nothing about it fails visibly - it fails when somebody tries
// to take the framework apart, which is the whole point of the exercise.
//
// Test files count. A fork that drops app/admin should be able to run go test
// ./... too.
func checkContractImportBoundary(s *snapshot) []Finding {
appPrefix := s.ModulePath + "/app/"
var out []Finding
for _, sf := range s.Files {
inContract := false
for _, root := range contractRoots {
if strings.HasPrefix(sf.Path, root) {
inContract = true
break
}
}
if !inContract {
continue
}
for _, spec := range sf.Syntax.Imports {
path, err := strconv.Unquote(spec.Path.Value)
if err != nil || !strings.HasPrefix(path, appPrefix) {
continue
}
out = append(out, s.finding(Error, checkImportBoundary, sf, spec,
"%s is a contract package and imports %s;\n"+
" a fork that replaces or drops that app can then no longer compile the contract surface it was told to build on.\n"+
" Move what is shared down into common/, or out of the contract package.",
sf.Path, path))
}
}
return out
}
// migrationVersion reads the 13-digit timestamp a migration file name starts
// with. Files outside the two migration directories are not migrations, however
// they are named.
func migrationVersion(rel string) (int64, bool) {
dir := path.Dir(rel)
if dir != "cmd/migrate/migration/version" && dir != "cmd/migrate/migration/version-local" {
return 0, false
}
name := path.Base(rel)
if len(name) < 13 {
return 0, false
}
v, err := strconv.ParseInt(name[:13], 10, 64)
if err != nil {
return 0, false
}
return v, true
}
// isMenuModel reports whether a literal is one of the SysMenu models rather
// than, say, the SysMenu service struct that shares the name.
func (s *snapshot) isMenuModel(lit structLiteral) bool {
return lit.Name == "SysMenu" && s.isModelPackage(lit.PkgPath)
}
func (s *snapshot) isModelPackage(path string) bool {
return path == s.pkg(pkgFrozenModels) || path == s.pkg(pkgAdminModels)
}
+454
View File
@@ -0,0 +1,454 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// fixture writes a miniature repository and returns its root. Every check gets
// one of these carrying the exact mistake it exists to find, so a check that
// stops working fails a test rather than going quiet - which would be the same
// failure mode the tool is about.
func fixture(t *testing.T, files map[string]string) string {
t.Helper()
dir := t.TempDir()
files["go.mod"] = "module go-admin\n\ngo 1.26\n"
for name, content := range files {
path := filepath.Join(dir, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
return dir
}
func check(t *testing.T, root string, opt options) []Finding {
t.Helper()
s, err := load(root)
if err != nil {
t.Fatalf("load: %v", err)
}
findings, err := runChecks(s, opt)
if err != nil {
t.Fatalf("run: %v", err)
}
return findings
}
func only(t *testing.T, findings []Finding, name string) []Finding {
t.Helper()
var out []Finding
for _, f := range findings {
if f.Check == name {
out = append(out, f)
}
}
return out
}
func requireOne(t *testing.T, findings []Finding, name string) Finding {
t.Helper()
got := only(t, findings, name)
if len(got) != 1 {
t.Fatalf("%s produced %d findings, want 1:\n%v", name, len(got), findings)
}
return got[0]
}
const frozenModelsPkg = `package models
import (
"time"
"gorm.io/gorm"
)
type ModelTime struct {
CreatedAt time.Time
DeletedAt gorm.DeletedAt
}
type SysMenu struct {
MenuId int
MenuName string
Component string
MenuType string
Sort int
ModelTime
}
func (SysMenu) TableName() string { return "sys_menu" }
type SysConfig struct {
ConfigKey string
ConfigValue string
ModelTime
}
func (SysConfig) TableName() string { return "sys_config" }
`
// ---------------------------------------------------------------------------
func TestModelTimeMixDetectsARuntimeModelOnTheFrozenShape(t *testing.T) {
root := fixture(t, map[string]string{
"cmd/migrate/migration/models/models.go": frozenModelsPkg,
"app/shop/models/product.go": `package models
import frozen "go-admin/cmd/migrate/migration/models"
type Product struct {
Id int
frozen.ModelTime
}
func (Product) TableName() string { return "shop_product" }
`,
})
f := requireOne(t, check(t, root, options{}), checkModelTimeMix)
if f.Severity != "ERROR" {
t.Errorf("severity = %s", f.Severity)
}
if !strings.Contains(f.Message, "shop_product") {
t.Errorf("message = %s", f.Message)
}
if f.File != "app/shop/models/product.go" {
t.Errorf("file = %s", f.File)
}
}
func TestModelTimeMixDetectsAPostConversionMigrationOnTheFrozenPackage(t *testing.T) {
root := fixture(t, map[string]string{
"cmd/migrate/migration/models/models.go": frozenModelsPkg,
"cmd/migrate/migration/version-local/1786700009000_seed.go": `package version_local
import "go-admin/cmd/migrate/migration/models"
func seed() interface{} { return &models.SysMenu{} }
`,
})
f := requireOne(t, check(t, root, options{}), checkModelTimeMix)
if !strings.Contains(f.Message, "1786700009000") {
t.Errorf("message = %s", f.Message)
}
if f.Line != 3 {
t.Errorf("expected the import line, got line %d", f.Line)
}
}
// A migration ordered before the conversion is right to use that package - the
// nullable column is the shape it had at the time.
func TestModelTimeMixLeavesPreConversionMigrationsAlone(t *testing.T) {
root := fixture(t, map[string]string{
"cmd/migrate/migration/models/models.go": frozenModelsPkg,
"cmd/migrate/migration/version/1786700001000_seed.go": `package version
import "go-admin/cmd/migrate/migration/models"
func seed() interface{} { return &models.SysMenu{} }
`,
})
if got := only(t, check(t, root, options{}), checkModelTimeMix); len(got) != 0 {
t.Errorf("reported %v", got)
}
}
// ---------------------------------------------------------------------------
func TestMenuSortOverflowIsDetectedThroughAnElidedSliceLiteral(t *testing.T) {
root := fixture(t, map[string]string{
"cmd/migrate/migration/models/models.go": frozenModelsPkg,
"cmd/migrate/migration/version/1786700001000_seed.go": `package version
import "go-admin/cmd/migrate/migration/models"
func seed() []models.SysMenu {
return []models.SysMenu{
{MenuId: 9000, Sort: 100},
{MenuId: 9001, Sort: 900},
}
}
`,
})
f := requireOne(t, check(t, root, options{}), checkMenuSort)
if f.Severity != "ERROR" || !strings.Contains(f.Message, "900") {
t.Errorf("finding = %+v", f)
}
if f.Line != 8 {
t.Errorf("line = %d, want the overflowing element", f.Line)
}
}
// The SysMenu of the service layer shares the name and has nothing to do with
// the column. Reporting it would be the false positive that gets the tool
// switched off.
func TestMenuSortIgnoresSameNamedTypesFromOtherPackages(t *testing.T) {
root := fixture(t, map[string]string{
"cmd/migrate/migration/models/models.go": frozenModelsPkg,
"app/admin/service/sys_menu.go": `package service
type SysMenu struct{ Sort int }
`,
"app/admin/apis/sys_menu.go": `package apis
import "go-admin/app/admin/service"
func handler() interface{} { return service.SysMenu{Sort: 9000} }
`,
})
if got := only(t, check(t, root, options{}), checkMenuSort); len(got) != 0 {
t.Errorf("reported %v", got)
}
}
// ---------------------------------------------------------------------------
func TestConfigValueTruncationIsDetected(t *testing.T) {
root := fixture(t, map[string]string{
"cmd/migrate/migration/models/models.go": frozenModelsPkg,
"cmd/migrate/migration/version/1786700001000_seed.go": `package version
import "go-admin/cmd/migrate/migration/models"
func seed() models.SysConfig {
return models.SysConfig{ConfigKey: "k", ConfigValue: "` + strings.Repeat("a", 300) + `"}
}
`,
})
f := requireOne(t, check(t, root, options{}), checkConfigValue)
if f.Severity != "ERROR" || !strings.Contains(f.Message, "300 characters") {
t.Errorf("finding = %+v", f)
}
}
// varchar(255) counts characters. Counting bytes would report a Chinese value
// that fits perfectly well.
func TestConfigValueCountsRunesNotBytes(t *testing.T) {
root := fixture(t, map[string]string{
"cmd/migrate/migration/models/models.go": frozenModelsPkg,
"cmd/migrate/migration/version/1786700001000_seed.go": `package version
import "go-admin/cmd/migrate/migration/models"
func seed() models.SysConfig {
return models.SysConfig{ConfigValue: "` + strings.Repeat("中", 200) + `"}
}
`,
})
if got := only(t, check(t, root, options{}), checkConfigValue); len(got) != 0 {
t.Errorf("200 Chinese characters fit in varchar(255) but were reported: %v", got)
}
}
// ---------------------------------------------------------------------------
func TestMenuIDCollisionAcrossFilesIsDetectedThroughConstants(t *testing.T) {
root := fixture(t, map[string]string{
"cmd/migrate/migration/models/models.go": frozenModelsPkg,
"cmd/migrate/migration/version/1786700001000_crm.go": `package version
import "go-admin/cmd/migrate/migration/models"
const crmMenuId = 9000
func seedCrm() models.SysMenu { return models.SysMenu{MenuId: crmMenuId, MenuName: "CRM"} }
`,
"cmd/migrate/migration/version/1786700002000_oms.go": `package version
import "go-admin/cmd/migrate/migration/models"
func seedOms() models.SysMenu { return models.SysMenu{MenuId: 9000, MenuName: "OMS"} }
`,
})
f := requireOne(t, check(t, root, options{}), checkMenuIDConflict)
if f.Severity != "ERROR" || !strings.Contains(f.Message, "9000") {
t.Errorf("finding = %+v", f)
}
// The second site has to be printed too, or the report says a collision
// happened without saying with what.
if len(f.Related) != 1 || !strings.Contains(f.Related[0], "1786700002000_oms.go") {
t.Errorf("related = %v", f.Related)
}
}
// The seeds write a menu and then refer to it again to attach permissions. That
// is one menu, not two modules fighting over an id.
func TestMenuIDRepeatedWithinOneFileIsNotACollision(t *testing.T) {
root := fixture(t, map[string]string{
"cmd/migrate/migration/models/models.go": frozenModelsPkg,
"cmd/migrate/migration/version/1786700001000_crm.go": `package version
import "go-admin/cmd/migrate/migration/models"
func seed() []models.SysMenu {
dir := models.SysMenu{MenuId: 9000, MenuName: "CRM"}
again := models.SysMenu{MenuId: 9000, MenuName: "CRM"}
return []models.SysMenu{dir, again}
}
`,
})
if got := only(t, check(t, root, options{}), checkMenuIDConflict); len(got) != 0 {
t.Errorf("reported %v", got)
}
}
// ---------------------------------------------------------------------------
func TestContractImportBoundaryIsDetected(t *testing.T) {
root := fixture(t, map[string]string{
"app/admin/service/dto/log.go": `package dto
const OperaStatusEnabel = "1"
`,
"common/middleware/logger.go": `package middleware
import "go-admin/app/admin/service/dto"
func status() string { return dto.OperaStatusEnabel }
`,
})
f := requireOne(t, check(t, root, options{}), checkImportBoundary)
if f.Severity != "ERROR" {
t.Errorf("severity = %s", f.Severity)
}
if !strings.Contains(f.Message, "go-admin/app/admin/service/dto") {
t.Errorf("message = %s", f.Message)
}
if f.File != "common/middleware/logger.go" || f.Line != 3 {
t.Errorf("position = %s:%d", f.File, f.Line)
}
}
// A fork that drops app/admin should be able to run the tests too, so a
// test-only import is the same violation.
func TestContractImportBoundaryCoversTestFiles(t *testing.T) {
root := fixture(t, map[string]string{
"app/admin/models/user.go": "package models\n\ntype SysUser struct{}\n",
"common/actions/permission_test.go": `package actions
import "go-admin/app/admin/models"
var _ = models.SysUser{}
`,
})
if got := only(t, check(t, root, options{}), checkImportBoundary); len(got) != 1 {
t.Errorf("findings = %v", got)
}
}
func TestContractImportBoundaryAllowsAppToImportCommon(t *testing.T) {
root := fixture(t, map[string]string{
"common/models/by.go": "package models\n\ntype ControlBy struct{}\n",
"app/demo/models/product.go": `package models
import "go-admin/common/models"
type Product struct{ models.ControlBy }
`,
})
if got := only(t, check(t, root, options{}), checkImportBoundary); len(got) != 0 {
t.Errorf("the dependency direction that is allowed was reported: %v", got)
}
}
// ---------------------------------------------------------------------------
func menuNameFixture(t *testing.T, menuName, componentName string) (string, string) {
t.Helper()
root := fixture(t, map[string]string{
"cmd/migrate/migration/models/models.go": frozenModelsPkg,
"cmd/migrate/migration/version/1786700001000_seed.go": `package version
import "go-admin/cmd/migrate/migration/models"
func seed() models.SysMenu {
return models.SysMenu{MenuId: 9001, MenuName: "` + menuName + `", MenuType: "C", Component: "/demo/product/index"}
}
`,
})
ui := t.TempDir()
path := filepath.Join(ui, "views", "demo", "product", "index.vue")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
vue := "<script setup>\ndefineOptions({ name: '" + componentName + "' })\n</script>\n<template><div/></template>\n"
if err := os.WriteFile(path, []byte(vue), 0o644); err != nil {
t.Fatal(err)
}
return root, ui
}
func TestMenuNameMismatchIsDetectedAsAWarning(t *testing.T) {
root, ui := menuNameFixture(t, "DemoProduct", "Product")
f := requireOne(t, check(t, root, options{UIDir: ui}), checkMenuName)
if f.Severity != "WARN" {
t.Errorf("severity = %s; this check must not decide an exit code yet", f.Severity)
}
if !strings.Contains(f.Message, "DemoProduct") || !strings.Contains(f.Message, "Product") {
t.Errorf("message = %s", f.Message)
}
// Acceptance 14c.
if !strings.Contains(f.Message, "heuristic") || !strings.Contains(f.Message, "false positives are possible") {
t.Errorf("the warning must say it is a heuristic:\n%s", f.Message)
}
}
func TestMenuNameMatchIsSilent(t *testing.T) {
root, ui := menuNameFixture(t, "DemoProduct", "DemoProduct")
if got := only(t, check(t, root, options{UIDir: ui}), checkMenuName); len(got) != 0 {
t.Errorf("reported %v", got)
}
}
// Without the frontend repository there is nothing to compare against, and the
// check has to disappear rather than guess.
func TestMenuNameIsSkippedWithoutTheUIDirectory(t *testing.T) {
root, _ := menuNameFixture(t, "DemoProduct", "Product")
if got := only(t, check(t, root, options{}), checkMenuName); len(got) != 0 {
t.Errorf("reported %v without -ui-dir", got)
}
}
// A component the frontend does not carry is the "app not installed" case F2
// handles with a placeholder; this check has nothing to say about it.
func TestMenuNameIsSilentWhenTheComponentIsMissing(t *testing.T) {
root, _ := menuNameFixture(t, "DemoProduct", "Product")
empty := t.TempDir()
if got := only(t, check(t, root, options{UIDir: empty}), checkMenuName); len(got) != 0 {
t.Errorf("reported %v", got)
}
}
func TestComponentNameParsesBothVueStyles(t *testing.T) {
for _, tc := range []struct {
name string
src string
want string
}{
{"script setup", "<script setup>\ndefineOptions({ name: 'Product' })\n</script>", "Product"},
{"options api", "<script>\nexport default {\n name: 'Product',\n data() {}\n}\n</script>", "Product"},
{"defineComponent", "<script>\nexport default defineComponent({\n name: 'Product'\n})\n</script>", "Product"},
} {
t.Run(tc.name, func(t *testing.T) {
got, ok := componentName(tc.src)
if !ok || got != tc.want {
t.Errorf("componentName = %q, %v", got, ok)
}
})
}
if _, ok := componentName("<script setup>\nconst a = 1\n</script>"); ok {
t.Error("a component with no declared name must not be compared")
}
}
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"fmt"
"sort"
)
// Severity decides whether a finding stops CI.
//
// Two levels and no more. A third would immediately be used to park findings
// nobody intends to fix, and the point of this tool is that everything it
// reports is something that fails without saying so.
type Severity int
const (
// Warn prints and does not affect the exit code.
Warn Severity = iota
// Error prints and makes the run fail.
Error
)
func (s Severity) String() string {
if s == Error {
return "ERROR"
}
return "WARN"
}
// Finding is one problem, located precisely enough to open the file at it.
type Finding struct {
Check string `json:"check"`
Severity string `json:"severity"`
File string `json:"file"`
Line int `json:"line"`
Col int `json:"col"`
Message string `json:"message"`
Related []string `json:"related,omitempty"`
severity Severity
}
func (f Finding) String() string {
s := fmt.Sprintf("%s:%d:%d: [%s] %s: %s", f.File, f.Line, f.Col, f.Severity, f.Check, f.Message)
for _, r := range f.Related {
s += "\n " + r
}
return s
}
// sortFindings orders by file, then position, then check, so two runs over the
// same tree print the same thing and a diff of the output is meaningful.
func sortFindings(fs []Finding) {
sort.Slice(fs, func(i, j int) bool {
a, b := fs[i], fs[j]
if a.File != b.File {
return a.File < b.File
}
if a.Line != b.Line {
return a.Line < b.Line
}
if a.Col != b.Col {
return a.Col < b.Col
}
if a.Check != b.Check {
return a.Check < b.Check
}
return a.Message < b.Message
})
}
func hasError(fs []Finding) bool {
for _, f := range fs {
if f.severity == Error {
return true
}
}
return false
}
+105
View File
@@ -0,0 +1,105 @@
// Command checksilent reports the failures in this repository that do not
// announce themselves: no error, no log line, behaviour quietly wrong.
//
// Six checks, five of them ERROR and one WARN. An ERROR fails the run; a WARN
// prints and does not. The split is not about how bad the consequence is - all
// six are bad - but about how certain the detection is. Everything reported as
// an ERROR is decided from this repository's own syntax. The one WARN compares
// against a second repository through a regular expression, and a check that
// can be wrong must not be able to stop a build, or the first response to it
// will be an ignore comment.
//
// Usage:
//
// go run ./tools/checksilent
// go run ./tools/checksilent -ui-dir ../go-admin-ui/src
// go run ./tools/checksilent -json
//
// Or through the Makefile, which is what CI runs:
//
// make checksilent
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
"strings"
)
func main() {
var (
root = flag.String("root", ".", "repository root to scan")
uiDir = flag.String("ui-dir", "", "go-admin-ui src directory; enables the menu-name check, which is skipped without it")
asJSON = flag.Bool("json", false, "print findings as JSON")
)
flag.Parse()
code, err := run(os.Stdout, *root, options{UIDir: *uiDir}, *asJSON)
if err != nil {
fmt.Fprintln(os.Stderr, "checksilent:", err)
os.Exit(2)
}
os.Exit(code)
}
// run returns the process exit code: 0 when nothing or only warnings were
// found, 1 when at least one error was.
func run(w io.Writer, root string, opt options, asJSON bool) (int, error) {
s, err := load(root)
if err != nil {
return 0, err
}
findings, err := runChecks(s, opt)
if err != nil {
return 0, err
}
if asJSON {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err = enc.Encode(findings); err != nil {
return 0, err
}
} else {
for _, f := range findings {
fmt.Fprintln(w, f)
}
printSummary(w, findings, opt, s)
}
if hasError(findings) {
return 1, nil
}
return 0, nil
}
func printSummary(w io.Writer, findings []Finding, opt options, s *snapshot) {
var errors, warnings int
for _, f := range findings {
if f.severity == Error {
errors++
} else {
warnings++
}
}
if len(findings) > 0 {
fmt.Fprintln(w)
}
fmt.Fprintf(w, "checksilent: %d error(s), %d warning(s)\n", errors, warnings)
if warnings > 0 {
fmt.Fprintln(w, "Warnings do not affect the exit code.")
}
if opt.UIDir == "" {
fmt.Fprintf(w, "The %s check was skipped: pass -ui-dir <go-admin-ui>/src to run it.\n", checkMenuName)
}
// Said out loud so nobody reads a clean run as "the boundary holds
// everywhere it was declared". core/ is a separate module and has no
// directory here, so this repository's copy of the check cannot cover it.
if scanned, absent := ScannedContractRoots(s); len(absent) > 0 {
fmt.Fprintf(w, "The %s check covered %s; %s does not exist here and was not scanned.\n",
checkImportBoundary, strings.Join(scanned, ", "), strings.Join(absent, ", "))
}
}
+138
View File
@@ -0,0 +1,138 @@
package main
import (
"bytes"
"encoding/json"
"strings"
"testing"
)
// Acceptance 14b, second half: the WARN check on its own leaves the exit code
// at zero. If it did not, the first person it misjudged would silence it, and a
// silenced check is worse than none - it looks like coverage.
func TestOnlyWarningsExitZero(t *testing.T) {
root, ui := menuNameFixture(t, "DemoProduct", "Product")
var buf bytes.Buffer
code, err := run(&buf, root, options{UIDir: ui}, false)
if err != nil {
t.Fatal(err)
}
if code != 0 {
t.Errorf("exit code = %d, want 0:\n%s", code, buf.String())
}
out := buf.String()
if !strings.Contains(out, "[WARN]") {
t.Errorf("the warning was not printed:\n%s", out)
}
if !strings.Contains(out, "0 error(s), 1 warning(s)") {
t.Errorf("summary = %s", out)
}
if !strings.Contains(out, "Warnings do not affect the exit code.") {
t.Errorf("output must say warnings are not fatal:\n%s", out)
}
}
// Acceptance 14b, first half: any of the other five fails the run.
func TestAnyErrorExitsNonZero(t *testing.T) {
root := fixture(t, map[string]string{
"app/admin/models/user.go": "package models\n\ntype SysUser struct{}\n",
"common/middleware/auth.go": `package middleware
import "go-admin/app/admin/models"
var _ = models.SysUser{}
`,
})
var buf bytes.Buffer
code, err := run(&buf, root, options{}, false)
if err != nil {
t.Fatal(err)
}
if code != 1 {
t.Errorf("exit code = %d, want 1:\n%s", code, buf.String())
}
if !strings.Contains(buf.String(), "[ERROR]") {
t.Errorf("output = %s", buf.String())
}
}
func TestCleanTreeExitsZero(t *testing.T) {
root := fixture(t, map[string]string{
"common/models/by.go": "package models\n\ntype ControlBy struct{}\n",
})
var buf bytes.Buffer
code, err := run(&buf, root, options{}, false)
if err != nil {
t.Fatal(err)
}
if code != 0 {
t.Errorf("exit code = %d, want 0:\n%s", code, buf.String())
}
if !strings.Contains(buf.String(), "0 error(s), 0 warning(s)") {
t.Errorf("output = %s", buf.String())
}
}
// Every message has to name a file and a line, or the report is a puzzle rather
// than a finding.
func TestTextOutputLocatesEveryFinding(t *testing.T) {
root := fixture(t, map[string]string{
"app/admin/models/user.go": "package models\n\ntype SysUser struct{}\n",
"common/middleware/auth.go": `package middleware
import "go-admin/app/admin/models"
var _ = models.SysUser{}
`,
})
var buf bytes.Buffer
if _, err := run(&buf, root, options{}, false); err != nil {
t.Fatal(err)
}
line := strings.SplitN(buf.String(), "\n", 2)[0]
if !strings.HasPrefix(line, "common/middleware/auth.go:3:") {
t.Errorf("first line does not locate the finding: %q", line)
}
}
func TestJSONOutput(t *testing.T) {
root := fixture(t, map[string]string{
"app/admin/models/user.go": "package models\n\ntype SysUser struct{}\n",
"common/middleware/auth.go": `package middleware
import "go-admin/app/admin/models"
var _ = models.SysUser{}
`,
})
var buf bytes.Buffer
code, err := run(&buf, root, options{}, true)
if err != nil {
t.Fatal(err)
}
if code != 1 {
t.Errorf("exit code = %d", code)
}
var findings []Finding
if err = json.Unmarshal(buf.Bytes(), &findings); err != nil {
t.Fatalf("output is not JSON: %v\n%s", err, buf.String())
}
if len(findings) != 1 || findings[0].Check != checkImportBoundary || findings[0].Severity != "ERROR" {
t.Errorf("findings = %+v", findings)
}
}
// The tool runs on this repository in CI, so it has to be clean here. This also
// covers acceptance 13b: nothing under common/ imports app/ any more.
func TestThisRepositoryIsClean(t *testing.T) {
var buf bytes.Buffer
code, err := run(&buf, "../..", options{}, false)
if err != nil {
t.Fatal(err)
}
if code != 0 {
t.Errorf("checksilent reports problems in this repository:\n%s", buf.String())
}
}
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// heuristicNote is appended to every menu-name finding.
//
// Required by the acceptance criteria, and for a reason worth restating: this
// is the one check that compares two repositories through a regular expression,
// so it will occasionally be wrong. Reporting it as a certainty is how a tool
// gets a reputation and then an ignore comment on every line.
const heuristicNote = "\n (heuristic: matched across repositories by regular expression - false positives are possible, verify before changing anything)"
var (
// Vue 3 <script setup>, which is the prevailing style in go-admin-ui.
defineOptionsName = regexp.MustCompile(`defineOptions\(\s*\{[^}]*?name:\s*['"` + "`" + `]([^'"` + "`" + `]+)['"` + "`" + `]`)
// Options API, still present in older views.
exportDefaultName = regexp.MustCompile(`export default\s*(?:defineComponent\(\s*)?\{[^}]*?name:\s*['"` + "`" + `]([^'"` + "`" + `]+)['"` + "`" + `]`)
)
// checkMenuNames compares the menu_name a migration seeds against the name the
// component declares.
//
// keep-alive caches by component name, and the page cache is configured by menu
// name. When they disagree nothing breaks and nothing is logged - the page just
// stops being cached, or the wrong page is evicted, and it looks like a
// performance quirk.
//
// WARN, not ERROR, and deliberately so. The two sides are in different
// repositories and different languages, so this is a regular expression against
// a .vue file, not a parse. It is scheduled to become an ERROR after two release
// cycles with no false positive; until then it must not decide an exit code.
func checkMenuNames(s *snapshot, uiDir string) ([]Finding, error) {
info, err := os.Stat(uiDir)
if err != nil {
return nil, fmt.Errorf("ui dir %s: %w", uiDir, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("ui dir %s is not a directory", uiDir)
}
var out []Finding
for _, sf := range s.Files {
forEachStructLiteral(sf, func(lit structLiteral) {
if !s.isMenuModel(lit) {
return
}
menuType, _ := stringField(sf, lit, "MenuType")
// A directory has no page of its own; its component is the layout.
if menuType == "M" || menuType == "F" {
return
}
component, ok := stringField(sf, lit, "Component")
if !ok || component == "" || component == "Layout" {
return
}
menuName, ok := stringField(sf, lit, "MenuName")
if !ok || menuName == "" {
return
}
path := componentFile(uiDir, component)
if path == "" {
return
}
b, err := os.ReadFile(path)
if err != nil {
// A component the frontend does not carry is F2's business -
// the placeholder that says the app is not installed - not this
// check's.
return
}
declared, ok := componentName(string(b))
if !ok {
// No literal name at all: the build may derive one from the
// file path. Nothing to compare, so nothing to say.
return
}
if declared == menuName {
return
}
expr, _ := field(lit.Lit, "MenuName")
rel := path
if r, err := filepath.Rel(uiDir, path); err == nil {
rel = r
}
out = append(out, s.finding(Warn, checkMenuName, sf, expr,
"menu_name %q does not match the component name %q declared in %s;\n"+
" keep-alive caches by component name and the cache is configured by menu name, so the page silently stops being cached.%s",
menuName, declared, rel, heuristicNote))
})
}
return out, nil
}
func stringField(sf *sourceFile, lit structLiteral, name string) (string, bool) {
expr, ok := field(lit.Lit, name)
if !ok {
return "", false
}
return stringValue(sf, expr)
}
// componentFile maps a menu component path to a file in the frontend tree.
//
// Two roots, because F2 adds a second: anything under apps/ is an installed
// app's view, everything else is a view of the main repository.
func componentFile(uiDir, component string) string {
c := strings.TrimPrefix(filepath.ToSlash(component), "/")
if c == "" {
return ""
}
if strings.HasPrefix(c, "apps/") {
return filepath.Join(uiDir, filepath.FromSlash(c)+".vue")
}
return filepath.Join(uiDir, "views", filepath.FromSlash(c)+".vue")
}
func componentName(src string) (string, bool) {
for _, re := range []*regexp.Regexp{defineOptionsName, exportDefaultName} {
if m := re.FindStringSubmatch(src); m != nil {
return m[1], true
}
}
return "", false
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strconv"
"strings"
)
// skippedDirs are never walked. Generated and vendored trees would produce
// findings nobody can act on, and node_modules would make the walk the slowest
// part of CI.
var skippedDirs = map[string]bool{
".git": true,
".idea": true,
"node_modules": true,
"vendor": true,
"testdata": true,
"dist": true,
"temp": true,
}
// sourceFile is one parsed Go file plus the mapping from the local names it
// uses for packages to the paths those names stand for.
//
// Resolving through the file's own import block, rather than matching on the
// identifier, is what keeps service.SysMenu apart from models.SysMenu.
type sourceFile struct {
// Path is relative to the scanned root and is what gets printed.
Path string
Pkg string // import path of the package this file belongs to
Syntax *ast.File
imports map[string]string // local name -> import path
consts map[string]int64 // package-level integer constants, filled per package
}
// snapshot is every Go file under the root, parsed once and shared by all the
// checks.
type snapshot struct {
Root string
ModulePath string
Fset *token.FileSet
Files []*sourceFile
}
// load parses every Go file under root.
//
// Type checking is deliberately not done. Every rule here is about a literal
// written into a seed or an import that should not be there, and both are in
// the syntax; a type checker would drag in a resolvable build of the whole
// module, which is exactly what a check meant to run on a half-broken tree
// cannot depend on.
func load(root string) (*snapshot, error) {
modulePath, err := readModulePath(root)
if err != nil {
return nil, err
}
s := &snapshot{Root: root, ModulePath: modulePath, Fset: token.NewFileSet()}
err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if path != root && skippedDirs[info.Name()] {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
f, err := parser.ParseFile(s.Fset, path, nil, 0)
if err != nil {
// A file that does not parse is someone's work in progress, not a
// silent failure. Reporting it here would only repeat what the
// compiler already says louder.
return nil
}
dir := filepath.ToSlash(filepath.Dir(rel))
pkg := modulePath
if dir != "." {
pkg = modulePath + "/" + dir
}
s.Files = append(s.Files, &sourceFile{
Path: rel,
Pkg: pkg,
Syntax: f,
imports: fileImports(f),
})
return nil
})
if err != nil {
return nil, err
}
s.resolveConstants()
return s, nil
}
func readModulePath(root string) (string, error) {
b, err := os.ReadFile(filepath.Join(root, "go.mod"))
if err != nil {
return "", fmt.Errorf("read go.mod: %w", err)
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if rest, ok := strings.CutPrefix(line, "module "); ok {
return strings.TrimSpace(rest), nil
}
}
return "", fmt.Errorf("%s has no module line", filepath.Join(root, "go.mod"))
}
func fileImports(f *ast.File) map[string]string {
out := make(map[string]string, len(f.Imports))
for _, spec := range f.Imports {
path, err := strconv.Unquote(spec.Path.Value)
if err != nil {
continue
}
name := path[strings.LastIndex(path, "/")+1:]
if spec.Name != nil {
name = spec.Name.Name
}
out[name] = path
}
return out
}
// resolveConstants collects package-level integer constants per directory, so a
// seed written as MenuId: demoMenuId can be compared against one written as
// MenuId: 9000. Without this the menu-id check would see the well-written code
// and miss exactly the collisions it exists to find.
func (s *snapshot) resolveConstants() {
byPkg := map[string]map[string]int64{}
for _, sf := range s.Files {
consts, ok := byPkg[sf.Pkg]
if !ok {
consts = map[string]int64{}
byPkg[sf.Pkg] = consts
}
for _, decl := range sf.Syntax.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.CONST {
continue
}
for _, spec := range gen.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for i, name := range vs.Names {
if i >= len(vs.Values) {
continue
}
if v, ok := intLiteral(vs.Values[i]); ok {
consts[name.Name] = v
}
}
}
}
}
for _, sf := range s.Files {
sf.consts = byPkg[sf.Pkg]
}
}
// Pos turns a token position into the file/line/col a Finding carries.
func (s *snapshot) Pos(sf *sourceFile, p token.Pos) (string, int, int) {
pos := s.Fset.Position(p)
return sf.Path, pos.Line, pos.Column
}
// Imports reports whether the file imports path.
func (sf *sourceFile) Imports(path string) bool {
for _, p := range sf.imports {
if p == path {
return true
}
}
return false
}
// ImportSpec returns the import declaration for path, for reporting a position
// on the import line itself.
func (sf *sourceFile) ImportSpec(path string) *ast.ImportSpec {
for _, spec := range sf.Syntax.Imports {
if p, err := strconv.Unquote(spec.Path.Value); err == nil && p == path {
return spec
}
}
return nil
}