Compare commits

...
Author SHA1 Message Date
zhangwenjian a43133ab7b fix🐛: stop demo mode serving the writes that are registered as GET
DemoEvn decided by HTTP method: GET and OPTIONS through, everything else
refused. Three of the code generator's routes are registered as GET and write
anyway - two emit Go source files onto the server's filesystem, and the third
inserts menus, APIs and casbin rules into the database. They sit in a group
whose own name says it does no role check, and a demo deployment lets anybody
log in. So on the demo host any visitor could write to the machine and to the
database, and the one that writes menus had in fact been used: three generated
SysCasbinRule entries is how this was noticed.

The guard now also looks at the matched route. The method cannot answer the
question - whether a request changes anything is not something the verb reports
truthfully here - so the three are named, as gin route patterns, which is what
Context.FullPath returns and how CasbinExclude already spells them.

Changing them to POST would be the better shape and is not this change. sys_api
records an endpoint by method and path and the casbin policy follows it, so
flipping the verb needs a migration and a policy resync; until both land, every
existing deployment would start answering 403 to a role that could use the
generator the day before.

The read-only half stays reachable: preview, the table tree, and the two
database listings. A demo host that cannot demonstrate the generator is as
broken as one that lets visitors write to it - refusing too much is the same
defect facing the other way, and there is a test for that direction too.

Half of the general hole is closed and the other half is written down. The
closed half is a test beside the route registrations: it builds the generator's
routes, enumerates them, and fails if any entry in the guard has stopped being
a real route, so renaming one turns the list red instead of quietly making it
match nothing. It lives there because common/ may not import app/ - which is
also why the guard cannot check its own list from where it is. The open half is
that no static check can tell a handler that writes from one that reads, so the
next GET that writes has to be added by hand. The comment says that rather than
leaving the impression the class is covered.

application.demomsg was configuration nothing read. The message was hard-coded
in the middleware, and the demo host's configured string happened to be
identical, so the setting looked like it worked and never had. It is read now,
with the old string kept verbatim as the fallback, so a deployment that never
set it is answered exactly as before.

This covers demo mode only. On a deployment that is not a demo those three
routes remain in CasbinExclude and stay reachable by any authenticated user
whatever their role; that is a separate decision and is not touched here.
2026-09-07 08:03:40 +08:00
wenjianzhang 7002cd4065 Merge pull request #912 from go-admin-team/feat/007-drain-window
feat: 关闭时先排空再停止接收——让 /ready 的 503 真的能被采到
2026-09-07 08:01:59 +08:00
zhangwenjian 8faa8d2aed feat: check the shutdown budget against every stop deadline
The budget is one number in config/settings.yml. The deadlines that have to
cover it are in four other files, none of which anybody edits while thinking
about shutdown - so raising the budget passes every test, deploys, and has the
cleanup callbacks killed on the next release.

Two checks share one arithmetic and one five-second margin.

shutdown-budget-overruns-grace compares preStop + drain + server + cleanup
against terminationGracePeriodSeconds in the shipped manifest. Those two files
are not merely adjacent examples: scripts/k8s/prerun.sh builds the
settings-admin ConfigMap out of config/settings.yml and the Deployment mounts
it, so the manifest deploys that file.

docker-stop-cuts-shutdown-short covers the three ways this container is
stopped: `docker stop` in the release workflow, the same in the Makefile, and
stop_grace_period on a compose service that runs this repository's own image. A
service running a database is not this process and is left alone. The duration
is parsed rather than scanned for digits - compose accepts 1m30s, and reading
the first number out of it would call ninety seconds one.

All three spellings of the deadline are read: --timeout, the deprecated --time,
and the short -t. A deadline the check cannot read is reported as no deadline at
all, so recognising only one of them would call a correct command broken and
send whoever fixed it towards the spelling docker is retiring. The message
quotes the flag back in the spelling it was written in, for the same reason:
suggesting a flag the line does not use is how a tool teaches people to
disbelieve it.

What neither covers is `docker rm -f`, which has no deadline to compare
against: it is SIGKILL by definition. That gap is deliberate, and it is why the
previous commit changed the one place that used it on a container that might
still be running.

Both report at two levels. A budget that already overruns is an ERROR; one that
fits with nothing to spare is a WARN, because it works today and failing the
build on a working configuration is how a project teaches people to ignore its
warnings. The two are exclusive: an overrun satisfies the headroom condition as
well, and an ERROR that always drags a duplicate WARN behind it teaches the same
lesson.

preStop is in the sum although the shipped manifest has no hook. That is the
point - a hook added later is spent before the process is told anything, and a
self-check that could not see it would understate the real budget by however
long somebody set it to, which is worse than not checking. A hook whose duration
cannot be read is reported rather than counted as zero.

The fallbacks for fields the settings file leaves out are read from the
constants in the scanned tree, not copied here; if they are renamed the run
stops instead of going quiet with the wrong numbers.

The wording differs by audience on purpose. At run time this is somebody else's
deployment under constraints the process cannot see, so the log states a
minimum. These checks read files this repository owns, where there is standing
to ask for headroom, so they name a target.

The table in AGENTS.md is relisted while it is being touched: the two new
checks, plus datascope-route-unguarded, which has been missing since it was
added. The hard-coded count is gone - it said seven and there were ten, which is
what a written-down count does. AGENTS.md and docs/contract.md both sent readers
to `go run ./tools/checksilent -h` for the list of checks; that prints
command-line flags and has never printed a check, so both now point at
runChecks.

The yaml parser moves from an indirect requirement to a direct one - it was
already in the module graph - and tidy drops four go.sum lines left over from
two older releases of core.
2026-09-06 22:07:08 +08:00
zhangwenjian 705427178d fix🐛: give every stop path enough time for the shutdown budget
Stopping this process takes drain + server + cleanup seconds: eight out of the
box, and more for anyone who configures a drain window. Three places decide
whether it gets that long, and none of them was written with it in mind.

The release workflow stopped the previous container with the default deadline,
which docker sets at ten seconds. The compose file - which the Makefile calls
the first way to run this - set no stop_grace_period, so it took the same ten.
Under either, a drain window over two seconds would have been cut off by
SIGKILL part-way through the cleanup callbacks: this project's own deployments
could not have run the capability it ships.

The third was worse. `make run` removed the previous container with `docker rm
-f`, and the force flag kills a running container outright - "uses SIGKILL", in
docker's own words - with no grace at all. Restarting locally cut every
shutdown short, so the drain window would never once have been reached on a
developer's machine. It now stops with a deadline and then removes, which
leaves what gets removed unchanged: on a container that has already stopped,
stop is a no-op.

So: --timeout 30 in the workflow, stop_grace_period: 30s on the compose
service, and stop --timeout 30 before the removal in the Makefile. --timeout
rather than --time, which docker still honours but has deprecated - it prints a
warning on every use, and a deploy log that always carries a warning is one
nobody reads.

The three remaining `docker rm -f` calls in the workflow are left alone. Two
remove containers that have already been stopped and one is the rollback path,
and nothing static can tell those apart from a container that is still running -
which is also why the check added next does not look at `rm -f` at all: a forced
removal has no deadline to compare against. What keeps that path honest is the
line above it, not a check.

Thirty will drift the first time somebody raises a budget. The next commit is
what notices, which is also why these comments name a check that does not exist
yet.
2026-09-06 22:06:35 +08:00
zhangwenjian 8f10d202e6 feat: give the shipped manifest probes and a stop grace period
The manifest in this repository had no probes at all. A pod was sent traffic as
soon as its container was running, whether or not the database it needs was
reachable, and it was stopped with whatever grace period Kubernetes defaults to
rather than one chosen against what this process actually spends shutting down.

It now mounts both probes, at the endpoint that answers each question:
readiness at /ready, which fails while a dependency is unreachable, and
liveness at /health, which is a bare 200 because restarting a process whose
database is down turns one outage into a crash loop. Both skip the rate
limiter, which is why that had to land first.

timeoutSeconds is 3, not the default 1. The handler allows its checks two
seconds, so at the default a database answering in 1.2s would be recorded as a
failed check while the handler was returning 200 - the probe would be failing on
the orchestrator's stopwatch, not on its own. The comment beside that constant
said the constraint was the polling period; the constraint is the per-check
timeout, and it is now written down correctly.

terminationGracePeriodSeconds is 30, against a shipped budget of 0 + 5 + 3.
Raising drain means raising this too, in the same commit; the check that
notices when somebody does not arrives two commits from here.

replicas stays at 1, and the comment says why that makes the drain window worth
nothing: there is nowhere to send the traffic this pod stops taking. Raising it
needs one more change than the number - the volume is shared by every replica
and the log path lives on it, so a second pod would append to the same rotating
file. The reason not to raise it is not the one the review assumed: the claim
was that the PVC is ReadWriteOnce, and it is not, it is ReadWriteMany on nfs-csi.

There is no preStop hook. How long one should sleep depends on how fast the
thing in front removes this instance, which the repository cannot know, and a
manifest carrying both a preStop sleep and a drain window is the double-counting
trap - the budget would be spent twice and the start-up line would report half
of it.
2026-09-06 21:49:17 +08:00
zhangwenjian 5648bd1dcf feat: state the shutdown budget at start-up
The three budgets are spent one after the other, so what has to fit inside the
orchestrator's grace period is their sum - and nothing said what that sum was.
Working it out meant reading a configuration file, remembering which fields
were absent, and knowing what each one falls back to.

Start-up now prints it: the three values and the total, taken from the resolved
budget rather than from the file. A field left out still costs its default, so
adding up what was written down understates the total by exactly the fields
nobody wrote - which is the arithmetic somebody doing this by hand gets wrong.

Whether the total fits is a separate question, and the framework cannot answer
it alone: `docker stop` allows ten seconds and Kubernetes thirty, three times
apart. A fixed threshold would have warned about the manifest this repository
is about to ship. So extend.shutdown.grace is optional, nothing reads it during
a shutdown, and when it is absent the line says so and prints both reference
values instead of judging.

When it is set and the budget does not fit, the warning names the shortfall:
how many more seconds are needed. A minimum, not a target - this is somebody
else's deployment under constraints this process cannot see, and asking them to
leave headroom on top is not this line's business. Equal does not fit either;
the grace period is when SIGKILL is sent, so a budget that ends exactly then
leaves the last callback no time to return.
2026-09-06 21:49:17 +08:00
zhangwenjian a442eadb96 feat: keep serving for a configurable window before the listener closes
/ready has failed from the moment shutdown begins since the readiness probe was
added, and the order it does that in is right: reversed, the state would be
reported after the connections were already cut. But order alone does not
produce a window. Nothing waited between the flip and Shutdown, so the two were
microseconds apart, and a poller on a multi-second interval never saw the 503 -
it saw a refused connection, which is the thing the probe was supposed to
avoid. Polling a container through a SIGTERM on the demo host recorded exactly
that: 200, then connection refused, and no 503 in between.

extend.shutdown.drain is that wait. The process keeps serving normally for it -
answering requests, not refusing them, because refusing them would move the
outage earlier rather than avoid it - and only then closes the listener.

It is zero by default, so nothing changes for a deployment that does not ask
for it. That is not timidity: the budgets are spent one after another, and a
non-zero default would push every existing shutdown closer to the orchestrator's
grace period, where being cut off part-way through the cleanup callbacks is
worse than never draining at all.

Keep-alive is switched off with the flip. The server keeps connections alive
until Shutdown sets shuttingDown() itself, so without this the pooled
connections a balancer holds would sit untouched for the whole window and be
cut at the end of it anyway - the cost of the window without its benefit. This
is the switch Shutdown flips, moved earlier by the window's length.

The signal disposition is restored after the window rather than on the first
signal. Before there was a window, the interval where a second signal killed
the process outright was only reachable while a cleanup callback hung; putting
a multi-second wait inside it would have made every ordinary shutdown
interruptible for the length of the drain. A second signal during the window is
taken by the channel and ends the window early instead - somebody sending
another kill wants this over with sooner - and the escape hatch comes back the
moment the window does.

What the window is worth depends on who removes this instance. A balancer that
polls /ready acts on the 503 and needs the window to cover its check interval
times its failure threshold; a Kubernetes Service withdraws the endpoint when
the Pod is deleted, concurrently with SIGTERM and regardless of what the probe
returns, and there the window covers the delay in that removal reaching every
node. The three comments that used to say a balancer "has a chance to" take the
instance out said it without either qualification, which is how a claim comes to
be repeated after a live test has refuted it.

The subprocess test polls the real probes on a connection it opens after the
signal - a reused one can be served after the listener is closed, which would
let this pass against a shutdown that had already broken it - and asserts on the
draining answer in the body, not on the status code. With no database the status
is 503 from start-up, so a status-code assertion would hold even with
BeginDraining deleted. Two window lengths, because one proves only that
something takes that long.
2026-09-06 21:49:17 +08:00
zhangwenjian f3b67e9abc fix🐛: keep the rate limiter away from the health probes
The limiter is installed on the engine and the probes are routes like any
other, so above the threshold they are answered with 429 too. Point a liveness
probe at one and the failure mode writes itself: traffic crosses the threshold,
the probe collects three 429s, the kubelet restarts the container, the capacity
that was already short gets shorter, and the instances that are left are pushed
further past the threshold. The limiter working exactly as designed is what
kills the pod.

It is the argument common/health already makes about restarting a process whose
database is unreachable, applied to load: turning one outage into a crash loop
is not an improvement on the outage.

Nothing points a liveness probe at these routes yet. The manifest that will is
two commits away, and this has to land first, because that manifest without
this change would be actively harmful.

The exemption wraps the middleware rather than teaching the limiter about these
paths. common/ may not import app/ - the contract check enforces it - so the
limiter cannot name routes that are registered over there. Wrapping it in the
command package, which imports both, is what keeps the boundary.

Naming those routes needs them exported, so the group prefix and the two paths
become constants and the router function becomes RegisterMonitorRouter. That
also gives a test something real to mount: a probe asserted against a
re-implementation of itself is a test of the copy.

The check that the middleware never runs is separate from the check that the
answer is not 429, because a probe can produce a 429 on its own. What has to be
true is that the request never reached the limiter.
2026-09-06 21:49:16 +08:00
zhangwenjian 4e51f56623 feat: make the shutdown budgets configurable
How long a shutdown may spend waiting for in-flight requests, and how long the
cleanup callbacks get after that, were compile-time constants. The two together
have to fit inside whatever grace period the orchestrator allows before it
sends SIGKILL, and that number is not the same everywhere - `docker stop`
allows ten seconds, Kubernetes thirty by default - so the one deployment shape
these constants suited was the one they were written for.

They now come from extend.shutdown, beside rateLimit. Not from application:
that section is a fixed struct in core, and the decoder discards keys it has no
field for without an error, so a budget written there would be accepted and
never applied. That is the failure this whole change is about, and putting the
configuration where it cannot be read would have reproduced it.

Both fields are pointers, following RateLimit.InboundQPS: nil means "not
configured" and takes the default, and a number that was written down is spent
literally, zero included. Without that separation `server: 0` - do not wait for
in-flight requests at all, which is a reasonable thing to ask when the grace
period is very short - could not be expressed, and the section would need a
paragraph explaining which zeros mean what.

A negative is refused rather than clamped. Correcting a value quietly is the
same failure in a different costume, and Budget returns the error instead of
ending the process so that the rule can be tested without a subprocess.

The defaults live in config as seconds and in cmd/api as durations, both from
the same constants, and a test asserts the two agree - a deployment that
configures nothing is entitled to one answer about what it spends, not two.

The last test loads the two settings files this repository ships through the
real loader and asserts the section arrives with the documented values. Nothing
weaker can tell "the key is read" from "the key is discarded": the struct
compiles either way.
2026-09-06 21:49:16 +08:00
zhangwenjian 7e4e17bbcf test: run the real shutdown sequence in the signal tests
The child process built a server, restored its own signal disposition and
called shutdownServer and runShutdownHooks itself, in an order it chose. It
never called anything run() calls. So the assertions were about a copy of the
sequence: move a step in the real one, or drop it, and every test here stays
green. The acceptance criteria these back are worth exactly as much as that.

The child now calls gracefulShutdown and asserts on what comes out of it. The
budget it spends is defaultBudget with one field shortened where a test needs a
deterministic timeout, which is also how the two waits stop being wired by
hand.

The stuck-shutdown case changes shape as a result. It used to sleep inside the
child, between the steps it had copied; there is no "between" to sleep in any
more, so it registers a BeforeExit callback that never returns and gives it a
budget long enough to hang on. That is where a shutdown actually hangs, and it
now runs through the same function - which means this test also pins where the
signal disposition is restored, rather than just asserting that the child dies.

It signals repeatedly rather than once. The marker is printed immediately
before gracefulShutdown is entered, so a single signal sent on seeing it can
still arrive before the disposition is restored, land in the buffered channel
and be dropped. Which signal does the killing is not the assertion; that one of
them can is.
2026-09-06 21:49:16 +08:00
zhangwenjian 799e892a68 refactor♻️: run the shutdown sequence from one function
The steps between the stop signal and the last log line were written inline in
run(), which left nothing for a test to call. The signal tests reproduce that
sequence instead: they build their own server, restore their own disposition,
and call shutdownServer and runShutdownHooks in an order of their own. So they
assert against a copy - reorder the real sequence, or drop a step from it, and
they stay green.

The sequence now lives in gracefulShutdown, and the waits it spends are a
budget rather than two constants read at the point of use. Nothing changes
about what happens or in what order: the same disposition is restored first,
the same two announcements are made, the same waits are spent, and run() logs
the same two errors with the same messages.

Returning those errors instead of logging them inside is what lets a caller
other than run() react to them. That matters for the next commit, where the
tests stop reproducing this sequence and start running it.
2026-09-06 21:49:16 +08:00
wenjianzhang 0e2adb3165 Merge pull request #909 from go-admin-team/fix/queue-swap-order
fix🐛: 热重载期间生产者被指向已关闭的队列
2026-09-06 21:23:01 +08:00
wenjianzhang 249e044ded Merge pull request #910 from go-admin-team/docs/readiness-claim
docs📝: 摘除实例的不是 readiness——订正 #908 的三处注释
2026-09-06 20:32:51 +08:00
zhangwenjian d6309c75be docs📝: state what the draining answer is worth
Three comments said readiness failing before the server stops accepting gives
a load balancer the chance to withdraw the instance before its connections are
cut. Nothing between the two lines makes that possible: BeginDraining is
immediately followed by the shutdown, and a poller on a multi-second interval
never observes the flip.

On Kubernetes the endpoint is withdrawn when the Pod receives a
deletionTimestamp, concurrently with SIGTERM and independent of what the probe
returns, so the probe result is not the mechanism there either.

The order itself stands - reporting the state after the connections are cut is
worse - so the comments now say the order is necessary and not sufficient, and
that a window needs a configured delay that does not exist yet.
2026-09-06 19:04:42 +08:00
zhangwenjian 211ae85a4e test: fail on an Append error this test does not model
Only ErrQueueClosed was examined and every other error was discarded, so a
run where nothing reached a queue at all would leave the refusal count at
zero and the test green. The first unexpected error is now kept and fails
the test.

The pool is sized so a full queue cannot be one of them: it returns an error
of its own and is expected while the consumer is held, which would otherwise
make the new check fire on the normal path.
2026-09-06 19:00:30 +08:00
zhangwenjian 3c3d94ca76 test: wait for the sample rather than for the clock
Two fixed sleeps decided when this test looked: one to let the consumer pick
a message up, one to let publishes accumulate inside the reload. Both are
guesses about how fast the runner is. The consumer now signals on its first
delivery, and the measurement waits until enough publishes have landed.

The "nothing was published" guard goes with them. It was the weaker form of
the same check, and it ran after the fact instead of holding the window open
until there was something to measure.
2026-09-06 19:00:25 +08:00
zhangwenjian 6138d2d74c test: assert the bound installing first actually gives
The assertion demanded zero refusals during a reload, and this ordering
cannot deliver that. GetQueuePrefix returns a wrapper that captured the
adapter, so a producer that fetched before the swap and appends after
Shutdown has begun is still holding the old queue. That window is one call
wide; closing it means resolving the adapter inside Append, which is core's
to change.

What the ordering removes is the sustained window - every producer that
fetches during the wait. Measured on a race-enabled run: 174 of 174 publishes
refused with the old order, 1 of 174 with the new one. The old assertion
therefore failed about half the time on a change that works.
2026-09-06 19:00:20 +08:00
zhangwenjian d5de79f75b test: join the producer before the test returns
The publishing goroutine was told to stop and never waited for. t.Cleanup
restores sdk.Runtime while a producer that has not yet noticed the stop is
still reading it, which -race reports as a write and a read on the same
package variable. Signalling is not joining.
2026-09-06 19:00:10 +08:00
zhangwenjian 46e793972c fix🐛: install the new queue before shutting the old one down
Shutdown now waits for its consumers to deliver what the queue still holds, and
setupQueue called it first. For that whole wait sdk.Runtime still pointed at the
adapter that had stopped accepting, so every Append landing in the window came
back ErrQueueClosed - and both call sites in common/middleware log that at error
level while the row never reaches the database.

Measured with a held consumer: 177 of 178 publishes during one reload.

Installing first leaves no window. A producer fetches the adapter per call and
gets either the new queue or the old one, and both accept; the old one still
drains, because Shutdown is what waits for that.

The difference only exists during the wait - after Setup returns the two orders
look identical, which is why the test holds a consumer and publishes throughout
the reload rather than checking the state afterwards.

The counter-proof compiles and reports the 177.
2026-09-06 17:37:01 +08:00
zhangwenjian 46f4092b43 build🔧: require go-admin-core v2.7.0
It carries the queue shutdown fixes: a closed queue now delivers what it already
accepted and stops the goroutines consuming it. Both matter here, because this
host rebuilds its queue adapter on every configuration reload and shuts one down
on the way out.

Nothing in this commit uses the new behaviour. The one place that has to change
because of it follows.
2026-09-06 17:37:01 +08:00
wenjianzhang 196195357b Merge pull request #908 from go-admin-team/feat/readiness-probe
feat: 新增 /ready,并让它在关闭一开始就失败
2026-09-06 10:07:45 +08:00
zhangwenjian c579c5f84c fix🐛: give every cache probe its own key
One fixed key meant two probes overlapping - two /ready requests, or two
instances against the one redis, which is the normal deployment - overwrote each
other's value between the write and the read, and each concluded the cache was
broken. A readiness probe that reports false negatives under load pulls healthy
instances out of the pool, which is worse than not probing.

The key now carries eight random bytes. The written value is still read back,
because that is what tells a healthy cache apart from one that answers "miss"
for everything, and the key is deleted afterwards on a best-effort basis - its
error is dropped deliberately, since the verdict is already decided and a cache
that cannot delete what it just wrote is not a reason to refuse traffic.

The first version of the concurrency test had no teeth: the probes are short
enough that the scheduler ran them one after another, so the fixed-key
counter-proof passed three times out of three. The fake cache now holds every
writer until all of them have written, which makes the interleaving the test is
about actually happen. With one key that is a deterministic 15 failures out of
16 - only the last writer's value survives - and with a key per probe, none.
2026-09-06 09:55:12 +08:00
zhangwenjian 241c27358b feat: answer readiness separately from liveness, and fail it while draining
/health returned 200 without asking anything. Whatever it was meant to say, an
orchestrator reading it learned only that a process was accepting connections.

The two questions are not the same one, and the answers differ:

  - /health stays a bare 200. It answers "should I restart you", and a process
    whose database is unreachable does not want restarting - that turns one
    outage into a crash loop and discards the connection pool, the cache and
    every request in flight on the way.
  - /ready is new. It answers "should I send you requests", fails while a
    dependency is unreachable, and fails from the moment shutdown begins.

That last part is what the life-cycle phases bought. BeginDraining sits next to
BeginShutdown, before the server stops accepting, so a load balancer is told to
stop sending while this instance can still finish what it holds. Reversed - and
that is where it was - the connections are cut first and the probe reports it
afterwards.

The queue is deliberately not checked. Nothing on AdapterQueue answers "are you
reachable" without publishing something, the memory backend cannot fail, and a
queue that is down degrades logging rather than stopping requests: a reason to
alert, not a reason to leave the pool.

The cache probe writes and reads back rather than only reading. A cache that
answers "miss" for every key - a client pointed at the wrong server - is
indistinguishable from a healthy one on a read alone.

Every check runs behind a recover, and that is not defensive habit. The test for
"nothing configured" found the reason: GetCacheAdapter builds a wrapper around
whatever is configured and returns it even when nothing is, so the value is not
nil, the cache inside it is, and Set dereferences it. A nil check cannot see
that, and GetQueueAdapter behaves the same way. Whatever the cause, a probe is
the last thing that should be able to take the process down - the caller is
asking whether this instance is well, and killing it is the wrong reply.

The counter-proof compiles and fails: without the recover, the unconfigured case
panics rather than reporting two failed checks.
2026-09-06 09:47:55 +08:00
wenjianzhang aa3c9866cb Merge pull request #907 from go-admin-team/test/892-reload-generation
test: 补上 #892 修复中没有断言的那一半
2026-09-06 09:47:07 +08:00
zhangwenjian 2ac01ea584 test: restore what Setup writes outside this test
Setup installs the cache and queue adapters on sdk.Runtime, which is a
package-level singleton, and records what it installed in this package's own
variables. The test left all of it behind, so what a later test in this binary
saw depended on whether this one had run - the leak cmd/api's freshRuntime and
common/middleware's copy of it exist to prevent.

sdk.Runtime is swapped for a fresh one and everything is put back in Cleanup.
2026-09-05 23:18:01 +08:00
zhangwenjian 7f9cc1e435 test: a configuration reload installs a new queue for the consumers to notice
Issue #892 is that a reload replaces the queue adapter and the consumers
registered against the previous one are left attached to a queue nobody
publishes to any more.

The fix has two halves and only one of them was covered. attachQueueConsumers
gives a new queue its own consumers and the same queue none, which cmd/api
tests against a queue it controls by passing generation numbers in by hand. What
nothing asserted is that a reload actually produces a new generation for it to
notice - the half that lives in this package.

Setup is what config re-runs on every change, so calling it twice is what a
reload does here. The generation goes 0, 1, 2.

The counter-proof compiles and fails: dropping the increment in setupQueue
reports that the first Setup installed no queue at all, because a generation
that never moves is indistinguishable from never having been set.

This closes the loop rather than adding coverage for its own sake: with both
halves asserted, the claim that #892 is fixed rests on tests instead of on
reading the two functions and believing they meet.
2026-09-05 23:01:27 +08:00
wenjianzhang 4fb0529d2d Merge pull request #906 from go-admin-team/feat/005-host-wiring
feat: 生命周期阶段接线,并修掉队列注册顺序与从未停止的 cron
2026-09-05 22:49:50 +08:00
zhangwenjian 8ee4141af6 fix🐛: check the certificate before announcing that the port is reachable
AfterListen promises a hook that the port answers. The bind was moved onto the
caller's goroutine to keep that promise, but with ssl enabled there was a second
way to fail after the announcement: ServeTLS reads the certificate files itself,
on the serving goroutine, so a bad path or an unreadable key surfaced once the
hooks had already run.

tls.LoadX509KeyPair now runs before anything is announced, and its error is
returned from startServing the way a failed bind is. ServeTLS still does the
real work - handing it a tls.Listener built here instead would take over the
HTTP/2 negotiation it sets up, and quietly drop h2 for every TLS deployment. The
cost is one extra read of the certificate at startup.

The fatal in the serving goroutine said "listen:". Neither the bind nor the
certificate reaches it any more, so it says "serve:".

The test covers the certificate path alongside the bind: neither may announce
the phase, and neither may seal it.

The counter-proof is not clean, and saying so is the point. Removing the check
does turn the run red, but through log.Fatal killing the process from the
serving goroutine - "fatal serve: open no-such.pem: no such file or directory" -
rather than through the assertion. That still demonstrates the defect, because
the process could only get there after startServing had returned successfully
and the phase had been announced; it cannot be observed from inside the test,
because the fatal races the assertion that would report it.

Also: the redis-backed queue tests now fail instead of skipping when CI is set
and GO_ADMIN_TEST_REDIS_ADDR is not. A workflow that renamed the variable or
dropped the service would otherwise stay green while those two tests quietly did
nothing - the same shape as the defect they exist to cover. Locally, with no CI
in the environment, they still skip.
2026-09-05 22:41:53 +08:00
zhangwenjian 36f2549172 test: pin BeforeRouter to the moment before the engine exists
buildRouter is split out of run() so the order it establishes can be asserted:
the phase is announced, then initRouter builds the engine, then runStartupHooks
drains the registries.

The test covers both halves of the distinction the contract draws. A
BeforeRouter callback sees no engine - that is what the phase means, the last
point at which a module can still affect how routes are built. A callback in the
before registry, two lines later, sees one. The names invite treating them as
the same moment and they are not.

No database is involved. AuthInit reads ApplicationConfig.Mode and JwtConfig and
nothing else, and building a router registers handlers rather than calling them,
so the whole sequence runs in a package test with two package-level values set.

freshRuntime swaps the global runtime for the duration: runStartupHooks seals
the registries it drains, and a sealed registry silently drops everything
registered afterwards, which would leave every later test in this binary passing
while proving nothing.

The counter-proof compiles and fails - announcing the phase after initRouter
reports "BeforeRouter saw engine &{...}, want nil".
2026-09-05 22:31:12 +08:00
zhangwenjian dff0e64f51 test: run the queue's ordering rule against a real redis in CI
The rule that consumers are registered before the queue is started had no test
that could fail on the backend it exists for. Everything so far ran on the
memory queue, which is the default: queue.Memory's Register starts another
consumer goroutine whatever the state, so the wrong order passes there. A suite
that only exercises the default reports success for a queue that accepts no
consumers at all.

CI gets a redis service, and two tests build the queue the way setupQueue does -
through config.QueueConfig.Setup, so what is under test is the adapter this
repository actually gets, LegacyQueueAdapter included. They skip without
GO_ADMIN_TEST_REDIS_ADDR, so a developer with no server still gets a green run.

Registering first and starting second delivers the message. Starting first and
registering second is refused: no consumer group was created, so Append comes
back with storage.ErrNoHandler. Pinning that particular error rather than "some
error" is deliberate - the test is about the missing consumer, and a connection
failure that happened to error too would otherwise pass for it.

Running it corrected something written two commits ago. The claim there was
that a late registration loses consumers "with nothing said". Only half of that
holds: the registration is silent, because Register returns nothing, but every
publish afterwards fails loudly - ErrNoHandler, logged at error level by both
call sites in common/middleware - while the log rows are never written. The
symptom is missing rows plus a lot of noise, not a quiet nothing. That commit's
message and the contract doc both say so now.
2026-09-05 22:31:12 +08:00
zhangwenjian 0b78bc1e2e docs📝: write down where the life-cycle phases land in this host
core's contract says what the four phases promise. This says which line of
cmd/api/server.go each of them is, which is the part an application author
cannot read out of core.

Three things are recorded because getting them wrong is silent:

  - BeforeRouter is not the before registry. Those callbacks run from
    runStartupHooks, which is after initRouter has built the engine; the phase
    is before it. The two are one line apart in the same function and describing
    them as equivalent is the mistake this paragraph exists to prevent.
  - AfterResource runs again after every configuration reload, so a callback
    there is idempotent with respect to a resource rather than doing nothing
    the second time. The queue consumers are the worked example, in both
    directions: a new adapter must get consumers, the same adapter must not get
    them twice. Identity has to come from where the resource is created -
    GetQueueAdapter and GetQueuePrefix build a fresh wrapper per call, so two
    of them never compare equal however often the adapter underneath changed.
  - Consumers must be registered before the queue is started, and which
    implementation is behind the interface decides whether that matters:
    QueueConfig.Setup hands back queue.NewMemory when there is no redis
    section, and that one does not care; a redis section reaches
    LegacyQueueAdapter, whose Register cannot report a refusal. The table says
    so rather than leaving "only on redis" as a claim. What is silent is the
    registration alone - every publish afterwards is refused with ErrNoHandler
    and logged at error level, so the symptom is missing log rows plus a lot of
    noise, not a quiet nothing.

The third-layer table loses its "queue consumers are lost after a hot reload"
row, which is what AfterResource is for, and gains the honest replacement: the
four phases are the only mount points there are. There is no "after the routes
are installed, before the socket is listening".

AfterListen is described as the port being bound rather than Serve being in its
accept loop. Serve runs on another goroutine and may not have reached the first
Accept; what is true is that the bind returned, so the kernel is queueing
connections. A bind that fails produces no phase at all - the error returns
from run() and the banner never prints.
2026-09-05 22:31:12 +08:00
zhangwenjian 94163f9afb fix🐛: stop the job scheduler on the way out, and start one per tenant
The per-tenant setup ended with `defer crontab.Stop()` on the line above
`select {}`. The select never returned, so the deferred call was unreachable
for the life of the process: the scheduler had never once been stopped. And
because setup never returned, the `for k, db := range dbs` loop in Setup never
reached its second iteration - with several tenant databases configured, only
whichever one came first out of the map ever got a scheduler at all.

Both fall out of deleting the select, which was blocking for nothing: cron.Start
is `go c.run()` and has never needed anything to hold the caller.

The stop becomes a BeforeExit callback. cron.Stop returns a context that closes
once the jobs already running have finished, so the shutdown budget has
something real to bound - and giving up on that wait leaves those jobs running
until the process exits, which is better than holding the whole shutdown open
for one job that will not end.

Startup moves from a bare goroutine in run() onto AfterListen. Two reasons: the
phase runs behind core's panic guard, which does not reach across a goroutine
boundary, so a panic while loading jobs used to take the process down; and the
jobs it starts can call the API, which is only true once the socket is
accepting. It can be synchronous now precisely because setup returns.

Tested where it can be: startCrontab is split out so a scheduler can be started
with no database in sight. The job runs every second; after RunShutdown, two
and a half seconds of silence is the assertion. The counter-proof - registering
no callback, which is what this commit replaces - compiles and reports "the job
fired 2 more times after shutdown".

There is one test, not several, because BeforeExit closes to further
registration once it has run; a second RunShutdown in the same binary would
find an empty registry and pass while proving nothing.

**The multi-tenant half has no test.** setup needs a *gorm.DB per tenant before
it reaches the line that was blocking, and this repository's CI has no database
- `make build` is CGO_ENABLED=0 with no sqlite tag. It is the same defect
though: the loop could not advance past a call that never returned.
2026-09-05 22:31:12 +08:00
zhangwenjian 4510b06959 fix🐛: register the queue consumers before the queue is started
setupQueue ended with `go queueAdapter.Run()`, and the three log consumers were
registered afterwards, from setup(). The contract implementations refuse a
registration once the queue is running - memqueue and the redis queue both
answer storage.ErrQueueAlreadyStarted - and Register cannot report it: it
returns nothing, which its own comment in core records as the reason the
interface is deprecated. Start first and register second, across two
goroutines, and the registration is dropped without the caller being able to
tell.

What follows is not quiet. No consumer group was created, so redis refuses
every later publish with storage.ErrNoHandler, and go-admin logs that at error
level from both call sites while the login and operation log rows are simply
never written. The silence is in the registration; the cost shows up on every
request after it.

Which implementation is behind the interface depends on the configuration.
config.QueueConfig.Setup returns queue.NewMemory directly when there is no
redis section - and that one does not care about the order, because its
Register just starts another consumer goroutine. Only a redis section reaches
storage.LegacyQueueAdapter, which wraps the contract implementation and
therefore refuses. So the defect is invisible in the default deployment and
shows up only where redis is configured, dropping the login log, the operation
log and the api check - the three things #892 was about.

The start therefore moves to the code that registers, and nothing starts the
queue but that.

The registration also moves onto AfterResource. It has to: a reload rebuilds
the adapter, and consumers attached to the one that existed at start-up are
attached to a queue nobody publishes to any more. Being on that phase means
running again on every reload, so the callback is idempotent with respect to a
given queue rather than "does nothing the second time" - registering twice on
the same queue would give every message two consumers and write every log row
twice.

Identity for that comes from common/storage, where the adapter is built, as a
generation counter. It cannot come from the accessors: GetQueueAdapter and
GetQueuePrefix build a fresh runtime.Queue wrapper on every call, so comparing
two of them compares two wrappers and never matches however many times the
adapter underneath has been replaced. A counter also keeps the comparison on a
uint64 rather than an `==` between two interface values, which would panic on
an adapter type that is not comparable.

Generation 0 means the configuration has no queue section, so nothing was
installed and the runtime hands back its own memory queue. That case still gets
consumers, because the registration this replaces was unconditional and
dropping it would stop the logs for anyone who commented the section out.

Two things fixed on the way past:

  - `if q := sdk.Runtime.GetQueueAdapter(); q != nil { q.Shutdown() }` was
    always true. GetQueueAdapter never returns nil - with nothing configured
    the runtime falls back to its own memory queue and wraps that - so the
    first start shut down the fallback queue before anything had used it. Only
    an adapter this package installed is shut down now.
  - config.Setup becomes bootstrap.SetupConfig, which is what announces
    AfterResource, and announces it after the callbacks that build the
    resources rather than before.

attachConsumersOnce is split out so the order and the once-ness can be checked
against a queue the test controls; neither can be read back out of a real
adapter. Four tests cover the ordering, both directions of the idempotency
rule, and the unconfigured case. Both counter-proofs compile and fail: calling
Run before the registrations reports each of the three as "came after Run", and
dropping the generation guard reports eight calls where four are wanted.

One honest limit: the counter-proof for the ordering makes Run synchronous.
The original arrangement started the queue on another goroutine, and a race
cannot be made to fail every time - which is the reason the order is enforced
by structure here instead of being left to be noticed in use.
2026-09-05 22:31:12 +08:00
zhangwenjian 750c7c744e feat: run the BeforeExit callbacks on the way out
A module can now register cleanup and have it happen. Until this commit the
process stopped serving and returned; anything a module had set up went down
with the process rather than being taken down.

BeginShutdown is said first, before anything is dismantled. Without it a
configuration reload arriving in this window re-runs AfterResource - rebuilding
the pool and the queue adapter and re-registering consumers - on top of cleanup
that has already run.

The cleanup runs whether or not Shutdown reported an error, which is the whole
reason that error stopped being fatal in the first place: Shutdown fails exactly
when connections were still in flight, and that is when there is most left to
take down.

The two budgets are spent one after the other, so what has to fit inside the
orchestrator's grace period is their sum. `docker stop` allows 10s by default
before SIGKILL; 5+3 leaves room to finish returning. Raising one without
lowering the other buys nothing.

Both halves are tested through the existing subprocess child, which now
registers a BeforeExit callback of its own:

  - after a Shutdown that timed out, the callback still runs. Moving the call
    into the success branch reports "the BeforeExit callback did not run after
    a failed Shutdown".
  - a callback that outlasts its budget is abandoned, not awaited. It sleeps
    two seconds against a 300ms budget; RunShutdown reports the deadline, the
    process exits cleanly inside one second, and the callback's own marker
    never appears. Widening the budget to five seconds makes the test time out
    waiting for the exit, which is what "awaited" looks like.

Both counter-proofs compile and fail.
2026-09-05 22:31:12 +08:00
zhangwenjian d52dca1cb6 feat: announce BeforeRouter and AfterListen, and bind before either
Two phases are now announced from the command that serves traffic, so a module
can attach to them instead of being called by name from here.

The listener is opened by this goroutine rather than left to ListenAndServe,
which binds on the goroutine that serves. That mattered for the phase: a hook
on AfterListen is promised a reachable port, and with the bind happening out of
sight there was no way to keep that promise - "address already in use" surfaced
on a goroutine nobody read, after the banner had already announced the server
was up. It is now returned from run() and the process exits non-zero without
claiming anything.

AfterListen is announced synchronously. Moving it to a goroutine to save the
few milliseconds would let it overlap the shutdown, and on a fast SIGTERM the
cleanup callbacks could finish before the startup ones did.

What is left in the serving goroutine is still log.Fatal, deliberately: the
bind is no longer among the errors that reach it, so what remains is a serve
that failed after the port was taken, and carrying on would park the process on
<-quit with nothing serving. ServeTLS is the one case that can still fail
immediately, since it reads the certificate files - with ssl enabled a hook can
still run against a server on its way down. That is not a regression (the old
code printed the banner in the same situation) and it is not fixed here.

BeforeRouter is placed before initRouter, which is a different moment from the
before registry runStartupHooks drains: those callbacks run after the engine
has been built, not before it.

AfterListen is tested here, in one test rather than two because the phase seals
itself once it has run: a second test would find a closed registry and pass
while proving nothing. Both counter-proofs compile and fail - announcing on a
failed bind reports "AfterListen ran 1 times after a failed bind", and
`go RunPhase(...)` reports "ran 0 times, want 1" against the hook's own pause.

BeforeRouter's placement is not asserted in this commit. The test for it comes
with the buildRouter extraction later in this branch.
2026-09-05 22:31:11 +08:00
zhangwenjian 71413a4248 build🔧: require go-admin-core v2.6.0
It carries the life-cycle phases and the shutdown registry. Nothing here uses
them yet - the wiring is the commits that follow, and keeping the bump on its
own means a bisect can tell "the dependency moved" apart from "the host
started calling into it".
2026-09-05 22:31:11 +08:00
wenjianzhang 4fede43254 Merge pull request #905 from go-admin-team/fix/checksilent-datascope-route
feat(checksilent): 抓「handler 读数据权限、路由却没挂中间件」
2026-09-05 21:56:08 +08:00
zhangwenjian 0a629e2f3f docs📝(checksilent): describe the two passes the code actually makes
The comment claimed bindings were collected as the body was walked so that a
registration only saw definitions above it. That is the single-pass design this
started as. The code does two passes - one to collect, one to report - and a
registration therefore sees every binding in the function.

That is the point rather than an accident: a `.Use` written below a route is
still part of the chain, because the chain is assembled before anything is
served. The price is that a name reused for two different things in one
function resolves to the last assignment, which the comment now says instead of
promising an ordering the code does not keep.
2026-09-05 21:51:51 +08:00
wenjianzhang 37065fb089 Merge pull request #904 from go-admin-team/fix/getinfo-data-permission
fix🐛: 开了数据权限就登不进去——/getinfo 拿不到它要的 DataPermission
2026-09-05 21:34:13 +08:00
zhangwenjian 6966f14dd4 feat(checksilent): report a route whose handler reads a data permission nobody supplies
GetPermissionFromContext cannot fail. When no middleware put a *DataPermission
in the context it returns the zero value, and the zero value's DataScope is the
empty string - which is not one of the five scopes Permission recognises, so it
takes the default branch and fails closed. The query is handed `1 = 0` and
matches nothing.

The endpoint then reports "not found" or "no permission" for rows that plainly
exist, and only where enabledp is true. With data permissions off - the
repository default - Permission returns the query untouched and the missing
middleware costs nothing at all. A test suite and a CI that run on the default
cannot see it.

That is what happened to /api/v1/getinfo: it read the permission on a group
carrying only the JWT middleware, so every login on a deployment with data
permissions enabled ended in a 401 from the endpoint the browser calls
immediately after signing in, and went back to the login page. Three /sys-api
routes had the same shape.

The check matches a handler to the group it is registered on, through the AST
rather than through the text - a scratch grep for the same thing reported four
false positives from a comment that happened to contain the function's name,
and before that, a dozen from matching handler names across packages. Handlers
are keyed by package, type and method, so two SysUser types are two handlers.
Subgroups inherit their parent's chain, as gin does, and a `.Use` written below
a registration still counts, because the chain is assembled before anything is
served.

Either half is a fix and the message says both, because which one is right
depends on the route. A handler reading other people's rows wants the
middleware. A handler reading the caller's own row - id from the token - wants
no scope at all: DataScopeSelf matches on create_by, so scoping a self-read
rejects every user who did not create their own account. Reporting only "add
the middleware" would have turned /getinfo from broken into worse.

Five tests: the mistake, both fixes, subgroup inheritance, and a same-named
handler in another package. TestThisRepositoryIsClean covers the real tree, and
it is what fails on the commit before this one - four findings, all real.
2026-09-05 21:31:45 +08:00
zhangwenjian 22716e90c1 fix🐛: /getinfo cannot be scoped by a data permission it never receives
Logging in on a deployment with enabledp: true ends on the login page. The
login itself succeeds - sys_login_log records it - and then /api/v1/getinfo
answers 401 "登录失败", which sends the browser straight back.

The query behind it reads:

  SELECT * FROM sys_user WHERE sys_user.user_id = 1 AND 1 = 0 AND deleted_at = 0

The 1 = 0 comes from the data-permission scope. GetInfo asked for a permission
with GetPermissionFromContext, but the group this route sits in installs only
the JWT middleware - no PermissionAction - so nothing ever put one in the
context and what came back was the zero value. An unset scope is not one of the
five recognised ones, and since unknown scopes began failing closed rather than
silently matching every row, that zero value now means "match nothing".

The route was working by accident before, and only on deployments that enable
data permissions: the repository default is enabledp: false, where Permission
returns the query untouched. That is why the local suite and CI are both green
and the demo site is not.

Two different faults, so two different fixes:

/getinfo reads the caller's own row - the id comes from the token. A data
scope answers "whose rows may this user see", so there is nothing left for it
to restrict, and applying one is not a stricter version of the query but a
broken one: DataScopeSelf matches on create_by, and an account is created by
whoever added it, so a scoped self-read would 401 every user who did not create
their own account. It now goes through GetSelf, which does no scoping at all -
which is how GetProfile has always read the same row.

/sys-api is the opposite case. Its three handlers do read the permission, and
they are listing and updating other people's rows, so the middleware belongs
there and was simply missing. Added.

Those four endpoints were found by checking every handler that reads the
permission against the group it is registered on. The check reports four before
this commit and none after.

No test. Both paths need a *gorm.DB with sys_user and sys_role rows before they
reach the line that matters, and this repository's CI has no database - `make
build` is CGO_ENABLED=0 with no sqlite tag. What can be tested is the shape of
the mistake rather than its effect, and that belongs in tools/checksilent as a
rule of its own; it is not in this commit because a site that cannot be logged
into should not wait for it.
2026-09-05 21:24:25 +08:00
wenjianzhang 73cce7fc2f Merge pull request #903 from go-admin-team/feat/005-sigterm
fix🐛: SIGTERM 从未被处理,优雅关闭在容器里是死代码
2026-09-05 18:00:58 +08:00
zhangwenjian b59c7f0d46 test: wait for the accept, not just the dial
Moving the dial to just before the shutdown removed one flake and introduced
another: Shutdown only waits for connections the server has already accepted,
so calling it in the gap between the dial and the accept finds nothing to
wait for and returns cleanly. The test then fails on its own "this proves
nothing" guard - which it did, after passing once.

A ConnState hook closes both gaps deterministically. The connection is opened
late enough not to age past the five seconds net/http stops counting it at,
and the child does not proceed until the server has taken it off the
listener.

Ran five times in a row rather than once, because a single green run is what
made the previous version look fixed.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 16:44:53 +08:00
zhangwenjian d3a44a2a6b test: dial the stalling connection after the signal, not at start-up
net/http stops counting a StateNew connection against Shutdown once it is
more than five seconds old. The connection was opened when the child started
and the parent then waited for readiness before signalling, so on a slow run
the connection could age past that mark and Shutdown would succeed - and the
test would fail on its own "this proves nothing" guard rather than on the
behaviour it is there to pin.

Opening it immediately before the shutdown keeps the timeout deterministic.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 16:42:47 +08:00
zhangwenjian 5c3c3907d5 fix🐛: arm the stop signals before announcing readiness
The previous commit split arming from waiting so a caller could arm first,
wrote a comment saying a signal landing in between reaches the default
handler and kills the process, used it that way in the subprocess test - and
then left run() calling the combined helper after the whole readiness banner.
The window it warned about was still there in the one place that ships.

The signals are now armed before the server starts serving, and the wait
happens where it did. The disposition is restored right after the first
signal rather than deferred, so a shutdown that hangs can still be
interrupted by a second one.

waitForStopSignal goes away: run() was its only caller, and what was worth
keeping from its comment is now on armStopSignals.

Note that no test covers this ordering. The subprocess test drives
armStopSignals directly, which is what makes it a test of the mechanism
rather than of run(); moving the call back below the banner leaves it green.
Verified by reading the sequence in run(), not by a failing test.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 16:42:44 +08:00
zhangwenjian f2215e132e fix🐛: handle SIGTERM, and stop exiting on a failed Shutdown
Three defects on one path, none of which could be seen from the code alone.

**SIGTERM was never registered.** signal.Notify listened for os.Interrupt
only, and Go terminates the process outright for a signal nothing handles.
`docker stop`, a Kubernetes pod deletion and `systemctl stop` all send
SIGTERM, so every line of the graceful shutdown below the wait was dead code
outside a terminal: measured on a real binary, SIGINT printed "Shutdown
Server ..." and "Server exiting" and SIGTERM printed neither.

**A stuck shutdown could not be interrupted.** quit is buffered and
signal.Notify stays armed after the first delivery, so further signals only
refill the buffer. That was harmless while SIGTERM went to the default
handler - it was the escape hatch. Registering it removes the hatch, so the
disposition is now restored once the first signal is taken, and a second one
kills the process the default way. Arming is split from waiting so a caller
can arm before it announces readiness; a signal in between reaches the
default handler, which is the very failure being fixed.

**A failed Shutdown skipped everything after it.** log.Fatal is an
unconditional os.Exit(1), and Shutdown reports an error precisely when
connections were still in flight - the moment the cleanup that follows
matters most. It is an error now, and the process carries on.

That failure is closer than it looks. net/http only treats a StateNew
connection as idle once it is over five seconds old, so a connection opened
shortly before the signal that has sent nothing holds the whole budget: with
the shipped settings.yml (readtimeout 1) the server closes it first and
shutdown takes 5ms, but with settings.demo.yml (readtimeout 10000) the same
connection made shutdown take 5.04s and exit 1, printing no "Server
exiting". The default configuration is what has been hiding this.

The wait and the shutdown are extracted so the subprocess tests can drive the
real functions against an empty http.Server: CI has no database, and none of
this needs one.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 15:28:15 +08:00
wenjianzhang a69afab34f Merge pull request #902 from go-admin-team/feat/006-contract-docs
docs📝: 契约文档指向 core,而不是宿主
2026-09-05 11:29:01 +08:00
zhangwenjian e0132db1b9 docs📝(checksilent): retire a comment that predates the lowering
The note explained the empty-shim summary as the expected state "until the
contract packages are lowered into core". They are lowered, and the shims
exist - so a count of zero now means they stopped being aliases, or stopped
being here, which is the interesting case rather than the ordinary one.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 11:23:31 +08:00
zhangwenjian 7c3f55a873 fix🔧(checksilent): suggest the fix with the qualifier the file uses
The contract-shim-alias message built its suggested line from path.Base of
the import path, so it told the author to write

    type ControlBy = models.ControlBy

in a file whose import is `contractmodels "…/sdk/contract/models"`. Every
shim in this repository aliases that import, so the suggestion never
compiled as written - in the one message whose whole job is to be pasted in.

qualifiedType already read the in-source identifier to resolve the import;
it now returns it.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 11:23:31 +08:00
zhangwenjian f7c0247394 fix🔧(checksilent): stop the seeded-value checks reporting their own tests
Widening menu-sort-overflow to see a contract MenuSpec made it fire on
app/admin/service/seed_test.go, on the case that asserts SeedMenus rejects
a sort of 900. The check was reading the proof that it works as a defect.

That is not specific to this one guard: menu-sort-overflow,
config-value-truncation, menu-id-collision and modeltime-mix are all about
a value that reaches a real database through a migration, a test fixture
reaches none, and every one of those guards needs a test that writes the
value it rejects. Skip _test.go in all four.

The two import and alias checks keep scanning tests - those are about the
dependency graph, where a test file's import is as real as any other, and
TestContractImportBoundaryCoversTestFiles already pins that.

Both directions are covered: a fixture is ignored, a real seed is still
reported.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 11:16:53 +08:00
wenjianzhang ac23556029 Merge pull request #900 from go-admin-team/feat/006-host-wiring
fix🐛: 契约注册面接上宿主的执行端
2026-09-05 11:14:01 +08:00
zhangwenjian f64115e03a docs📝: state what an off-convention migration file name does
The naming rule was documented; what happens when it is broken was not.
It now panics naming the offending file, which is worth saying out loud
because the alternative it replaced was silent: a name that is not a
timestamp used to register as its own version, and that migration would
never run and never report anything.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 11:14:01 +08:00
zhangwenjian a2524c31bf docs📝(contract): state the two menu seed rules a caller cannot infer
Sort has an upper bound: sys_menu.sort is built as a tinyint, sqlite ignores
the width, and an overflow surfaces as Error 1264 partway through a migration
rather than as a rejected value.

MenuSpec carries no menu name, and the host synthesises one from the app code
and the spec code rather than using Code directly - two applications both
choosing "list" would otherwise share a keep-alive cache key on the frontend.
Nothing in the type says so, and every Seeder implementer would have to
rediscover it.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 11:14:01 +08:00
zhangwenjian 71d6211c61 fix🔧(checksilent): see a menu written as a contract MenuSpec
The sort-overflow check recognised a SysMenu literal from this repository's
model packages and nothing else. An application installed from outside cannot
reach that type - it describes the same row as a seed.MenuSpec and hands it to
the host's Seeder - so the check went quiet for exactly the author furthest
from the schema it protects.

Not hypothetical: this repository's own reference application shipped a Sort
of 200, past the tinyint sys_menu.sort is built as, and this check passed it.
sqlite ignores the width, so it would have surfaced first on a real install,
as Error 1264 partway through a migration with everything after it unapplied.

The check still cannot see an application in the module cache; that half is
the Seeder's runtime validation. This closes the half that is in the tree.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 11:14:01 +08:00
zhangwenjian c67760bc39 docs📝(agents): match the contract rules the reference document now states
Two of the three bullets on the contract surface disagreed with
docs/contract.md and with core's own. Registration is constrained by
ordering - it must happen before the startup hooks run - not by being
written inside `init()`; and the claim that core's setters take no lock
is not true of them. The check table gains the new alias rule.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 11:14:01 +08:00
zhangwenjian d3e7f46a46 docs📝(contract): point applications at core, not at the host
The list of stable packages named four packages of this repository, on
the stated grounds that deduplicating app/demo's imports produces
exactly those four. That reasoning was wrong in the one direction that
matters: it sends a third-party author to depend on the host, and the
host is a fork that every user edits. `go-admin` is also not a
resolvable module path - it has no dot in its first element - so an
application cannot require it at all without a replace directive, which
is ignored outside the main module.

Rewritten around what core promises instead, and around a different
question: not "which packages does an application import" but "which
conventions fail without saying anything". Those are now spelled out
one by one, each with the mechanism that makes it silent - the response
envelope the frontend reads by `code`, the tenant-scoped connection,
`create_by` and the soft-delete marker, the data-scope middleware, the
transaction shape, and the `apps/` prefix a packaged application's menu
component must carry.

Also states two things the document was missing: installing an
application means trusting it with the host's database connection, at
the same level of trust as importing any other Go package - there is no
sandbox here and this does not pretend otherwise - and wiring an
application in touches two places, not one, where missing the second
means the migrations simply do not run, with no error.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 11:14:01 +08:00
zhangwenjian 55866682ae feat(checksilent): require a contract shim to be a type alias
A shim of a core contract type written as `type X pkg.Y` instead of
`type X = pkg.Y` keeps the fields and drops the method set, so anything
embedding it stops satisfying the interfaces it satisfied before.

The compiler catches that only where the method set is actually
exercised. This repository exercises some of the contract types through
an interface and some not at all, so the ones it does not exercise
compile here and break in a fork or a third-party application - which
is the half nobody is watching.

The trigger is the right-hand side of the declaration rather than a
list of package names, so it covers whatever the lowering ends up
shaping without a list to keep in step. Until the contract packages
land in core there is nothing here to guard, and the summary says so
rather than letting the silence read as a pass.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 11:14:01 +08:00
zhangwenjian 550e95ff43 docs📝: say which way sys_menu.visible points
The comment called Visible "0" "hidden by default" and then said an
administrator should not have to unhide the menu - which cannot both be
true. "0" is shown; every menu this repository seeds, including the demo
product menu that is visible on the demo site, uses it.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 11:08:02 +08:00
zhangwenjian 060b6cfd64 fix🐛: grant an application's apis even when it registers no menus
grantToAdminRole does two independent things - it grants the menus to the
admin role and writes a casbin rule per api - and SeedMenus skipped the
whole call whenever the menu list came back empty.

An application is free to register apis with no menus: endpoints another
service calls, a webhook, a UI mounted somewhere else. Those installs wrote
their sys_api rows and then no casbin rule for any of them, so every one of
those endpoints was denied to everyone, admin included - from a migration
that reported success and left rows in the table to prove it had run. There
is nothing to look at afterwards that says what went wrong.

Guard on both lists instead, so nothing registered stays a no-op and apis
alone still get granted.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 11:08:02 +08:00
wenjianzhang 89a4738394 Merge pull request #901 from go-admin-team/feat/006-example
feat: example/app-order —— 只依赖 core 的参照应用
2026-09-05 10:58:40 +08:00
zhangwenjian d8529289cf fix🐛: fold the host's GetFilename into the contract's
The host kept its own copy of the version-naming rule, byte-identical to
the one in contract/migration: slice the leading 13 characters, no check.
Two copies of a convention that applications also have to follow is two
things to keep in step, and the copies had already stopped matching - core
now rejects a name that carries no timestamp, and this one still accepted
"add_orders.go" and registered a migration under that string as its
version, which nothing would ever match and nothing would report.

Delegate instead, so there is one implementation of the rule and an app's
migration and a host migration derive their version the same way.

The test pins the reject case, not just the happy path: a re-divergence
that only sliced would still pass the happy path.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:58:39 +08:00
zhangwenjian 4a8f97b1ee feat: implement the menu seeder an application registers against
core's seed package defines what an application may ask for and leaves the
writing to the host, which is the only side that knows its own tables. No
host implemented it, so SeedMenus returned ErrNoSeeder and an application's
menus never appeared.

adminSeeder writes all four kinds of row, not the two an obvious reading
would stop at: without sys_menu_api_rule and the sys_role_menu / casbin_rule
grants, the menu exists and no role can reach it.

Ids are always autoincrement, never caller-assigned - checksilent's
menu-id-collision check reads literals in this repository's tree and cannot
see an application in the module cache, so the collision is removed by
construction instead of guarded. The runtime validation covers what a static
scan cannot reach for a third-party spec: duplicate codes, unresolved parents
and api references, an unknown kind, and a sort outside sys_menu.sort's
tinyint range.

MenuSpec carries no menu name, so one is synthesised from the app code and
the spec code - two applications both choosing "list" would otherwise collide
on the frontend's keep-alive key.

It lives in app/admin/service because cmd links both subcommands into one
binary, so its init runs whichever one is invoked, and cmd/migrate never has
to import app/admin to reach it.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:58:39 +08:00
zhangwenjian d54ac844ef feat: record which application a menu row and an api row came from
sys_migration already carries app_code; sys_menu and sys_api did not, so
nothing said which application seeded a row - which is what an uninstall or
an audit would have to ask.

The migration adds the columns through the runtime models rather than
cmd/migrate/migration/models, whose frozen ModelTime is wrong for anything
ordered after the soft-delete conversion.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:58:39 +08:00
zhangwenjian 379fba515f fix🐛: run the migrations a third-party application registers
core's sdk/contract/migration keeps its own process-wide registry, because
that is the only door open to an application that must not import the host.
Nothing here ever opened it: ForApp("crm").SetVersion(...) compiled,
registered, and then never ran - no error, no mention in status, nothing.

mergedEntries unions the host's own registry with contract/migration's
Snapshot(), and status, run and AppCodes all read through it, so migrate,
status, --dry-run and --app see an application's migrations exactly as they
see the host's. Version namespacing already keeps the two apart, so a key
collision should not be reachable; the host's own registration wins if one
ever is, rather than being silently replaced.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:58:39 +08:00
zhangwenjian 58105cb478 docs📝(example): drop a stale ordering claim from the router test
The comment said the test had to be declared first because Go runs a
package's tests in source order. That is not a guarantee, and it is not what
makes this work: the test that registers RoleCheck puts it back in a
t.Cleanup, and the guard here turns a wrong order into a loud failure rather
than a silent pass. Verified with go test -shuffle on seeds that run the two
in either order.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:53:48 +08:00
zhangwenjian 47af6f4306 fix🐛(example): make the swagger annotations resolve
The Create handler's @Param named dto.OrderCreateReq, but this file imports
that package as orderdto. swag stops on it:

    ParseComment error ... cannot find type definition: dto.OrderCreateReq

The @Success annotations name models.Response, which resolves - through
--parseDependency - to core's sdk/contract/models.Response rather than to
this package. That is the right envelope, and worth a note next to the
import, because the obvious "correction" is wrong: core's response.Response,
which the framework's own handlers name, carries no data field, so switching
to it would document these endpoints as returning no payload.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:53:45 +08:00
wenjianzhang 7d29c9953a Merge pull request #899 from go-admin-team/feat/006-jwt-hoist
fix🐛: JWT 中间件注册成了取不出来的形状
2026-09-05 10:31:44 +08:00
zhangwenjian 0729624c2f fix🐛(example): bring the directory menu's sort inside a tinyint
sys_menu.sort is `gorm:"size:4"`, which MySQL builds as a tinyint holding
-128..127. Sort: 200 passes every sqlite test - sqlite ignores the width -
and fails on a real install with Error 1264, partway through a migration.

This is the exact incident class checksilent's menu-sort-overflow check
exists to prevent, and it reached a hand-written deliverable anyway: that
check only recognises a SysMenu literal from the host's model packages, so
a seed.MenuSpec is invisible to it. Widening the check is tracked
separately; this is the value it would have caught.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:27:42 +08:00
zhangwenjian b3a740ab2a feat(example): register the order routes, migration and menu seed
Registration goes through core's package-level facades: SetAppRouters for
the routes, migration.ForApp for the schema, and seed.MenuSpec/ApiSpec
for the menu rows - none of which requires importing the host.

The menu component is spelled apps/order/order/index. The frontend tells
a packaged view from a built-in one by that first segment alone, and
getting it wrong is silent: the page falls back to the not-installed
placeholder while the console names a src/views path that was never going
to exist. The tests assert that prefix, that every Parent reference
closes, and that every ApiCode resolves - the three ways a menu graph is
wrong without anything saying so.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:27:41 +08:00
zhangwenjian adf617f5d0 feat(example): hand-write the order service and api layers
No generic CRUD action anywhere: real business - a cross-table order
placement, a payment transition - is what the contract surface has to
carry, and the actions cover only the single-table case that a real
application outgrows immediately.

The transaction is Orm.Transaction(), not the Begin/defer shape that
app/admin/service/sys_role.go and three other files use. That shape
commits a half-written transaction when the body panics, because the
deferred check reads err, which a panic leaves nil.

The payment transition guards concurrency through the update itself -
WHERE status = 'pending' plus RowsAffected - rather than a read followed
by a write.

The tests cover both rollback paths, because they fail differently: a
mid-transaction error returns, a panic unwinds - and the second is what
tells Orm.Transaction() apart from the shape it replaces. The concurrency
test pins the pool to one writer so sqlite's own single-writer semantics
cannot stand in for the guard being tested. The data-scope tests assert
the fail-closed direction too: an unrecognised scope must return no rows
rather than every row.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:27:41 +08:00
zhangwenjian 049a20cd04 feat(example): add the order example's models
A reference application for a third-party author: its own module, and a
require list that names go-admin-core and nothing else. The point of the
example is that constraint - an application that reaches for the host
cannot be installed through a module proxy at all, because `go-admin` has
no dot in its first path element and a replace directive is ignored
outside the main module.

Two tables rather than one, because a single-table example proves only
what the generic CRUD actions already proved. The interesting question is
whether the contract surface holds up for business that spans tables.

The table names carry an app_ prefix: "order" is a reserved word, and
Permission() interpolates the table name into raw SQL without quoting.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:27:41 +08:00
zhangwenjian be3c4452e3 test: pin the jwt handler being retrievable and shared
Two properties the previous shape broke silently: GetHandlerFunc must
report ok for the JwtToken key, and every module must read back the same
instance. Reverting the registration to the unbound method expression
still compiles and turns the first of these red, which is the failure
this pins.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:26:54 +08:00
zhangwenjian 5b01c9ada8 fix🐛: build the shared jwt middleware instance once in InitMiddleware
Four modules each called AuthInit and built their own instance, so which
one Runtime handed back was decided by whichever module initialised last.
The JwtToken key was also registered as an unbound method expression,
which GetHandlerFunc's type assertion can never match - the key was
registered and unusable at the same time.

The instance is now built once here and registered as a bound closure.
Modules read it back through GetAuthMiddleware, which is fatal rather
than nil when called before InitMiddleware has run: a process without a
JWT middleware should not reach the point of serving a request.

Only one call site needs the instance itself rather than the handler
(admin's /login, for LoginHandler); the thirty-odd MiddlewareFunc() call
sites are unchanged.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:26:54 +08:00
wenjianzhang ffd82a6a10 Merge pull request #898 from go-admin-team/feat/006-shim
refactor🎨: 契约包改为 core 的薄壳
2026-09-05 10:26:52 +08:00
zhangwenjian dd8d89a990 test: make the index probe return a copy, like every real dto.Index does
IndexAction closes over one dto.Index and serves every request to the route
from it; Generate exists so each request gets its own instance, and every
implementation in this repository returns a copy for that reason. The probe
returned the receiver, which made it the one shape IndexAction is not
written against - and inconsistent with probeRow in the same file, which
already copied.

A single-request test cannot tell the two apart, so the assertion is on
Generate itself rather than on the action's behaviour.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:22:12 +08:00
zhangwenjian 2e5b23565e test: cover data permission through a real CRUD action
create.go/delete.go/index.go/update.go/view.go were not lowered to
core (PRD 006 F3) and still call actions.Permission directly, in this
repository, on a code path core's own test suite knows nothing about:
core pins down what Permission builds for a given scope, but nothing
covered whether this package's five Actions still remember to call it
at all. TestIndexActionAppliesDataPermission runs IndexAction exactly
as a real request would, against a real in-memory database, and
inspects the SQL GORM actually executed - not just that the handler
returned success, which it would just as happily do with the filter
missing entirely.

The SQL is captured through a gorm.io/gorm/logger.Interface wrapper
rather than read back from IndexAction's own *gorm.DB: IndexAction
builds and executes its query in one unbroken chain
(Model().Scopes().Find()...Count()) and never hands the built
statement back to its caller, so there is nothing else to inspect it
through.

Counterproof performed and reverted (not part of this commit): with
Permission(object.TableName(), p) removed from IndexAction's Scopes
call, the test failed with the captured SQL carrying no WHERE clause
at all (`SELECT * FROM action_probe_row LIMIT 10`); index.go was then
restored to its committed content (`git diff --exit-code` verified
clean).

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:15:58 +08:00
zhangwenjian f4e3f04d30 refactor🎨: turn common/actions' data permission into a thin forward
DataPermission, PermissionAction, Permission, GetPermissionFromContext,
IsValidDataScope, PermissionKey and the five DataScope* constants now
forward to go-admin-core's sdk/contract/actions, which carries the
already-fixed logic from feat/006-security-prereq (PRD 006 F14/H1-H3).
create.go/delete.go/index.go/update.go/view.go - the five generic CRUD
actions - are untouched: they call Permission and
GetPermissionFromContext by the same names, which now resolve to
forwards with identical behaviour, and stay in this package rather
than moving to core (PRD 006 F3: core's exports are a permanent
promise every fork inherits, and CRUD shape is this framework's most
volatile surface).

PermissionKey is declared as `const PermissionKey =
contractactions.PermissionKey`, a direct reference rather than a
restated literal, per PRD 006's hard constraint 4: PermissionAction
sets this gin context key and GetPermissionFromContext reads it back,
and an independently declared copy could silently drift from core's if
one were ever edited without the other. permission_test.go replaces
the detailed data-permission regression suite - which now lives in
core, next to the logic itself - with a test of this package's own
wiring: that PermissionAction and both of this package's own read
paths (GetPermissionFromContext, and c.Get(actions.PermissionKey)
directly) still meet on the same key.

Counterproof performed and reverted (not part of this commit): with
PermissionKey redeclared here as the literal "dataPermission" and
core's copy changed to a different value, TestPermissionKeyMatches-
WhatPermissionActionSets went red while GetPermissionFromContext's own
round-trip stayed green - confirming the exported constant, not the
GetPermissionFromContext wrapper, is what an independent literal would
put at risk.

PRD 006 F3/F5.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:15:58 +08:00
zhangwenjian 954ebdc9eb refactor🎨: turn common/dto into a thin alias of go-admin-core
AutoForm, ObjectById/ObjectGetReq/ObjectDeleteReq, Pagination,
GeneralDelDto/GeneralGetDto and Index/Control are now type aliases of
go-admin-core's sdk/contract/dto; OrderDest, MakeCondition and
Paginate forward to the same package (functions cannot be aliased the
way types can).

MakeCondition no longer reads common/global.Driver to choose which SQL
dialect to resolve search tags against. The lowered version reads
db.Dialector.Name() from inside the closure it returns instead, which
is always the driver the caller's own *gorm.DB is bound to - correct
even with more than one database open with different drivers, which a
single process-wide variable could never be. global.Driver is marked
Deprecated accordingly; it is still set and still readable for fork
code that reads it directly.

PRD 006 F2/F5.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:15:58 +08:00
zhangwenjian 2840010dfd refactor🎨: turn common/models into a thin alias of go-admin-core
ControlBy, Model, ModelTime, ActiveRecord, BaseUser, Response, Page,
Migration and the menu type constants now read `type X = pkg.X` /
`const X = pkg.X` against go-admin-core's sdk/contract/models instead
of defining these shapes locally. Every embed, GORM tag and JSON tag
is unchanged - a type alias is the same type, not a new one - and
every existing import of go-admin/common/models keeps compiling with
no changes of its own (verified with `git diff --exit-code` over the
70 files that import common/models, common/dto or common/actions).

The menu type constants (Directory/Menu/Button) are declared as direct
references rather than restated literals: an independently written
copy of the same value can be edited out of step with go-admin-core's,
where a direct reference cannot (PRD 006 hard constraint 4).

PRD 006 F1/F5.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:15:51 +08:00
zhangwenjian b147d9b833 build🔧(deps): require go-admin-core v2.5.0
v2.5.0 carries the sdk/contract packages the commits that follow alias
common/models, common/dto and common/actions onto.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:15:33 +08:00
wenjianzhang b3ecb81614 Merge pull request #896 from go-admin-team/fix/sys-user-privesc
fix🐛: 修复 sys-user 更新接口的垂直越权
2026-09-05 00:59:59 +08:00
wenjianzhang ce4581bb99 Merge pull request #897 from go-admin-team/feat/006-security-prereq
fix🐛: 数据权限的三处静默失效
2026-09-05 00:59:12 +08:00
zhangwenjian f406ca0160 test: fail loudly instead of skipping when the sqlite setup breaks
The privilege-escalation tests skipped themselves when opening the in-memory
database or running AutoMigrate failed. Both depend on nothing outside the
process, so a failure there means the environment is genuinely broken - and a
security regression that quietly does not run is worse than one that is
missing, because CI stays green either way.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 19:10:53 +08:00
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
zhangwenjian 4d6456a588 test: cover vertical privilege escalation on sys-user update
Two directions, because the fix has to hold both: an attacker with no policy on
this route cannot raise another user's role, and a self-edit cannot raise its
own. The second one is what keeps the fix from being "just remove the route
from CasbinExclude", which would break the profile page.

The tests drive the handler directly rather than through the router, because
the middleware is exactly what does not run for this route - the defence lives
in the handler, so that is where it has to be proven.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:23:07 +08:00
zhangwenjian 07ff92aa55 fix🐛: lock privileged fields on self-edit
The profile page posts the whole user object back, including roleId, deptId and
status, because it renders from a full SysUser it fetched earlier. A caller
editing their own record can therefore hand back a tampered roleId.

Self-edits now reload those three fields from the database and ignore whatever
the request carried. For an honest client this is a no-op - the values it sends
are already its own - so the profile page keeps working unchanged.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:23:07 +08:00
zhangwenjian 4156387eb9 fix🐛: enforce Casbin when editing another user
PUT /api/v1/sys-user sits in CasbinExclude so the profile page can reach it,
which means AuthCheckRole never runs for this route. The handler took the
target user id from the request body, so any authenticated caller could edit
another user's record - including their roleId.

The route has to stay excluded: the profile page and the admin user list share
this one endpoint, so removing the exclusion would break self-service editing
for every non-admin role. The check therefore moves into the handler: when the
target is not the caller, the request is put through Casbin explicitly.

EnforceRoleFor carries the same admin short-circuit and enforcement AuthCheckRole
uses, so a route that opts out of the middleware can still ask the same question.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-04 17:23:01 +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
wenjianzhang 76c9d1211e Merge pull request #880 from go-admin-team/fix/dsn-in-logs
ci🔧: migrate before deploying, roll back on failure — and stop logging the database password
2026-08-27 17:31:52 +08:00
zhangwenjian f5273f5a58 ci🔧: migrate before deploying, and roll back when the new version does not come up
Closes #871.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  go run main.go app -n demo

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tidy 与 Build 不受影响,PR 仍会执行编译校验。
2026-08-12 12:27:33 +08:00
zhangwenjian 92834d6e39 publish🚀: 版本号更新至 2.3.0 2026-08-12 00:22:31 +08:00
zhangwenjian f06540883b fix🐛: 修复 Docker 镜像发布的 tag 条件失效问题
if 表达式中不应使用 ${{ }} 包裹:startsWith(${{github.ref}}, 'refs/tags/')
会先将 github.ref 替换为裸字符串再参与表达式求值,导致条件判断失效,
使得每次 push 到 master 都会构建并推送镜像至 ghcr.io,而非仅在打 tag 时发布。

同时 on.push 缺少 tags 配置,打 tag 实际不会触发该工作流。

修正后:push 分支仅执行 Go 构建,打 tag 才发布镜像。
2026-08-11 11:09:39 +08:00
zhangwenjian 3c9ce5b6b0 chore🔧: 升级 x/image 修复 TIFF 解码漏洞 2026-08-10 22:17:28 +08:00
zhangwenjian ff8a59550a fix🐛: 修复镜像同步因浅克隆被拒绝的问题 2026-08-10 21:21:01 +08:00
zhangwenjian 7cddef33a2 git🙈: 将 go.sum 纳入版本控制 2026-08-10 20:51:00 +08:00
zhangwenjian 7013c2fa4a chore🔧: 移除依赖已封禁 action 的 issue 自动化流程 2026-08-10 20:51:00 +08:00
zhangwenjian 65bacacb38 docs📝: 更新 README 环境要求版本说明 2026-08-10 20:45:39 +08:00
zhangwenjian 45587028c3 config🔧: CI 升级 Go 版本并更新 Actions 至最新 2026-08-10 20:45:39 +08:00
zhangwenjian 887c9cca4b chore🔧: 升级 Go 至 1.26.5 并同步升级依赖 2026-08-10 20:45:36 +08:00
wenjianzhang a6ddb113fc Update LICENSE.md 2026-08-08 13:55:08 +08:00
wenjianzhang b83eef8670 Fix image source in README.md
Updated image source in README.md for go-admin.
2026-05-22 11:20:39 +08:00
zhangwenjian 43dcd61c51 config🔧: update go-version to 1.24 in build workflow
go.mod requires go 1.24, go mod tidy fails when runner uses 1.18.
2026-05-15 17:52:12 +08:00
zhangwenjian 1bd64d4562 config🔧: pin all GitHub Actions to full-length commit SHAs
Replace version tags (@v1/@v2/@v3/@master) with pinned commit SHAs
across all workflow files to satisfy go-admin-team organization
security policy requiring immutable action references.
2026-05-15 17:48:41 +08:00
zhangwenjian 44e81bc72f git🙈: 补充忽略本地开发配置文件
- 新增忽略 config/settings.local.dev.yml
2026-05-15 17:37:42 +08:00
zhangwenjian 3312f8b7b9 chore🔧: 升级依赖 mergo 模块路径
- 替换 github.com/imdario/mergo 为上游迁移后的 dario.cat/mergo v1.0.1
2026-05-15 17:37:42 +08:00
zhangwenjian d6a2272f9d git🙈: 完善 .gitignore 忽略规则
- 新增忽略编译产物 go-admin-server
- 新增忽略本地工具配置目录
2026-05-15 17:37:42 +08:00
wenjianzhang a5cc0a9e29 Add read and write timeout to HTTP server 2025-09-10 09:39:54 +08:00
wenjianzhang 3f995735e9 Merge pull request #834 from hosea3000/edit-no-confirm
点击编辑的时候不需要弹框确认,交互不太友好
2025-05-20 11:41:02 +08:00
wenjianzhang b65b74dee5 Merge pull request #832 from hosea3000/fix-number-input
fix🐛: 修复自动生成代码时选择字段类型为int64, 前端提交还是string 导致报错的问题
2025-05-20 11:40:21 +08:00
Hosea 98cf3ad95a fix🐛: 点击编辑的时候不需要弹框确认,交互不友好 2025-05-20 10:57:36 +08:00
Hosea 8649d8d791 fix🐛: 修复自动生成代码时选择字段类型为int64, 前端提交还是string 导致报错的问题 2025-05-13 15:48:45 +08:00
wenjianzhang 817e34c6aa refactor🎨: 重构文件上传逻辑,拆分处理函数以提高可读性和维护性 2025-04-13 22:20:06 +08:00
wenjianzhang 952cd92648 refactor🎨: 清理 sys_server_monitor.go 文件,移除未使用的导入并格式化代码 2025-04-13 22:17:17 +08:00
wenjianzhang 6b1e961a7f refactor🎨: 重构系统监控代码,拆分功能为多个函数以提高可读性和维护性 2025-04-13 22:15:10 +08:00
wenjianzhang 762eba5af7 refactor🎨: 重构 Setup 函数,拆分为多个子函数以提高可读性和维护性 2025-04-13 22:04:12 +08:00
wenjianzhang e82128f679 refactor🎨: 优化获取客户端 IP 的逻辑,增加对 X-Forwarded-For 和 X-Real-IP 的处理 2025-04-13 22:00:04 +08:00
wenjianzhang 8f8a197db1 delete🎉: 移除示例代码 run.go 2025-04-08 20:49:36 +08:00
wenjianzhang 364854eda0 docs📝: 更新 go-admin 版本号至 2.2.0 2025-04-08 20:49:29 +08:00
wenjianzhang b259e91f4d Merge remote-tracking branch 'origin/master'
# Conflicts:
#	go.mod
2025-04-08 20:45:25 +08:00
wenjianzhang 76411f80bc refactor🎨: 优化日志记录方式,统一使用 log.Info 替代 log.Println 2025-04-08 20:30:35 +08:00
wenjianzhang 5494353229 fix🐛: 修复获取本地主机IP的函数调用错误 2025-04-08 20:30:24 +08:00
wenjianzhang afe5efbe36 refactor🎨: remove unused distributed lock setup code in initialize.go 2025-04-08 20:30:05 +08:00
wenjianzhang db422785fc fix🐛: include captcha answer in GenerateCaptchaHandler for improved logging 2025-04-08 20:29:38 +08:00
wenjianzhang 4ac68323da fix: improve error logging in jobbase.go for better clarity 2025-04-08 20:23:23 +08:00
wenjianzhang 44002fcb11 chore: update dependencies in go.mod to latest versions 2025-04-08 20:22:59 +08:00
wenjianzhang 5bbd919745 chore: update dependencies in go.mod to latest versions 2025-03-25 17:16:40 +08:00
wenjianzhang 54dd3de5b6 chore: update dependencies in go.mod to latest versions 2025-03-25 16:48:47 +08:00
wenjianzhang 9540fdfc30 refactor: remove unused GetMenuIDS function and clean up code 2025-03-25 16:44:31 +08:00
wenjianzhang 937775e2a7 chore: update Go version from 1.21 to 1.24 in build configuration 2025-03-25 08:53:39 +08:00
wenjianzhang 84721265dd fix: simplify error handling in GenerateCaptchaHandler 2025-03-24 22:41:02 +08:00
wenjianzhang 3ab67dfa7d chore: update Go version from 1.21 to 1.24 2025-03-24 20:53:09 +08:00
wenjianzhang 6a1941a820 Merge pull request #814 from keemozhang/master
fix🐛: declaration of new local variable causes transactions to be ign…
2025-03-21 15:36:16 +08:00
wenjianzhang 3ae7c44585 Merge pull request #816 from Tiper-In-Github/patch-1
Fix:err is never used
2025-03-21 15:35:23 +08:00
wenjianzhang 9d809f6392 Merge pull request #821 from pigwantacat/master
fix:修复定时任务的日志打印
2024-12-18 00:11:54 +08:00
pigwantacat 4b477b3103 fix:修复定时任务的日志打印 2024-11-01 14:14:30 +08:00
wenjianzhang e7ae2fe019 更新 go_admin.go 2024-10-30 22:12:17 +08:00
wenjianzhang d5ba3d9770 更新 READMEN.md 2024-10-30 22:10:06 +08:00
Akiraka f3d744f6f5 修复获取getinfo时候,userName 事件结果为 nickName 问题 2024-10-24 09:30:15 +08:00
无别 0315631b53 Fix:err is never used
Fix the problem that err is overwritten and becomes invalid
2024-09-29 15:32:36 +08:00
wenjianzhang 48e7ce88ff perf👌: rollback base64Captcha 2024-09-09 15:46:05 +08:00
wenjianzhang 357db6b1c9 Merge remote-tracking branch 'origin/master' 2024-09-08 22:04:20 +08:00
wenjianzhang 83e0531f43 perf👌: format 2024-09-08 22:04:08 +08:00
wenjianzhang 898ba7d8eb Update README.Zh-cn.md 2024-09-06 23:11:48 +08:00
wenjianzhang 8751f34539 perf👌: correct attribute definition 2024-09-05 18:33:11 +08:00
wenjianzhang 4aa0068d2d perf👌: update SysDept Get First to FirstOrInit 2024-09-05 18:29:50 +08:00
keemozhang b954a2f092 fix🐛: declaration of new local variable causes transactions to be ignored 2024-09-05 15:46:20 +08:00
wenjianzhang 9227bd2be1 perf👌: format code 2024-09-04 20:25:02 +08:00
wenjianzhang e70a0b1314 perf👌: update SysConfig Get First to FirstOrInit 2024-09-04 20:22:49 +08:00
wenjianzhang 21c262a31e perf👌: update build file 2024-09-03 22:17:11 +08:00
wenjianzhang f30889bd19 perf👌: update SysApi Get Func First to FirstOrInit 2024-09-03 21:22:09 +08:00
wenjianzhang 23e519999e perf👌: update go mod 2024-08-30 16:00:29 +08:00
wenjianzhang bedf064ace Merge pull request #802 from zhanluxianshen/drop-base-model
replace basemodel by common.model
2024-08-29 16:26:22 +08:00
wenjianzhang 9a8e0cddde Merge pull request #803 from zhanluxianshen/clean-err-use-in-method
clean err define in methods.
2024-08-29 16:23:54 +08:00
wenjianzhang c0c16036d3 Merge pull request #811 from wangle201210/fix/logger
fix🐛: reset default logger fields
2024-08-29 16:17:18 +08:00
wanna dd905a2bed fix🐛: reset default logger fields 2024-08-23 16:49:21 +08:00
zhanluxianshen 2d76430f89 clean err define in methods.
Signed-off-by: zhanluxianshen <zhanluxianshen@163.com>
2024-07-10 15:03:33 +08:00
zhanluxianshen 5dde1d2a00 replace basemodel by common.model
Signed-off-by: zhanluxianshen <zhanluxianshen@163.com>
2024-07-10 11:26:52 +08:00
lwnmengjing 93f25c6cdf Add mss-boot-io link 2023-11-07 23:28:52 +08:00
wenjianzhang d366df372d feat: Log file size control and retention days control 2023-11-03 18:59:20 +08:00
wenjianzhang 7281d05efc fix🐛: Fixed system startup Network output problem 2023-11-03 17:42:01 +08:00
wenjianzhang e6d6a65267 Merge remote-tracking branch 'origin/master' 2023-11-03 17:36:21 +08:00
wenjianzhang 9ff094b6f5 fix🐛: Fixed data migration issue during multi-tenant configuration 2023-11-03 17:36:04 +08:00
wenjianzhang fc9c253a9f tag📌: Upgrade go1.21 2023-11-03 17:35:01 +08:00
wenjianzhang 9d735ed5aa docs📝: Update README.md 2023-11-02 17:42:40 +08:00
wenjianzhang d782b00117 tag📌: Change version 2023-11-02 17:10:52 +08:00
wenjianzhang 239159dd2a fix🐛: Fix the problem that el-popconfirm does not take effect 2023-11-02 17:08:58 +08:00
wenjianzhang 0c1e91c3b5 Add files via upload 2023-10-11 21:25:01 -05:00
wenjianzhang c09347b387 Merge pull request #768 from majiayu000/fix-pgerror
[BugFix] 修复一个``引发的bug
2023-10-11 21:18:42 -05:00
lif e49e47c7a1 Delete go.mod 2023-09-22 14:02:38 +08:00
wenjianzhang 98b46535aa Merge pull request #767 from zgxme/fix-gen-0909
[fix](gen) ignore default time type columns in table
2023-09-21 22:00:57 +08:00
lif 014a23aac3 [BugFix] Fix pgsql error with 2023-09-14 16:35:41 +08:00
zgxme f1dfba79e0 [fix](gen) ignore default time type columns in table 2023-09-09 22:59:45 +08:00
wenjianzhang a282e44b1d Merge pull request #753 from Vingurzhou/master
-installsuffix 参数没有指定后缀字符串。它被设定为空,这可能导致一些问题
2023-08-02 09:24:48 +08:00
wenjianzhang d1279e67fb Merge pull request #757 from NipGeihou/master
fix: 修复go generate命令不更新Swagger文档问题
2023-08-02 09:22:25 +08:00
NipGeihou 73118e49b9 fix: 修复go generate命令不更新Swagger文档问题
修复go generate不更新Swagger文档问题,并更新生成后文档文件
2023-06-20 00:37:59 +08:00
Vingurzhou 7b43982595 Update Makefile
fix(makefile): -installsuffix 参数没有指定后缀字符串。它被设定为空,这可能导致一些问题
2023-06-10 15:57:18 +08:00
257 changed files with 21155 additions and 1985 deletions
+100
View File
@@ -0,0 +1,100 @@
---
name: new-business-module
description: Scaffold a new single-table CRUD business module end to end — migration, Actions-mode model/dto/router, and the sys_menu/sys_api/casbin seed data that makes it show up in the UI with working permissions. Use when the user wants to add a new business table/module to go-admin, not for cross-table or non-CRUD business logic.
---
# 新增业务模块
给一张新的业务表配齐"能跑、能看见、能授权"的完整闭环:迁移 → 后端代码 → 菜单与权限种子数据。
只适用于单表增删改查;跨表事务、外部调用、复杂校验等超出这个范围(见下方"何时不适用")。
开始前先读 `AGENTS.md`(分层边界、通用 Action 使用前提、命名规则)和 `app/demo/` 下的全部文件——
这是可编译、有测试、CI 会跑的参照物,本文与它冲突时以它为准。
## 何时不适用
业务超出单表 CRUD(跨表事务、外部服务调用、复杂校验)时,不要用这个 skill 硬套——
改成手写 Handler + Service,参照 `app/admin/apis/sys_post.go` 及其 Service,遵守
`AGENTS.md` 的分层约束(Api 不碰 OrmService 不碰 `gin.Context`,一律用 `e.Orm`)。
## 步骤
### 1. 确认表结构
表结构需符合命名规范:`sys_`/业务前缀 + 下划线(如 `tb_article`)。核对字段是否已有
`created_at`/`updated_at`/`deleted_at` 这类约定字段。
### 2. 写数据库迁移
放在 `cmd/migrate/migration/version/` 目录(**不是** `version-local/` —— 后者在
`.gitignore` 中,提交时会被忽略,`git status` 也看不到)。
- 文件名前 13 位是时间戳版本号
- 已执行过的迁移文件不可修改;需要修正时新增一个迁移
- 包名为 `version`
### 3. 生成 model / dto / router 三个文件(Actions 模式)
不要手写 Api 与 Service。使用 `common/actions` 的通用 Action,一个模块只需
model、dto、router 三个文件,完整写法照抄 `app/demo/` 的结构。
**关键正确性要求**(这三条是实际出问题最多的地方):
- Model 实现 `models.ActiveRecord``Generate` / `GetId` / `TableName`),
`TableName()` 必须显式声明——GORM 配置了 `SingularTable`,不会自动推导
- **`Generate()` 必须返回副本,不要就地返回**——Action 在并发请求间复用实例,
就地返回会导致请求之间串数据;这个问题单人测试时几乎不出现,上线后才暴露
- 完成后确认 `cmd/api/` 中已用 `_` 导入新包,否则路由不会被注册
### 4. 写菜单、接口与权限种子数据
这一步最容易被漏掉——代码能编译、接口能测通,但界面上看不到菜单、点了按钮说
没权限,往往就是漏了这一步。结构参照 `cmd/migrate/migration/version/1786700001000_demo_menu.go`
——它是可运行、幂等(用 `upsert`,重复跑不会报错)的真实例子。
:::danger
**但不要照抄它的 import。** 那个文件用的是 `cmd/migrate/migration/models`
只因为它的版本号排在软删除转换(`1786700003000`)之前才是安全的。
**你新写的迁移版本号在转换之后,必须改用 `app/` 下的运行时模型**
`app/admin/models.SysApi``SysMenu`),否则第一条 insert 就会
`NOT NULL constraint failed: sys_api.deleted_at`
`TestPostConversionMigrationsAvoidFrozenSeedModels` 会拦住这个错误。
:::
一个模块要在界面上可用,需要四类数据,缺一样都不行:
| 表 | 作用 |
|---|---|
| `sys_api` | 后端路由登记,Casbin 据此判定权限 |
| `sys_menu` | 侧边栏菜单(目录用 `M`、菜单用 `C`、按钮用 `F` |
| `sys_menu_api_rule` | 菜单与接口的多对多关联,角色保存时据此生成策略 |
| `casbin_rule` | 实际生效的权限策略(**不是** `sys_casbin_rule`,那张表的唯一索引在 MySQL 下会超长,不要迁移它) |
必须核对的两处一致性——**错了不会报错,只会在界面上表现为"看不到/点不动"**
- `sys_menu.menu_name` 必须与前端组件的 `defineOptions({ name: 'XxxManage' })` 一致,
否则 `keep-alive` 缓存静默失效
- 按钮级 `sys_menu.permission`(格式 `模块:资源:操作`)必须与前端
`v-permisaction="['模块:资源:操作']"` 完全一致,否则按钮权限判断静默失效
### 5. 收尾检查
| 检查项 | 出错后果 |
| --- | --- |
| `Generate()` 是否返回副本 | 并发请求之间串数据 |
| 是否使用 `e.Orm` 而非全局 DB | 多租户下拿到错误的数据库连接 |
| `TableName()` 是否显式声明 | GORM 不会自动推导 |
| 迁移文件是否放在 `version/` | 放进 `version-local/` 会被忽略,别人拉代码看不到 |
| `sys_menu.menu_name` 是否与前端组件 `name` 一致 | keep-alive 缓存静默失效 |
| `sys_menu.permission` 是否与前端 `v-permisaction` 一致 | 按钮权限静默失效 |
跑一遍 `go run -tags sqlite3 . migrate -c config/settings.sqlite.yml` 验证迁移可执行,
`go run -tags sqlite3 . server -c config/settings.sqlite.yml` 启动服务,用 admin
账号登录确认新菜单和按钮权限都出现了。
如果前端页面还没生成,下一步用 go-admin-ui 仓库里的 `new-list-page` skill——两边靠
`sys_menu.permission` / `v-permisaction` 这个字符串对齐。
> 不要把 `config/settings.yml` 的真实内容贴给 AI 工具——`database.source` 含数据库
> 账号密码,`jwt.secret` 泄露后可被用来伪造任意用户的 token。
+102 -6
View File
@@ -1,10 +1,35 @@
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
# just created, and the first's docker run then failed on a name conflict -
# leaving the demo on the older image with a red deploy.
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
env:
IMAGE_NAME: registry.ap-northeast-1.aliyuncs.com/go-admin/go-admin-api # 镜像名称
@@ -16,12 +41,12 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@v3
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: 1.18
go-version: 1.26.5
- name: Tidy
run: go mod tidy
@@ -29,7 +54,12 @@ jobs:
- name: Build
run: env CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -tags "sqlite3,json1" --ldflags "-extldflags -static" -o main .
# 以下推镜像与重启步骤仅在 master 收到 push 时执行。
# pull_request 事件同样会触发本工作流,若不加限制,任何指向 master 的
# PR 一经创建就会把 PR 分支的镜像推上仓库,并直接重启线上 API 服务,
# 且发生在合并之前。构建与编译校验不受影响,PR 仍会执行。
- name: Build the Docker image and push
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
run: |
docker login --username=${{ secrets.DOCKER_USERNAME }} registry.ap-northeast-1.aliyuncs.com --password=${{ secrets.DOCKER_PASSWORD }}
echo "************ docker login end"
@@ -43,7 +73,8 @@ jobs:
echo "************ docker push end"
- name: Restart server # 第五步,重启服务
uses: appleboy/ssh-action@master
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
env:
GITHUB_SHA_X: ${GITHUB_SHA}
with:
@@ -51,7 +82,72 @@ jobs:
username: ${{ secrets.SSH_USERNAME }}
key: ${{ secrets.DEPLOY_KEY }}
# 重启的脚本,根据自身情况做相应改动,一般要做的是migrate数据库以及重启服务器
#
# 配置从宿主机挂载,不使用镜像里的那份:演示站连的是托管数据库,
# 而 config/settings.demo.yml 会随仓库公开、也会打进镜像,凭据不能写在那里。
# 镜像里那份保持 sqlite,供 clone 仓库的人开箱即用。
#
# 路径本身走 secret:它不是凭据,但本仓库公开,没有理由把服务器的
# 目录结构一并公布。DEMO_CONFIG_PATH 指向宿主机上那份配置。
#
# 顺序是有意的:迁移先跑,跑不过就保持现有版本不动;
# 旧容器改名保留而不是删除,新容器不健康时能原样恢复。
# 健康检查两条都要过——HTTP 活着不代表数据库通了。
script: |
sudo docker rm -f go-admin-api
set -u
CFG="${{ secrets.DEMO_CONFIG_PATH }}"
IMG="${{ env.IMAGE_NAME_TAG }}"
NAME=go-admin-api
PREV="$NAME-prev"
test -f "$CFG" || { echo "宿主机配置缺失,中止部署"; exit 1; }
sudo docker login --username=${{ secrets.DOCKER_USERNAME }} registry.ap-northeast-1.aliyuncs.com --password=${{ secrets.DOCKER_PASSWORD }}
sudo docker run -d -p 8000:8000 --name go-admin-api ${{ env.IMAGE_NAME_TAG }}
sudo docker pull "$IMG" || { echo "拉取镜像失败,中止部署"; exit 1; }
# 迁移用新镜像跑。失败时线上仍是旧版本配旧 schema,是自洽的;
# 硬切过去才会得到代码与表对不上的服务。
if ! sudo docker run --rm -v "$CFG":/config/settings.yml:ro "$IMG" \
/main migrate -c /config/settings.yml; then
echo "迁移失败,保持现有版本"; exit 1
fi
if sudo docker ps -a --format '{{.Names}}' | grep -qx "$NAME"; then
sudo docker rm -f "$PREV" >/dev/null 2>&1 || true
sudo docker rename "$NAME" "$PREV"
# --timeout, because the default is 10 seconds and the process
# spends drain + server + cleanup from extend.shutdown before it
# exits - 8 seconds out of the box, and more for anyone who
# configures a drain window. Past the deadline docker sends
# SIGKILL and the cleanup callbacks are cut off part-way through.
# checksilent's docker-stop-cuts-shutdown-short check compares
# this number against config/settings.yml.
sudo docker stop --timeout 30 "$PREV" >/dev/null
fi
sudo docker run -d -p 8000:8000 \
-v "$CFG":/config/settings.yml:ro \
--name "$NAME" "$IMG"
ok=0
for i in $(seq 1 20); do
sleep 3
code=$(curl -s -o /dev/null -w '%{http_code}' -m 5 http://127.0.0.1:8000/api/v1/captcha 2>/dev/null || true)
if [ "$code" = "200" ] && sudo docker logs "$NAME" 2>&1 | grep -q 'connect success'; then
ok=1; echo "健康检查通过(第 $i 次探测)"; break
fi
done
if [ "$ok" = "1" ]; then
sudo docker rm -f "$PREV" >/dev/null 2>&1 || true
else
echo "健康检查失败,回滚到上一版本"
sudo docker logs --tail 40 "$NAME" 2>&1 || true
sudo docker rm -f "$NAME" >/dev/null 2>&1 || true
if sudo docker ps -a --format '{{.Names}}' | grep -qx "$PREV"; then
sudo docker rename "$PREV" "$NAME"
sudo docker start "$NAME" >/dev/null
echo "已恢复"
fi
exit 1
fi
+4 -4
View File
@@ -19,11 +19,11 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
uses: github/codeql-action/init@24c7eb380a2dc368f2d129e4c65e51d172983a1e # v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -34,7 +34,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
uses: github/codeql-action/autobuild@24c7eb380a2dc368f2d129e4c65e51d172983a1e # v4
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -48,4 +48,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
uses: github/codeql-action/analyze@24c7eb380a2dc368f2d129e4c65e51d172983a1e # v4
+50 -10
View File
@@ -3,6 +3,7 @@ name: build
on:
push:
branches: [ master, dev ]
tags: [ 'v*', '[0-9]*' ]
pull_request:
branches: [ master ]
env:
@@ -14,25 +15,64 @@ jobs:
build:
name: Build
runs-on: ubuntu-latest
# The queue's ordering rule - consumers registered before the queue is
# started - is invisible on the memory backend, which is the default and
# therefore what every other test runs on: queue.Memory's Register starts a
# consumer goroutine whatever the state. Only redis refuses a late
# registration, so without a server here the tests that cover it would skip
# and the suite would report success for a queue that accepts no consumers.
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
env:
GO_ADMIN_TEST_REDIS_ADDR: 127.0.0.1:6379
steps:
- name: Set up Go 1.18
uses: actions/setup-go@v3
- name: Set up Go 1.26
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: 1.18
go-version: 1.26.5
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- 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/')
- name: Log in to the Container registry
uses: docker/login-action@v2
if: startsWith(${{github.ref}}, 'refs/tags/')
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
if: startsWith(github.ref, 'refs/tags/')
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -40,8 +80,8 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
if: startsWith(${{github.ref}}, 'refs/tags/')
uses: docker/metadata-action@v4
if: startsWith(github.ref, 'refs/tags/')
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
@@ -52,8 +92,8 @@ jobs:
type=sha,prefix=,format=long,enable=true,priority=100
- name: Build and push Docker image
uses: docker/build-push-action@v3
if: startsWith(${{github.ref}}, 'refs/tags/')
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
if: startsWith(github.ref, 'refs/tags/')
with:
context: .
file: scripts/Dockerfile
@@ -1,22 +0,0 @@
name: Issue Check Inactive
on:
schedule:
- cron: "0 0 */15 * *"
permissions:
contents: read
jobs:
issue-check-inactive:
permissions:
issues: write # for actions-cool/issues-helper to update issues
pull-requests: write # for actions-cool/issues-helper to update PRs
runs-on: ubuntu-latest
steps:
- name: check-inactive
uses: actions-cool/issues-helper@v3
with:
actions: 'check-inactive'
inactive-label: 'Inactive'
inactive-day: 30
-32
View File
@@ -1,32 +0,0 @@
name: Issue Close Require
on:
schedule:
- cron: "0 0 * * *"
permissions:
contents: read
jobs:
issue-close-require:
permissions:
issues: write # for actions-cool/issues-helper to update issues
pull-requests: write # for actions-cool/issues-helper to update PRs
runs-on: ubuntu-latest
steps:
- name: need reproduce
uses: actions-cool/issues-helper@v3
with:
actions: 'close-issues'
labels: '🤔 Need Reproduce'
inactive-day: 3
- name: needs more info
uses: actions-cool/issues-helper@v3
with:
actions: 'close-issues'
labels: 'needs-more-info'
inactive-day: 3
body: |
Since the issue was labeled with `needs-more-info`, but no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.
由于该 issue 被标记为需要更多信息,却 3 天未收到回应。现关闭 issue,若有任何问题,可评论回复。
-76
View File
@@ -1,76 +0,0 @@
# Origin Source
# https://github.com/ant-design/ant-design/blob/79f566b7f8abb1012ef55b0d2793bfdf5595b85d/.github/workflows/issue-reply.yml
name: Issue Labeled
on:
issues:
types: [labeled]
permissions:
contents: read
jobs:
issue-labeled:
permissions:
issues: write # for actions-cool/issues-helper to update issues
pull-requests: write # for actions-cool/issues-helper to update PRs
runs-on: ubuntu-latest
steps:
- name: help wanted
if: github.event.label.name == 'help wanted'
uses: actions-cool/issues-helper@v3
with:
actions: 'create-comment'
token: ${{ secrets.ADMIN_TOKEN }}
issue-number: ${{ github.event.issue.number }}
body: |
Hello @${{ github.event.issue.user.login }}. We totally like your proposal/feedback, welcome to [send us a Pull Request](https://help.github.com/en/articles/creating-a-pull-request) for it. Please send your Pull Request to proper branch (feature branch for the new feature, master for bugfix and other changes), fill the [Pull Request Template](https://github.com/go-admin-team/go-admin/blob/master/.github/PULL_REQUEST_TEMPLATE.md) here, provide changelog/TypeScript/documentation/test cases if needed and make sure CI passed, we will review it soon. We appreciate your effort in advance and looking forward to your contribution!
你好 @${{ github.event.issue.user.login }},我们完全同意你的提议/反馈,欢迎直接在此仓库 [创建一个 Pull Request](https://help.github.com/en/articles/creating-a-pull-request) 来解决这个问题。请将 Pull Request 发到正确的分支(新特性发到 feature 分支,其他发到 master 分支),务必填写 Pull Request 内的[预设模板](https://github.com/go-admin-team/go-admin/blob/master/.github/PULL_REQUEST_TEMPLATE.md),提供改动所需相应的 changelog、TypeScript 定义、测试用例、文档等,并确保 CI 通过,我们会尽快进行 Review,提前感谢和期待您的贡献。
![giphy](https://user-images.githubusercontent.com/507615/62342668-4735dc00-b51a-11e9-92a7-d46fbb1cc0c7.gif)
- name: 🤔 Need Reproduce
if: github.event.label.name == '🤔 Need Reproduce'
uses: actions-cool/issues-helper@v3
with:
actions: 'create-comment'
token: ${{ secrets.ADMIN_TOKEN }}
issue-number: ${{ github.event.issue.number }}
body: |
Hello @${{ github.event.issue.user.login }}. Please provide a online reproduction by forking this link https://u.ant.design/codesandbox-repro or a minimal GitHub repository. Issues labeled by `Need Reproduce` will be closed if no activities in 3 days.
你好 @${{ github.event.issue.user.login }}, 我们需要你提供一个在线的重现实例以便于我们帮你排查问题。你可以通过点击 [此处](https://u.ant.design/codesandbox-repro) 创建一个 codesandbox 或者提供一个最小化的 GitHub 仓库。3 天内未跟进此 issue 将会被自动关闭。
![](https://gw.alipayobjects.com/zos/antfincdn/y9kwg7DVCd/reproduce.gif)
- name: Usage
if: github.event.label.name == 'Usage' || github.event.label.name == 'Question'
uses: actions-cool/issues-helper@v3
with:
actions: 'create-comment,close-issue'
token: ${{ secrets.ADMIN_TOKEN }}
issue-number: ${{ github.event.issue.number }}
body: |
Hello @${{ github.event.issue.user.login }}, we use GitHub issues to trace bugs or discuss plans of Ant Design. So, please [don't ask usage questions](https://github.com/ant-design/ant-design/issues/2320) here. You can try to open a new discussion in [antd discussions](https://github.com/ant-design/ant-design/discussions), select `Q&A` to ask questions, also can ask questions on [Stack Overflow](https://stackoverflow.com/questions/tagged/antd) or [Segment Fault](https://segmentfault.com/t/antd), then apply tag `antd` and `react` to your question.
你好 @${{ github.event.issue.user.login }}go-admin Issue 板块是用于 bug 反馈与需求讨论的地方。请[勿询问如何使用的问题](https://github.com/go-admin-te/ant-design/issues/699),你可以试着在 [antd discussions](https://github.com/ant-design/ant-design/discussions) 新开一个 discussion,选择 `Q&A` 类别进行提问,也可以在 [Stack Overflow](https://stackoverflow.com/questions/tagged/antd) 或者 [Segment Fault](https://segmentfault.com/t/antd) 中提问(记得添加 `antd` 和 `react` 标签哦~)。
- name: 3.x
if: github.event.label.name == '3.x'
uses: actions-cool/issues-helper@v3
with:
actions: 'create-comment,close-issue'
token: ${{ secrets.ADMIN_TOKEN }}
issue-number: ${{ github.event.issue.number }}
body: |
Hi @${{ github.event.issue.user.login }}. Current version (3.x) is off the maintenance period. We may not accept pull request or fix bug with it anymore. This topic will be auto closed.
你好 @${{ github.event.issue.user.login }},当前版本(3.x)已经过了维护期。我们不会再接受对其的相关 PR 与 issue。当前 topic 会被自动关闭。
- name: invalid
if: github.event.label.name == 'Invalid'
uses: actions-cool/issues-helper@v3
with:
actions: 'create-comment,close-issue'
token: ${{ secrets.ADMIN_TOKEN }}
issue-number: ${{ github.event.issue.number }}
body: |
Hello @${{ github.event.issue.user.login }}, your issue has been closed because it does not conform to our issue requirements. Please use the [Issue Helper](https://new-issue.go-admin.dev) to create an issue, thank you!
你好 @${{ github.event.issue.user.login }},为了能够进行高效沟通,我们对 issue 有一定的格式要求,你的 issue 因为不符合要求而被自动关闭。你可以通过 [issue 助手](https://new-issue.go-admin.dev) 来创建 issue 以方便我们定位错误。谢谢配合!
- name: rtl
if: github.event.label.name == 'rtl'
uses: actions-cool/issues-helper@v3
with:
actions: 'add-assignees'
assignees: 'xrkffgg'
-29
View File
@@ -1,29 +0,0 @@
name: 'GitHub Actions Mirror'
on: [push, delete]
jobs:
mirror_to_gitee:
runs-on: ubuntu-latest
steps:
- name: 'Checkout'
uses: actions/checkout@v1
- name: 'Mirror to gitee'
uses: pixta-dev/repository-mirroring-action@v1
with:
target_repo_url:
git@gitee.com:go-admin-team/go-admin.git
ssh_private_key:
${{ secrets.GITEE_KEY }}
mirror_to_gitlab:
runs-on: ubuntu-latest
steps:
- name: 'Checkout'
uses: actions/checkout@v1
- name: 'Mirror to gitlab'
uses: pixta-dev/repository-mirroring-action@v1
with:
target_repo_url:
git@gitlab.com:go-admin-team/go-admin.git
ssh_private_key:
${{ secrets.GITLAB_KEY }}
+22 -3
View File
@@ -1,11 +1,15 @@
.idea
.vscode
*/.DS_Store
.DS_Store
static/uploadfile
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
@@ -18,6 +22,21 @@ config/settings.b.dev.yml
cmd/migrate/migration/version-local/*
!cmd/migrate/migration/version-local/doc.go
# go sum
go.sum
config/settings.deva.yml
go-admin-server
CLAUDE.md
# Everything under .claude is private by default. Skills meant for people using
# go-admin are re-included one directory at a time, so a personal one dropped in
# here is never committed by accident.
.claude/*
!.claude/skills/
.claude/skills/*
!.claude/skills/new-business-module/
config/settings.local.dev.yml
# 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
+273
View File
@@ -0,0 +1,273 @@
# AGENTS.md — go-admin 后端
> 给 AI 编码工具与新贡献者的约定。**只写"不遵守就会出错"的规则**;技术栈版本以
> `go.mod` 为准,命令以 `Makefile` 为准,此处不复述,避免与代码脱节。
>
> 标准 CRUD 模块的完整写法见 **`app/demo/`** —— 那是可编译、有测试、CI 会跑的参照物。
> 本文与它冲突时,以 `app/demo/` 为准。
## 分层
```
Router → Api → Service → Model
路由注册 参数绑定 业务逻辑 GORM 结构体
中间件链 调用 Service 操作数据库 TableName()
```
对应目录:`app/{模块}/router|apis|service|models`DTO 位于 `service/dto`
**不可跨层**Api 不直接操作 `Orm`Service 不接触 `gin.Context`
## 优先使用通用 Action
单表 CRUD **不要手写 Handler 与 Service**`common/actions` 提供的五个
Action 已覆盖参数绑定、数据权限过滤、操作人注入、分页与错误响应:
```go
r := v1.Group("/demo-product").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
m := &models.DemoProduct{}
r.GET("", actions.PermissionAction(), actions.IndexAction(m, new(dto.DemoProductSearch), func() interface{} {
list := make([]models.DemoProduct, 0); return &list
}))
r.GET("/:id", actions.PermissionAction(), actions.ViewAction(new(dto.DemoProductById), func() interface{} {
return &models.DemoProduct{}
}))
r.POST("", actions.CreateAction(new(dto.DemoProductControl)))
r.PUT("/:id", actions.PermissionAction(), actions.UpdateAction(new(dto.DemoProductControl)))
r.DELETE("", actions.PermissionAction(), actions.DeleteAction(new(dto.DemoProductById)))
}
```
这样一个模块只需 **model + dto + router** 三个文件,完整示例见 `app/demo/`
使用通用 Action 的前提:
- Model 实现 `models.ActiveRecord``Generate` / `GetId` / `TableName`
- 列表 DTO 实现 `dto.Index`,增改删 DTO 实现 `dto.Control`
- **所有 `Generate()` 必须返回副本** —— Action 在并发请求间复用实例,
就地返回会串数据(`app/demo` 的测试锁定了这一点)
- 详情/删除 DTO 内嵌 `dto.ObjectById` 即可继承 `Bind``GetId`,无需重写
仅当业务超出单表 CRUD(跨表事务、外部调用、复杂校验)时才自行编写 Handler
与 Service,写法见下。
## Api 层(仅在通用 Action 不适用时)
结构体嵌入 `api.Api`,链式初始化后**必须检查 `Errors`**
```go
func (e SysPost) GetPage(c *gin.Context) {
s := service.SysPost{}
req := dto.SysPostPageReq{}
err := e.MakeContext(c).MakeOrm().Bind(&req, binding.Form).MakeService(&s.Service).Errors
if err != nil {
e.Logger.Error(err)
e.Error(500, err, err.Error())
return
}
// ... 调用 s.GetPage(...)
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
}
```
响应一律走 `e.OK` / `e.PageOK` / `e.Error`,不要自行 `c.JSON`
## Service 层(仅在通用 Action 不适用时)
结构体嵌入 `service.Service`(持有 `Orm``Log`)。查询通过 Scopes 组合:
```go
err = e.Orm.Model(&data).Scopes(
cDto.MakeCondition(c.GetNeedSearch()), // 由 search tag 生成 WHERE
cDto.Paginate(c.GetPageSize(), c.GetPageIndex()),
actions.Permission(data.TableName(), p), // 数据权限,列表/详情必须带
).Find(list).Limit(-1).Offset(-1).Count(count).Error
```
**遗漏 `actions.Permission` 会使数据权限配置静默失效** —— 这是最容易出的错。
错误一律 `return err` 向上传递,日志用 `e.Log.Errorf`,不使用 `panic`
## DTO
搜索条件由 tag 声明,`MakeCondition` 据此拼 SQL
```go
type SysPostPageReq struct {
dto.Pagination `search:"-"`
PostName string `form:"postName" search:"type:contains;column:post_name;table:sys_post"`
}
func (m *SysPostPageReq) GetNeedSearch() interface{} { return *m }
```
`type` 可选:`exact` `iexact` `contains` `gt` `gte` `lt` `lte` `order` `left`(联表)。
## Model
```go
type SysPost struct {
PostId int `gorm:"primaryKey;autoIncrement" json:"postId"`
// ... 业务字段
models.ControlBy // CreateBy / UpdateBy
models.ModelTime // CreatedAt / UpdatedAt / DeletedAt
}
func (SysPost) TableName() string { return "sys_post" }
```
`TableName()` 必须显式声明(GORM 配置了 `SingularTable`,不会自动推导复数)。
## 公共契约面
第三方应用(`app/` 下的业务模块)可以稳定依赖哪些包、路由与迁移怎么注册、
哪些约束是硬的,见 `docs/contract.md`
两条与主仓贡献者直接相关的:
- **`common/``core/` 不得 import `app/`** —— `make checksilent` 在 CI 里守着,违反即红。
- **从 core 契约包声明出来的类型必须写成别名**(`type X = pkg.Y`,不是 `type X pkg.Y`
—— `contract-shim-alias` 检查守着。defined type 会丢掉整个方法集,
而且**不一定在本仓编译失败**,理由见 `docs/contract.md` 末节。
- **注册类 API`AppRouters` / `sdk.Runtime.SetAppRouters` / `migration.ForApp`
必须在 `runStartupHooks()` 之前调用完** —— `init()` 是最省事的位置,
但约束的是**顺序**,不是写在哪个函数里;晚到的注册会被丢弃并只记一条 ERROR。
## 路由注册
通过 `init()` 自注册,不在中心文件手工添加:
```go
func init() { routerCheckRole = append(routerCheckRole, registerSysPostRouter) }
func registerSysPostRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
api := apis.SysPost{}
r := v1.Group("/post").
Use(authMiddleware.MiddlewareFunc()).
Use(middleware.AuthCheckRole()). // Casbin 鉴权
Use(actions.PermissionAction()) // 注入数据权限
{ r.GET("", api.GetPage); r.POST("", api.Insert); /* ... */ }
}
```
新增路由文件后,需确认 `cmd/api/` 中已用 `_` 导入该包。
## 命名
| 对象 | 规则 | 示例 |
|---|---|---|
| 数据表 | `sys_` 前缀 + 下划线 | `sys_post` |
| API 路径 | `/api/v1/` + kebab-case | `/api/v1/sys-user` |
| DTO | `{Model}{Action}Req` | `SysPostPageReq` |
| 权限标识 | `模块:资源:操作` | `admin:sysPost:add` |
权限标识需与前端 `v-permisaction` 一致,并写入 `sys_menu` 种子数据——完整可运行的
参照见 `cmd/migrate/migration/version/1786700001000_demo_menu.go`sys_api /
sys_menu / sys_menu_api_rule / casbin_rule 四张表如何配齐,用的是幂等 upsert,
可以直接照抄结构)。
## Swagger
Handler 必须带完整注解,`go generate` 会据此生成文档:
```go
// @Summary 岗位列表
// @Tags 岗位
// @Success 200 {object} response.Response
// @Router /api/v1/post [get]
// @Security Bearer
```
## 本地运行
**配置 `driver: sqlite3` 时必须带构建标签**,否则启动即 panic
```bash
go run -tags sqlite3 . migrate -c config/settings.sqlite.yml
go run -tags sqlite3 . server -c config/settings.sqlite.yml
```
原因:`common/database/open.go``//go:build !sqlite3`,不加标签时编进的是
不含 sqlite3 的版本,`opens["sqlite3"]` 为 nil,调用时在 nil 函数上崩溃。
报错信息不会提到构建标签,容易误判成环境损坏。MySQL / PostgreSQL 无此问题。
对应 `Makefile``build-sqlite` 目标。
## 数据库迁移
文件名前 13 位为毫秒时间戳版本号,不合规的名字会在启动时 panic 并报出该文件名。
**已执行过的迁移文件不可修改** ——
`sys_migration` 表按版本号去重,改动不会重跑,只能新增一个迁移来修正。
放哪个目录取决于身份:
| 目录 | 用途 | 是否入库 |
|---|---|---|
| `version/` | 框架自带迁移,随仓库分发给所有使用者 | 是 |
| `version-local/` | 使用者自己项目的迁移 | 否(已在 `.gitignore` |
**向本仓库提交迁移必须放 `version/`** —— 放进 `version-local/` 会被忽略掉,
`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 即失败。这里不写条数——写死的数字会悄悄过时,
真正的清单是 `tools/checksilent/checks.go``runChecks` 跑的那几个:
| 检查 | 级别 | 静默后果 |
|---|---|---|
| `modeltime-mix` | ERROR | 两个 `ModelTime` 混用,整张表查不到数据 |
| `menu-sort-overflow` | ERROR | 菜单 `sort` 超 127MySQL tinyint 拒绝写入,迁移中断 |
| `config-value-truncation` | ERROR | `sys_config.config_value` 超 255 字符被静默截断 |
| `menu-id-collision` | ERROR | 两个模块硬编码同一菜单 ID,互相覆盖 |
| `contract-import-boundary` | ERROR | 契约包 import `app/`,应用无法独立编译 |
| `contract-shim-alias` | ERROR | 契约薄壳写成 defined type 而非别名,方法集丢失,本仓可能照常编译、第三方应用编译不过 |
| `datascope-route-unguarded` | ERROR | handler 读调用方的数据权限,而注册它的路由组没装提供权限的中间件。取不到时拿到零值、走 fail-closed 分支,查询被塞进 `1 = 0`:接口对确实存在的行返回「查不到」,且只在 `enabledp: true` 的部署上出现 |
| `shutdown-budget-overruns-grace` | ERROR / WARN | `settings.yml``extend.shutdown` 预算(含清单里的 `preStop`)放不进自带 k8s 清单的 `terminationGracePeriodSeconds`,SIGKILL 在清理回调跑到一半时到达 |
| `docker-stop-cuts-shutdown-short` | ERROR / WARN | 停止容器的两条路径——脚本/工作流里的 `docker stop`,和 `docker-compose.yml``stop_grace_period`——没写或写得不够关闭预算用。两边默认都是 10 秒,而这个数字离命令很远,调大预算的人不会想起它 |
| `menu-name-mismatch` | WARN | 菜单名与前端组件 `name` 不一致,keep-alive 缓存静默失效 |
两条关闭预算检查分两级,用的是同一条算术和同一个 5 秒边际:真的超限报 ERROR,
放得进但余量不足 5 秒报 WARN。余量不足做 WARN 不做 ERROR,是因为那是个技术上
跑得通的配置——**一条在正确配置下也会响的 ERROR,训练的是忽略它**。
最后一条要跨仓库比对,只能做正则启发式,因此是 WARN,**不影响退出码**,
且默认跳过;要跑它得指定前端目录:
```bash
make checksilent UI_DIR=../go-admin-ui/src
```
升级门槛:连续 2 个发版周期零误报后转为 ERROR。
## 提交规范
格式 `type+emoji: 描述`
`feat✨` `fix🐛` `style💄` `docs📝` `perf👌` `test✅` `refactor🎨` `chore🔧`
一个提交只做一件事。改动跨越多个语义时拆分提交,不要混在一起。
## 红线
- 不使用全局 DB 变量,一律用 `e.Orm`(来自请求上下文,多租户依赖它)
- 不在 Service 中引用 `gin.Context`
- 生产部署前确认 `mode: prod` 且已修改 `jwt.secret`(dev 模式下 token 几乎不过期)
- 不提交 `config/settings.yml` 中的真实凭据
-28
View File
@@ -1,28 +0,0 @@
FROM golang:alpine as builder
MAINTAINER lwnmengjing
ENV GOPROXY https://goproxy.cn/
WORKDIR /go/release
#RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
RUN apk update && apk add tzdata
COPY go.mod ./go.mod
RUN go mod tidy
COPY . .
RUN pwd && ls
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -a -installsuffix cgo -o go-admin .
FROM alpine
COPY --from=builder /go/release/go-admin /
COPY --from=builder /go/release/config/settings.yml /config/settings.yml
COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
EXPOSE 8000
CMD ["/go-admin","server","-c", "/config/settings.yml"]
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020 go-admin-team
Copyright (c) 2026 go-admin-team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+34 -7
View File
@@ -2,7 +2,7 @@ PROJECT:=go-admin
.PHONY: build
build:
CGO_ENABLED=0 go build -ldflags="-w -s" -a -installsuffix -o go-admin .
CGO_ENABLED=0 go build -ldflags="-w -s" -a -installsuffix "" -o go-admin .
# make build-linux
build-linux:
@@ -15,13 +15,22 @@ build-sqlite:
# make run
run:
# delete go-admin-api container
@if [ $(shell docker ps -aq --filter name=go-admin --filter publish=8000) ]; then docker rm -f go-admin; fi
#
# stop then rm, rather than `rm -f`. The force flag kills a running
# container with SIGKILL and no grace at all, so restarting locally cut
# short every shutdown this application does - the drain window was never
# once reached on a developer's machine. --timeout has to cover
# extend.shutdown's drain + server + cleanup; checksilent's
# docker-stop-cuts-shutdown-short check compares it against
# config/settings.yml. On a container that has already stopped, stop is a
# no-op and the removal is unchanged.
@if [ $(shell docker ps -aq --filter name=go-admin --filter publish=8000) ]; then docker stop --timeout 30 go-admin && docker rm go-admin; fi
# 启动方法一 run go-admin-api container docker-compose 启动方式
# 进入到项目根目录 执行 make run 命令
@docker-compose up -d
# 启动方式二 docker run 这里注意-v挂载的宿主机的地址改为部署时的实际对路径
# 启动方式二 docker run 这里注意-v挂载的宿主机的地址改为部署时的实际对路径
#@docker run --name=go-admin -p 8000:8000 -v /home/code/go/src/go-admin/go-admin/config:/go-admin-api/config -v /home/code/go/src/go-admin/go-admin-api/static:/go-admin/static -v /home/code/go/src/go-admin/go-admin/temp:/go-admin-api/temp -d --restart=always go-admin:latest
@echo "go-admin service is running..."
@@ -37,9 +46,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:
@@ -51,4 +78,4 @@ deploy:
#@git checkout master
#@git pull origin master
make build-linux
make run
make run
+24 -25
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服务
@@ -19,13 +19,10 @@
## 🎬 在线体验
Element UI vue体验:[https://vue2.go-admin.dev](https://vue2.go-admin.dev/#/login)
Element Plus vue3 体验:[https://vue.go-admin.pro](https://vue.go-admin.pro/#/login)
> ⚠️⚠️⚠️ 账号 / 密码: admin / 123456
Arco Design vue3 demo[https://vue3.go-admin.dev](https://vue3.go-admin.dev/#/login)
> ⚠️⚠️⚠️ 账号 / 密码: admin / 123456
antd体验:[https://antd.go-admin.pro](https://antd.go-admin.pro/)
antd 体验(go-admin-pro[https://antd.go-admin.pro](https://antd.go-admin.pro/)
> ⚠️⚠️⚠️ 账号 / 密码: admin / 123456
## ✨ 特性
@@ -81,9 +78,9 @@ antd体验:[https://antd.go-admin.pro](https://antd.go-admin.pro/)
### 轻松实现go-admin写出第一个应用 - 文档教程
[步骤一 - 基础内容介绍](https://doc.zhangwj.com/guide/intro/tutorial01.html)
[步骤一 - 基础内容介绍](https://www.go-admin.pro/guide/intro/tutorial01.html)
[步骤二 - 实际应用 - 编写增删改查](https://doc.zhangwj.com/guide/intro/tutorial02.html)
[步骤二 - 实际应用 - 编写增删改查](https://www.go-admin.pro/guide/intro/tutorial02.html)
### 手把手教你从入门到放弃 - 视频教程
@@ -109,11 +106,11 @@ antd体验:[https://antd.go-admin.pro](https://antd.go-admin.pro/)
### 环境要求
go 1.18
go 1.26.5
node版本: v14.16.0
node版本: v22+(推荐 v24 LTS
npm版本: 6.14.11
包管理器: pnpm v9+UI 项目使用 pnpm
### 开发目录创建
@@ -160,7 +157,7 @@ vi ./config/settings.yml
# 2. 确认log路径
```
:::tip ⚠️注意 在windows环境如果没有安装中CGO,会出现这个问题;
⚠️注意 在windows环境如果没有安装中CGO,会出现这个问题;
```bash
E:\go-admin>go build
@@ -176,9 +173,8 @@ 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)
:::
#### 初始化数据库,以及服务启动
@@ -238,14 +234,17 @@ env GOOS=linux GOARCH=amd64 go build main.go
### UI交互端启动说明
```bash
# 安装依赖
npm install
# 安装 pnpm(若未安装)
npm install -g pnpm
# 建议不要直接使用 cnpm 安装依赖,会有各种诡异的 bug。可以通过如下操作解决 npm 下载速度慢的问题
npm install --registry=https://registry.npmmirror.com
# 安装依赖
pnpm install
# 国内网络可指定镜像源加速
pnpm install --registry=https://registry.npmmirror.com
# 启动服务
npm run dev
pnpm dev
```
## 📨 互动
@@ -328,9 +327,9 @@ npm run 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. [jwt-go](https://github.com/dgrijalva/jwt-go)
9. [golang-jwt](https://github.com/golang-jwt/jwt)
10. [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
11. [ruoyi-vue](https://gitee.com/y_project/RuoYi-Vue)
12. [form-generator](https://github.com/JakHuang/form-generator)
@@ -344,10 +343,10 @@ npm run dev
## 🤝 链接
[Go开发者成长线路图](http://www.golangroadmap.com/)
- [mss-boot-io](https://docs.mss-boot-io.top/)
## 🔑 License
[MIT](https://github.com/go-admin-team/go-admin/blob/master/LICENSE.md)
Copyright (c) 2022 wenjianzhang
Copyright (c) 2026 wenjianzhang
+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
+26 -28
View File
@@ -1,18 +1,18 @@
# go-admin
<img align="right" width="320" src="https://doc-image.zhangwj.com/img/go-admin.svg">
<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)
@@ -20,14 +20,11 @@ The front-end and back-end separation authority management system based on Gin +
## 🎬 Online Demo
Element UI vue demo[https://vue2.go-admin.dev](https://vue2.go-admin.dev/#/login)
> 账号 / 密码: admin / 123456
Element Plus vue3 demo[https://vue.go-admin.pro](https://vue.go-admin.pro/#/login)
> Account / Password: admin / 123456
Arco Design vue3 demo[https://vue3.go-admin.dev](https://vue3.go-admin.dev/#/login)
> 账号 / 密码: admin / 123456
antd demo[https://antd.go-admin.pro](https://antd.go-admin.pro/)
> 账号 / 密码: admin / 123456
antd demo (go-admin-pro)[https://antd.go-admin.pro](https://antd.go-admin.pro/)
> Account / Password: admin / 123456
>
## ✨ Feature
@@ -79,9 +76,9 @@ At the same time, a series of tutorials including videos and documents are provi
### Easily implement go-admin to write the first application-documentation tutorial
[Step 1 - basic content introduction](https://doc.zhangwj.com/guide/intro/tutorial01.html)
[Step 1 - basic content introduction](https://www.go-admin.pro/guide/intro/tutorial01.html)
[Step 2 - Practical application - writing database operations](https://doc.zhangwj.com/guide/intro/tutorial02.html)
[Step 2 - Practical application - writing database operations](https://www.go-admin.pro/guide/intro/tutorial02.html)
### Teach you from getting started to giving up-video tutorial
@@ -107,11 +104,11 @@ At the same time, a series of tutorials including videos and documents are provi
### Environmental requirements
go 1.18
go 1.26.5
nodejs: v14.16.0
nodejs: v22+ (v24 LTS recommended)
npm: 6.14.11
package manager: pnpm v9+ (the UI project uses pnpm)
### Development directory creation
@@ -158,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
@@ -174,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
@@ -229,11 +224,14 @@ env GOOS=linux GOARCH=amd64 go build main.go
### UI interactive terminal startup instructions
```bash
# Install pnpm if you don't have it
npm install -g pnpm
# Installation dependencies
npm install # or cnpm install
pnpm install
# Start service
npm run dev
pnpm dev
```
## 📨 Interactive
@@ -318,9 +316,9 @@ 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. [jwt-go](https://github.com/dgrijalva/jwt-go)
2. [golang-jwt](https://github.com/golang-jwt/jwt)
2. [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
2. [ruoyi-vue](https://gitee.com/y_project/RuoYi-Vue)
2. [form-generator](https://github.com/JakHuang/form-generator)
@@ -332,10 +330,10 @@ The `go-admin` project has always been developed in the GoLand integrated develo
<img class="no-margin" src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/pay.png" height="200px" >
## 🤝 Link
[Go developer growth roadmap](http://www.golangroadmap.com/)
- [mss-boot-io](https://docs.mss-boot-io.top/)
## 🔑 License
[MIT](https://github.com/go-admin-team/go-admin/blob/master/LICENSE.md)
Copyright (c) 2022 wenjianzhang
Copyright (c) 2026 wenjianzhang
+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
+8 -5
View File
@@ -2,8 +2,8 @@ package apis
import (
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/captcha"
"github.com/go-admin-team/go-admin-core/v2/captcha"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
)
type System struct {
@@ -17,12 +17,15 @@ type System struct {
// @Success 200 {object} response.Response{data=string,id=string,msg=string} "{"code": 200, "data": [...]}"
// @Router /api/v1/captcha [get]
func (e System) GenerateCaptchaHandler(c *gin.Context) {
err := e.MakeContext(c).Errors
if err != nil {
if err := e.MakeContext(c).Errors; err != nil {
e.Error(500, err, "服务初始化失败!")
return
}
id, b64s, 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, "验证码获取失败")
+6 -5
View File
@@ -11,10 +11,11 @@ const INDEX = `
<meta charset="utf-8">
<title>GO-ADMIN欢迎您</title>
<style>
body{
margin:0;
padding:0;
overflow-y:hidden
html,body{
margin:0;
padding:0;
height:100%;
overflow-y:hidden;
}
</style>
<script src="https://libs.baidu.com/jquery/1.9.0/jquery.js"></script>
@@ -28,7 +29,7 @@ $(function(){
</script>
</head>
<body>
<iframe id="iframe" frameborder="0" src="https://doc.go-admin.dev" style="width:100%;"></iframe>
<iframe id="iframe" frameborder="0" src="https://www.go-admin.pro" style="width:100%;height:100%;"></iframe>
</body>
</html>
`
+3 -3
View File
@@ -3,9 +3,9 @@ package apis
import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
+2 -2
View File
@@ -3,8 +3,8 @@ package apis
import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
+6 -6
View File
@@ -3,10 +3,10 @@ package apis
import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
@@ -187,7 +187,7 @@ func (e SysDept) Get2Tree(c *gin.Context) {
req := dto.SysDeptGetPageReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req,binding.Form).
Bind(&req, binding.Form).
MakeService(&s.Service).
Errors
if err != nil {
@@ -235,4 +235,4 @@ func (e SysDept) GetDeptTreeRoleSelect(c *gin.Context) {
"depts": result,
"checkedKeys": menuIds,
}, "")
}
}
+3 -3
View File
@@ -3,9 +3,9 @@ package apis
import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
+1 -1
View File
@@ -3,7 +3,7 @@ package apis
import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
+4 -42
View File
@@ -3,8 +3,8 @@ package apis
import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
@@ -202,44 +202,6 @@ func (e SysMenu) GetMenuRole(c *gin.Context) {
e.OK(result, "")
}
//// GetMenuIDS 获取角色对应的菜单id数组
//// @Summary 获取角色对应的菜单id数组,设置角色权限使用
//// @Description 获取JSON
//// @Tags 菜单
//// @Param id path int true "id"
//// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
//// @Router /api/v1/menuids/{id} [get]
//// @Security Bearer
//func (e SysMenu) GetMenuIDS(c *gin.Context) {
// s := new(service.SysMenu)
// r := service.SysRole{}
// m := dto.SysRoleByName{}
// err := e.MakeContext(c).
// MakeOrm().
// Bind(&m, binding.JSON).
// MakeService(&s.Service).
// MakeService(&r.Service).
// Errors
// if err != nil {
// e.Logger.Error(err)
// e.Error(500, err, err.Error())
// return
// }
// var data models.SysRole
// err = r.GetWithName(&m, &data).Error
//
// //data.RoleName = c.GetString("role")
// //data.UpdateBy = user.GetUserId(c)
// //result, err := data.GetIDS(s.Orm)
//
// if err != nil {
// e.Logger.Errorf("GetIDS error, %s", err.Error())
// e.Error(500, err, "获取失败")
// return
// }
// e.OK(result, "")
//}
// GetMenuTreeSelect 根据角色ID查询菜单下拉树结构
// @Summary 角色修改使用的菜单列表
// @Description 获取JSON
@@ -253,7 +215,7 @@ func (e SysMenu) GetMenuRole(c *gin.Context) {
func (e SysMenu) GetMenuTreeSelect(c *gin.Context) {
m := service.SysMenu{}
r := service.SysRole{}
req :=dto.SelectRole{}
req := dto.SelectRole{}
err := e.MakeContext(c).
MakeOrm().
MakeService(&m.Service).
@@ -284,4 +246,4 @@ func (e SysMenu) GetMenuTreeSelect(c *gin.Context) {
"menus": result,
"checkedKeys": menuIds,
}, "获取成功")
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
"go-admin/app/admin/service/dto"
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
+7 -7
View File
@@ -6,13 +6,13 @@ import (
"net/http"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"go-admin/app/admin/models"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/admin/service"
"go-admin/app/admin/service/dto"
@@ -122,7 +122,7 @@ func (e SysRole) Insert(c *gin.Context) {
if req.Status == "" {
req.Status = "2"
}
cb := sdk.Runtime.GetCasbinKey(c.Request.Host)
cb := sdk.Runtime.GetCasbinByTenant(c.Request.Host)
err = s.Insert(&req, cb)
if err != nil {
e.Logger.Error(err)
@@ -161,7 +161,7 @@ func (e SysRole) Update(c *gin.Context) {
e.Error(500, err, err.Error())
return
}
cb := sdk.Runtime.GetCasbinKey(c.Request.Host)
cb := sdk.Runtime.GetCasbinByTenant(c.Request.Host)
req.SetUpdateBy(user.GetUserId(c))
@@ -203,7 +203,7 @@ func (e SysRole) Delete(c *gin.Context) {
return
}
cb := sdk.Runtime.GetCasbinKey(c.Request.Host)
cb := sdk.Runtime.GetCasbinByTenant(c.Request.Host)
err = s.Remove(&req, cb)
if err != nil {
e.Logger.Error(err)
+38 -8
View File
@@ -1,20 +1,22 @@
package apis
import (
"errors"
"github.com/gin-gonic/gin/binding"
"go-admin/app/admin/models"
"golang.org/x/crypto/bcrypt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/google/uuid"
"go-admin/app/admin/service"
"go-admin/app/admin/service/dto"
"go-admin/common/actions"
"go-admin/common/middleware"
)
type SysUser struct {
@@ -149,12 +151,34 @@ func (e SysUser) Update(c *gin.Context) {
return
}
req.SetUpdateBy(user.GetUserId(c))
callerId := user.GetUserId(c)
// This route is in CasbinExclude so the personal-center screen can edit
// the caller's own record without a policy grant (see settings.go). That
// exclusion covers the whole route, not just the caller's own record, and
// the request carries the target userId in the body - so without this
// check here, any authenticated caller could edit any other user, up to
// and including their roleId. When the target is someone else, ask Casbin
// directly for the permission AuthCheckRole skipped.
if req.UserId != callerId {
allowed, err := middleware.EnforceRoleFor(c, c.Request.URL.Path, c.Request.Method)
if err != nil {
e.Logger.Error(err)
e.Error(500, err, err.Error())
return
}
if !allowed {
e.Error(http.StatusForbidden, errors.New("无权更新其他用户数据"), "对不起,您没有该接口访问权限,请联系管理员")
return
}
}
req.SetUpdateBy(callerId)
//数据权限检查
p := actions.GetPermissionFromContext(c)
err = s.Update(&req, p)
err = s.Update(&req, p, callerId)
if err != nil {
e.Logger.Error(err)
return
@@ -420,7 +444,6 @@ func (e SysUser) GetInfo(c *gin.Context) {
e.Error(500, err, err.Error())
return
}
p := actions.GetPermissionFromContext(c)
var roles = make([]string, 1)
roles[0] = user.GetRoleName(c)
var permissions = make([]string, 1)
@@ -440,7 +463,14 @@ func (e SysUser) GetInfo(c *gin.Context) {
}
sysUser := models.SysUser{}
req.Id = user.GetUserId(c)
err = s.Get(&req, p, &sysUser)
// Unscoped on purpose: the id is the caller's own, taken from the token.
// This used to go through Get with whatever GetPermissionFromContext
// returned - and this route installs no PermissionAction, so that was the
// zero value. An unset scope is not a recognised one, so once unknown
// scopes started failing closed rather than silently matching everything,
// every login on a deployment with enabledp: true ended here with a 401
// and the browser went straight back to the login page.
err = s.GetSelf(&req, &sysUser)
if err != nil {
e.Error(http.StatusUnauthorized, err, "登录失败")
return
@@ -450,7 +480,7 @@ func (e SysUser) GetInfo(c *gin.Context) {
if sysUser.Avatar != "" {
mp["avatar"] = sysUser.Avatar
}
mp["userName"] = sysUser.NickName
mp["userName"] = sysUser.Username
mp["userId"] = sysUser.UserId
mp["deptId"] = sysUser.DeptId
mp["name"] = sysUser.NickName
+175
View File
@@ -0,0 +1,175 @@
package apis
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
mycasbin "github.com/go-admin-team/go-admin-core/v2/casbin"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
"go-admin/app/admin/models"
)
// PUT /api/v1/sys-user is in settings.go's CasbinExclude so the
// personal-center screen (go-admin-ui's userInfo.vue) can edit the caller's
// own record without holding a policy grant on this route. AuthCheckRole
// skips Enforce entirely for an excluded route, so this file's job is to pin
// what the handler itself now has to hold shut: the target userId comes from
// the request body, and nothing upstream of the handler ever checked it
// against the caller.
// setupPrivescDB wires an in-memory database and a Casbin enforcer with an
// empty policy - the state of a fresh install for any role but admin - under
// a tenant unique to the calling test, so mycasbin's process-wide enforcer
// cache can't hand one test's database to another.
func setupPrivescDB(t *testing.T) (*gorm.DB, string) {
t.Helper()
// Fatalf, not Skipf: this database is in-memory sqlite with no external
// dependency, so failing to open or migrate it means the environment is
// actually broken. Skipping here would let these two anti-privesc
// regression tests silently stop running while CI stays green - a
// standing assertion that never fires is worse than no assertion.
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("sqlite unavailable: %v", err)
}
if err := db.AutoMigrate(&models.SysUser{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
tenant := "sys-user-privesc-" + t.Name()
previousInterval := mycasbin.ReloadInterval
mycasbin.ReloadInterval = 0 // opt out of the background reload goroutine; the test never writes a policy
t.Cleanup(func() { mycasbin.ReloadInterval = previousInterval })
e := mycasbin.Setup(db, tenant)
previousEnforcer := sdk.Runtime.GetCasbinByTenant(tenant)
sdk.Runtime.SetCasbinByTenant(tenant, e)
t.Cleanup(func() { sdk.Runtime.SetCasbinByTenant(tenant, previousEnforcer) })
return db, tenant
}
// callUpdate drives SysUser.Update the way the router does for an
// authenticated, non-admin caller: JWT claims already decoded into the
// context (that is jwtauth's job, not this handler's) and a database - but
// without AuthCheckRole, since that middleware never runs Enforce for this
// route at all.
func callUpdate(t *testing.T, db *gorm.DB, tenant string, callerId int, body map[string]interface{}) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request body: %v", err)
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/sys-user", bytes.NewReader(raw))
c.Request.Host = tenant
c.Request.Header.Set("Content-Type", "application/json")
c.Set("db", db)
c.Set(pkg.LoggerKey, logger.NewHelper(logger.DefaultLogger))
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{
"identity": float64(callerId),
"rolekey": "ordinary-role", // holds no Casbin policy anywhere in this test
})
SysUser{}.Update(c)
return w
}
// TestUpdate_CannotEscalatePrivilegeThroughAnotherUsersRecord is the
// regression for H6. Before the fix, an ordinary authenticated user could PUT
// a body naming another user's id and change that user's roleId - the route
// being Casbin-excluded meant no permission check ever ran, and the data
// permission scope that would otherwise gate this is off by default.
func TestUpdate_CannotEscalatePrivilegeThroughAnotherUsersRecord(t *testing.T) {
db, tenant := setupPrivescDB(t)
victim := models.SysUser{Username: "bob", NickName: "Bob", RoleId: 2, DeptId: 1, Status: "1"}
if err := db.Create(&victim).Error; err != nil {
t.Fatal(err)
}
attacker := models.SysUser{Username: "alice", NickName: "Alice", RoleId: 2, DeptId: 1, Status: "1"}
if err := db.Create(&attacker).Error; err != nil {
t.Fatal(err)
}
const elevatedRoleId = 1 // a role the attacker does not hold and has no policy for
callUpdate(t, db, tenant, attacker.UserId, map[string]interface{}{
"userId": victim.UserId,
"username": victim.Username,
"nickName": "pwned",
"phone": "13800000000",
"email": "bob@example.com",
"roleId": elevatedRoleId,
"deptId": victim.DeptId,
"status": victim.Status,
})
var after models.SysUser
if err := db.First(&after, victim.UserId).Error; err != nil {
t.Fatal(err)
}
if after.RoleId == elevatedRoleId {
t.Fatalf("an attacker with no Casbin permission on this route escalated the victim's roleId to %d", after.RoleId)
}
if after.NickName == "pwned" {
t.Fatalf("an attacker with no Casbin permission on this route modified another user's record: %+v", after)
}
}
// TestUpdate_SelfEditCannotChangePrivilegedFields covers the case the
// CasbinExclude entry exists for: the personal-center screen has to keep
// working for the caller's own record. The fields that screen exposes
// (nickName/phone/email/sex) must still save, while roleId/deptId/status stay
// whatever the database already had even if the request carries something
// else - a compromised or hand-crafted client is the only way that request
// would ever differ from what the honest form sends.
func TestUpdate_SelfEditCannotChangePrivilegedFields(t *testing.T) {
db, tenant := setupPrivescDB(t)
self := models.SysUser{Username: "carol", NickName: "Carol", RoleId: 2, DeptId: 1, Status: "1"}
if err := db.Create(&self).Error; err != nil {
t.Fatal(err)
}
const elevatedRoleId = 1
callUpdate(t, db, tenant, self.UserId, map[string]interface{}{
"userId": self.UserId,
"username": self.Username,
"nickName": "Carol Updated",
"phone": "13900000000",
"email": "carol@example.com",
"roleId": elevatedRoleId, // tampered; must not take effect
"deptId": self.DeptId,
"status": self.Status,
})
var after models.SysUser
if err := db.First(&after, self.UserId).Error; err != nil {
t.Fatal(err)
}
if after.RoleId == elevatedRoleId {
t.Fatalf("a self-edit changed the caller's own roleId to %d", after.RoleId)
}
if after.NickName != "Carol Updated" {
t.Fatalf("the legitimate personal-center edit did not go through: %+v", after)
}
}
-81
View File
@@ -1,81 +0,0 @@
package models
import (
"errors"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"gorm.io/gorm"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk/config"
)
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
// }
//}
-11
View File
@@ -1,11 +0,0 @@
package models
import (
"time"
)
type BaseModel struct {
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt *time.Time `json:"deletedAt"`
}
+8 -4
View File
@@ -9,9 +9,9 @@ import (
"strings"
"github.com/bitly/go-simplejson"
"github.com/go-admin-team/go-admin-core/sdk"
"github.com/go-admin-team/go-admin-core/sdk/runtime"
"github.com/go-admin-team/go-admin-core/storage"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
"github.com/go-admin-team/go-admin-core/v2/storage"
"go-admin/common/models"
)
@@ -23,6 +23,10 @@ type SysApi struct {
Path string `json:"path" gorm:"size:128;comment:地址"`
Action string `json:"action" gorm:"size:16;comment:请求类型"`
Type string `json:"type" gorm:"size:16;comment:接口类型"`
// AppCode identifies which application's seed.SeedMenus call wrote this
// row; empty for the host's own built-in APIs. Same NOT NULL DEFAULT ''
// reasoning as SysMenu.AppCode.
AppCode string `json:"appCode" gorm:"type:varchar(64);not null;default:'';index:idx_sys_api_app_code;comment:AppCode"`
models.ModelTime
models.ControlBy
}
@@ -54,7 +58,7 @@ func SaveSysApi(message storage.Messager) (err error) {
err = fmt.Errorf("json Unmarshal error, %s", err.Error())
return err
}
dbList := sdk.Runtime.GetDb()
dbList := sdk.Runtime.GetAllDb()
for _, d := range dbList {
for _, v := range l.List {
if v.HttpMethod != "HEAD" ||
+4 -4
View File
@@ -5,9 +5,9 @@ import (
"errors"
"time"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
"github.com/go-admin-team/go-admin-core/storage"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/storage"
"go-admin/common/models"
)
@@ -45,7 +45,7 @@ func (e *SysLoginLog) GetId() interface{} {
// SaveLoginLog 从队列中获取登录日志
func SaveLoginLog(message storage.Messager) (err error) {
//准备db
db := sdk.Runtime.GetDbByKey(message.GetPrefix())
db := sdk.Runtime.GetDbByTenant(message.GetPrefix())
if db == nil {
err = errors.New("db not exist")
log.Errorf("host[%s]'s %s", message.GetPrefix(), err.Error())
+6
View File
@@ -26,6 +26,12 @@ type SysMenu struct {
RoleId int `gorm:"-"`
Children []SysMenu `json:"children,omitempty" gorm:"-"`
IsSelect bool `json:"is_select" gorm:"-"`
// AppCode identifies which application's seed.SeedMenus call wrote this
// row; empty for the host's own built-in menus. NOT NULL DEFAULT '' for
// the same reason sys_migration.app_code is (see contract/models.Migration):
// AutoMigrate adding this column to an existing table leaves every
// pre-existing row reading back as "" rather than NULL.
AppCode string `json:"appCode" gorm:"type:varchar(64);not null;default:'';index:idx_sys_menu_app_code;comment:AppCode"`
models.ControlBy
models.ModelTime
}
+4 -4
View File
@@ -5,9 +5,9 @@ import (
"errors"
"time"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
"github.com/go-admin-team/go-admin-core/storage"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/storage"
"go-admin/common/models"
)
@@ -53,7 +53,7 @@ func (e *SysOperaLog) GetId() interface{} {
// SaveOperaLog 从队列中获取操作日志
func SaveOperaLog(message storage.Messager) (err error) {
//准备db
db := sdk.Runtime.GetDbByKey(message.GetPrefix())
db := sdk.Runtime.GetDbByTenant(message.GetPrefix())
if db == nil {
err = errors.New("db not exist")
log.Errorf("host[%s]'s %s", message.GetPrefix(), err.Error())
+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)
}
}
})
}
+5 -7
View File
@@ -4,8 +4,8 @@ import (
"os"
"github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
common "go-admin/common/middleware"
)
@@ -25,11 +25,9 @@ func InitRouter() {
os.Exit(-1)
}
// the jwt middleware
authMiddleware, err := common.AuthInit()
if err != nil {
log.Fatalf("JWT Init Error, %s", err.Error())
}
// the jwt middleware: shared instance InitMiddleware built at startup,
// not one built here per module (see common/middleware.GetAuthMiddleware).
authMiddleware := common.GetAuthMiddleware()
// 注册系统路由
InitSysRouter(r, authMiddleware)
+2 -2
View File
@@ -3,8 +3,8 @@ package router
import (
"github.com/gin-gonic/gin"
_ "github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/go-admin-team/go-admin-core/v2/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
)
var (
+6 -2
View File
@@ -2,9 +2,10 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/actions"
"go-admin/common/middleware"
)
@@ -15,7 +16,10 @@ func init() {
// registerSysApiRouter
func registerSysApiRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
api := apis.SysApi{}
r := v1.Group("/sys-api").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
// PermissionAction is not optional here: all three handlers below read the
// data permission out of the context, and without it they read the zero
// value - an unset scope, which Permission now fails closed on.
r := v1.Group("/sys-api").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
{
r.GET("", api.GetPage)
r.GET("/:id", api.Get)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"go-admin/common/middleware"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
)
func init() {
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/middleware"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/middleware"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/middleware"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/middleware"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/middleware"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/middleware"
)
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/middleware"
+12 -5
View File
@@ -4,11 +4,11 @@ import (
"go-admin/app/admin/apis"
"mime"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/go-admin-team/go-admin-core/sdk/pkg/ws"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/ws"
ginSwagger "github.com/swaggo/gin-swagger"
swaggerfiles "github.com/swaggo/files"
@@ -69,8 +69,15 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
v1 := r.Group("/api/v1")
{
v1.POST("/login", authMiddleware.LoginHandler)
// Refresh time can be longer than token timeout
v1.GET("/refresh_token", authMiddleware.RefreshHandler)
// GET /api/v1/refresh_token 已移除,原因见 issue #820
// 该接口用业务 token 即可换取新 token,而续期上限 MaxRefresh 依据的
// orig_iat 在每次续期时被一并重置,上限永远无法到达 —— token 一旦泄
// 露即等同于永久访问权。它此前还位于 CasbinExclude 中,任何角色的已
// 登录用户都能调用,不受权限约束。
//
// 官方前端从未调用该接口(store 中的 refreshToken action 无人 dispatch),
// 移除不影响正常使用。若确需无感续期,应在 go-admin-core 中区分
// access token 与 refresh token 后重新实现,而非沿用此路由。
}
registerBaseRouter(v1, authMiddleware)
}
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/admin/apis"
"go-admin/common/actions"
"go-admin/common/middleware"
+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)
}
}
})
}
+308
View File
@@ -0,0 +1,308 @@
package service
import (
"errors"
"fmt"
"strconv"
"strings"
"gorm.io/gorm"
contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed"
"go-admin/app/admin/models"
)
// adminSeeder is go-admin's own implementation of seed.Seeder: it turns the
// MenuSpec/ApiSpec values a third-party application asks for into rows
// across the four tables a visible, working menu entry needs - sys_api,
// sys_menu, sys_menu_api_rule, and sys_role_menu/casbin_rule - following the
// same shape cmd/migrate/migration/version/1786700001000_demo_menu.go
// already hand-writes for the host's own demo module.
//
// See go-admin-core's docs/contract.md, "Application-supplied menu and API
// entries", for the requirements this satisfies, and the security note on
// seed.Seeder for what this boundary does and does not protect against: an
// application already holds the same *gorm.DB this receives and could write
// sys_menu/sys_api/casbin_rule directly, bypassing this entirely.
type adminSeeder struct{}
func init() {
seed.RegisterSeeder(adminSeeder{})
}
// adminRoleKey is the role every seeded menu is granted to. This mirrors
// 1786700001000_demo_menu.go's own convention rather than inventing a
// second one: MenuSpec carries no "which roles should see this" field for a
// Seeder to consult instead, and admin is the one role guaranteed to exist
// once the framework's own seed data has run.
const adminRoleKey = "admin"
// menuSortRange is what sys_menu.sort's column type actually holds.
//
// sort is `gorm:"size:4"`, which MySQL builds as a tinyint (-128..127);
// sqlite ignores the width and accepts anything, so this only ever surfaces
// on a real install, mid-migration, as Error 1264 - by which point the
// migration has already run other, non-transactional DDL that will not be
// retried. tools/checksilent's menu-sort-overflow check catches this for
// every MenuSpec-shaped literal committed to this repository, but it walks
// the repository's own source tree: a third-party application living in the
// module cache is invisible to it. This is the equivalent check for that
// application, run when its migration actually calls SeedMenus rather than
// never.
const (
menuSortMin = -128
menuSortMax = 127
)
func (adminSeeder) SeedMenus(tx *gorm.DB, appCode string, menus []seed.MenuSpec, apis []seed.ApiSpec) error {
apiRows, err := seedApis(tx, appCode, apis)
if err != nil {
return fmt.Errorf("seed: app %q: apis: %w", appCode, err)
}
menuIDs, err := seedMenuTree(tx, appCode, menus, apiRows)
if err != nil {
return fmt.Errorf("seed: app %q: menus: %w", appCode, err)
}
// Not `len(menuIDs) == 0`: grantToAdminRole grants two independent
// things, and an application is free to register apis without menus -
// endpoints another service calls, or a UI mounted somewhere else.
// Skipping the whole call on an empty menu list wrote the sys_api rows
// and then no casbin rule for them, so those endpoints were denied to
// everyone, admin included, with a migration that reported success.
if len(menuIDs) == 0 && len(apiRows) == 0 {
return nil
}
return grantToAdminRole(tx, menuIDs, apiRows)
}
// seedApis writes one sys_api row per ApiSpec and returns them keyed by
// ApiSpec.Code, so seedMenuTree can resolve a MenuSpec's ApiCodes into the
// rows sys_menu_api_rule needs to reference.
//
// sys_api.id is left to autoincrement rather than assigned by the caller,
// unlike 1786700001000_demo_menu.go's hand-picked ids: that migration is
// the one file tools/checksilent's menu-id-collision check can see, because
// it lives in this repository; nothing plays that role for a third-party
// application's ids in the module cache. Never accepting a caller-chosen id
// here removes the collision this Seeder has no way to detect instead of
// trying to detect it after the fact.
func seedApis(tx *gorm.DB, appCode string, apis []seed.ApiSpec) (map[string]models.SysApi, error) {
seen := make(map[string]bool, len(apis))
rows := make(map[string]models.SysApi, len(apis))
for _, a := range apis {
if a.Code == "" {
return nil, errors.New("ApiSpec.Code must not be empty")
}
if seen[a.Code] {
return nil, fmt.Errorf("duplicate ApiSpec.Code %q", a.Code)
}
seen[a.Code] = true
row := models.SysApi{
Handle: a.Handle,
Title: a.Title,
Path: a.Path,
Action: a.Method,
Type: "SYS",
AppCode: appCode,
}
if err := tx.Create(&row).Error; err != nil {
return nil, fmt.Errorf("api %q: %w", a.Code, err)
}
rows[a.Code] = row
}
return rows, nil
}
// seedMenuTree writes one sys_menu row per MenuSpec, resolving Parent/Code
// references into parent_id/paths, and returns every menu id created so the
// caller can grant them to a role.
//
// Specs do not have to be given in parent-before-child order: this makes
// repeated passes over the remaining specs, creating whichever ones have
// their Parent (if any) already created, until every spec is placed. A
// spec whose Parent never resolves - naming a Code missing from this call,
// or only reachable through a cycle - stops making progress and is reported
// rather than looping forever.
func seedMenuTree(tx *gorm.DB, appCode string, specs []seed.MenuSpec, apiRows map[string]models.SysApi) ([]int, error) {
byCode := make(map[string]seed.MenuSpec, len(specs))
for _, s := range specs {
if s.Code == "" {
return nil, errors.New("MenuSpec.Code must not be empty")
}
if _, dup := byCode[s.Code]; dup {
return nil, fmt.Errorf("duplicate MenuSpec.Code %q", s.Code)
}
if err := validateMenuSpec(s); err != nil {
return nil, fmt.Errorf("%q: %w", s.Code, err)
}
byCode[s.Code] = s
}
created := make(map[string]models.SysMenu, len(specs))
ids := make([]int, 0, len(specs))
for len(created) < len(specs) {
progressed := false
for _, s := range specs {
if _, done := created[s.Code]; done {
continue
}
var parentRow models.SysMenu
if s.Parent != "" {
parent, ok := created[s.Parent]
if !ok {
if _, exists := byCode[s.Parent]; !exists {
return nil, fmt.Errorf("%q: Parent %q is not a Code in this call", s.Code, s.Parent)
}
continue // s.Parent exists but has not been created yet; retry next pass
}
parentRow = parent
}
row := models.SysMenu{
MenuName: menuName(appCode, s.Code),
Title: s.Title,
Icon: s.Icon,
Path: s.Path,
MenuType: s.Kind,
Permission: s.Permission,
ParentId: parentRow.MenuId,
Component: s.Component,
Sort: s.Sort,
// Visible "0" is shown, not hidden - the same defaults
// 1786700001000_demo_menu.go seeds its own menu with. A
// freshly installed application's menu should not need an
// administrator to first find and unhide it.
Visible: "0",
IsFrame: "1",
AppCode: appCode,
}
for _, code := range s.ApiCodes {
api, ok := apiRows[code]
if !ok {
return nil, fmt.Errorf("%q: ApiCodes references %q, which is not an ApiSpec.Code in this call", s.Code, code)
}
// The full row, not just {Id: api.Id}: gorm's many2many
// association save upserts an associated row whose primary
// key is already set, so a stub carrying only Id would
// overwrite every other column of an sys_api row this same
// call just wrote with zero values.
row.SysApi = append(row.SysApi, api)
}
if err := tx.Create(&row).Error; err != nil {
return nil, fmt.Errorf("%q: %w", s.Code, err)
}
// paths is a materialized path from the root ("/0"), built from
// ids that only exist once the row above is created - the same
// two-step create-then-update 1786700001000_demo_menu.go's
// hand-assigned ids let it do in one literal, sequenced here
// instead.
if s.Parent == "" {
row.Paths = "/0/" + strconv.Itoa(row.MenuId)
} else {
row.Paths = parentRow.Paths + "/" + strconv.Itoa(row.MenuId)
}
if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", row.MenuId).
Update("paths", row.Paths).Error; err != nil {
return nil, fmt.Errorf("%q: writing paths: %w", s.Code, err)
}
created[s.Code] = row
ids = append(ids, row.MenuId)
progressed = true
}
if !progressed {
return nil, fmt.Errorf("unresolved Parent reference(s) among %d remaining spec(s); check for a cycle", len(specs)-len(created))
}
}
return ids, nil
}
// validateMenuSpec rejects the malformed input tools/checksilent's
// menu-sort-overflow and Kind-adjacent checks would catch for an in-tree
// seed but cannot for a third-party application's - see menuSortRange's doc
// comment.
func validateMenuSpec(s seed.MenuSpec) error {
switch s.Kind {
case contractmodels.Directory, contractmodels.Menu, contractmodels.Button:
default:
return fmt.Errorf("Kind %q is not one of Directory/Menu/Button", s.Kind)
}
if s.Sort < menuSortMin || s.Sort > menuSortMax {
return fmt.Errorf("Sort %d does not fit sys_menu.sort's tinyint column (%d..%d)", s.Sort, menuSortMin, menuSortMax)
}
return nil
}
// menuName synthesizes sys_menu.menu_name from appCode and the spec's Code,
// since MenuSpec carries no field of its own for it - contract/seed's
// package doc says a MenuSpec is what rendering a menu and checking a
// button permission need, not a mirror of sys_menu's columns.
//
// PascalCasing both and concatenating them, rather than using Code alone,
// is what keeps two applications that both picked the plain word "list" as
// a Code from producing the identical menu_name: the frontend's keep-alive
// cache matches a route by this exact string, not by (appCode, Code), so a
// collision there is a UI bug, not a database error, and nothing else here
// would ever surface it.
func menuName(appCode, code string) string {
return pascalCase(appCode) + pascalCase(code)
}
func pascalCase(s string) string {
var b strings.Builder
for _, part := range strings.FieldsFunc(s, func(r rune) bool { return r == '-' || r == '_' }) {
b.WriteString(strings.ToUpper(part[:1]))
b.WriteString(part[1:])
}
return b.String()
}
// grantToAdminRole is sys_role_menu and casbin_rule: the two tables
// go-admin-core's contract.md requires alongside sys_menu/sys_api, without
// which a seeded menu is invisible to every role and its apis are
// authorized for no one.
//
// It follows 1786700001000_demo_menu.go's exact pattern, including
// tolerating a missing admin role: a database that has not yet run the
// framework's own seed data (config/db.sql, inside 1599190683659_tables.go)
// has nothing to grant to yet, and namespacedKey's ordering guarantee - every
// framework migration sorts before every app-prefixed one - means that
// should not happen in practice, but failing this call over it would be
// worse than a menu with no grant yet.
func grantToAdminRole(tx *gorm.DB, menuIDs []int, apiRows map[string]models.SysApi) error {
var role models.SysRole
if err := tx.Where("role_key = ?", adminRoleKey).First(&role).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
for _, id := range menuIDs {
if err := tx.Exec(
"INSERT INTO sys_role_menu (role_id, menu_id) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu WHERE role_id = ? AND menu_id = ?)",
role.RoleId, id, role.RoleId, id,
).Error; err != nil {
return err
}
}
for _, a := range apiRows {
if err := tx.Exec(
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) SELECT 'p', ?, ?, ?, '', '', '' WHERE NOT EXISTS (SELECT 1 FROM casbin_rule WHERE ptype='p' AND v0=? AND v1=? AND v2=?)",
role.RoleKey, a.Path, a.Action, role.RoleKey, a.Path, a.Action,
).Error; err != nil {
return err
}
}
return nil
}
+291
View File
@@ -0,0 +1,291 @@
package service
import (
"errors"
"strconv"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed"
"go-admin/app/admin/models"
)
// newSeedTestDB builds the tables adminSeeder.SeedMenus writes to. sys_menu,
// sys_api, sys_role and sys_role_menu (GORM's own join table for
// SysRole.SysMenu) come from AutoMigrate; casbin_rule does not have a GORM
// model anywhere in this codebase - see 1786700001000_demo_menu.go's own
// comment on why models.CasbinRule (-> sys_casbin_rule) is the wrong table -
// so it is created directly, matching the columns grantToAdminRole's INSERT
// addresses.
func newSeedTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.AutoMigrate(&models.SysMenu{}, &models.SysApi{}, &models.SysRole{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
if err := db.Exec(`CREATE TABLE casbin_rule (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ptype TEXT, v0 TEXT, v1 TEXT, v2 TEXT, v3 TEXT, v4 TEXT, v5 TEXT
)`).Error; err != nil {
t.Fatalf("create casbin_rule: %v", err)
}
return db
}
func seedAdminRole(t *testing.T, db *gorm.DB) models.SysRole {
t.Helper()
role := models.SysRole{RoleName: "Administrator", RoleKey: adminRoleKey}
if err := db.Create(&role).Error; err != nil {
t.Fatalf("seed admin role: %v", err)
}
return role
}
// This is the acceptance case go-admin-core's docs/contract.md requires: one
// SeedMenus call populates all four tables a visible, working menu entry
// needs, every row tagged with the appCode it was called with, and the
// parent/child tree resolved into sys_menu's parent_id/paths.
func TestSeedMenusPopulatesAllFourTables(t *testing.T) {
db := newSeedTestDB(t)
seedAdminRole(t, db)
menus := []seed.MenuSpec{
{Code: "dir", Kind: contractmodels.Directory, Title: "Order Example", Path: "/apps/order", Component: "Layout", Sort: 10},
{Code: "list", Parent: "dir", Kind: contractmodels.Menu, Title: "Orders", Path: "list", Component: "apps/order/order/index", Sort: 1, ApiCodes: []string{"list"}},
{Code: "btn-create", Parent: "list", Kind: contractmodels.Button, Title: "Create", Permission: "order:order:create", Sort: 1},
}
apis := []seed.ApiSpec{
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
}
err := db.Transaction(func(tx *gorm.DB) error {
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
})
if err != nil {
t.Fatalf("SeedMenus: %v", err)
}
var apiRows []models.SysApi
if err := db.Find(&apiRows).Error; err != nil {
t.Fatal(err)
}
if len(apiRows) != 1 || apiRows[0].AppCode != "order" || apiRows[0].Path != "/api/v1/order" {
t.Fatalf("sys_api = %+v", apiRows)
}
var menuRows []models.SysMenu
if err := db.Order("sort").Find(&menuRows).Error; err != nil {
t.Fatal(err)
}
if len(menuRows) != 3 {
t.Fatalf("sys_menu has %d rows, want 3: %+v", len(menuRows), menuRows)
}
byName := map[string]models.SysMenu{}
for _, m := range menuRows {
if m.AppCode != "order" {
t.Errorf("menu %q app_code = %q, want order", m.MenuName, m.AppCode)
}
byName[m.MenuName] = m
}
dir, ok := byName[menuName("order", "dir")]
if !ok || dir.ParentId != 0 || dir.Paths != "/0/"+strconv.Itoa(dir.MenuId) {
t.Fatalf("dir menu = %+v", dir)
}
list, ok := byName[menuName("order", "list")]
if !ok || list.ParentId != dir.MenuId || list.Paths != dir.Paths+"/"+strconv.Itoa(list.MenuId) {
t.Fatalf("list menu = %+v (dir=%+v)", list, dir)
}
btn, ok := byName[menuName("order", "btn-create")]
if !ok || btn.ParentId != list.MenuId {
t.Fatalf("btn menu = %+v (list=%+v)", btn, list)
}
// sys_menu_api_rule: gorm's own many2many join table for SysMenu.SysApi.
var joinCount int64
if err := db.Table("sys_menu_api_rule").
Where("sys_menu_menu_id = ? AND sys_api_id = ?", list.MenuId, apiRows[0].Id).
Count(&joinCount).Error; err != nil {
t.Fatal(err)
}
if joinCount != 1 {
t.Errorf("sys_menu_api_rule has %d row(s) linking list to its api, want 1", joinCount)
}
// sys_role_menu: every seeded menu granted to the admin role.
var roleMenuCount int64
if err := db.Table("sys_role_menu").Count(&roleMenuCount).Error; err != nil {
t.Fatal(err)
}
if roleMenuCount != 3 {
t.Errorf("sys_role_menu has %d row(s), want 3 (one per seeded menu)", roleMenuCount)
}
// casbin_rule: the api's path/method granted to the admin role.
var casbinCount int64
if err := db.Table("casbin_rule").
Where("ptype = 'p' AND v0 = ? AND v1 = ? AND v2 = ?", adminRoleKey, "/api/v1/order", "GET").
Count(&casbinCount).Error; err != nil {
t.Fatal(err)
}
if casbinCount != 1 {
t.Errorf("casbin_rule has %d matching row(s), want 1", casbinCount)
}
}
// A database that has not run the framework's own seed data yet (no admin
// role) must not fail SeedMenus - 1786700001000_demo_menu.go tolerates
// exactly the same condition for the host's own demo module.
func TestSeedMenusToleratesMissingAdminRole(t *testing.T) {
db := newSeedTestDB(t)
err := db.Transaction(func(tx *gorm.DB) error {
return adminSeeder{}.SeedMenus(tx, "order", []seed.MenuSpec{
{Code: "dir", Kind: contractmodels.Directory, Title: "Order"},
}, nil)
})
if err != nil {
t.Fatalf("SeedMenus: %v", err)
}
var roleMenuCount int64
if err := db.Table("sys_role_menu").Count(&roleMenuCount).Error; err != nil {
t.Fatal(err)
}
if roleMenuCount != 0 {
t.Errorf("sys_role_menu has %d row(s) with no role to grant to", roleMenuCount)
}
}
func TestSeedMenusRejectsMalformedSpecs(t *testing.T) {
cases := []struct {
name string
menus []seed.MenuSpec
apis []seed.ApiSpec
want string
}{
{
name: "duplicate menu code",
menus: []seed.MenuSpec{{Code: "a", Kind: contractmodels.Directory}, {Code: "a", Kind: contractmodels.Directory}},
want: `duplicate MenuSpec.Code "a"`,
},
{
name: "unresolved parent",
menus: []seed.MenuSpec{{Code: "a", Parent: "missing", Kind: contractmodels.Menu}},
want: `Parent "missing" is not a Code in this call`,
},
{
name: "unresolved api code",
menus: []seed.MenuSpec{{Code: "a", Kind: contractmodels.Menu, ApiCodes: []string{"missing"}}},
want: `ApiCodes references "missing"`,
},
{
name: "unknown kind",
menus: []seed.MenuSpec{{Code: "a", Kind: "X"}},
want: `Kind "X" is not one of Directory/Menu/Button`,
},
{
name: "sort overflows a tinyint",
menus: []seed.MenuSpec{{Code: "a", Kind: contractmodels.Directory, Sort: 900}},
want: `Sort 900 does not fit sys_menu.sort's tinyint column`,
},
{
name: "duplicate api code",
apis: []seed.ApiSpec{{Code: "x"}, {Code: "x"}},
want: `duplicate ApiSpec.Code "x"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
db := newSeedTestDB(t)
err := db.Transaction(func(tx *gorm.DB) error {
return adminSeeder{}.SeedMenus(tx, "order", tc.menus, tc.apis)
})
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want it to contain %q", err, tc.want)
}
})
}
}
// TestSeederIsRegistered pins the registration itself, not the behaviour.
//
// Every other test here calls adminSeeder{}.SeedMenus directly, which proves
// the implementation is right and proves nothing about whether anything ever
// reaches it: delete the RegisterSeeder call in init() and they all stay
// green, while a real migrate fails with ErrNoSeeder and no menu is written.
// Going through the package-level SeedMenus is what closes that gap - it is
// the door an application actually knocks on.
func TestSeederIsRegistered(t *testing.T) {
db := newSeedTestDB(t)
err := db.Transaction(func(tx *gorm.DB) error {
return seed.SeedMenus(tx, "probe", []seed.MenuSpec{{
Code: "root", Kind: contractmodels.Directory, Title: "Probe", Sort: 1,
}}, nil)
})
if errors.Is(err, seed.ErrNoSeeder) {
t.Fatal("no Seeder is registered: an application's SeedMenus would write no menu at all")
}
if err != nil {
t.Fatalf("SeedMenus through the package-level entry point: %v", err)
}
}
// An application is free to register apis with no menus at all - endpoints
// another service calls, or a UI mounted somewhere else. Skipping
// grantToAdminRole on an empty menu list wrote the sys_api rows and then no
// casbin rule for them, so every one of those endpoints was denied to
// everyone including admin, from a migration that reported success.
func TestSeedMenusGrantsApisWhenThereAreNoMenus(t *testing.T) {
db := newSeedTestDB(t)
role := seedAdminRole(t, db)
apis := []seed.ApiSpec{
{Code: "hook", Title: "Inbound hook", Path: "/api/v1/hook", Method: "POST", Handle: "hook.Receive"},
{Code: "sync", Title: "Sync", Path: "/api/v1/sync", Method: "GET", Handle: "hook.Sync"},
}
if err := (adminSeeder{}).SeedMenus(db, "hooks", nil, apis); err != nil {
t.Fatalf("SeedMenus: %v", err)
}
var apiCount int64
db.Model(&models.SysApi{}).Where("app_code = ?", "hooks").Count(&apiCount)
if apiCount != int64(len(apis)) {
t.Fatalf("sys_api rows = %d, want %d", apiCount, len(apis))
}
for _, a := range apis {
var n int64
db.Table("casbin_rule").
Where("ptype = 'p' AND v0 = ? AND v1 = ? AND v2 = ?", role.RoleKey, a.Path, a.Method).
Count(&n)
if n != 1 {
t.Errorf("casbin_rule for %s %s = %d rows, want 1: the endpoint is denied to admin", a.Method, a.Path, n)
}
}
}
// The other half of the same guard: nothing registered at all must stay a
// no-op rather than start touching sys_role_menu or casbin_rule.
func TestSeedMenusWithNothingRegisteredWritesNothing(t *testing.T) {
db := newSeedTestDB(t)
seedAdminRole(t, db)
if err := (adminSeeder{}).SeedMenus(db, "empty", nil, nil); err != nil {
t.Fatalf("SeedMenus: %v", err)
}
for _, table := range []string{"casbin_rule", "sys_role_menu"} {
var n int64
db.Table(table).Count(&n)
if n != 0 {
t.Errorf("%s has %d rows, want 0", table, n)
}
}
}
+27 -12
View File
@@ -4,14 +4,15 @@ import (
"errors"
"fmt"
"github.com/go-admin-team/go-admin-core/sdk/runtime"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
"go-admin/app/admin/models"
"go-admin/app/admin/service/dto"
"go-admin/common/actions"
cDto "go-admin/common/dto"
"go-admin/common/global"
)
type SysApi struct {
@@ -34,7 +35,12 @@ func (e *SysApi) GetPage(c *dto.SysApiGetPageReq, p *actions.DataPermission, lis
if qType == "暂无" {
qType = ""
}
orm = orm.Where("`type` = ?", qType)
if global.Driver == "postgres" {
orm = orm.Where("type = ?", qType)
} else {
orm = orm.Where("`type` = ?", qType)
}
}
err = orm.Find(list).Limit(-1).Offset(-1).
Count(count).Error
@@ -52,15 +58,15 @@ func (e *SysApi) Get(d *dto.SysApiGetReq, p *actions.DataPermission, model *mode
Scopes(
actions.Permission(data.TableName(), p),
).
First(model, d.GetId()).Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
err = errors.New("查看对象不存在或无权查看")
e.Log.Errorf("Service GetSysApi error:%s", err)
FirstOrInit(model, d.GetId()).Error
if err != nil {
e.Log.Errorf("db error:%s", err)
_ = e.AddError(err)
return e
}
if err != nil {
e.Log.Errorf("db error:%s", err)
if model.Id == 0 {
err = errors.New("查看对象不存在或无权查看")
e.Log.Errorf("Service GetSysApi error: %s", err)
_ = e.AddError(err)
return e
}
@@ -70,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)
}
}
+12 -11
View File
@@ -7,8 +7,7 @@ import (
"go-admin/app/admin/service/dto"
cDto "go-admin/common/dto"
"github.com/go-admin-team/go-admin-core/sdk/service"
"gorm.io/gorm"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
)
type SysConfig struct {
@@ -33,14 +32,18 @@ func (e *SysConfig) GetPage(c *dto.SysConfigGetPageReq, list *[]models.SysConfig
// Get 获取SysConfig对象
func (e *SysConfig) Get(d *dto.SysConfigGetReq, model *models.SysConfig) error {
err := e.Orm.First(model, d.GetId()).Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
err = errors.New("查看对象不存在或无权查看")
e.Log.Errorf("Service GetSysConfigPage error:%s", err)
err := e.Orm.
FirstOrInit(model, d.GetId()).
Error
if err != nil {
e.Log.Errorf("db error:%s", err)
_ = e.AddError(err)
return err
}
if err != nil {
e.Log.Errorf("Service GetSysConfig error:%s", err)
if model.Id == 0 {
err = errors.New("查看对象不存在或无权查看")
e.Log.Errorf("Service GetSysApi error: %s", err)
_ = e.AddError(err)
return err
}
return nil
@@ -143,7 +146,6 @@ func (e *SysConfig) Remove(d *dto.SysConfigDeleteReq) error {
db := e.Orm.Delete(&data, d.Ids)
if err = db.Error; err != nil {
err = db.Error
e.Log.Errorf("Service RemoveSysConfig error:%s", err)
return err
}
@@ -168,8 +170,7 @@ func (e *SysConfig) GetWithKey(c *dto.SysConfigByKeyReq, resp *dto.GetSysConfigB
}
func (e *SysConfig) GetWithKeyList(c *dto.SysConfigGetToSysAppReq, list *[]models.SysConfig) error {
var err error
err = e.Orm.
err := e.Orm.
Scopes(
cDto.MakeCondition(c.GetNeedSearch()),
).
+22 -23
View File
@@ -4,15 +4,13 @@ import (
"errors"
"go-admin/app/admin/models"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"gorm.io/gorm"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"go-admin/app/admin/service/dto"
cDto "go-admin/common/dto"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
)
type SysDept struct {
@@ -41,16 +39,18 @@ func (e *SysDept) Get(d *dto.SysDeptGetReq, model *models.SysDept) error {
var err error
var data models.SysDept
db := e.Orm.Model(&data).
First(model, d.GetId())
err = db.Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
err = errors.New("查看对象不存在或无权查看")
err = e.Orm.Model(&data).
FirstOrInit(model, d.GetId()).
Error
if err != nil {
e.Log.Errorf("db error:%s", err)
_ = e.AddError(err)
return err
}
if err = db.Error; err != nil {
e.Log.Errorf("db error:%s", err)
if model.DeptId == 0 {
err = errors.New("查看对象不存在或无权查看")
e.Log.Errorf("Service GetSysApi error: %s", err)
_ = e.AddError(err)
return err
}
return nil
@@ -84,7 +84,7 @@ func (e *SysDept) Insert(c *dto.SysDeptInsertReq) error {
}
var mp = map[string]string{}
mp["dept_path"] = deptPath
if err := tx.Model(&data).Update("dept_path", deptPath).Error; err != nil {
if err = tx.Model(&data).Update("dept_path", deptPath).Error; err != nil {
e.Log.Errorf("db error:%s", err)
return err
}
@@ -133,7 +133,6 @@ func (e *SysDept) Remove(d *dto.SysDeptDeleteReq) error {
db := e.Orm.Model(&data).Delete(&data, d.GetId())
if err = db.Error; err != nil {
err = db.Error
e.Log.Errorf("Delete error: %s", err)
return err
}
@@ -184,16 +183,16 @@ func (e *SysDept) SetDeptTree(c *dto.SysDeptGetPageReq) (m []dto.DeptLabel, err
// Call 递归构造组织数据
func deptTreeCall(deptList *[]models.SysDept, dept dto.DeptLabel) dto.DeptLabel {
list := *deptList
min := make([]dto.DeptLabel, 0)
childrenList := make([]dto.DeptLabel, 0)
for j := 0; j < len(list); j++ {
if dept.Id != list[j].ParentId {
continue
}
mi := dto.DeptLabel{Id: list[j].DeptId, Label: list[j].DeptName, Children: []dto.DeptLabel{}}
ms := deptTreeCall(deptList, mi)
min = append(min, ms)
childrenList = append(childrenList, ms)
}
dept.Children = min
dept.Children = childrenList
return dept
}
@@ -213,7 +212,7 @@ func (e *SysDept) SetDeptPage(c *dto.SysDeptGetPageReq) (m []models.SysDept, err
func (e *SysDept) deptPageCall(deptlist *[]models.SysDept, menu models.SysDept) models.SysDept {
list := *deptlist
min := make([]models.SysDept, 0)
childrenList := make([]models.SysDept, 0)
for j := 0; j < len(list); j++ {
if menu.DeptId != list[j].ParentId {
continue
@@ -231,9 +230,9 @@ func (e *SysDept) deptPageCall(deptlist *[]models.SysDept, menu models.SysDept)
mi.CreatedAt = list[j].CreatedAt
mi.Children = []models.SysDept{}
ms := e.deptPageCall(deptlist, mi)
min = append(min, ms)
childrenList = append(childrenList, ms)
}
menu.Children = min
menu.Children = childrenList
return menu
}
@@ -281,15 +280,15 @@ func (e *SysDept) SetDeptLabel() (m []dto.DeptLabel, err error) {
func deptLabelCall(deptList *[]models.SysDept, dept dto.DeptLabel) dto.DeptLabel {
list := *deptList
var mi dto.DeptLabel
min := make([]dto.DeptLabel, 0)
childrenList := make([]dto.DeptLabel, 0)
for j := 0; j < len(list); j++ {
if dept.Id != list[j].ParentId {
continue
}
mi = dto.DeptLabel{Id: list[j].DeptId, Label: list[j].DeptName, Children: []dto.DeptLabel{}}
ms := deptLabelCall(deptList, mi)
min = append(min, ms)
childrenList = append(childrenList, ms)
}
dept.Children = min
dept.Children = childrenList
return dept
}
+1 -2
View File
@@ -3,7 +3,7 @@ package service
import (
"errors"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
"go-admin/app/admin/models"
@@ -92,7 +92,6 @@ func (e *SysDictData) Remove(c *dto.SysDictDataDeleteReq) error {
db := e.Orm.Delete(&data, c.GetId())
if err = db.Error; err != nil {
err = db.Error
e.Log.Errorf("Delete error: %s", err)
return err
}
+9 -4
View File
@@ -3,7 +3,8 @@ package service
import (
"errors"
"fmt"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
"go-admin/app/admin/models"
@@ -58,9 +59,14 @@ func (e *SysDictType) Insert(c *dto.SysDictTypeInsertReq) error {
var data models.SysDictType
c.Generate(&data)
var count int64
e.Orm.Model(&data).Where("dict_type = ?", data.DictType).Count(&count)
// The error was dropped, so a query that failed left count at zero and the
// insert went ahead as though the name were free.
if err = e.Orm.Model(&data).Where("dict_type = ?", data.DictType).Count(&count).Error; err != nil {
e.Log.Errorf("db error: %s", err)
return err
}
if count > 0 {
return errors.New(fmt.Sprintf("当前字典类型[%s]已经存在!", data.DictType))
return fmt.Errorf("当前字典类型[%s]已经存在!", data.DictType)
}
err = e.Orm.Create(&data).Error
if err != nil {
@@ -95,7 +101,6 @@ func (e *SysDictType) Remove(d *dto.SysDictTypeDeleteReq) error {
db := e.Orm.Delete(&data, d.GetId())
if err = db.Error; err != nil {
err = db.Error
e.Log.Errorf("Delete error: %s", err)
return err
}
+1 -2
View File
@@ -3,7 +3,7 @@ package service
import (
"errors"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
"go-admin/app/admin/models"
@@ -58,7 +58,6 @@ func (e *SysLoginLog) Remove(c *dto.SysLoginLogDeleteReq) error {
db := e.Orm.Delete(&data, c.GetId())
if err = db.Error; err != nil {
err = db.Error
e.Log.Errorf("Delete error: %s", err)
return err
}
+7 -4
View File
@@ -5,7 +5,7 @@ import (
"sort"
"strings"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"github.com/pkg/errors"
"gorm.io/gorm"
@@ -14,7 +14,7 @@ import (
cDto "go-admin/common/dto"
cModels "go-admin/common/models"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
)
type SysMenu struct {
@@ -192,7 +192,6 @@ func (e *SysMenu) Remove(d *dto.SysMenuDeleteReq) *SysMenu {
db := e.Orm.Model(&data).Delete(&data, d.Ids)
if err = db.Error; err != nil {
err = db.Error
e.Log.Errorf("Delete error: %s", err)
_ = e.AddError(err)
}
@@ -396,7 +395,11 @@ func (e *SysMenu) getByRoleName(roleName string) ([]models.SysMenu, error) {
data := make([]models.SysMenu, 0)
if roleName == "admin" {
err = e.Orm.Where(" menu_type in ('M','C') and deleted_at is null").
// The soft-delete condition is GORM's to add: it appends one for the
// model's DeletedAt field on every query. Writing it by hand duplicates
// that and hard-codes what "deleted" looks like — a column that stops
// being nullable turns this clause into one that matches nothing.
err = e.Orm.Where("menu_type in ('M','C')").
Order("sort").
Find(&data).
Error
@@ -0,0 +1,55 @@
package service
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"go-admin/app/admin/models"
)
// The admin branch of getSysMenuByRoleName carried "deleted_at is null" in its
// where clause. GORM adds that condition itself for a model with a DeletedAt
// field, so the clause was a duplicate — and one written in terms of a column
// being null, which stops being true the moment the column stops being
// nullable. This pins the behaviour the clause was there for.
func TestSoftDeletedMenusAreNotReturned(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.AutoMigrate(&models.SysMenu{}); err != nil {
t.Fatalf("migrate: %v", err)
}
live := models.SysMenu{MenuName: "live", MenuType: "M"}
gone := models.SysMenu{MenuName: "gone", MenuType: "M"}
if err := db.Create(&live).Error; err != nil {
t.Fatalf("create: %v", err)
}
if err := db.Create(&gone).Error; err != nil {
t.Fatalf("create: %v", err)
}
if err := db.Delete(&gone).Error; err != nil {
t.Fatalf("delete: %v", err)
}
// Through getByRoleName rather than a copy of its query: a test that
// reissues the statement passes whether or not the production line still
// says what it is supposed to, which is what the first version of this
// test did.
e := &SysMenu{}
e.Orm = db
got, err := e.getByRoleName("admin")
if err != nil {
t.Fatalf("getByRoleName: %v", err)
}
if len(got) != 1 {
t.Fatalf("got %d rows, want 1", len(got))
}
if got[0].MenuName != "live" {
t.Errorf("got %q, want the row that was not deleted", got[0].MenuName)
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"go-admin/app/admin/service/dto"
cDto "go-admin/common/dto"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
)
+3 -4
View File
@@ -3,7 +3,7 @@ package service
import (
"errors"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
"go-admin/app/admin/models"
@@ -47,7 +47,7 @@ func (e *SysPost) Get(d *dto.SysPostGetReq, model *models.SysPost) error {
e.Log.Errorf("db error:%s", err)
return err
}
if err = db.Error; err != nil {
if err != nil {
e.Log.Errorf("db error:%s", err)
return err
}
@@ -93,7 +93,6 @@ func (e *SysPost) Remove(d *dto.SysPostDeleteReq) error {
db := e.Orm.Model(&data).Delete(&data, d.GetId())
if err = db.Error; err != nil {
err = db.Error
e.Log.Errorf("Delete error: %s", err)
return err
}
@@ -102,4 +101,4 @@ func (e *SysPost) Remove(d *dto.SysPostDeleteReq) error {
return err
}
return nil
}
}
+8 -8
View File
@@ -3,12 +3,12 @@ package service
import (
"errors"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"gorm.io/gorm/clause"
"github.com/casbin/casbin/v2"
"github.com/casbin/casbin/v3"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
"go-admin/app/admin/models"
@@ -75,7 +75,7 @@ func (e *SysRole) Insert(c *dto.SysRoleInsertReq, cb *casbin.SyncedEnforcer) err
c.Generate(&data)
tx := e.Orm
if config.DatabaseConfig.Driver != "sqlite3" {
tx := e.Orm.Begin()
tx = e.Orm.Begin()
defer func() {
if err != nil {
tx.Rollback()
@@ -132,7 +132,7 @@ func (e *SysRole) Update(c *dto.SysRoleUpdateReq, cb *casbin.SyncedEnforcer) err
var err error
tx := e.Orm
if config.DatabaseConfig.Driver != "sqlite3" {
tx := e.Orm.Begin()
tx = e.Orm.Begin()
defer func() {
if err != nil {
tx.Rollback()
@@ -197,7 +197,7 @@ func (e *SysRole) Remove(c *dto.SysRoleDeleteReq, cb *casbin.SyncedEnforcer) err
var err error
tx := e.Orm
if config.DatabaseConfig.Driver != "sqlite3" {
tx := e.Orm.Begin()
tx = e.Orm.Begin()
defer func() {
if err != nil {
tx.Rollback()
@@ -244,7 +244,7 @@ func (e *SysRole) UpdateDataScope(c *dto.RoleDataScopeReq) *SysRole {
var err error
tx := e.Orm
if config.DatabaseConfig.Driver != "sqlite3" {
tx := e.Orm.Begin()
tx = e.Orm.Begin()
defer func() {
if err != nil {
tx.Rollback()
@@ -285,7 +285,7 @@ func (e *SysRole) UpdateStatus(c *dto.UpdateStatusReq) error {
var err error
tx := e.Orm
if config.DatabaseConfig.Driver != "sqlite3" {
tx := e.Orm.Begin()
tx = e.Orm.Begin()
defer func() {
if err != nil {
tx.Rollback()
+1 -1
View File
@@ -1,7 +1,7 @@
package service
import (
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
)
// SysRoleMenu 即将弃用结构体
+42 -4
View File
@@ -5,9 +5,9 @@ import (
"go-admin/app/admin/models"
"go-admin/app/admin/service/dto"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/service"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
"go-admin/common/actions"
@@ -38,6 +38,30 @@ func (e *SysUser) GetPage(c *dto.SysUserGetPageReq, p *actions.DataPermission, l
return nil
}
// GetSelf 获取调用者自己的 SysUser 对象,不套数据权限
//
// The data scope answers "whose rows may this user see"; the caller here is
// reading their own, and the id comes from the token, so there is nothing left
// for a scope to restrict. Applying one is not a stricter version of this
// query - it is a broken one. DataScopeSelf matches on create_by, and a user
// account is created by whoever added it, so a scoped self-read would fail for
// every user who did not create their own account.
//
// GetProfile has always read the same row this way, with no scope at all.
func (e *SysUser) GetSelf(d *dto.SysUserById, model *models.SysUser) error {
err := e.Orm.First(model, d.GetId()).Error
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
err = errors.New("查看对象不存在或无权查看")
e.Log.Errorf("db error: %s", err)
return err
}
if err != nil {
e.Log.Errorf("db error: %s", err)
return err
}
return nil
}
// Get 获取SysUser对象
func (e *SysUser) Get(d *dto.SysUserById, p *actions.DataPermission, model *models.SysUser) error {
var data models.SysUser
@@ -84,7 +108,16 @@ func (e *SysUser) Insert(c *dto.SysUserInsertReq) error {
}
// Update 修改SysUser对象
func (e *SysUser) Update(c *dto.SysUserUpdateReq, p *actions.DataPermission) error {
//
// callerId is who is asking, not who SetUpdateBy recorded - that field only
// says who to blame, it never constrained who could be edited. When the
// target is the caller themselves, roleId/deptId/status are kept at whatever
// the database already has no matter what the request body carries: this is
// the personal-center screen's route (see CasbinExclude in settings.go, and
// the check in the API handler ahead of this call), and letting a caller
// grant themselves a different role or department through it would be a
// privilege escalation the exclusion was never meant to open.
func (e *SysUser) Update(c *dto.SysUserUpdateReq, p *actions.DataPermission, callerId int) error {
var err error
var model models.SysUser
db := e.Orm.Scopes(
@@ -98,6 +131,11 @@ func (e *SysUser) Update(c *dto.SysUserUpdateReq, p *actions.DataPermission) err
return errors.New("无权更新该数据")
}
if model.UserId == callerId {
c.RoleId = model.RoleId
c.DeptId = model.DeptId
c.Status = model.Status
}
c.Generate(&model)
update := e.Orm.Model(&model).Where("user_id = ?", &model.UserId).Omit("password", "salt").Updates(&model)
if err = update.Error; err != nil {
+37
View File
@@ -0,0 +1,37 @@
package models
import (
"go-admin/common/models"
)
// DemoProduct 示例模型
//
// 内嵌 ControlBy 与 ModelTime 后,创建人/更新人与时间戳由框架自动维护;
// 数据权限(actions.Permission)正是按 create_by 过滤,缺少 ControlBy 会使其失效。
type DemoProduct struct {
models.Model
Name string `json:"name" gorm:"size:128;comment:名称"`
Code string `json:"code" gorm:"size:64;comment:编码"`
Price float64 `json:"price" gorm:"comment:单价"`
Status string `json:"status" gorm:"size:4;comment:状态"`
Remark string `json:"remark" gorm:"size:255;comment:备注"`
models.ControlBy
models.ModelTime
}
func (DemoProduct) TableName() string {
return "demo_product"
}
// Generate 返回副本,供通用 Action 使用。
// 必须返回新实例:Action 在并发请求间复用同一个模型指针,就地返回会串数据。
func (e *DemoProduct) Generate() models.ActiveRecord {
o := *e
return &o
}
func (e *DemoProduct) GetId() interface{} {
return e.Id
}
+49
View File
@@ -0,0 +1,49 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/demo/models"
"go-admin/app/demo/service/dto"
"go-admin/common/actions"
"go-admin/common/middleware"
)
// 路由通过 init 自注册,无需在任何中心文件登记。
// 新建应用时用 `go run main.go app -n <名称>` 生成骨架,
// 它会同时产出 cmd/api/<名称>.go 完成注册。
func init() {
routerCheckRole = append(routerCheckRole, registerDemoProductRouter)
}
// registerDemoProductRouter 标准 CRUD 的推荐写法。
//
// 五个通用 Action 覆盖了增删改查的全部样板逻辑——参数绑定、数据权限过滤、
// 操作人注入、分页、错误响应,因此本模块没有 apis 与 service 文件。
//
// 仅当业务逻辑超出单表 CRUD(如跨表事务、外部调用、复杂校验)时,才需要
// 自行编写 Handler 与 Service,写法参照 app/admin/apis/sys_post.go。
func registerDemoProductRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
r := v1.Group("/demo-product").
Use(authMiddleware.MiddlewareFunc()). // JWT 认证
Use(middleware.AuthCheckRole()) // Casbin 鉴权
{
m := &models.DemoProduct{}
// actions.PermissionAction() 注入数据权限上下文,
// 列表与详情缺少它会绕过 DataScope 过滤
r.GET("", actions.PermissionAction(), actions.IndexAction(m, new(dto.DemoProductSearch), func() interface{} {
list := make([]models.DemoProduct, 0)
return &list
}))
r.GET("/:id", actions.PermissionAction(), actions.ViewAction(new(dto.DemoProductById), func() interface{} {
return &models.DemoProduct{}
}))
r.POST("", actions.CreateAction(new(dto.DemoProductControl)))
r.PUT("/:id", actions.PermissionAction(), actions.UpdateAction(new(dto.DemoProductControl)))
r.DELETE("", actions.PermissionAction(), actions.DeleteAction(new(dto.DemoProductById)))
}
}
+72
View File
@@ -0,0 +1,72 @@
package router
import (
"github.com/gin-gonic/gin"
_ "github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
// "github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
common "go-admin/common/middleware"
"os"
)
var (
routerNoCheckRole = make([]func(*gin.RouterGroup), 0)
routerCheckRole = make([]func(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware), 0)
)
// InitRouter 路由初始化
func InitRouter() {
var r *gin.Engine
h := sdk.Runtime.GetEngine()
if h == nil {
h = gin.New()
sdk.Runtime.SetEngine(h)
}
switch h.(type) {
case *gin.Engine:
r = h.(*gin.Engine)
default:
log.Fatal("not support other engine")
os.Exit(-1)
}
// the jwt middleware: shared instance InitMiddleware built at startup,
// not one built here per module (see common/middleware.GetAuthMiddleware).
authMiddleware := common.GetAuthMiddleware()
// 注册业务路由
InitBusinessRouter(r, authMiddleware)
}
func InitBusinessRouter(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) *gin.Engine {
// 无需认证的路由
noCheckRoleRouter(r)
// 需要认证的路由
checkRoleRouter(r, authMiddleware)
return r
}
// noCheckRoleRouter 无需认证的路由
func noCheckRoleRouter(r *gin.Engine) {
// 可根据业务需求来设置接口版本
v := r.Group("/api/v1")
for _, f := range routerNoCheckRole {
f(v)
}
}
// checkRoleRouter 需要认证的路由
func checkRoleRouter(r *gin.Engine, authMiddleware *jwtauth.GinJWTMiddleware) {
// 可根据业务需求来设置接口版本
v := r.Group("/api/v1")
for _, f := range routerCheckRole {
f(v, authMiddleware)
}
}
+96
View File
@@ -0,0 +1,96 @@
package dto
import (
"github.com/gin-gonic/gin"
"go-admin/app/demo/models"
"go-admin/common/dto"
common "go-admin/common/models"
)
// DemoProductSearch 列表查询条件
//
// search tag 决定 MakeCondition 拼出的 WHERE
//
// exact 精确匹配 / icontains 忽略大小写模糊 / gte 大于等于 …
//
// 未打 search tag 的字段不参与查询,可避免无意间开放过滤维度。
type DemoProductSearch struct {
dto.Pagination `search:"-"`
Name string `form:"name" search:"type:icontains;column:name;table:demo_product"`
Code string `form:"code" search:"type:exact;column:code;table:demo_product"`
Status string `form:"status" search:"type:exact;column:status;table:demo_product"`
DemoProductOrder
}
// DemoProductOrder 排序字段单独成组,避免与查询字段混在一起
type DemoProductOrder struct {
CreatedAtOrder string `form:"createdAtOrder" search:"type:order;column:created_at;table:demo_product"`
}
func (m *DemoProductSearch) GetNeedSearch() interface{} { return *m }
func (m *DemoProductSearch) Bind(ctx *gin.Context) error {
return ctx.ShouldBind(m)
}
func (m *DemoProductSearch) Generate() dto.Index {
o := *m
return &o
}
// DemoProductControl 新增与修改共用的入参
//
// 通用 ActionCreate / Update)通过 GenerateM 拿到落库对象,
// 因此这里不直接暴露 Model,字段校验用 validate tag 声明。
type DemoProductControl struct {
Id int `json:"id" comment:"主键"`
Name string `json:"name" comment:"名称" validate:"required"`
Code string `json:"code" comment:"编码" validate:"required"`
Price float64 `json:"price" comment:"单价" validate:"gte=0"`
Status string `json:"status" comment:"状态"`
Remark string `json:"remark" comment:"备注"`
}
func (s *DemoProductControl) Bind(ctx *gin.Context) error {
return ctx.ShouldBind(s)
}
func (s *DemoProductControl) Generate() dto.Control {
o := *s
return &o
}
func (s *DemoProductControl) GetId() interface{} { return s.Id }
// GenerateM 组装落库对象。CreateBy / UpdateBy 由通用 Action 在此之后注入,
// 此处不要手动赋值。
func (s *DemoProductControl) GenerateM() (common.ActiveRecord, error) {
return &models.DemoProduct{
Model: common.Model{Id: s.Id},
Name: s.Name,
Code: s.Code,
Price: s.Price,
Status: s.Status,
Remark: s.Remark,
}, nil
}
// DemoProductById 详情与删除共用,支持单个 id 与批量 ids
type DemoProductById struct {
dto.ObjectById
}
// Bind 与 GetId 由内嵌的 dto.ObjectById 提供:它已处理好 uri 绑定、
// DELETE 时的批量 ids 合并与参数校验,无需在此重复实现。
func (s *DemoProductById) Generate() dto.Control {
o := *s
return &o
}
func (s *DemoProductById) GenerateM() (common.ActiveRecord, error) {
return &models.DemoProduct{}, nil
}
+91
View File
@@ -0,0 +1,91 @@
package dto
import (
"testing"
"go-admin/app/demo/models"
"go-admin/common/dto"
common "go-admin/common/models"
)
// 通用 Action 依赖 DTO 与 Model 实现一组接口。这些约束在编译期无法完全覆盖
// (接口是在路由注册处才被要求的),因此用测试锁定,避免改动后在运行时才暴露。
func TestImplementsIndexInterface(t *testing.T) {
var _ dto.Index = (*DemoProductSearch)(nil)
}
func TestImplementsControlInterface(t *testing.T) {
var _ dto.Control = (*DemoProductControl)(nil)
var _ dto.Control = (*DemoProductById)(nil)
}
func TestModelImplementsActiveRecord(t *testing.T) {
var _ common.ActiveRecord = (*models.DemoProduct)(nil)
}
// Generate 必须返回副本:通用 Action 在并发请求间复用同一个实例,
// 就地返回会导致请求之间串数据。
func TestGenerateReturnsCopy(t *testing.T) {
src := &DemoProductControl{Id: 1, Name: "原始"}
got := src.Generate().(*DemoProductControl)
if got == src {
t.Fatal("Generate 返回了同一指针,应返回副本")
}
got.Name = "被修改"
if src.Name != "原始" {
t.Errorf("修改副本影响了原对象:src.Name = %q", src.Name)
}
}
func TestSearchGenerateReturnsCopy(t *testing.T) {
src := &DemoProductSearch{Name: "原始"}
got := src.Generate().(*DemoProductSearch)
if got == src {
t.Fatal("Generate 返回了同一指针,应返回副本")
}
got.Name = "被修改"
if src.Name != "原始" {
t.Errorf("修改副本影响了原对象:src.Name = %q", src.Name)
}
}
func TestModelGenerateReturnsCopy(t *testing.T) {
src := &models.DemoProduct{Name: "原始"}
got := src.Generate().(*models.DemoProduct)
if got == src {
t.Fatal("Generate 返回了同一指针,应返回副本")
}
got.Name = "被修改"
if src.Name != "原始" {
t.Errorf("修改副本影响了原对象:src.Name = %q", src.Name)
}
}
// GenerateM 组装落库对象,主键需正确传递,否则更新会退化成插入。
func TestGenerateMCarriesId(t *testing.T) {
c := &DemoProductControl{Id: 42, Name: "示例", Code: "P-42", Price: 9.9}
m, err := c.GenerateM()
if err != nil {
t.Fatalf("GenerateM 返回错误: %v", err)
}
p, ok := m.(*models.DemoProduct)
if !ok {
t.Fatalf("GenerateM 返回类型错误: %T", m)
}
if p.Id != 42 {
t.Errorf("主键未传递: got %d, want 42", p.Id)
}
if p.Name != "示例" || p.Code != "P-42" || p.Price != 9.9 {
t.Errorf("字段映射有误: %+v", p)
}
}
func TestTableName(t *testing.T) {
if got := (models.DemoProduct{}).TableName(); got != "demo_product" {
t.Errorf("TableName() = %q, want %q", got, "demo_product")
}
}
+4 -4
View File
@@ -4,8 +4,8 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/jobs/service"
"go-admin/common/dto"
@@ -29,7 +29,7 @@ func (e SysJob) RemoveJobForService(c *gin.Context) {
return
}
s.Cron = sdk.Runtime.GetCrontabKey(c.Request.Host)
s.Cron = sdk.Runtime.GetCrontabByTenant(c.Request.Host)
err = s.RemoveJob(&v)
if err != nil {
e.Logger.Errorf("RemoveJob error, %s", err.Error())
@@ -58,7 +58,7 @@ func (e SysJob) StartJobForService(c *gin.Context) {
s := service.SysJob{}
s.Orm = db
s.Log = log
s.Cron = sdk.Runtime.GetCrontabKey(c.Request.Host)
s.Cron = sdk.Runtime.GetCrontabByTenant(c.Request.Host)
err = s.StartJob(&v)
if err != nil {
log.Errorf("GetCrontabKey error, %s", err.Error())
+38 -12
View File
@@ -1,17 +1,18 @@
package jobs
import (
"context"
"fmt"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
models2 "go-admin/app/jobs/models"
"gorm.io/gorm"
"time"
"github.com/robfig/cron/v3"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/cronjob"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/cronjob"
)
var timeFormat = "2006-01-02 15:04:05"
@@ -59,7 +60,7 @@ func (e *ExecJob) Run() {
//TODO: 待完善部分
//str := time.Now().Format(timeFormat) + " [INFO] JobCore " + string(e.EntryId) + "exec success , spend :" + latencyTime.String()
//ws.SendAll(str)
log.Info("[Job] JobCore %s exec success , spend :%v", e.Name, latencyTime)
log.Infof("[Job] JobCore %s exec success , spend :%v", e.Name, latencyTime)
return
}
@@ -77,8 +78,8 @@ LOOP:
str, err = pkg.Get(h.InvokeTarget)
if err != nil {
// 如果失败暂停一段时间重试
fmt.Println(time.Now().Format(timeFormat), " [ERROR] mission failed! ", err)
fmt.Printf(time.Now().Format(timeFormat)+" [INFO] Retry after the task fails %d seconds! %s \n", (count+1)*5, str)
log.Warnf("[Job] mission failed! %v", err)
log.Warnf("[Job] Retry after the task fails %d seconds! %s \n", (count+1)*5, str)
time.Sleep(time.Duration(count+1) * 5 * time.Second)
count = count + 1
goto LOOP
@@ -101,13 +102,13 @@ func Setup(dbs map[string]*gorm.DB) {
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore Starting...")
for k, db := range dbs {
sdk.Runtime.SetCrontab(k, cronjob.NewWithSeconds())
sdk.Runtime.SetCrontabByTenant(k, cronjob.NewWithSeconds())
setup(k, db)
}
}
func setup(key string, db *gorm.DB) {
crontab := sdk.Runtime.GetCrontabKey(key)
crontab := sdk.Runtime.GetCrontabByTenant(key)
sysJob := models2.SysJob{}
jobList := make([]models2.SysJob, 0)
err := sysJob.GetList(db, &jobList)
@@ -145,11 +146,36 @@ func setup(key string, db *gorm.DB) {
}
// 其中任务
crontab.Start()
startCrontab(crontab)
}
// startCrontab starts c and arranges for it to be stopped on the way out.
//
// The stop used to be `defer crontab.Stop()` followed by `select {}`. The
// select never returned, so the defer never ran and the scheduler was never
// stopped; and because setup never returned, the loop in Setup never reached
// the second tenant - only whichever database came first out of the map ever
// got a scheduler at all. cron.Start is itself `go c.run()`, so the select was
// blocking for nothing.
//
// cron.Stop returns a context that closes once the jobs already running have
// finished. That is the wait the shutdown budget exists to bound: giving up on
// it leaves those jobs running until the process exits, which is better than
// holding the whole shutdown open for one job that will not end.
func startCrontab(c *cron.Cron) {
c.Start()
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore start success.")
// 关闭任务
defer crontab.Stop()
select {}
sdk.Runtime.SetShutdown(func(ctx context.Context) {
stopped := c.Stop()
select {
case <-stopped.Done():
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore stopped.")
case <-ctx.Done():
fmt.Println(time.Now().Format(timeFormat), " [WARN] JobCore stop gave up waiting for running jobs")
}
})
}
// AddJob 添加任务 AddJob(invokeTarget string, jobId int, jobName string, cronExpression string)
+52
View File
@@ -0,0 +1,52 @@
package jobs
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/cronjob"
)
// The scheduler had never been stopped. `defer crontab.Stop()` sat directly
// above a `select {}` that never returned, so the deferred call was
// unreachable for the life of the process.
//
// There is one test rather than several because BeforeExit closes to further
// registration once it has run: a second RunShutdown in this binary would find
// an empty registry and pass while proving nothing.
func TestTheSchedulerIsStoppedOnTheWayOut(t *testing.T) {
var ticks atomic.Int64
c := cronjob.NewWithSeconds()
if _, err := c.AddFunc("* * * * * *", func() { ticks.Add(1) }); err != nil {
t.Fatalf("AddFunc: %v", err)
}
startCrontab(c)
// It has to be running before stopping it can mean anything.
deadline := time.Now().Add(5 * time.Second)
for ticks.Load() == 0 && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if ticks.Load() == 0 {
t.Fatal("the scheduler never ran the job, so this test cannot show it was stopped")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := sdk.Runtime.RunShutdown(ctx); err != nil {
t.Fatalf("RunShutdown: %v", err)
}
// Two and a half seconds is two more firings of a job that runs every
// second, so silence here is the assertion.
at := ticks.Load()
time.Sleep(2500 * time.Millisecond)
if n := ticks.Load() - at; n > 0 {
t.Errorf("the job fired %d more times after shutdown: the scheduler is still running", n)
}
}
@@ -1,12 +1,12 @@
package router
import (
//"github.com/go-admin-team/go-admin-core/sdk/pkg"
//"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"os"
"github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
common "go-admin/common/middleware"
)
@@ -26,10 +26,9 @@ func InitRouter() {
os.Exit(-1)
}
authMiddleware, err := common.AuthInit()
if err != nil {
log.Fatalf("JWT Init Error, %s", err.Error())
}
// the jwt middleware: shared instance InitMiddleware built at startup,
// not one built here per module (see common/middleware.GetAuthMiddleware).
authMiddleware := common.GetAuthMiddleware()
// 注册业务路由
initRouter(r, authMiddleware)
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
)
var (
+1 -1
View File
@@ -2,7 +2,7 @@ package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/app/jobs/apis"
models2 "go-admin/app/jobs/models"
dto2 "go-admin/app/jobs/service/dto"
+1 -1
View File
@@ -2,7 +2,7 @@ package dto
import (
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/jobs/models"
"go-admin/common/dto"
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"errors"
"time"
"github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"github.com/robfig/cron/v3"
"go-admin/app/jobs"
+111 -103
View File
@@ -8,12 +8,13 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/utils"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/utils"
"github.com/google/uuid"
"go-admin/common/file_store"
"go-admin/config"
)
type FileResponse struct {
@@ -45,62 +46,60 @@ func (e File) UploadFile(c *gin.Context) {
e.MakeContext(c)
tag, _ := c.GetPostForm("type")
urlPrefix := fmt.Sprintf("%s://%s/", "http", c.Request.Host)
var fileResponse FileResponse
switch tag {
case "1": // 单图
var done bool
fileResponse, done = e.singleFile(c, fileResponse, urlPrefix)
if done {
return
}
e.OK(fileResponse, "上传成功")
return
e.handleSingleFile(c, urlPrefix)
case "2": // 多图
multipartFile := e.multipleFile(c, urlPrefix)
e.OK(multipartFile, "上传成功")
return
e.handleMultipleFiles(c, urlPrefix)
case "3": // base64
fileResponse = e.baseImg(c, fileResponse, urlPrefix)
e.OK(fileResponse, "上传成功")
e.handleBase64File(c, urlPrefix)
default:
var done bool
fileResponse, done = e.singleFile(c, fileResponse, urlPrefix)
if done {
return
}
e.OK(fileResponse, "上传成功")
return
e.handleSingleFile(c, urlPrefix)
}
}
func (e File) baseImg(c *gin.Context, fileResponse FileResponse, urlPerfix string) FileResponse {
func (e File) handleSingleFile(c *gin.Context, urlPrefix string) {
fileResponse, done := e.singleFile(c, FileResponse{}, urlPrefix)
if done {
return
}
e.OK(fileResponse, "上传成功")
}
func (e File) handleMultipleFiles(c *gin.Context, urlPrefix string) {
multipartFile := e.multipleFile(c, urlPrefix)
e.OK(multipartFile, "上传成功")
}
func (e File) handleBase64File(c *gin.Context, urlPrefix string) {
fileResponse := e.baseImg(c, FileResponse{}, urlPrefix)
e.OK(fileResponse, "上传成功")
}
func (e File) baseImg(c *gin.Context, fileResponse FileResponse, urlPrefix string) FileResponse {
files, _ := c.GetPostForm("file")
file2list := strings.Split(files, ",")
ddd, _ := base64.StdEncoding.DecodeString(file2list[1])
guid := uuid.New().String()
fileName := guid + ".jpg"
err := utils.IsNotExistMkDir(path)
if err != nil {
decodedData, _ := base64.StdEncoding.DecodeString(file2list[1])
fileName := uuid.New().String() + ".jpg"
if err := utils.IsNotExistMkDir(path); err != nil {
e.Error(500, errors.New(""), "初始化文件路径失败")
}
base64File := path + fileName
_ = ioutil.WriteFile(base64File, ddd, 0666)
typeStr := strings.Replace(strings.Replace(file2list[0], "data:", "", -1), ";base64", "", -1)
fileResponse = FileResponse{
Size: pkg.GetFileSize(base64File),
Path: base64File,
FullPath: urlPerfix + base64File,
Name: "",
Type: typeStr,
}
source, _ := c.GetPostForm("source")
err = thirdUpload(source, fileName, base64File)
if err != nil {
e.Error(200, errors.New(""), "上传第三方失败")
return fileResponse
}
base64File := path + fileName
_ = ioutil.WriteFile(base64File, decodedData, 0666)
typeStr := strings.Replace(strings.Replace(file2list[0], "data:", "", -1), ";base64", "", -1)
fileResponse = e.buildFileResponse(base64File, urlPrefix, "", typeStr)
source, _ := c.GetPostForm("source")
if err := thirdUpload(source, fileName, base64File); err != nil {
e.Error(200, err, "上传第三方失败")
return fileResponse
}
if source != "1" {
fileResponse.Path = "/static/uploadfile/" + fileName
fileResponse.FullPath = "/static/uploadfile/" + fileName
@@ -108,97 +107,106 @@ func (e File) baseImg(c *gin.Context, fileResponse FileResponse, urlPerfix strin
return fileResponse
}
func (e File) multipleFile(c *gin.Context, urlPerfix string) []FileResponse {
func (e File) multipleFile(c *gin.Context, urlPrefix string) []FileResponse {
files := c.Request.MultipartForm.File["file"]
source, _ := c.GetPostForm("source")
var multipartFile []FileResponse
for _, f := range files {
guid := uuid.New().String()
fileName := guid + utils.GetExt(f.Filename)
err := utils.IsNotExistMkDir(path)
if err != nil {
for _, f := range files {
fileName := uuid.New().String() + utils.GetExt(f.Filename)
if err := utils.IsNotExistMkDir(path); err != nil {
e.Error(500, errors.New(""), "初始化文件路径失败")
continue
}
multipartFileName := path + fileName
err1 := c.SaveUploadedFile(f, multipartFileName)
fileType, _ := utils.GetType(multipartFileName)
if err1 == nil {
err := thirdUpload(source, fileName, multipartFileName)
if err != nil {
e.Error(500, errors.New(""), "上传第三方失败")
} else {
fileResponse := FileResponse{
Size: pkg.GetFileSize(multipartFileName),
Path: multipartFileName,
FullPath: urlPerfix + multipartFileName,
Name: f.Filename,
Type: fileType,
}
if source != "1" {
fileResponse.Path = "/static/uploadfile/" + fileName
fileResponse.FullPath = "/static/uploadfile/" + fileName
}
multipartFile = append(multipartFile, fileResponse)
}
if err := c.SaveUploadedFile(f, multipartFileName); err != nil {
continue
}
fileType, _ := utils.GetType(multipartFileName)
if err := thirdUpload(source, fileName, multipartFileName); err != nil {
e.Error(500, err, "上传第三方失败")
continue
}
fileResponse := e.buildFileResponse(multipartFileName, urlPrefix, f.Filename, fileType)
if source != "1" {
fileResponse.Path = "/static/uploadfile/" + fileName
fileResponse.FullPath = "/static/uploadfile/" + fileName
}
multipartFile = append(multipartFile, fileResponse)
}
return multipartFile
}
func (e File) singleFile(c *gin.Context, fileResponse FileResponse, urlPerfix string) (FileResponse, bool) {
func (e File) singleFile(c *gin.Context, fileResponse FileResponse, urlPrefix string) (FileResponse, bool) {
files, err := c.FormFile("file")
if err != nil {
e.Error(200, errors.New(""), "图片不能为空")
return FileResponse{}, true
}
// 上传文件至指定目录
guid := uuid.New().String()
fileName := guid + utils.GetExt(files.Filename)
err = utils.IsNotExistMkDir(path)
if err != nil {
fileName := uuid.New().String() + utils.GetExt(files.Filename)
if err := utils.IsNotExistMkDir(path); err != nil {
e.Error(500, errors.New(""), "初始化文件路径失败")
return FileResponse{}, true
}
singleFile := path + fileName
_ = c.SaveUploadedFile(files, singleFile)
fileType, _ := utils.GetType(singleFile)
fileResponse = FileResponse{
Size: pkg.GetFileSize(singleFile),
Path: singleFile,
FullPath: urlPerfix + singleFile,
Name: files.Filename,
Type: fileType,
if err := c.SaveUploadedFile(files, singleFile); err != nil {
e.Error(500, errors.New(""), "文件保存失败")
return FileResponse{}, true
}
//source, _ := c.GetPostForm("source")
//err = thirdUpload(source, fileName, singleFile)
//if err != nil {
// e.Error(200, errors.New(""), "上传第三方失败")
// return FileResponse{}, true
//}
fileType, _ := utils.GetType(singleFile)
fileResponse = e.buildFileResponse(singleFile, urlPrefix, files.Filename, fileType)
fileResponse.Path = "/static/uploadfile/" + fileName
fileResponse.FullPath = "/static/uploadfile/" + fileName
return fileResponse, false
}
func (e File) buildFileResponse(filePath, urlPrefix, fileName, fileType string) FileResponse {
return FileResponse{
Size: pkg.GetFileSize(filePath),
Path: filePath,
FullPath: urlPrefix + filePath,
Name: fileName,
Type: fileType,
}
}
// thirdUpload copies the file that was already stored locally to the object
// store the request asked for. source "1", and anything unrecognised, keeps the
// local copy only.
//
// Both branches used to construct a zero-value ALiYunOSS and call UpLoad on it,
// which panicked - and the qiniu branch constructed the aliyun client, so
// source=3 never reached qiniu even in principle.
func thirdUpload(source string, name string, path string) error {
switch source {
case "2":
return ossUpload("img/"+name, path)
return upload(file_store.AliYunOSS, config.ExtConfig.FileStore.AliYun, "img/"+name, path)
case "3":
return qiniuUpload("img/"+name, path)
return upload(file_store.QiNiuKodo, config.ExtConfig.FileStore.QiNiu, "img/"+name, path)
}
return nil
}
func ossUpload(name string, path string) error {
oss := file_store.ALiYunOSS{}
return oss.UpLoad(name, path)
}
func qiniuUpload(name string, path string) error {
oss := file_store.ALiYunOSS{}
return oss.UpLoad(name, path)
func upload(driver file_store.DriverType, store config.ObjectStore, name, path string) error {
if !store.Configured() {
return fmt.Errorf("file store %s is not configured; set it under extend.fileStore", driver)
}
oxs := file_store.OXS{
Endpoint: store.Endpoint,
AccessKeyID: store.AccessKeyID,
AccessKeySecret: store.AccessKeySecret,
BucketName: store.BucketName,
}
client, err := oxs.Setup(driver)
if err != nil {
return err
}
return client.UpLoad(name, path)
}
+100 -103
View File
@@ -9,9 +9,8 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/host"
@@ -25,22 +24,9 @@ const (
GB = 1024 * MB
)
var (
//Version string
//expectDiskFsTypes = []string{
// "apfs", "ext4", "ext3", "ext2", "f2fs", "reiserfs", "jfs", "btrfs",
// "fuseblk", "zfs", "simfs", "ntfs", "fat32", "exfat", "xfs", "fuse.rclone",
//}
excludeNetInterfaces = []string{
"lo", "tun", "docker", "veth", "br-", "vmbr", "vnet", "kube",
}
//getMacDiskNo = regexp.MustCompile(`\/dev\/disk(\d)s.*`)
)
var (
netInSpeed, netOutSpeed, netInTransfer, netOutTransfer, lastUpdateNetStats uint64
cachedBootTime time.Time
)
var excludeNetInterfaces = []string{
"lo", "tun", "docker", "veth", "br-", "vmbr", "vnet", "kube",
}
type ServerMonitor struct {
api.Api
@@ -48,137 +34,148 @@ type ServerMonitor struct {
// GetHourDiffer 获取相差时间
func GetHourDiffer(startTime, endTime string) int64 {
var hour int64
t1, err := time.ParseInLocation("2006-01-02 15:04:05", startTime, time.Local)
t2, err := time.ParseInLocation("2006-01-02 15:04:05", endTime, time.Local)
if err == nil && t1.Before(t2) {
diff := t2.Unix() - t1.Unix() //
hour = diff / 3600
return hour
} else {
return hour
t1, err1 := time.ParseInLocation("2006-01-02 15:04:05", startTime, time.Local)
t2, err2 := time.ParseInLocation("2006-01-02 15:04:05", endTime, time.Local)
if err1 != nil || err2 != nil || !t1.Before(t2) {
return 0
}
return (t2.Unix() - t1.Unix()) / 3600
}
// ServerInfo 获取系统信息
// @Summary 系统信息
// @Description 获取JSON
// @Tags 系统信息
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
// @Router /api/v1/server-monitor [get]
// @Security Bearer
func (e ServerMonitor) ServerInfo(c *gin.Context) {
e.Context = c
sysInfo, err := host.Info()
osDic := make(map[string]interface{}, 0)
osDic["goOs"] = runtime.GOOS
osDic["arch"] = runtime.GOARCH
osDic["mem"] = runtime.MemProfileRate
osDic["compiler"] = runtime.Compiler
osDic["version"] = runtime.Version()
osDic["numGoroutine"] = runtime.NumGoroutine()
osDic["ip"] = pkg.GetLocaHonst()
osDic["projectDir"] = pkg.GetCurrentPath()
osDic["hostName"] = sysInfo.Hostname
osDic["time"] = time.Now().Format("2006-01-02 15:04:05")
osInfo := getOSInfo()
memInfo := getMemoryInfo()
swapInfo := getSwapInfo()
cpuInfo := getCPUInfo()
diskInfo := getDiskInfo()
netInfo := getNetworkInfo()
bootTime, _ := host.BootTime()
cachedBootTime := time.Unix(int64(bootTime), 0)
e.Custom(gin.H{
"code": 200,
"os": osInfo,
"mem": memInfo,
"cpu": cpuInfo,
"disk": diskInfo,
"net": netInfo,
"swap": swapInfo,
"location": "Aliyun",
"bootTime": GetHourDiffer(cachedBootTime.Format("2006-01-02 15:04:05"), time.Now().Format("2006-01-02 15:04:05")),
})
}
func getOSInfo() map[string]interface{} {
sysInfo, _ := host.Info()
return map[string]interface{}{
"goOs": runtime.GOOS,
"arch": runtime.GOARCH,
"mem": runtime.MemProfileRate,
"compiler": runtime.Compiler,
"version": runtime.Version(),
"numGoroutine": runtime.NumGoroutine(),
"ip": pkg.GetLocalHost(),
"projectDir": pkg.GetCurrentPath(),
"hostName": sysInfo.Hostname,
"time": time.Now().Format("2006-01-02 15:04:05"),
}
}
func getMemoryInfo() map[string]interface{} {
memory, _ := mem.VirtualMemory()
memDic := make(map[string]interface{}, 0)
memDic["used"] = memory.Used / MB
memDic["total"] = memory.Total / MB
return map[string]interface{}{
"used": memory.Used / MB,
"total": memory.Total / MB,
"percent": pkg.Round(memory.UsedPercent, 2),
}
}
fmt.Println("mem", int(memory.Total/memory.Used*100))
memDic["percent"] = pkg.Round(memory.UsedPercent, 2)
func getSwapInfo() map[string]interface{} {
memory, _ := mem.VirtualMemory()
return map[string]interface{}{
"used": memory.SwapTotal - memory.SwapFree,
"total": memory.SwapTotal,
}
}
swapDic := make(map[string]interface{}, 0)
swapDic["used"] = memory.SwapTotal - memory.SwapFree
swapDic["total"] = memory.SwapTotal
cpuDic := make(map[string]interface{}, 0)
cpuDic["cpuInfo"], _ = cpu.Info()
func getCPUInfo() map[string]interface{} {
cpuInfo, _ := cpu.Info()
percent, _ := cpu.Percent(0, false)
cpuDic["percent"] = pkg.Round(percent[0], 2)
cpuDic["cpuNum"], _ = cpu.Counts(false)
cpuNum, _ := cpu.Counts(false)
return map[string]interface{}{
"cpuInfo": cpuInfo,
"percent": pkg.Round(percent[0], 2),
"cpuNum": cpuNum,
}
}
//服务器磁盘信息
disklist := make([]disk.UsageStat, 0)
//所有分区
func getDiskInfo() map[string]interface{} {
var diskTotal, diskUsed, diskUsedPercent float64
diskList := make([]disk.UsageStat, 0)
diskInfo, err := disk.Partitions(true)
if err == nil {
for _, p := range diskInfo {
diskDetail, err := disk.Usage(p.Mountpoint)
if err == nil {
diskDetail.UsedPercent, _ = strconv.ParseFloat(fmt.Sprintf("%.2f", diskDetail.UsedPercent), 64)
diskDetail.Total = diskDetail.Total / 1024 / 1024
diskDetail.Used = diskDetail.Used / 1024 / 1024
diskDetail.Free = diskDetail.Free / 1024 / 1024
disklist = append(disklist, *diskDetail)
diskDetail.Total /= MB
diskDetail.Used /= MB
diskDetail.Free /= MB
diskList = append(diskList, *diskDetail)
}
}
}
d, _ := disk.Usage("/")
diskTotal = float64(d.Total / GB)
diskUsed = float64(d.Used / GB)
diskUsedPercent, _ = strconv.ParseFloat(fmt.Sprintf("%.2f", d.UsedPercent), 64)
diskDic := make(map[string]interface{}, 0)
diskDic["total"] = diskTotal
diskDic["used"] = diskUsed
diskDic["percent"] = diskUsedPercent
bootTime, _ := host.BootTime()
cachedBootTime = time.Unix(int64(bootTime), 0)
TrackNetworkSpeed()
netDic := make(map[string]interface{}, 0)
netDic["in"] = pkg.Round(float64(netInSpeed/KB), 2)
netDic["out"] = pkg.Round(float64(netOutSpeed/KB), 2)
e.Custom(gin.H{
"code": 200,
"os": osDic,
"mem": memDic,
"cpu": cpuDic,
"disk": diskDic,
"net": netDic,
"swap": swapDic,
"location": "Aliyun",
"bootTime": GetHourDiffer(cachedBootTime.Format("2006-01-02 15:04:05"), time.Now().Format("2006-01-02 15:04:05")),
})
return map[string]interface{}{
"total": diskTotal,
"used": diskUsed,
"percent": diskUsedPercent,
}
}
func TrackNetworkSpeed() {
var innerNetInTransfer, innerNetOutTransfer uint64
func getNetworkInfo() map[string]interface{} {
netInSpeed, netOutSpeed := trackNetworkSpeed()
return map[string]interface{}{
"in": pkg.Round(float64(netInSpeed/KB), 2),
"out": pkg.Round(float64(netOutSpeed/KB), 2),
}
}
func trackNetworkSpeed() (uint64, uint64) {
var netInSpeed, netOutSpeed, netInTransfer, netOutTransfer, lastUpdateNetStats uint64
nc, err := net.IOCounters(true)
if err == nil {
for _, v := range nc {
if isListContainsStr(excludeNetInterfaces, v.Name) {
continue
}
innerNetInTransfer += v.BytesRecv
innerNetOutTransfer += v.BytesSent
netInTransfer += v.BytesRecv
netOutTransfer += v.BytesSent
}
now := uint64(time.Now().Unix())
diff := now - lastUpdateNetStats
if diff > 0 {
netInSpeed = (innerNetInTransfer - netInTransfer) / diff
fmt.Println("netInSpeed", netInSpeed)
netOutSpeed = (innerNetOutTransfer - netOutTransfer) / diff
fmt.Println("netOutSpeed", netOutSpeed)
netInSpeed = (netInTransfer - netInTransfer) / diff
netOutSpeed = (netOutTransfer - netOutTransfer) / diff
}
netInTransfer = innerNetInTransfer
netOutTransfer = innerNetOutTransfer
lastUpdateNetStats = now
}
return netInSpeed, netOutSpeed
}
func isListContainsStr(list []string, str string) bool {
for i := 0; i < len(list); i++ {
if strings.Contains(str, list[i]) {
for _, item := range list {
if strings.Contains(str, item) {
return true
}
}
+3 -3
View File
@@ -2,8 +2,8 @@ package tools
import (
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"go-admin/app/other/models/tools"
)
@@ -41,7 +41,7 @@ func (e Gen) GetDBColumnList(c *gin.Context) {
}
data.TableName = c.Request.FormValue("tableName")
pkg.Assert(data.TableName == "", "table name cannot be empty", 500)
pkg.Assert(data.TableName != "", "table name cannot be empty", 500)
result, count, err := data.GetPage(db, pageSize, pageIndex)
if err != nil {
log.Errorf("GetPage error, %s", err.Error())
+78
View File
@@ -0,0 +1,78 @@
package tools
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
"go-admin/common/middleware"
)
const emptyTableNameMsg = "table name cannot be empty"
// bodyOf covers both the success and the CustomError shape: both carry msg.
type bodyOf struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
// newColumnListEngine wires the handler the way the router does, including the
// middleware that turns pkg.Assert's panic into a response.
func newColumnListEngine(t *testing.T) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
// The query targets MySQL's information_schema; the driver setting only has
// to select that branch, the statement itself is never expected to succeed.
previous := config.DatabaseConfig.Driver
config.DatabaseConfig.Driver = "mysql"
t.Cleanup(func() { config.DatabaseConfig.Driver = previous })
r := gin.New()
r.Use(middleware.CustomError)
r.GET("/db/columns/page", func(c *gin.Context) {
c.Set("db", db)
c.Set(pkg.LoggerKey, logger.NewHelper(logger.DefaultLogger))
Gen{}.GetDBColumnList(c)
})
return r
}
func columnListMsg(t *testing.T, r *gin.Engine, query string) bodyOf {
t.Helper()
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/db/columns/page"+query, nil))
var body bodyOf
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %q: %v", w.Body.String(), err)
}
return body
}
func TestGetDBColumnList_AcceptsATableName(t *testing.T) {
body := columnListMsg(t, newColumnListEngine(t), "?tableName=sys_user")
if body.Msg == emptyTableNameMsg {
t.Fatalf("request carried a table name and was still rejected as empty: %+v", body)
}
}
func TestGetDBColumnList_RejectsAMissingTableName(t *testing.T) {
body := columnListMsg(t, newColumnListEngine(t), "")
if body.Msg != emptyTableNameMsg {
t.Fatalf("missing table name should be rejected, got %+v", body)
}
}
+3 -3
View File
@@ -3,9 +3,9 @@ package tools
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"go-admin/app/other/models/tools"
)
+3 -3
View File
@@ -11,9 +11,9 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"go-admin/app/other/models/tools"
)
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"gorm.io/gorm"
"go-admin/app/other/models/tools"
+38
View File
@@ -0,0 +1,38 @@
package apis
import (
"strings"
"testing"
"go-admin/config"
)
// Both branches used to construct a zero-value ALiYunOSS and call UpLoad on it,
// which panicked; the qiniu branch built the aliyun client, so source=3 could
// not have reached qiniu even with credentials. Unconfigured now reports which
// store is missing.
func TestThirdUploadReportsAnUnconfiguredStore(t *testing.T) {
previous := config.ExtConfig.FileStore
config.ExtConfig.FileStore = config.FileStore{}
t.Cleanup(func() { config.ExtConfig.FileStore = previous })
for source, want := range map[string]string{"2": "AliYunOSS", "3": "QiNiuKodo"} {
err := thirdUpload(source, "x.png", "/tmp/x.png")
if err == nil {
t.Errorf("source=%s: no error from an unconfigured store", source)
continue
}
if !strings.Contains(err.Error(), want) {
t.Errorf("source=%s: error names %q, want it to mention %s", source, err, want)
}
}
}
// source 1 and anything unrecognised keep the local copy and do nothing else.
func TestThirdUploadIgnoresLocalAndUnknownSources(t *testing.T) {
for _, source := range []string{"", "1", "9"} {
if err := thirdUpload(source, "x.png", "/tmp/x.png"); err != nil {
t.Errorf("source=%q returned %v, want nil", source, err)
}
}
}
+16 -25
View File
@@ -3,8 +3,8 @@ package tools
import (
"errors"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
)
@@ -24,47 +24,38 @@ type DBColumns struct {
}
func (e *DBColumns) GetPage(tx *gorm.DB, pageSize int, pageIndex int) ([]DBColumns, int, error) {
pkg.Assert(config.DatabaseConfig.Driver == "mysql", "目前只支持mysql数据库", 500)
var doc []DBColumns
var count int64
table := new(gorm.DB)
if config.DatabaseConfig.Driver == "mysql" {
table = tx.Table("information_schema.`COLUMNS`")
table = table.Where("table_schema= ? ", config.GenConfig.DBName)
if e.TableName != "" {
return nil, 0, errors.New("table name cannot be empty")
}
table = table.Where("TABLE_NAME = ?", e.TableName)
if e.TableName == "" {
return nil, 0, errors.New("table name cannot be empty")
}
table := tx.Table("information_schema.`COLUMNS`")
table = table.Where("table_schema= ? ", config.GenConfig.DBName)
table = table.Where("TABLE_NAME = ?", e.TableName)
if err := table.Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&doc).Offset(-1).Limit(-1).Count(&count).Error; err != nil {
return nil, 0, err
}
//table.Count(&count)
return doc, int(count), nil
}
func (e *DBColumns) GetList(tx *gorm.DB) ([]DBColumns, error) {
var doc []DBColumns
table := new(gorm.DB)
pkg.Assert(config.DatabaseConfig.Driver == "mysql", "目前只支持mysql数据库", 500)
var doc []DBColumns
if e.TableName == "" {
return nil, errors.New("table name cannot be empty")
}
if config.DatabaseConfig.Driver == "mysql" {
table = tx.Table("information_schema.columns")
table = table.Where("table_schema= ? ", config.GenConfig.DBName)
table = table.Where("TABLE_NAME = ?", e.TableName).Order("ORDINAL_POSITION asc")
} else {
pkg.Assert(true, "目前只支持mysql数据库", 500)
}
table := tx.Table("information_schema.columns")
table = table.Where("table_schema= ? ", config.GenConfig.DBName)
table = table.Where("TABLE_NAME = ?", e.TableName).Order("ORDINAL_POSITION asc")
if err := table.Find(&doc).Error; err != nil {
return doc, err
}
return doc, nil
}
}
+35 -29
View File
@@ -2,11 +2,11 @@ package tools
import (
"errors"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
config2 "github.com/go-admin-team/go-admin-core/sdk/config"
config2 "github.com/go-admin-team/go-admin-core/v2/sdk/config"
)
type DBTables struct {
@@ -20,43 +20,49 @@ type DBTables struct {
}
func (e *DBTables) GetPage(tx *gorm.DB, pageSize int, pageIndex int) ([]DBTables, int, error) {
pkg.Assert(config2.DatabaseConfig.Driver == "mysql", "目前只支持mysql数据库", 500)
var doc []DBTables
table := new(gorm.DB)
var count int64
if config2.DatabaseConfig.Driver == "mysql" {
table = tx.Table("information_schema.tables")
table = table.Where("TABLE_NAME not in (select table_name from `" + config2.GenConfig.DBName + "`.sys_tables) ")
table = table.Where("table_schema= ? ", config2.GenConfig.DBName)
if e.TableName != "" {
table = table.Where("TABLE_NAME = ?", e.TableName)
}
if err := table.Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&doc).Offset(-1).Limit(-1).Count(&count).Error; err != nil {
return nil, 0, err
}
} else {
pkg.Assert(true, "目前只支持mysql数据库", 500)
// Tables already registered with the generator are not candidates. Read them
// through the model on this connection: the subquery used to spell the
// schema out by hand, so it only resolved when sys_tables happened to live
// in the schema being generated from, and it counted soft-deleted rows.
var generated []string
if err := tx.Model(&SysTables{}).Pluck("table_name", &generated).Error; err != nil {
return nil, 0, err
}
//table.Count(&count)
table := tx.Table("information_schema.tables")
table = table.Where("table_schema= ? ", config2.GenConfig.DBName)
if len(generated) > 0 {
// NOT IN (NULL) is unknown for every row, so an empty list has to skip
// the clause instead of rendering it.
table = table.Where("TABLE_NAME not in (?)", generated)
}
if e.TableName != "" {
table = table.Where("TABLE_NAME = ?", e.TableName)
}
if err := table.Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&doc).Offset(-1).Limit(-1).Count(&count).Error; err != nil {
return nil, 0, err
}
return doc, int(count), nil
}
func (e *DBTables) Get(tx *gorm.DB) (DBTables, error) {
pkg.Assert(config2.DatabaseConfig.Driver == "mysql", "目前只支持mysql数据库", 500)
var doc DBTables
if config2.DatabaseConfig.Driver == "mysql" {
table := tx.Table("information_schema.tables")
table = table.Where("table_schema= ? ", config2.GenConfig.DBName)
if e.TableName == "" {
return doc, errors.New("table name cannot be empty")
}
table = table.Where("TABLE_NAME = ?", e.TableName)
if err := table.First(&doc).Error; err != nil {
return doc, err
}
} else {
pkg.Assert(true, "目前只支持mysql数据库", 500)
if e.TableName == "" {
return doc, errors.New("table name cannot be empty")
}
table := tx.Table("information_schema.tables")
table = table.Where("table_schema= ? ", config2.GenConfig.DBName)
table = table.Where("TABLE_NAME = ?", e.TableName)
if err := table.First(&doc).Error; err != nil {
return doc, err
}
return doc, nil
}
+116
View File
@@ -0,0 +1,116 @@
package tools
import (
"testing"
"github.com/glebarez/sqlite"
config2 "github.com/go-admin-team/go-admin-core/v2/sdk/config"
"gorm.io/gorm"
)
const generatorSchema = "go_admin_test"
// newCandidateDB stands in for MySQL: sqlite is given an attached database
// called information_schema so the same query runs, and sys_tables lives on the
// connection the way it does in production.
func newCandidateDB(t *testing.T, tables ...string) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.Exec(`ATTACH DATABASE ':memory:' AS information_schema`).Error; err != nil {
t.Fatalf("attach information_schema: %v", err)
}
if err := db.Exec("CREATE TABLE information_schema.`tables` (" +
"TABLE_NAME text, TABLE_SCHEMA text, `ENGINE` text, TABLE_ROWS text," +
"TABLE_COLLATION text, CREATE_TIME text, UPDATE_TIME text, TABLE_COMMENT text)").Error; err != nil {
t.Fatalf("create information_schema.tables: %v", err)
}
for _, name := range tables {
if err := db.Exec("INSERT INTO information_schema.`tables` (TABLE_NAME, TABLE_SCHEMA) VALUES (?, ?)",
name, generatorSchema).Error; err != nil {
t.Fatalf("insert %s: %v", name, err)
}
}
if err := db.AutoMigrate(new(SysTables)); err != nil {
t.Fatalf("migrate sys_tables: %v", err)
}
previousDriver := config2.DatabaseConfig.Driver
previousName := config2.GenConfig.DBName
config2.DatabaseConfig.Driver = "mysql"
config2.GenConfig.DBName = generatorSchema
t.Cleanup(func() {
config2.DatabaseConfig.Driver = previousDriver
config2.GenConfig.DBName = previousName
})
return db
}
func candidateNames(t *testing.T, db *gorm.DB) []string {
t.Helper()
found, _, err := new(DBTables).GetPage(db, 100, 1)
if err != nil {
t.Fatalf("GetPage: %v", err)
}
names := make([]string, 0, len(found))
for _, row := range found {
names = append(names, row.TableName)
}
return names
}
func contains(names []string, want string) bool {
for _, name := range names {
if name == want {
return true
}
}
return false
}
// An empty sys_tables must not filter everything out - that is what a bare
// NOT IN (empty set) does, and a fresh install is exactly the case where the
// list matters most.
func TestGetPageListsEveryTableWhenNoneAreRegistered(t *testing.T) {
db := newCandidateDB(t, "sys_user", "sys_role")
names := candidateNames(t, db)
if len(names) != 2 {
t.Fatalf("want both tables offered on a fresh install, got %v", names)
}
}
func TestGetPageSkipsAlreadyRegisteredTables(t *testing.T) {
db := newCandidateDB(t, "sys_user", "sys_role")
if err := db.Create(&SysTables{TBName: "sys_user"}).Error; err != nil {
t.Fatalf("register sys_user: %v", err)
}
names := candidateNames(t, db)
if contains(names, "sys_user") {
t.Errorf("sys_user is already registered and was offered again: %v", names)
}
if !contains(names, "sys_role") {
t.Errorf("sys_role is not registered and was withheld: %v", names)
}
}
// Deleting the generator entry has to hand the table back, which the raw
// subquery never did: it read the row whether or not it was soft-deleted.
func TestGetPageOffersTablesWhoseEntryWasDeleted(t *testing.T) {
db := newCandidateDB(t, "sys_user")
registered := SysTables{TBName: "sys_user"}
if err := db.Create(&registered).Error; err != nil {
t.Fatalf("register sys_user: %v", err)
}
if err := db.Delete(&registered).Error; err != nil {
t.Fatalf("delete the entry: %v", err)
}
if names := candidateNames(t, db); !contains(names, "sys_user") {
t.Errorf("the generator entry is deleted, sys_user should be a candidate again: %v", names)
}
}
@@ -0,0 +1,52 @@
package tools
import (
"strings"
"testing"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"gorm.io/gorm"
)
// The generator reads MySQL's information_schema and nothing else. Every entry
// point says so, but the guard used to be written pkg.Assert(true, ...), which
// never fires: pkg.Assert panics when its condition is false. A non-mysql
// deployment therefore fell through to a query built on a zero-value *gorm.DB.
//
// tx is nil on purpose - the assertion has to come before anything touches it.
func TestCodegenModelsRefuseNonMySQLDrivers(t *testing.T) {
previous := config.DatabaseConfig.Driver
config.DatabaseConfig.Driver = "postgres"
t.Cleanup(func() { config.DatabaseConfig.Driver = previous })
cases := map[string]func(*gorm.DB){
"DBTables.GetPage": func(tx *gorm.DB) {
_, _, _ = new(DBTables).GetPage(tx, 10, 1)
},
"DBTables.Get": func(tx *gorm.DB) {
_, _ = (&DBTables{TableName: "sys_user"}).Get(tx)
},
"DBColumns.GetPage": func(tx *gorm.DB) {
_, _, _ = (&DBColumns{TableName: "sys_user"}).GetPage(tx, 10, 1)
},
"DBColumns.GetList": func(tx *gorm.DB) {
_, _ = (&DBColumns{TableName: "sys_user"}).GetList(tx)
},
}
for name, call := range cases {
t.Run(name, func(t *testing.T) {
defer func() {
raised := recover()
if raised == nil {
t.Fatal("driver is not mysql and the call went through anyway")
}
msg, ok := raised.(string)
if !ok || !strings.Contains(msg, "目前只支持mysql数据库") {
t.Fatalf("want the mysql-only assertion, got %v", raised)
}
}()
call(nil)
})
}
}
+3 -2
View File
@@ -1,7 +1,8 @@
package tools
import (
"go-admin/app/admin/models"
common "go-admin/common/models"
"gorm.io/gorm"
)
@@ -44,7 +45,7 @@ type SysColumns struct {
CreateBy int `gorm:"column:create_by;size:20;" json:"createBy"`
UpdateBy int `gorm:"column:update_By;size:20;" json:"updateBy"`
models.BaseModel
common.ModelTime
}
func (*SysColumns) TableName() string {
-4
View File
@@ -5,8 +5,6 @@ import (
"strings"
"gorm.io/gorm"
"go-admin/app/admin/models"
)
type SysTables struct {
@@ -43,8 +41,6 @@ type SysTables struct {
DataScope string `gorm:"-" json:"dataScope"`
Params Params `gorm:"-" json:"params"`
Columns []SysColumns `gorm:"-" json:"columns"`
models.BaseModel
}
func (*SysTables) TableName() string {
+75
View File
@@ -0,0 +1,75 @@
package router
import (
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"go-admin/common/middleware"
)
// registeredRoutes builds the generator's routes on an engine of its own and
// reports the patterns they were registered under.
//
// The JWT middleware is a zero value. MiddlewareFunc only closes over the
// receiver and is never called here - no request is served, the engine is
// asked what it has - so nothing dereferences it.
func registeredRoutes(t *testing.T) map[string]bool {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
v1 := r.Group("/api/v1")
sysNoCheckRoleRouter(v1, &jwt.GinJWTMiddleware{})
registerDBRouter(v1, &jwt.GinJWTMiddleware{})
out := map[string]bool{}
for _, route := range r.Routes() {
out[route.Path] = true
}
return out
}
// The list of routes demo mode refuses lives in common/middleware, which may
// not import app/ and therefore cannot see whether any of them is still a
// route. This is the half that can be checked, and it is checked here because
// this is where the routes are declared: rename one, and the entry over there
// stops matching anything, demo mode silently starts serving it again, and
// nothing else would say so.
func TestEveryRouteDemoModeRefusesStillExists(t *testing.T) {
routes := registeredRoutes(t)
for _, guarded := range middleware.DemoWriteRoutes() {
if !routes[guarded] {
t.Errorf("demo mode refuses %q, but no route is registered under that pattern - "+
"either it was renamed, or it moved to another file; the guard now matches nothing",
guarded)
}
}
}
// The other direction, and the one the demo host cares about: the generator's
// read-only routes have to stay reachable, or a demo deployment cannot show
// the feature at all. Refusing too much is as much of a defect as refusing too
// little.
func TestTheGeneratorsReadOnlyRoutesAreNotRefused(t *testing.T) {
refused := map[string]bool{}
for _, guarded := range middleware.DemoWriteRoutes() {
refused[guarded] = true
}
for _, readOnly := range []string{
"/api/v1/gen/preview/:tableId",
"/api/v1/gen/tabletree",
"/api/v1/db/tables/page",
"/api/v1/db/columns/page",
} {
if !registeredRoutes(t)[readOnly] {
t.Fatalf("%s is not registered, so this test is asserting against nothing", readOnly)
}
if refused[readOnly] {
t.Errorf("demo mode refuses %s, which only reads - the demo host needs it to "+
"demonstrate the generator", readOnly)
}
}
}

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