Compare commits

...
Author SHA1 Message Date
zhangwenjian 63bcc912ef ci👷: stop the k8s manifests from redeploying the demo
The comment at the top of this workflow says documentation-only changes
skip it, because a push to master pushes an image, runs the migrations
and restarts the demo container. The ignore list did not cover
scripts/k8s, so editing a manifest that the deploy never reads bought
the site an outage.

The pattern is scripts/k8s/** rather than scripts/** because
scripts/Dockerfile is a build input - go.yml builds the release image
from it on a tag.

This workflow file stays outside the list on purpose. paths-ignore skips
only when every changed path matches, so a change that edits the deploy
still runs it, which is the point.
2026-09-07 14:14:39 +08:00
zhangwenjian adcdd2edcd docs📝: name the job scheduler as the other reason for one replica
The comment on replicas gave one obstacle to raising it, the shared log
volume, which reads as the only one. Someone who moves the log path off
that volume would conclude the way is clear.

The scheduler in app/jobs is the second, and it is the one that does not
announce itself. Its handle on a job lives in sys_job.entry_id, one
column shared by every process, and startup zeroes the whole column
before writing its own ids. A second pod therefore erases the first
pod's, and both pods run the full enabled list. Stopping a job from the
UI then removes an entry from whichever process is asked, by an id that
belongs to another one, and answers 200.

See #915.
2026-09-07 13:32:43 +08:00
wenjianzhang 29406f839e Merge pull request #913 from go-admin-team/fix/demo-mode-guard
fix🐛: demo 模式放行了注册成 GET 的写接口
2026-09-07 08:11:14 +08:00
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
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
121 changed files with 13833 additions and 865 deletions
+12 -2
View File
@@ -49,8 +49,18 @@ model、dto、router 三个文件,完整写法照抄 `app/demo/` 的结构。
### 4. 写菜单、接口与权限种子数据
这一步最容易被漏掉——代码能编译、接口能测通,但界面上看不到菜单、点了按钮说
没权限,往往就是漏了这一步。**完整参照 `cmd/migrate/migration/version/1786700001000_demo_menu.go`**
——那是可运行、幂等(用 `upsert`,重复跑不会报错)的真实例子,逐字照抄结构,只换 ID 和业务字段。
没权限,往往就是漏了这一步。结构参照 `cmd/migrate/migration/version/1786700001000_demo_menu.go`
——它是可运行、幂等(用 `upsert`,重复跑不会报错)的真实例子。
:::danger
**但不要照抄它的 import。** 那个文件用的是 `cmd/migrate/migration/models`,
只因为它的版本号排在软删除转换(`1786700003000`)之前才是安全的。
**你新写的迁移版本号在转换之后,必须改用 `app/` 下的运行时模型**
(`app/admin/models.SysApi`、`SysMenu`),否则第一条 insert 就会
`NOT NULL constraint failed: sys_api.deleted_at`。
`TestPostConversionMigrationsAvoidFrozenSeedModels` 会拦住这个错误。
:::
一个模块要在界面上可用,需要四类数据,缺一样都不行:
+24 -2
View File
@@ -1,12 +1,25 @@
name: Build
# Documentation-only changes skip this workflow entirely.
# Documentation-only changes, and changes confined to the Kubernetes
# manifests, 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.
#
# scripts/k8s holds deploy.yml, storage.yml and prerun.sh, and the deploy below
# reads none of them - it is an ssh into one host that runs docker, building the
# Dockerfile at the repository root. Those manifests are for people deploying to
# a cluster of their own. The pattern is scripts/k8s/** rather than scripts/**
# because scripts/Dockerfile is a build input: go.yml builds the release image
# from it on a tag.
#
# A file outside these patterns still runs the workflow even when the rest of
# the change is ignorable: paths-ignore skips only when every changed path
# matches. Editing this file is one such case, on purpose - a deploy script
# that is never exercised by the change that broke it is worse than an outage.
on:
push:
branches: [ master ]
@@ -15,6 +28,7 @@ on:
- 'docs/**'
- 'LICENSE*'
- '.github/ISSUE_TEMPLATE/**'
- 'scripts/k8s/**'
pull_request:
branches: [ master ]
paths-ignore:
@@ -22,6 +36,7 @@ on:
- 'docs/**'
- 'LICENSE*'
- '.github/ISSUE_TEMPLATE/**'
- 'scripts/k8s/**'
# 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
@@ -115,7 +130,14 @@ jobs:
if sudo docker ps -a --format '{{.Names}}' | grep -qx "$NAME"; then
sudo docker rm -f "$PREV" >/dev/null 2>&1 || true
sudo docker rename "$NAME" "$PREV"
sudo docker stop "$PREV" >/dev/null
# --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 \
+35
View File
@@ -15,6 +15,27 @@ 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.26
@@ -28,9 +49,23 @@ jobs:
- name: Get dependencies
run: go mod tidy
# go build does not compile _test.go, so building alone never ran a single
# test. This is the only workflow that fires on every push and pull request,
# which makes it the one place a test gate belongs.
- name: Test
run: make test
- name: Build
run: make build
# Fails the build on the silent-failure classes listed in
# tools/checksilent, one of which is the contract boundary: nothing under
# common/ may import app/. A boundary that is only written down erodes; this
# is what keeps it true.
- name: Silent-failure checks
run: make checksilent
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
if: startsWith(github.ref, 'refs/tags/')
+11
View File
@@ -6,6 +6,10 @@ main.exe
*.exe
go-admin
go-admin.exe
# `go build ./tools/checksilent` drops the binary here, next to the one for the
# server. Anchored with a leading slash: unanchored, the same pattern matches
# tools/checksilent/ as well and the tool's own source never gets committed.
/checksilent
temp/
!temp
vendor
@@ -29,3 +33,10 @@ CLAUDE.md
.claude/skills/*
!.claude/skills/new-business-module/
config/settings.local.dev.yml
# Go workspace files. They exist to point this module at a local checkout of
# go-admin-core while the two are developed together, which is a private
# arrangement between one machine's directories - committing one would break
# the build for everyone else.
go.work
go.work.sum
+65 -1
View File
@@ -117,6 +117,21 @@ func (SysPost) TableName() string { return "sys_post" }
`TableName()` 必须显式声明(GORM 配置了 `SingularTable`,不会自动推导复数)。
## 公共契约面
第三方应用(`app/` 下的业务模块)可以稳定依赖哪些包、路由与迁移怎么注册、
哪些约束是硬的,见 `docs/contract.md`。
两条与主仓贡献者直接相关的:
- **`common/`、`core/` 不得 import `app/`** —— `make checksilent` 在 CI 里守着,违反即红。
- **从 core 契约包声明出来的类型必须写成别名**(`type X = pkg.Y`,不是 `type X pkg.Y`)
—— `contract-shim-alias` 检查守着。defined type 会丢掉整个方法集,
而且**不一定在本仓编译失败**,理由见 `docs/contract.md` 末节。
- **注册类 API(`AppRouters` / `sdk.Runtime.SetAppRouters` / `migration.ForApp`)
必须在 `runStartupHooks()` 之前调用完** —— `init()` 是最省事的位置,
但约束的是**顺序**,不是写在哪个函数里;晚到的注册会被丢弃并只记一条 ERROR。
## 路由注册
通过 `init()` 自注册,不在中心文件手工添加:
@@ -179,7 +194,8 @@ go run -tags sqlite3 . server -c config/settings.sqlite.yml
## 数据库迁移
文件名前 13 位为时间戳版本号。**已执行过的迁移文件不可修改** ——
文件名前 13 位为毫秒时间戳版本号,不合规的名字会在启动时 panic 并报出该文件名。
**已执行过的迁移文件不可修改** ——
`sys_migration` 表按版本号去重,改动不会重跑,只能新增一个迁移来修正。
放哪个目录取决于身份:
@@ -193,6 +209,54 @@ go run -tags sqlite3 . server -c config/settings.sqlite.yml
`git status` 看不到,PR 里也不会出现。两个目录的包名分别是 `version` 与
`version_local`(后者与目录名不一致,因为标识符不能含连字符)。
### 写种子数据用哪个 models 包
`1786700003000` 之后新增的迁移,**种子数据要用 `app/` 下的运行时模型**
(如 `app/admin/models.SysApi`、`SysMenu`),**不要用 `cmd/migrate/migration/models`**。
后者的 `ModelTime` 声明的是可空的 `gorm.DeletedAt`,这对它之前的迁移是对的(那正是
当时列的形状),转换之后就不再成立,两个方向都会出问题:
- **写**:往 NOT NULL 列里塞 NULL,第一条 insert 就 `NOT NULL constraint failed`
- **读**:GORM 拼 `WHERE deleted_at IS NULL`,而活跃行存的是 `0`,静默查不到——
照抄 `demo_menu.go` 的授权段落会因此跳过授权,菜单建好、权限没授、迁移仍记为成功
干净库跑不出这个问题,今天所有用该包的迁移都排在转换之前。完整推导见
`schema_coverage_test.go` 里 `TestPostConversionMigrationsAvoidFrozenSeedModels`
的注释,那个测试也守着这条边界。
## 静默失败校验
`make checksilent` 逐条检查那些**不报错、不记日志、行为悄悄变得不对**的问题,
CI 会跑,命中 ERROR 即失败。这里不写条数——写死的数字会悄悄过时,
真正的清单是 `tools/checksilent/checks.go` 里 `runChecks` 跑的那几个:
| 检查 | 级别 | 静默后果 |
|---|---|---|
| `modeltime-mix` | ERROR | 两个 `ModelTime` 混用,整张表查不到数据 |
| `menu-sort-overflow` | ERROR | 菜单 `sort` 超 127,MySQL tinyint 拒绝写入,迁移中断 |
| `config-value-truncation` | ERROR | `sys_config.config_value` 超 255 字符被静默截断 |
| `menu-id-collision` | ERROR | 两个模块硬编码同一菜单 ID,互相覆盖 |
| `contract-import-boundary` | ERROR | 契约包 import `app/`,应用无法独立编译 |
| `contract-shim-alias` | ERROR | 契约薄壳写成 defined type 而非别名,方法集丢失,本仓可能照常编译、第三方应用编译不过 |
| `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: 描述`:
+31 -4
View File
@@ -15,7 +15,16 @@ 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 命令
@@ -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:
+34 -4
View File
@@ -1,6 +1,7 @@
package apis
import (
"errors"
"github.com/gin-gonic/gin/binding"
"go-admin/app/admin/models"
"golang.org/x/crypto/bcrypt"
@@ -15,6 +16,7 @@ import (
"go-admin/app/admin/service"
"go-admin/app/admin/service/dto"
"go-admin/common/actions"
"go-admin/common/middleware"
)
type SysUser struct {
@@ -149,12 +151,34 @@ func (e SysUser) Update(c *gin.Context) {
return
}
req.SetUpdateBy(user.GetUserId(c))
callerId := user.GetUserId(c)
// This route is in CasbinExclude so the personal-center screen can edit
// the caller's own record without a policy grant (see settings.go). That
// exclusion covers the whole route, not just the caller's own record, and
// the request carries the target userId in the body - so without this
// check here, any authenticated caller could edit any other user, up to
// and including their roleId. When the target is someone else, ask Casbin
// directly for the permission AuthCheckRole skipped.
if req.UserId != callerId {
allowed, err := middleware.EnforceRoleFor(c, c.Request.URL.Path, c.Request.Method)
if err != nil {
e.Logger.Error(err)
e.Error(500, err, err.Error())
return
}
if !allowed {
e.Error(http.StatusForbidden, errors.New("无权更新其他用户数据"), "对不起,您没有该接口访问权限,请联系管理员")
return
}
}
req.SetUpdateBy(callerId)
//数据权限检查
p := actions.GetPermissionFromContext(c)
err = s.Update(&req, p)
err = s.Update(&req, p, callerId)
if err != nil {
e.Logger.Error(err)
return
@@ -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
+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/v2/sdk/pkg"
"gorm.io/gorm"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
)
type DataPermission struct {
DataScope string
UserId int
DeptId int
RoleId int
}
func (e *DataPermission) GetDataScope(tableName string, db *gorm.DB) (*gorm.DB, error) {
if !config.ApplicationConfig.EnableDP {
usageStr := `数据权限已经为您` + pkg.Green(`关闭`) + `,如需开启请参考配置文件字段说明`
log.Debug("%s\n", usageStr)
return db, nil
}
user := new(SysUser)
role := new(SysRole)
err := db.Find(user, e.UserId).Error
if err != nil {
return nil, errors.New("获取用户数据出错 msg:" + err.Error())
}
err = db.Find(role, user.RoleId).Error
if err != nil {
return nil, errors.New("获取用户数据出错 msg:" + err.Error())
}
if role.DataScope == "2" {
db = db.Where(tableName+".create_by in (select sys_user.user_id from sys_role_dept left join sys_user on sys_user.dept_id=sys_role_dept.dept_id where sys_role_dept.role_id = ?)", user.RoleId)
}
if role.DataScope == "3" {
db = db.Where(tableName+".create_by in (SELECT user_id from sys_user where dept_id = ? )", user.DeptId)
}
if role.DataScope == "4" {
db = db.Where(tableName+".create_by in (SELECT user_id from sys_user where sys_user.dept_id in(select dept_id from sys_dept where dept_path like ? ))", "%"+pkg.IntToString(user.DeptId)+"%")
}
if role.DataScope == "5" || role.DataScope == "" {
db = db.Where(tableName+".create_by = ?", e.UserId)
}
return db, nil
}
//func DataScopes(tableName string, userId int) func(db *gorm.DB) *gorm.DB {
// return func(db *gorm.DB) *gorm.DB {
// user := new(SysUser)
// role := new(SysRole)
// user.UserId = userId
// err := db.Find(user, userId).Error
// if err != nil {
// db.Error = errors.New("获取用户数据出错 msg:" + err.Error())
// return db
// }
// err = db.Find(role, user.RoleId).Error
// if err != nil {
// db.Error = errors.New("获取用户数据出错 msg:" + err.Error())
// return db
// }
// if role.DataScope == "2" {
// return db.Where(tableName+".create_by in (select sys_user.user_id from sys_role_dept left join sys_user on sys_user.dept_id=sys_role_dept.dept_id where sys_role_dept.role_id = ?)", user.RoleId)
// }
// if role.DataScope == "3" {
// return db.Where(tableName+".create_by in (SELECT user_id from sys_user where dept_id = ? )", user.DeptId)
// }
// if role.DataScope == "4" {
// return db.Where(tableName+".create_by in (SELECT user_id from sys_user where sys_user.dept_id in(select dept_id from sys_dept where dept_path like ? ))", "%"+pkg.IntToString(user.DeptId)+"%")
// }
// if role.DataScope == "5" || role.DataScope == "" {
// return db.Where(tableName+".create_by = ?", userId)
// }
// return db
// }
//}
+4
View File
@@ -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
}
+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
}
+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)
}
}
})
}
+3 -5
View File
@@ -25,11 +25,9 @@ func InitRouter() {
os.Exit(-1)
}
// the jwt middleware
authMiddleware, err := common.AuthInit()
if err != nil {
log.Fatalf("JWT Init Error, %s", err.Error())
}
// the jwt middleware: shared instance InitMiddleware built at startup,
// not one built here per module (see common/middleware.GetAuthMiddleware).
authMiddleware := common.GetAuthMiddleware()
// 注册系统路由
InitSysRouter(r, authMiddleware)
+5 -1
View File
@@ -5,6 +5,7 @@ import (
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)
+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)
}
}
}
+14 -3
View File
@@ -6,6 +6,8 @@ import (
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
"go-admin/app/admin/models"
"go-admin/app/admin/service/dto"
"go-admin/common/actions"
@@ -74,9 +76,18 @@ func (e *SysApi) Get(d *dto.SysApiGetReq, p *actions.DataPermission, model *mode
// Update 修改SysApi对象
func (e *SysApi) Update(c *dto.SysApiUpdateReq, p *actions.DataPermission) error {
var model = models.SysApi{}
db := e.Orm.Debug().First(&model, c.GetId())
if db.RowsAffected == 0 {
return errors.New("无权更新该数据")
db := e.Orm.Scopes(
actions.Permission(model.TableName(), p),
).First(&model, c.GetId())
if err := db.Error; err != nil {
// First reports a row the data permission excluded exactly as it
// reports one that does not exist, and the caller should not be able
// to tell those apart either.
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("无权更新该数据")
}
e.Log.Errorf("Service UpdateSysApi error:%s", err)
return err
}
c.Generate(&model)
db = e.Orm.Save(&model)
@@ -0,0 +1,70 @@
package service
import (
"strings"
"testing"
"github.com/glebarez/sqlite"
"github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/service"
"gorm.io/gorm"
"go-admin/app/admin/models"
"go-admin/app/admin/service/dto"
"go-admin/common/actions"
)
// An update the data permission excludes has to be refused, and refused in a
// way that does not tell the caller whether the row exists. First reports both
// cases the same way - no rows - so the message has to come from there rather
// than from a RowsAffected check the error return has already skipped past.
func TestSysApiUpdateRefusesARowOutsideTheDataPermission(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:sysapi-perm?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Skipf("sqlite unavailable: %v", err)
}
if err := db.AutoMigrate(&models.SysApi{}); err != nil {
t.Skipf("automigrate: %v", err)
}
prev := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = true
t.Cleanup(func() { config.ApplicationConfig.EnableDP = prev })
// Owned by user 1.
row := models.SysApi{Handle: "h", Title: "t", Path: "/api/v1/probe", Type: "BUS", Action: "GET"}
row.CreateBy = 1
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
e := &SysApi{Service: service.Service{Orm: db, Log: logger.NewHelper(logger.DefaultLogger)}}
req := &dto.SysApiUpdateReq{Id: row.Id, Title: "changed"}
// User 2, scope 5: only rows they created.
outsider := &actions.DataPermission{DataScope: "5", UserId: 2, DeptId: 1, RoleId: 2}
err = e.Update(req, outsider)
if err == nil {
t.Fatal("the update was allowed on a row the data permission excludes")
}
if !strings.Contains(err.Error(), "无权更新该数据") {
t.Errorf("refused with %q, want the permission message; a raw database error tells the "+
"caller the row exists", err)
}
var after models.SysApi
if err := db.First(&after, row.Id).Error; err != nil {
t.Fatal(err)
}
if after.Title != "t" {
t.Errorf("the row was modified: title is now %q", after.Title)
}
// The owner still gets through, so the scope is refusing rather than
// everything failing.
owner := &actions.DataPermission{DataScope: "5", UserId: 1, DeptId: 1, RoleId: 1}
if err := e.Update(&dto.SysApiUpdateReq{Id: row.Id, Title: "by owner"}, owner); err != nil {
t.Fatalf("the owner could not update their own row: %v", err)
}
}
+39 -1
View File
@@ -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 {
+3 -5
View File
@@ -33,11 +33,9 @@ func InitRouter() {
os.Exit(-1)
}
// the jwt middleware
authMiddleware, err := common.AuthInit()
if err != nil {
log.Fatalf("JWT Init Error, %s", err.Error())
}
// the jwt middleware: shared instance InitMiddleware built at startup,
// not one built here per module (see common/middleware.GetAuthMiddleware).
authMiddleware := common.GetAuthMiddleware()
// 注册业务路由
InitBusinessRouter(r, authMiddleware)
+29 -3
View File
@@ -1,6 +1,7 @@
package jobs
import (
"context"
"fmt"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
@@ -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)
}
}
+3 -4
View File
@@ -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)
+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)
}
}
}
+3 -4
View File
@@ -25,10 +25,9 @@ func InitRouter() {
os.Exit(-1)
}
// the jwt middleware
authMiddleware, err := common.AuthInit()
if err != nil {
log.Fatalf("JWT Init Error, %s", err.Error())
}
// the jwt middleware: shared instance InitMiddleware built at startup,
// not one built here per module (see common/middleware.GetAuthMiddleware).
authMiddleware := common.GetAuthMiddleware()
// 注册业务路由
// TODO: 这里可存放业务路由,里边并无实际路由只有演示代码
+65 -6
View File
@@ -1,23 +1,82 @@
package router
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/v2/tools/transfer"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go-admin/common/health"
)
func init() {
routerNoCheckRole = append(routerNoCheckRole, registerMonitorRouter)
routerNoCheckRole = append(routerNoCheckRole, RegisterMonitorRouter)
}
// 需认证的路由代码
func registerMonitorRouter(v1 *gin.RouterGroup) {
// readyTimeout bounds the whole probe. What constrains it is the orchestrator's
// per-check timeout rather than its polling period: Kubernetes allows a probe
// one second by default, so a dependency that answers in 1.2s is recorded as a
// failed check however promptly this handler returns. A manifest that mounts
// this probe has to raise timeoutSeconds above this value, and
// scripts/k8s/deploy.yml does.
const readyTimeout = 2 * time.Second
// HealthPath and ReadyPath are the two probe routes, relative to APIPrefix.
//
// Exported for the same reason as the prefix: the rate limiter has to be told
// to skip them, and it is installed in a package that cannot import this one.
const (
HealthPath = "/health"
ReadyPath = "/ready"
)
// RegisterMonitorRouter mounts the metrics endpoint and the two probes on v1.
//
// Exported so that a test can put the real probes on a server of its own. The
// alternative - a test that re-implements the handler it means to check - is
// how a probe comes to be asserted against a copy of itself.
//
// 无需认证的路由代码
func RegisterMonitorRouter(v1 *gin.RouterGroup) {
v1.GET("/metrics", transfer.Handler(promhttp.Handler()))
//健康检查
v1.GET("/health", func(c *gin.Context) {
// 健康检查(存活)
//
// Stays a bare 200 on purpose. This is the answer to "should I restart
// you", and a process whose database is unreachable does not want
// restarting - that turns one outage into a crash loop and throws away the
// connection pool, the cache and every in-flight request along the way.
v1.GET(HealthPath, func(c *gin.Context) {
c.Status(http.StatusOK)
})
}
// 就绪检查
//
// The answer to "should I send you requests". It fails while a dependency
// is unreachable, and from the moment shutdown begins - for as long as
// extend.shutdown.drain says, which is zero unless it is configured. The
// package comment in common/health says what that window is worth, and to
// whom.
v1.GET(ReadyPath, func(c *gin.Context) {
if health.Draining() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "draining",
"checks": []health.Check{},
})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), readyTimeout)
defer cancel()
checks := health.Ready(ctx)
status := http.StatusOK
if !health.Healthy(checks) {
status = http.StatusServiceUnavailable
}
c.JSON(status, gin.H{"status": http.StatusText(status), "checks": checks})
})
}
+9 -2
View File
@@ -10,6 +10,13 @@ var (
routerCheckRole = make([]func(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware), 0)
)
// APIPrefix is the group every route below is registered under.
//
// Exported because the middleware chain in cmd/api has to name two of those
// routes in full - the rate limiter is installed on the engine and must skip
// the probes - and a prefix spelled in two places is a prefix that drifts.
const APIPrefix = "/api/v1"
// initRouter 路由示例
func initRouter(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) *gin.Engine {
@@ -24,7 +31,7 @@ func initRouter(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) *gin.Engine
// noCheckRoleRouter 无需认证的路由示例
func noCheckRoleRouter(r *gin.Engine) {
// 可根据业务需求来设置接口版本
v1 := r.Group("/api/v1")
v1 := r.Group(APIPrefix)
for _, f := range routerNoCheckRole {
f(v1)
@@ -34,7 +41,7 @@ func noCheckRoleRouter(r *gin.Engine) {
// checkRoleRouter 需要认证的路由示例
func checkRoleRouter(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) {
// 可根据业务需求来设置接口版本
v1 := r.Group("/api/v1")
v1 := r.Group(APIPrefix)
for _, f := range routerCheckRole {
f(v1, authMiddleware)
+160
View File
@@ -0,0 +1,160 @@
package api
import (
"fmt"
"net"
"net/http"
"testing"
"time"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
)
// freePort returns a port nothing is listening on. It is inherently a guess -
// the port is free when it is handed back and could be taken a moment later -
// but every alternative needs the caller to hold the listener, which is the one
// thing these tests cannot do.
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("probe listen: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port
_ = ln.Close()
return port
}
// AfterListen promises a hook that the port is reachable. Both halves of that
// are asserted here, and in one test rather than two, because the phase seals
// itself once it has run: a second test calling RunPhase again would find a
// closed registry and pass while proving nothing.
//
// The failing bind comes first for the same reason. It must leave the phase
// unsealed, which is only visible if nothing has sealed it yet.
func TestAfterListenIsAnnouncedOnlyOnceThePortIsBound(t *testing.T) {
// The pause makes the "announced synchronously" claim testable: if the
// announcement were moved onto a goroutine, startServing would return
// while the hook was still sleeping and the count below would be zero.
var ran int
sdk.Runtime.SetPhase(runtime.AfterListen, func() {
time.Sleep(50 * time.Millisecond)
ran++
})
// Somebody else already has the port. Under ListenAndServe this surfaced
// on the serving goroutine, far too late to stop the announcement.
taken, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("occupy: %v", err)
}
defer func() { _ = taken.Close() }()
blocked := &http.Server{Addr: taken.Addr().String(), Handler: http.NewServeMux()}
if err := startServing(blocked, false, "", ""); err == nil {
t.Fatal("startServing returned no error for a port that was already taken")
}
if ran != 0 {
t.Errorf("AfterListen ran %d times after a failed bind; a hook there is told the port is reachable", ran)
}
if sdk.Runtime.PhaseSealed(runtime.AfterListen) {
t.Error("a failed bind sealed AfterListen, so the phase could never run for a server that did start")
}
// A certificate that cannot be read is the other way to fail before there
// is anything to announce. ServeTLS reads it on the serving goroutine, so
// without the check in startServing this would be a hook told the port was
// reachable while the server was already on its way down.
if err := startServing(&http.Server{Addr: "127.0.0.1:0"}, true, "no-such.pem", "no-such.key"); err == nil {
t.Fatal("startServing returned no error for a certificate that does not exist")
}
if ran != 0 {
t.Errorf("AfterListen ran %d times after a certificate failure", ran)
}
if sdk.Runtime.PhaseSealed(runtime.AfterListen) {
t.Error("a certificate failure sealed AfterListen")
}
// And now a bind that works.
port := freePort(t)
srv := &http.Server{Addr: fmt.Sprintf("127.0.0.1:%d", port), Handler: http.NewServeMux()}
if err := startServing(srv, false, "", ""); err != nil {
t.Fatalf("startServing on a free port: %v", err)
}
defer func() { _ = srv.Close() }()
// Checked the instant startServing returns, so this is also the assertion
// that it did not return early: an asynchronous announcement would still
// be inside the sleep. Synchrony matters because an announcement that
// overlaps the wait below could, on a fast SIGTERM, have the shutdown
// callbacks finish before the startup ones.
if ran != 1 {
t.Fatalf("AfterListen ran %d times, want 1", ran)
}
// The claim is not "Serve was called" but "the port answers". Dial it.
c, err := net.DialTimeout("tcp", srv.Addr, 5*time.Second)
if err != nil {
t.Fatalf("AfterListen ran but the port does not answer: %v", err)
}
_ = c.Close()
}
// BeforeRouter is the last point at which a module can still affect how routes
// are built, so it has to run while there is no engine yet. The before registry
// is a different moment despite the name: those callbacks run after initRouter
// has built the engine.
//
// The two are two lines apart in buildRouter, and calling them equivalent is a
// mistake this repository has already made in writing. Until this test the
// ordering was checked by reading - which is how the stop signals came to be
// armed after the readiness banner in the same file.
func TestBeforeRouterRunsWhileThereIsNoEngine(t *testing.T) {
freshRuntime(t)
// AuthInit reads these two package-level values and nothing else. No
// database is involved in building a router: the handlers are registered,
// not called.
config.ApplicationConfig.Mode = "dev"
config.JwtConfig.Secret = "test-secret-for-the-router-build"
type observation struct {
ran int
engineWas interface{}
engineSeen bool
}
var phase, before observation
sdk.Runtime.SetPhase(runtime.BeforeRouter, func() {
phase.ran++
phase.engineWas = sdk.Runtime.GetEngine()
phase.engineSeen = true
})
sdk.Runtime.SetBefore(func() {
before.ran++
before.engineWas = sdk.Runtime.GetEngine()
before.engineSeen = true
})
buildRouter()
if phase.ran != 1 {
t.Fatalf("BeforeRouter ran %d times, want 1", phase.ran)
}
if !phase.engineSeen || phase.engineWas != nil {
t.Errorf("BeforeRouter saw engine %v, want nil: it is meant to run before initRouter builds one", phase.engineWas)
}
if before.ran != 1 {
t.Fatalf("the before registry ran %d times, want 1", before.ran)
}
if before.engineWas == nil {
t.Error("a before callback saw no engine; that registry is meant to run after initRouter, and describing it as equivalent to BeforeRouter is the error this asserts against")
}
if sdk.Runtime.GetEngine() == nil {
t.Error("buildRouter returned with no engine built")
}
}
+167
View File
@@ -0,0 +1,167 @@
package api
import (
"strings"
"sync"
"testing"
"time"
corestorage "github.com/go-admin-team/go-admin-core/v2/storage"
)
// recordingQueue records what was done to it, in order. Register and Run are
// the two calls whose order is the point of this file; Append and Shutdown are
// here to satisfy the interface.
type recordingQueue struct {
mu sync.Mutex
events []string
ran chan struct{}
}
func newRecordingQueue() *recordingQueue {
return &recordingQueue{ran: make(chan struct{}, 4)}
}
func (q *recordingQueue) record(e string) {
q.mu.Lock()
q.events = append(q.events, e)
q.mu.Unlock()
}
func (q *recordingQueue) seen() []string {
q.mu.Lock()
defer q.mu.Unlock()
return append([]string(nil), q.events...)
}
func (q *recordingQueue) String() string { return "recording" }
func (q *recordingQueue) Append(corestorage.Messager) error { return nil }
func (q *recordingQueue) Register(name string, _ corestorage.ConsumerFunc) {
q.record("register:" + name)
}
func (q *recordingQueue) Shutdown() {}
func (q *recordingQueue) Run() {
q.record("run")
select {
case q.ran <- struct{}{}:
default:
}
}
// waitForRun waits for Run, which is started on a goroutine.
func (q *recordingQueue) waitForRun(t *testing.T) {
t.Helper()
select {
case <-q.ran:
case <-time.After(5 * time.Second):
t.Fatalf("Run was never called; saw: %v", q.seen())
}
}
// The consumers must be registered before the queue is started. A queue that
// is already running refuses further registration - the contract
// implementations answer storage.ErrQueueAlreadyStarted - and the legacy
// adapter this path goes through drops that error, so the wrong order loses
// consumers with nothing said about it. The memory backend does not care,
// which is exactly why this cannot be left to be noticed in use.
func TestConsumersAreRegisteredBeforeTheQueueIsStarted(t *testing.T) {
attachedQueue.Store(0)
t.Cleanup(func() { attachedQueue.Store(0) })
q := newRecordingQueue()
attachConsumersOnce(1, q)
q.waitForRun(t)
seen := q.seen()
runAt := -1
registers := 0
for i, e := range seen {
switch {
case e == "run":
if runAt < 0 {
runAt = i
}
case strings.HasPrefix(e, "register:"):
registers++
if runAt >= 0 {
t.Errorf("%q came after Run; a running queue refuses registration", e)
}
}
}
if registers != 3 {
t.Errorf("registered %d consumers, want 3; saw %v", registers, seen)
}
if runAt < 0 {
t.Errorf("the queue was never started; saw %v", seen)
}
}
// AfterResource runs again on every configuration reload, so the hook has to
// be idempotent with respect to a given queue - not "does nothing the second
// time". Registering twice on the same queue would give every message two
// consumers and write every log row twice.
func TestTheSameQueueIsNotGivenConsumersTwice(t *testing.T) {
attachedQueue.Store(0)
t.Cleanup(func() { attachedQueue.Store(0) })
q := newRecordingQueue()
attachConsumersOnce(1, q)
q.waitForRun(t)
attachConsumersOnce(1, q)
// Nothing to wait for on the second call, so give a wrong implementation
// the time it would need to show up.
time.Sleep(200 * time.Millisecond)
if n := len(q.seen()); n != 4 {
t.Errorf("%d calls after attaching twice to the same queue, want 4 (3 registers + 1 run); saw %v", n, q.seen())
}
}
// The other half of the same rule: a reload builds a new adapter, and the
// consumers on the old one are attached to a queue nobody publishes to any
// more. A new generation must get its own set.
func TestANewQueueGetsItsOwnConsumers(t *testing.T) {
attachedQueue.Store(0)
t.Cleanup(func() { attachedQueue.Store(0) })
first := newRecordingQueue()
attachConsumersOnce(1, first)
first.waitForRun(t)
second := newRecordingQueue()
attachConsumersOnce(2, second)
second.waitForRun(t)
if n := len(second.seen()); n != 4 {
t.Errorf("the queue from the second generation saw %d calls, want 4; saw %v", n, second.seen())
}
if n := len(first.seen()); n != 4 {
t.Errorf("the queue from the first generation saw %d calls, want 4 - it should not have been touched again; saw %v", n, first.seen())
}
}
// Generation 0 means the configuration has no queue section at all, so nothing
// was installed and the runtime hands back its own memory queue. That case
// still has to get consumers - the registration it replaces was unconditional,
// and dropping it would stop the login and operation logs for anyone who
// commented the section out.
func TestAnUnconfiguredQueueStillGetsConsumers(t *testing.T) {
attachedQueue.Store(0)
t.Cleanup(func() { attachedQueue.Store(0) })
q := newRecordingQueue()
attachConsumersOnce(0, q)
q.waitForRun(t)
if n := len(q.seen()); n != 4 {
t.Errorf("an unconfigured queue saw %d calls, want 4; saw %v", n, q.seen())
}
// And still only once.
attachConsumersOnce(0, q)
time.Sleep(200 * time.Millisecond)
if n := len(q.seen()); n != 4 {
t.Errorf("generation 0 was attached to twice: %d calls, want 4; saw %v", n, q.seen())
}
}
+464 -44
View File
@@ -2,10 +2,14 @@ package api
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"sync/atomic"
"syscall"
"time"
"github.com/gin-gonic/gin"
@@ -13,16 +17,21 @@ import (
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/bootstrap"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
corestorage "github.com/go-admin-team/go-admin-core/v2/storage"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"go-admin/app/admin/models"
"go-admin/app/admin/router"
"go-admin/app/jobs"
otherrouter "go-admin/app/other/router"
"go-admin/common/database"
"go-admin/common/global"
"go-admin/common/health"
common "go-admin/common/middleware"
"go-admin/common/middleware/handler"
"go-admin/common/storage"
@@ -59,46 +68,123 @@ func init() {
func setup() {
// 注入配置扩展项
config.ExtendConfig = &ext.ExtConfig
// Registered before the configuration is read. SetupConfig announces
// AfterResource as soon as the callbacks that build the resources have
// run, so a hook added after that call would miss the first round and the
// queue would have no consumers until somebody edited the config file.
sdk.Runtime.SetPhase(runtime.AfterResource, attachQueueConsumers)
// On AfterListen rather than on a bare goroutine from run(). Two reasons:
// the phase runs behind core's panic guard, which does not reach across a
// goroutine boundary - a panic while loading jobs used to take the whole
// process down with a stack that named this file - and the jobs it starts
// can call the API, which is only true once the socket is accepting.
sdk.Runtime.SetPhase(runtime.AfterListen, startCronJobs)
//1. 读取配置
config.Setup(
bootstrap.SetupConfig(
file.NewSource(file.WithPath(configYml)),
database.Setup,
storage.Setup,
)
//注册监听函数
queue := sdk.Runtime.GetQueuePrefix("")
queue.Register(global.LoginLog, models.SaveLoginLog)
queue.Register(global.OperateLog, models.SaveOperaLog)
queue.Register(global.ApiCheck, models.SaveSysApi)
go queue.Run()
usageStr := `starting api server...`
log.Info(usageStr)
}
// startCronJobs registers the job implementations and starts a scheduler for
// every tenant database.
//
// It is synchronous, like the phase that runs it. jobs.Setup returns now that
// the `select {}` at the end of its per-tenant setup is gone, which is what
// makes that possible; while it was there this could only be a goroutine, and
// a goroutine is outside the panic guard.
func startCronJobs() {
jobs.InitJob()
jobs.Setup(sdk.Runtime.GetAllDb())
}
// attachedQueue is the queue generation the consumers are attached to, plus
// one, so that the zero value means "attached to nothing yet". Written from
// the goroutine running the phase, read from the next one - rounds never
// overlap, but they are not the same goroutine.
var attachedQueue atomic.Uint64
// attachQueueConsumers registers the log consumers against the queue that is
// current, and starts it.
//
// It runs on AfterResource, so it runs again after every configuration reload
// - and it has to. A reload rebuilds the queue adapter, and consumers
// registered against the one that existed at start-up are attached to an
// adapter nobody publishes to any more, so the login and operation logs stop
// being written with nothing said about it.
//
// It is therefore idempotent with respect to a given queue rather than "does
// nothing the second time": a new adapter gets a fresh set of consumers, the
// same one gets none. Registering twice on the same queue would give every
// message two consumers and write every log row twice.
//
// Generation 0 means the configuration has no queue section, so nothing was
// installed and GetQueuePrefix hands back the runtime's own memory queue.
// That case still gets consumers - it is what the previous unconditional
// registration did, and dropping it would silently stop logging for anyone who
// commented the section out - it just never gets them twice.
func attachQueueConsumers() {
attachConsumersOnce(storage.QueueGeneration(), sdk.Runtime.GetQueuePrefix(""))
}
// attachConsumersOnce puts the log consumers on q and starts it, unless gen
// says this queue already has them.
//
// Split out from attachQueueConsumers so that the order and the once-ness can
// be checked against a queue the test controls: the sequence that matters here
// cannot be read back out of a real adapter.
func attachConsumersOnce(gen uint64, q corestorage.AdapterQueue) {
if attachedQueue.Load() == gen+1 {
return
}
attachedQueue.Store(gen + 1)
//注册监听函数
q.Register(global.LoginLog, models.SaveLoginLog)
q.Register(global.OperateLog, models.SaveOperaLog)
q.Register(global.ApiCheck, models.SaveSysApi)
// Started only now, and by whoever registered. setupQueue deliberately
// leaves it stopped: a queue that is already running refuses further
// registration, and the adapter in this path drops that error on the
// floor, so starting first loses consumers without a word.
go q.Run()
}
func run() error {
// Resolved first, and used both for the line it prints and for the
// shutdown that spends it. Reading the configuration again at signal time
// would let the two disagree, and the sum that gets printed is the whole
// point of printing it.
//
// Refused rather than corrected, and refused before anything is built: a
// budget that cannot be spent as written is a configuration error, and the
// moment to say so is while nothing depends on this process yet.
seconds, err := ext.ExtConfig.Shutdown.Budget()
if err != nil {
return err
}
reportShutdownBudget(seconds)
if config.ApplicationConfig.Mode == pkg.ModeProd.String() {
gin.SetMode(gin.ReleaseMode)
}
initRouter()
for _, f := range AppRouters {
f()
}
buildRouter()
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
Handler: sdk.Runtime.GetEngine(),
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
Handler: sdk.Runtime.GetEngine(),
ReadTimeout: time.Duration(config.ApplicationConfig.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(config.ApplicationConfig.WriterTimeout) * time.Second,
}
go func() {
jobs.InitJob()
jobs.Setup(sdk.Runtime.GetAllDb())
}()
if apiCheck {
var routers = sdk.Runtime.GetRouter()
q := sdk.Runtime.GetQueuePrefix("")
@@ -116,18 +202,17 @@ func run() error {
}
}
go func() {
// 服务连接
if config.SslConfig.Enable {
if err := srv.ListenAndServeTLS(config.SslConfig.Pem, config.SslConfig.KeyStr); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal("listen: ", err)
}
} else {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal("listen: ", err)
}
}
}()
// Armed before the server starts serving, and well before the readiness
// banner: a signal arriving between "the process is up" and "the process
// is listening for signals" reaches the default handler and kills it
// without any of the shutdown below. That window is the whole reason
// arming is separate from waiting.
quit, disarmStopSignals := armStopSignals()
if err := startServing(srv, config.SslConfig.Enable, config.SslConfig.Pem, config.SslConfig.KeyStr); err != nil {
return err
}
fmt.Println(pkg.Red(string(global.LogoContent)))
tip()
fmt.Println(pkg.Green("Server run at:"))
@@ -137,23 +222,330 @@ func run() error {
fmt.Printf("- Local: http://localhost:%d/swagger/admin/index.html \r\n", config.ApplicationConfig.Port)
fmt.Printf("- Network: %s://%s:%d/swagger/admin/index.html \r\n", "http", pkg.GetLocalHost(), config.ApplicationConfig.Port)
fmt.Printf("%s Enter Control + C Shutdown Server \r\n", pkg.GetCurrentTimeStr())
// 等待中断信号以优雅地关闭服务器(设置 5 秒的超时时间)
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
log.Info("Shutdown Server ... ")
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Server Shutdown:", err)
serverErr, cleanupErr := gracefulShutdown(srv, quit, disarmStopSignals, budgetFrom(seconds))
if serverErr != nil {
// Not log.Fatal: that is an unconditional os.Exit(1), and Shutdown
// reports an error exactly when connections were still in flight -
// which is when the cleanup that ran after it mattered most.
log.Error("Server Shutdown: ", serverErr)
}
if cleanupErr != nil {
log.Error("Cleanup: ", cleanupErr)
}
log.Info("Server exiting")
return nil
}
// budget is the three waits a shutdown spends, in the order it spends them.
type budget struct {
drain time.Duration
server time.Duration
cleanup time.Duration
}
// budgetFrom turns the resolved seconds into the durations the sequence waits
// on.
func budgetFrom(s ext.ShutdownBudget) budget {
return budget{
drain: time.Duration(s.Drain) * time.Second,
server: time.Duration(s.Server) * time.Second,
cleanup: time.Duration(s.Cleanup) * time.Second,
}
}
// defaultBudget is what a process with no extend.shutdown section spends.
func defaultBudget() budget {
return budget{drain: drainTimeout, server: shutdownTimeout, cleanup: cleanupTimeout}
}
// gracefulShutdown takes the process down in the order that gives something
// else a chance to notice first.
//
// The whole order lives here, and run() is not the only caller: the signal
// tests run this function rather than reproducing it. A test that reproduces a
// sequence asserts against its own copy and stays green while the sequence it
// was written for regresses.
//
// The caller has already taken the first signal off quit. quit is handed on
// because a second signal during the drain window ends the window early -
// somebody sending another kill wants this over with sooner - and because
// until the window is over that signal must not reach the default handler and
// kill the process outright.
//
// disarm is therefore called at the end of the window rather than on the first
// signal. After it, a second signal is handled by the default disposition
// again, which is the only way out of a Shutdown or a cleanup callback that
// never returns. Restoring it any earlier would put every ordinary shutdown
// inside that escape hatch for the whole length of the drain, where before
// this window existed only a hung callback could reach it.
//
// The two waits' errors are returned separately rather than logged: they fail
// for different reasons, and the caller decides what each is worth.
func gracefulShutdown(srv *http.Server, quit <-chan os.Signal, disarm func(), b budget) (serverErr, cleanupErr error) {
// Said before anything is taken apart. A configuration reload arriving in
// this window would otherwise re-run AfterResource - rebuilding the pool
// and the queue adapter, and re-registering consumers - on top of cleanup
// that has already run.
sdk.Runtime.BeginShutdown()
// Readiness fails from here, which is before the server stops accepting.
// That order is necessary and not sufficient: with nothing between this
// line and the listener closing, the two are microseconds apart and a
// poller on a multi-second interval sees the refused connection instead of
// the 503. The window below is what turns the order into something
// observable - extend.shutdown.drain, which is zero unless it is
// configured.
health.BeginDraining()
// Keep-alive off for the same window, and for the same reason. The server
// keeps connections alive while !disableKeepAlives && !shuttingDown(), and
// shuttingDown() is only set by Shutdown itself - so without this line
// every pooled connection stays open for the whole drain and is cut at the
// end of it anyway, which is the cost of the window without its benefit.
// This is the switch Shutdown flips, moved earlier by the window's length:
// answers now carry Connection: close, and the idle connections a balancer
// is holding are closed at once rather than when it next tries to use one.
srv.SetKeepAlivesEnabled(false)
drain(quit, b.drain)
// Restored here, not on the first signal: from this point a second signal
// must reach the default handler, so a shutdown that hangs can still be
// interrupted.
disarm()
log.Info("Shutdown Server ... ")
serverErr = shutdownServer(srv, b.server)
// Runs whether or not the wait above failed, and deliberately so: Shutdown
// reports an error exactly when connections were still in flight, which is
// when there is most left to clean up after.
cleanupErr = runShutdownHooks(b.cleanup)
log.Info("Server exiting")
return serverErr, cleanupErr
}
// drain keeps serving for d, or until another stop signal arrives.
//
// Requests are answered normally throughout. Refusing them would move the
// outage earlier rather than avoid it - the point of the window is that this
// instance is still able to work while whoever routes to it stops routing.
func drain(quit <-chan os.Signal, d time.Duration) {
if d <= 0 {
return
}
log.Infof("Draining for %s: still serving, /ready answers 503 from here", d)
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-quit:
log.Info("Second signal during the drain window, closing the listener now")
case <-timer.C:
}
}
// Reference stop grace periods, printed when nothing was configured to compare
// against. They are three times apart, which is why the check below needs a
// configured value rather than a constant of its own: a budget that overruns
// under one of them fits comfortably under the other.
const (
dockerStopGraceSeconds = 10
kubernetesGraceSeconds = 30
)
// reportShutdownBudget states what a shutdown will spend and whether it fits.
//
// The sum is taken from the resolved values, not from the configuration file:
// a field left out of extend.shutdown still costs its default, so adding up
// what was written down understates the budget by exactly the fields nobody
// wrote.
func reportShutdownBudget(s ext.ShutdownBudget) {
log.Infof("shutdown budget: drain %ds + server %ds + cleanup %ds = %ds",
s.Drain, s.Server, s.Cleanup, s.Total())
if s.Grace <= 0 {
log.Infof("shutdown budget: extend.shutdown.grace is not set, so nothing is compared against it - "+
"for reference `docker stop` allows %ds and Kubernetes terminationGracePeriodSeconds defaults to %ds",
dockerStopGraceSeconds, kubernetesGraceSeconds)
return
}
if over := s.Overrun(); over > 0 {
// A minimum, not a target. This is somebody else's deployment under
// constraints this process cannot see, so the honest thing to state is
// how much is missing - the repository's own files are where there is
// standing to ask for headroom on top, and checksilent does that.
log.Warnf("shutdown budget of %ds does not fit inside the %ds of extend.shutdown.grace: "+
"SIGKILL arrives while the cleanup callbacks are still running, and the work they "+
"were about to finish is lost. It needs at least %ds more, or %ds less budget.",
s.Total(), s.Grace, over, over)
return
}
log.Infof("shutdown budget of %ds fits inside the %ds of extend.shutdown.grace", s.Total(), s.Grace)
}
// The budgets a shutdown spends when extend.shutdown configures nothing:
// drainTimeout keeps the process serving after the stop signal, then
// shutdownTimeout waits for in-flight requests, then cleanupTimeout is what
// the BeforeExit callbacks get.
//
// The seconds come from config, which is where an absent field falls back, so
// the default is one number rather than two that can drift apart.
//
// They are consumed one after the other, so their sum is what has to stay
// inside the orchestrator's grace period: `docker stop` allows 10s by default
// before it sends SIGKILL, and 0+5+3 leaves room for the process to finish
// returning. Raising one without lowering another buys nothing - the budget
// that runs out is the orchestrator's, and reportShutdownBudget is what says
// so at start-up.
var (
drainTimeout = time.Duration(ext.DefaultDrainSeconds) * time.Second
shutdownTimeout = time.Duration(ext.DefaultServerSeconds) * time.Second
cleanupTimeout = time.Duration(ext.DefaultCleanupSeconds) * time.Second
)
// armStopSignals registers for the stop signals and returns the channel they
// arrive on together with the function that restores the default disposition.
//
// SIGTERM is what actually arrives in production: `docker stop`, a Kubernetes
// pod deletion and `systemctl stop` all send it, and Go terminates the process
// immediately for a signal nobody listens for. Registering only os.Interrupt
// meant every graceful shutdown below the wait was dead code outside a
// terminal.
//
// Registering is separate from waiting so a caller can arm before it announces
// that it is ready: a signal that arrives between the two is delivered to the
// default handler, which for both of these means the process dies without
// running any of this.
func armStopSignals() (<-chan os.Signal, func()) {
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
return quit, func() { signal.Stop(quit) }
}
// startServing binds srv.Addr, hands the listener to srv on its own goroutine,
// and announces AfterListen.
//
// The bind is done here rather than left to ListenAndServe, which binds on the
// goroutine that serves. That put the failure every deployment actually hits -
// "address already in use" - on a goroutine nobody was reading, so the banner
// went on to claim the server was up, and there would be no way to keep
// AfterListen from announcing a socket that does not exist. A hook there is
// promised a reachable port; the only way to keep that promise is for the bind
// to have already happened on this goroutine.
//
// AfterListen is announced synchronously. Running it in a goroutine to save the
// few milliseconds would let it overlap the shutdown: on a fast SIGTERM the
// cleanup callbacks could finish before the startup ones had.
//
// Both ways of failing to start are therefore checked before the announcement:
// the bind, and - with ssl enabled - the certificate.
func startServing(srv *http.Server, useTLS bool, pem, key string) error {
if useTLS {
// Read the certificate before anything is announced. ServeTLS reads
// these files itself, but on the serving goroutine - so a bad
// certificate used to surface after AfterListen had already promised a
// reachable port. Loading it here costs one extra read and moves the
// failure onto this goroutine, where run() can return it.
//
// ServeTLS still does the real work below rather than this handing it a
// tls.Listener: that is what sets up HTTP/2 negotiation, and taking it
// over here would quietly drop h2 for every TLS deployment.
if _, err := tls.LoadX509KeyPair(pem, key); err != nil {
return errors.Wrap(err, "tls certificate")
}
}
ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
return errors.Wrap(err, "listen")
}
go func() {
// 服务连接
var err error
if useTLS {
err = srv.ServeTLS(ln, pem, key)
} else {
err = srv.Serve(ln)
}
if err != nil && !errors.Is(err, http.ErrServerClosed) {
// Still fatal, as it was. Neither the bind nor the certificate is
// among the errors that reach here any more - both are checked
// above, on the caller's goroutine. What is left is a serve that
// failed after the port was taken, and carrying on would park the
// process on <-quit with nothing serving.
log.Fatal("serve: ", err)
}
}()
sdk.Runtime.RunPhase(runtime.AfterListen)
return nil
}
// shutdownServer stops srv, giving in-flight requests up to timeout to finish.
//
// It returns the error instead of exiting on it. A caller that exits here skips
// its own cleanup, and Shutdown fails precisely when there was something left
// to clean up after.
func shutdownServer(srv *http.Server, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return srv.Shutdown(ctx)
}
// runShutdownHooks runs the BeforeExit callbacks with timeout to share.
//
// What the budget bounds is the wait, not the work. When it is gone RunShutdown
// stops waiting and returns; a callback that never looks at its context carries
// on until the process exits, and may leave a partial write behind. Go cannot
// cancel a function that does not check for cancellation, which is why the
// callbacks are handed a context at all.
func runShutdownHooks(timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return sdk.Runtime.RunShutdown(ctx)
}
// buildRouter announces BeforeRouter, builds the engine, and then drains the
// startup registries.
//
// The order is the contract. BeforeRouter is the last point at which a module
// can still affect how routes are built, so it has to run while there is no
// engine yet. The before registry runStartupHooks drains is a different moment
// despite the name: those callbacks run after initRouter has built the engine.
// Two lines apart, and describing them as equivalent is a mistake this
// repository has already made once in writing.
func buildRouter() {
sdk.Runtime.RunPhase(runtime.BeforeRouter)
initRouter()
runStartupHooks()
}
// runStartupHooks runs the router registries and then the before callbacks.
//
// The package-level slice runs first and in its existing order, so a fork that
// only ever appended to AppRouters sees no change at all. The core registry
// runs second, through RunAppRouters: a module can register through
// sdk.Runtime.SetAppRouters and no longer has to import this command package -
// which is a main package's plumbing - just to be routed.
//
// The loop over the core registry now lives in core, which is what brings the
// panic guard and the registration seal with it. RunBefore closes a gap rather
// than moving one: the open-source edition never executed the before callbacks
// at all, so SetBefore was accepted and silently ignored. It has to stay ahead
// of ListenAndServe, because a callback registered WithFatal exits the process
// and that must not happen to one that is already serving.
func runStartupHooks() {
for _, f := range AppRouters {
f()
}
sdk.Runtime.RunAppRouters()
sdk.Runtime.RunBefore()
}
//var Router runtime.Router
func tip() {
@@ -179,10 +571,38 @@ func initRouter() {
r.Use(handler.TlsHandler())
}
//r.Use(middleware.Metrics())
r.Use(common.Sentinel()).
r.Use(exemptProbes(common.Sentinel())).
Use(common.RequestId(pkg.TrafficKey)).
Use(api.SetRequestLogger)
common.InitMiddleware(r)
}
// probePaths are the two routes the rate limiter must not answer for.
var probePaths = map[string]bool{
otherrouter.APIPrefix + otherrouter.HealthPath: true,
otherrouter.APIPrefix + otherrouter.ReadyPath: true,
}
// exemptProbes wraps a middleware so the health and readiness routes skip it.
//
// The limiter is installed on the engine and the probes are routes like any
// other, so above the threshold they are answered with 429 as well. A liveness
// probe that collects 429s fails its threshold and the container is restarted,
// which takes capacity out of a deployment that is already short of it and
// pushes the rest closer to the threshold - the limiter working exactly as
// intended is what causes it. It is the argument common/health makes about
// restarting a process whose database is unreachable, applied to load.
//
// Wrapping rather than teaching the limiter about these paths: the limiter
// lives under common/, which may not import the package that registers them.
func exemptProbes(h gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
if probePaths[c.FullPath()] {
c.Next()
return
}
h(c)
}
}
+116
View File
@@ -0,0 +1,116 @@
package api
import (
"strings"
"testing"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
)
// freshRuntime hands the test its own Runtime and puts the old one back.
//
// Both registries close permanently the first time they are run, and
// sdk.Runtime is a process-wide singleton, so a test that runs the startup
// hooks would otherwise leave every later test in this binary registering into
// a closed registry - which is only an ERROR log, not a failure. The symptom
// is a test that passes alone and loses its routes when run with the others.
func freshRuntime(t *testing.T) {
t.Helper()
previous := sdk.Runtime
t.Cleanup(func() { sdk.Runtime = previous })
sdk.Runtime = runtime.NewConfig()
}
// Acceptance 1 and 2 together: the package-level slice a fork appends to and
// the core registry a module registers through both run, package-level first,
// registration order preserved inside each.
//
// The order matters beyond neatness. A module that appends to AppRouters has to
// import go-admin/cmd/api, which is why every module used to need a seven-line
// file in the command package; SetAppRouters is the way out of that. Running
// the old registry first is what makes the change invisible to anyone who never
// takes it.
func TestRunStartupHooksRunsBothRegistriesInOrder(t *testing.T) {
freshRuntime(t)
savedPackage := AppRouters
t.Cleanup(func() { AppRouters = savedPackage })
var order []string
AppRouters = []func(){
func() { order = append(order, "package-1") },
func() { order = append(order, "package-2") },
}
sdk.Runtime.SetAppRouters(func() { order = append(order, "runtime-1") })
sdk.Runtime.SetAppRouters(func() { order = append(order, "runtime-2") })
runStartupHooks()
const want = "package-1,package-2,runtime-1,runtime-2"
if got := strings.Join(order, ","); got != want {
t.Errorf("ran %q, want %q", got, want)
}
}
// Acceptance 17: a before callback registered through core actually runs.
//
// It did not, ever: core stored the callbacks and nothing executed them, so
// SetBefore was accepted and silently did nothing. The gap survived because
// core offered the registry without ever running it, leaving each consumer to
// write - or forget - its own loop.
func TestBeforeCallbacksRun(t *testing.T) {
freshRuntime(t)
savedPackage := AppRouters
t.Cleanup(func() { AppRouters = savedPackage })
AppRouters = nil
var order []string
sdk.Runtime.SetBefore(func() { order = append(order, "before-1") })
sdk.Runtime.SetBefore(func() { order = append(order, "before-2") })
sdk.Runtime.SetAppRouters(func() { order = append(order, "router") })
runStartupHooks()
// Routers first, then before: both happen ahead of ListenAndServe, and a
// router callback is what puts the engine in place for anything that comes
// after it.
const want = "router,before-1,before-2"
if got := strings.Join(order, ","); got != want {
t.Errorf("ran %q, want %q", got, want)
}
}
// A panicking module must not take the server down with it. The guard lives in
// core; this asserts that go-admin actually goes through it rather than around
// it with a loop of its own.
func TestAPanickingRouterDoesNotStopStartup(t *testing.T) {
freshRuntime(t)
savedPackage := AppRouters
t.Cleanup(func() { AppRouters = savedPackage })
AppRouters = nil
var order []string
sdk.Runtime.SetAppRouters(func() { order = append(order, "first") })
sdk.Runtime.SetAppRouters(func() { panic("a third-party module blew up") })
sdk.Runtime.SetAppRouters(func() { order = append(order, "third") })
sdk.Runtime.SetBefore(func() { order = append(order, "before") })
runStartupHooks()
const want = "first,third,before"
if got := strings.Join(order, ","); got != want {
t.Errorf("ran %q, want %q", got, want)
}
}
// The default AppRouters must keep the admin routes on it. Emptying the slice
// would not fail to compile anywhere - it would just serve a server with no
// admin API and no error.
func TestAdminRouterIsRegisteredOnThePackageSlice(t *testing.T) {
if len(AppRouters) == 0 {
t.Fatal("AppRouters is empty; the admin router is registered in init()")
}
}
+139
View File
@@ -0,0 +1,139 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
otherrouter "go-admin/app/other/router"
"go-admin/common/health"
ext "go-admin/config"
)
// The seconds in the configuration and the durations the sequence waits on are
// two spellings of one budget, and only one of them is printed at start-up.
func TestBudgetFromSeconds(t *testing.T) {
got := budgetFrom(ext.ShutdownBudget{Drain: 10, Server: 5, Cleanup: 3})
want := budget{
drain: 10 * time.Second,
server: 5 * time.Second,
cleanup: 3 * time.Second,
}
if got != want {
t.Errorf("budgetFrom = %+v, want %+v", got, want)
}
}
// The package variables and config.Default*Seconds have to say the same thing.
// They are the same default written twice - once as durations for the shutdown
// and once as seconds for the fallback - and a deployment that configures
// nothing is entitled to one answer, not two.
func TestDefaultBudgetIsTheConfiguredFallback(t *testing.T) {
unconfigured, err := ext.Shutdown{}.Budget()
if err != nil {
t.Fatalf("the empty section did not resolve: %v", err)
}
if got, want := defaultBudget(), budgetFrom(unconfigured); got != want {
t.Errorf("defaultBudget = %+v, want the unconfigured budget %+v", got, want)
}
}
// The rate limiter must not answer for the probes.
//
// It is installed on the engine, so without this the probes are limited like
// any other route and answer 429 above the threshold. A liveness probe that
// collects 429s fails its threshold and the container is restarted - taking
// capacity out of a deployment that is already short of it and pushing the
// rest closer to the threshold. The limiter working exactly as designed is
// what would cause it.
//
// The stand-in rejects everything rather than being a real limiter: what is
// under test is which requests reach it, and a real one would need the traffic
// to cross a threshold before it said anything.
func TestTheProbesSkipTheRateLimiter(t *testing.T) {
gin.SetMode(gin.TestMode)
var reached []string
r := gin.New()
r.Use(exemptProbes(func(c *gin.Context) {
reached = append(reached, c.FullPath())
c.AbortWithStatus(http.StatusTooManyRequests)
}))
v1 := r.Group(otherrouter.APIPrefix)
otherrouter.RegisterMonitorRouter(v1)
v1.GET("/business", func(c *gin.Context) { c.Status(http.StatusOK) })
for _, tc := range []struct {
path string
limited bool
}{
{otherrouter.APIPrefix + otherrouter.HealthPath, false},
{otherrouter.APIPrefix + otherrouter.ReadyPath, false},
// Not a probe, and deliberately not exempt: the exemption is for the
// two routes an orchestrator acts on, not for everything under
// /api/v1 that happens to be unauthenticated.
{otherrouter.APIPrefix + "/metrics", true},
{otherrouter.APIPrefix + "/business", true},
} {
t.Run(tc.path, func(t *testing.T) {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, tc.path, nil))
if tc.limited {
if w.Code != http.StatusTooManyRequests {
t.Errorf("answered %d, want the middleware's 429 - it was skipped for a route that is not a probe", w.Code)
}
return
}
if w.Code == http.StatusTooManyRequests {
t.Errorf("answered 429; a probe that can be rate-limited gets the container restarted under load")
}
})
}
// Said separately, because a probe could also answer 429 by itself: what
// has to be true is that the middleware never saw the request.
for _, p := range reached {
if probePaths[p] {
t.Errorf("the middleware ran for %s", p)
}
}
}
// /health has to stay 200 while draining, and it is the assertion most easily
// lost by accident: making the liveness probe follow the readiness flag reads
// like tidying up, and it turns every rolling restart into a kubelet-issued
// kill part-way through the drain.
func TestHealthStaysUpWhileDraining(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
v1 := r.Group(otherrouter.APIPrefix)
otherrouter.RegisterMonitorRouter(v1)
ask := func(path string) int {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
return w.Code
}
if got := ask(otherrouter.APIPrefix + otherrouter.HealthPath); got != http.StatusOK {
t.Fatalf("/health answered %d before draining, want 200", got)
}
// Process-wide and one-way - nothing clears it - so this is the last thing
// in this package that may run in-process and care. Everything else that
// exercises draining does so in a child process of its own.
health.BeginDraining()
if got := ask(otherrouter.APIPrefix + otherrouter.HealthPath); got != http.StatusOK {
t.Errorf("/health answered %d while draining, want 200 - liveness is "+
"\"should I restart you\", and the answer during a drain is no", got)
}
if got := ask(otherrouter.APIPrefix + otherrouter.ReadyPath); got != http.StatusServiceUnavailable {
t.Errorf("/ready answered %d while draining, want 503", got)
}
}
+739
View File
@@ -0,0 +1,739 @@
package api
import (
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/v2/sdk"
otherrouter "go-admin/app/other/router"
)
// The signal path cannot be exercised in-process: delivering a signal to the
// test binary would race with the test framework, and the disposition changes
// are global. So the test re-executes itself as a child, and the child runs
// gracefulShutdown - the same function run() runs, not a second copy of the
// sequence. A test that reproduces the sequence asserts against its own copy:
// move BeginDraining after the drain window and the process regresses while
// the test stays green, which is the failure mode this file exists to avoid.
//
// The child serves the real probe routes on an http.Server of its own rather
// than the configured one: this repository's CI has no database
// (.github/workflows/go.yml runs neither MySQL nor a sqlite-tagged build), and
// none of what is under test needs one. /ready answers 503 either way - with
// no database its checks fail - so the assertions below are on the draining
// answer specifically, not on the status code alone.
const (
childEnv = "GO_ADMIN_SIGNAL_CHILD"
childStuckEnv = "GO_ADMIN_SIGNAL_CHILD_STUCK"
childHangConn = "GO_ADMIN_SIGNAL_CHILD_HANGCONN"
childSlowCleanup = "GO_ADMIN_SIGNAL_CHILD_SLOWCLEANUP"
childDrainMS = "GO_ADMIN_SIGNAL_CHILD_DRAIN_MS"
markerAddr = "CHILD-ADDR"
markerReady = "CHILD-READY"
markerSignal = "CHILD-SIGNAL"
markerShutdown = "CHILD-SHUTDOWN-OK"
markerCleanup = "CHILD-CLEANUP-RAN"
markerTook = "CHILD-TOOK-NS"
markerExiting = "CHILD-EXITING"
)
// childPingRoute is an ordinary route, registered beside the probes so the
// window can be checked for what it promises: requests arriving inside it are
// served, not refused. Refusing them would move the outage earlier instead of
// avoiding it.
const childPingRoute = "/signal-test-ping"
var (
readyPath = otherrouter.APIPrefix + otherrouter.ReadyPath
healthPath = otherrouter.APIPrefix + otherrouter.HealthPath
pingPath = otherrouter.APIPrefix + childPingRoute
)
// TestSignalChild is the child process. It is skipped in a normal run.
func TestSignalChild(t *testing.T) {
if os.Getenv(childEnv) != "1" {
t.Skip("child process entry point")
}
gin.SetMode(gin.TestMode)
engine := gin.New()
v1 := engine.Group(otherrouter.APIPrefix)
otherrouter.RegisterMonitorRouter(v1)
v1.GET(childPingRoute, func(c *gin.Context) { c.String(http.StatusOK, "pong") })
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Println("listen:", err)
os.Exit(3)
}
// accepted fires once the server has taken a connection off the listener.
// Dialling is not enough: Shutdown only waits for connections the server
// has already accepted, so calling it between the dial and the accept
// finds nothing to wait for and returns immediately.
accepted := make(chan struct{}, 1)
srv := &http.Server{
Handler: engine,
ConnState: func(_ net.Conn, state http.ConnState) {
if state == http.StateNew {
select {
case accepted <- struct{}{}:
default:
}
}
},
}
go func() { _ = srv.Serve(ln) }()
// The budget the child spends. Nothing here calls bootstrap.SetupConfig, so
// with no environment set this is the budget of a deployment that
// configures no extend.shutdown section at all.
b := defaultBudget()
if ms := os.Getenv(childDrainMS); ms != "" {
n, err := strconv.Atoi(ms)
if err != nil {
fmt.Println("drain:", err)
os.Exit(4)
}
b.drain = time.Duration(n) * time.Millisecond
}
// A BeforeExit callback, registered the way a module would. What the tests
// below care about is whether it runs at all - after a Shutdown that
// failed, and after its own budget has been spent.
sdk.Runtime.SetShutdown(func(ctx context.Context) {
switch {
case os.Getenv(childStuckEnv) == "1":
// Stands in for a cleanup hook that never finishes. The point of
// restoring the signal disposition after the drain window is that
// a second signal still reaches the default handler and kills this.
time.Sleep(2 * time.Minute)
case os.Getenv(childSlowCleanup) == "1":
// Outlasts the budget on purpose, and does not consult ctx - which
// is the case the contract is explicit about: what the context
// bounds is the wait, not the work.
time.Sleep(2 * time.Second)
}
fmt.Println(markerCleanup)
_ = os.Stdout.Sync()
})
switch {
case os.Getenv(childStuckEnv) == "1":
b.cleanup = 2 * time.Minute
case os.Getenv(childSlowCleanup) == "1":
b.cleanup = 300 * time.Millisecond
}
// Arm before announcing readiness. Doing it the other way round leaves a
// window in which the parent's signal reaches the default handler and
// kills the child before any of this runs - which is exactly the failure
// this whole change is about, so the test must not reproduce it by
// accident.
quit, disarm := armStopSignals()
fmt.Println(markerAddr, ln.Addr().String())
fmt.Println(markerReady)
_ = os.Stdout.Sync()
sig := <-quit
fmt.Println(markerSignal, sig)
_ = os.Stdout.Sync()
if os.Getenv(childHangConn) == "1" {
// Dialled here, not at start-up. net/http stops counting a StateNew
// connection against Shutdown once it is more than five seconds old,
// so a connection opened before the wait would age out on a slow CI
// run and Shutdown would succeed - leaving the test asserting nothing.
c, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
fmt.Println("dial:", err)
os.Exit(5)
}
defer func() { _ = c.Close() }()
// And wait for the accept, for the opposite reason: an unaccepted
// connection is not one Shutdown waits for either.
select {
case <-accepted:
case <-time.After(10 * time.Second):
fmt.Println("the server never accepted the stalling connection")
os.Exit(6)
}
// A connection that has sent nothing keeps Shutdown busy: net/http
// only treats a StateNew connection as idle once it is more than five
// seconds old. A short budget makes the timeout deterministic without
// waiting out the real one.
b.server = 300 * time.Millisecond
}
started := time.Now()
serverErr, cleanupErr := gracefulShutdown(srv, quit, disarm, b)
spent := time.Since(started)
if serverErr != nil {
// Deliberately not fatal, and deliberately not a bare return: the
// point is that whatever follows still runs.
fmt.Println("shutdown error:", serverErr)
} else {
fmt.Println(markerShutdown)
}
if cleanupErr != nil {
fmt.Println("cleanup error:", cleanupErr)
}
fmt.Println(markerTook, spent.Nanoseconds())
fmt.Println(markerExiting)
_ = os.Stdout.Sync()
}
func startChild(t *testing.T, stuck bool, extraEnv ...string) (*exec.Cmd, chan string) {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
cmd := exec.Command(os.Args[0], "-test.run=TestSignalChild", "-test.v")
cmd.Env = append(os.Environ(), childEnv+"=1")
if stuck {
cmd.Env = append(cmd.Env, childStuckEnv+"=1")
}
cmd.Env = append(cmd.Env, extraEnv...)
cmd.Stdout = w
cmd.Stderr = w
if err := cmd.Start(); err != nil {
t.Fatalf("start child: %v", err)
}
_ = w.Close()
lines := make(chan string, 256)
go func() {
defer close(lines)
buf := make([]byte, 4096)
var acc strings.Builder
for {
n, err := r.Read(buf)
if n > 0 {
acc.Write(buf[:n])
for {
s := acc.String()
i := strings.IndexByte(s, '\n')
if i < 0 {
break
}
lines <- s[:i]
acc.Reset()
acc.WriteString(s[i+1:])
}
}
if err != nil {
if acc.Len() > 0 {
lines <- acc.String()
}
return
}
}
}()
t.Cleanup(func() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
_ = r.Close()
})
return cmd, lines
}
// await drains lines until one contains want, or the deadline passes. It
// returns everything it saw, so a failure says what the child actually did,
// and the matching line, so a marker can carry a value.
func await(t *testing.T, lines chan string, want string, d time.Duration) ([]string, string) {
t.Helper()
var seen []string
deadline := time.After(d)
for {
select {
case l, ok := <-lines:
if !ok {
t.Fatalf("child output ended before %q; saw:\n%s", want, strings.Join(seen, "\n"))
}
seen = append(seen, l)
if strings.Contains(l, want) {
return seen, l
}
case <-deadline:
t.Fatalf("timed out waiting for %q; saw:\n%s", want, strings.Join(seen, "\n"))
}
}
}
// childAddr waits for the address the child is listening on.
func childAddr(t *testing.T, lines chan string) string {
t.Helper()
_, line := await(t, lines, markerAddr, 30*time.Second)
fields := strings.Fields(line)
return fields[len(fields)-1]
}
// took reads the nanoseconds gracefulShutdown spent, as the child measured
// them. Measured inside the child on purpose: the parent's own clock includes
// process scheduling, which is the noise the tightest assertion here cannot
// afford.
func took(t *testing.T, lines chan string, d time.Duration) time.Duration {
t.Helper()
_, line := await(t, lines, markerTook, d)
fields := strings.Fields(line)
ns, err := strconv.ParseInt(fields[len(fields)-1], 10, 64)
if err != nil {
t.Fatalf("unreadable %s line %q: %v", markerTook, line, err)
}
return time.Duration(ns)
}
// sample is one answer, or the refusal that replaced it.
type sample struct {
at time.Time
path string
// status is zero when the connection could not be made at all, which is
// what a closed listener looks like from outside.
status int
draining bool
// willClose is what the server answered about the connection: the header
// it sends is Connection: close, which the transport consumes and reports
// here rather than leaving in Response.Header.
willClose bool
}
// probe asks once, on a connection of its own.
//
// A new transport per request, because a connection opened before the signal
// can still be served after the listener is closed: reusing one would let this
// test pass against a shutdown that had already broken the listener. Keep-alive
// is left enabled so the server's own Connection: close is observable - a
// client that asked for close would get that header back either way, and the
// assertion would prove nothing.
func probe(addr, path string) sample {
tr := &http.Transport{}
defer tr.CloseIdleConnections()
c := &http.Client{Transport: tr, Timeout: 3 * time.Second}
s := sample{at: time.Now(), path: path}
resp, err := c.Get("http://" + addr + path)
if err != nil {
return s
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
s.status = resp.StatusCode
s.willClose = resp.Close
s.draining = strings.Contains(string(body), `"status":"draining"`)
return s
}
// watcher polls the child until it stops accepting connections, keeping every
// answer.
type watcher struct {
mu sync.Mutex
samples []sample
done chan struct{}
}
func watch(addr string, paths ...string) *watcher {
w := &watcher{done: make(chan struct{})}
go func() {
defer close(w.done)
for {
refused := false
for _, p := range paths {
s := probe(addr, p)
w.mu.Lock()
w.samples = append(w.samples, s)
w.mu.Unlock()
if s.status == 0 {
refused = true
}
}
if refused {
return
}
time.Sleep(20 * time.Millisecond)
}
}()
return w
}
// sawDraining reports whether /ready has answered "draining" yet.
func (w *watcher) sawDraining() bool {
w.mu.Lock()
defer w.mu.Unlock()
for _, s := range w.samples {
if s.path == readyPath && s.draining {
return true
}
}
return false
}
func (w *watcher) wait(t *testing.T, d time.Duration) []sample {
t.Helper()
select {
case <-w.done:
case <-time.After(d):
t.Fatal("the child never stopped accepting connections")
}
w.mu.Lock()
defer w.mu.Unlock()
return w.samples
}
func describe(samples []sample) string {
var b strings.Builder
for _, s := range samples {
fmt.Fprintf(&b, " %s %s -> %d draining=%v willClose=%v\n",
s.at.Format("15:04:05.000"), s.path, s.status, s.draining, s.willClose)
}
return b.String()
}
// Acceptance 19. Registering only os.Interrupt meant SIGTERM - the signal
// `docker stop`, Kubernetes and systemd all send - terminated the process
// before any of the shutdown path ran. Both must now reach it.
func TestBothSignalsRunTheShutdownPath(t *testing.T) {
for _, tc := range []struct {
name string
sig syscall.Signal
}{
{"SIGINT", syscall.SIGINT},
{"SIGTERM", syscall.SIGTERM},
} {
t.Run(tc.name, func(t *testing.T) {
cmd, lines := startChild(t, false)
await(t, lines, markerReady, 30*time.Second)
if err := cmd.Process.Signal(tc.sig); err != nil {
t.Fatalf("signal: %v", err)
}
await(t, lines, markerSignal, 10*time.Second)
await(t, lines, markerShutdown, 10*time.Second)
await(t, lines, markerExiting, 10*time.Second)
if err := cmd.Wait(); err != nil {
t.Fatalf("child exited with %v, want a clean exit", err)
}
})
}
}
// Acceptance 20. quit is a buffered channel and signal.Notify stays armed, so
// without restoring the disposition a second signal only refills the buffer:
// once SIGTERM is registered, a shutdown that hangs could not be interrupted by
// anything short of SIGKILL.
//
// The hang is now a cleanup callback that never returns, which is where a
// shutdown actually hangs, and it is reached through gracefulShutdown - so this
// also pins where the disposition is restored. Restore it before the drain
// window and the window itself becomes the interruptible part; restore it never
// and this test hangs.
func TestASecondSignalStillKillsAStuckShutdown(t *testing.T) {
cmd, lines := startChild(t, true)
await(t, lines, markerReady, 30*time.Second)
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatalf("first signal: %v", err)
}
await(t, lines, markerSignal, 10*time.Second)
// The child is now on its way into a cleanup that will not finish on its
// own. Signalled repeatedly rather than once: the marker is printed just
// before gracefulShutdown is entered, and the disposition is not restored
// until the drain window is over - zero seconds here, but not zero
// instructions - so a single signal sent immediately after the marker can
// still land in the buffered channel and be dropped. Which of them does
// the killing is not the assertion; that one of them can is.
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
retry := time.NewTicker(200 * time.Millisecond)
defer retry.Stop()
deadline := time.After(15 * time.Second)
for {
select {
case err := <-done:
if err == nil {
t.Fatal("child exited cleanly; it was supposed to be killed by the second signal")
}
return
case <-retry.C:
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatalf("second signal: %v", err)
}
case <-deadline:
t.Fatal("the second signal did not kill a stuck shutdown - the escape hatch is gone")
}
}
}
// Acceptance 21. srv.Shutdown reports an error exactly when connections were
// still in flight, and the old code answered that with log.Fatal - an
// unconditional os.Exit(1). Everything after it, which is where the cleanup
// hooks will hang, never ran. A failed Shutdown must not end the process.
func TestShutdownTimeoutDoesNotStopWhatFollows(t *testing.T) {
cmd, lines := startChild(t, false, childHangConn+"=1")
await(t, lines, markerReady, 30*time.Second)
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatalf("signal: %v", err)
}
await(t, lines, markerSignal, 10*time.Second)
seen, _ := await(t, lines, markerExiting, 20*time.Second)
var timedOut bool
for _, l := range seen {
if strings.Contains(l, "shutdown error:") {
timedOut = true
}
}
if !timedOut {
t.Fatalf("Shutdown did not time out, so this test proves nothing; saw:\n%s",
strings.Join(seen, "\n"))
}
var cleaned bool
for _, l := range seen {
if strings.Contains(l, markerCleanup) {
cleaned = true
}
}
if !cleaned {
t.Fatalf("the BeforeExit callback did not run after a failed Shutdown; saw:\n%s",
strings.Join(seen, "\n"))
}
if err := cmd.Wait(); err != nil {
t.Fatalf("child exited with %v after a failed Shutdown, want a clean exit", err)
}
}
// A callback that outlasts its budget must not take the process with it, and
// must not be waited for: RunShutdown reports the deadline and returns, the
// callback carries on, and the process still exits cleanly. This is the half of
// the contract that is easy to get backwards - the context bounds the wait, not
// the work, because Go cannot cancel a function that does not check for it.
func TestACleanupThatOutlastsItsBudgetIsAbandonedNotAwaited(t *testing.T) {
cmd, lines := startChild(t, false, childSlowCleanup+"=1")
await(t, lines, markerReady, 30*time.Second)
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatalf("signal: %v", err)
}
await(t, lines, markerSignal, 10*time.Second)
// The budget is 300ms and the callback sleeps two seconds. If RunShutdown
// waited for it, this marker would not arrive for two seconds; the one
// second here is what makes "abandoned, not awaited" the thing asserted.
seen, _ := await(t, lines, markerExiting, 1*time.Second)
var reported bool
for _, l := range seen {
if strings.Contains(l, "cleanup error:") {
reported = true
}
if strings.Contains(l, markerCleanup) {
t.Fatalf("the slow callback finished before the process moved on, so nothing was abandoned; saw:\n%s",
strings.Join(seen, "\n"))
}
}
if !reported {
t.Fatalf("RunShutdown returned no error for a callback that outlasted the budget; saw:\n%s",
strings.Join(seen, "\n"))
}
if err := cmd.Wait(); err != nil {
t.Fatalf("child exited with %v, want a clean exit despite the abandoned callback", err)
}
}
// The core acceptance: with a drain window configured, something outside the
// process can observe that this instance is draining, on a connection it opens
// after the signal, and can still be served while it does.
//
// Two windows rather than one. A single value proves only that something takes
// that long, which a hard-coded sleep anywhere in the sequence would satisfy;
// two say the wait is the configured one.
//
// What each answer is for:
//
// - /ready reporting "draining" is the window being observable at all. The
// status code alone would not say it: with no database configured the
// probe's own checks fail and 503 is also the answer before the signal.
// - The server refusing to keep those connections alive is the window being
// useful. It keeps them alive until Shutdown sets shuttingDown(), so
// without switching keep-alive off here a balancer's pool would sit
// untouched for the whole window and be cut at the end of it anyway. The
// header saying so is Connection: close; the transport consumes it and
// reports it as Response.Close, which is what a sample records.
// - /health staying 200 is the window not asking to be restarted, and the
// ordinary route staying 200 is the window not refusing work. Draining is
// "stop sending me new work", not "reject what arrives".
func TestTheDrainWindowIsObservableWhileStillServing(t *testing.T) {
for _, drain := range []time.Duration{300 * time.Millisecond, 1200 * time.Millisecond} {
t.Run(drain.String(), func(t *testing.T) {
cmd, lines := startChild(t, false,
fmt.Sprintf("%s=%d", childDrainMS, drain.Milliseconds()))
addr := childAddr(t, lines)
await(t, lines, markerReady, 30*time.Second)
w := watch(addr, readyPath, healthPath, pingPath)
// Long enough for a round of answers from a server that is not yet
// draining, which is what the keep-alive assertion below compares
// against.
time.Sleep(150 * time.Millisecond)
signalAt := time.Now()
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatalf("signal: %v", err)
}
samples := w.wait(t, drain+30*time.Second)
spent := took(t, lines, 10*time.Second)
await(t, lines, markerExiting, 10*time.Second)
if spent < drain {
t.Errorf("the shutdown took %s, want at least the %s window", spent, drain)
}
var refusedAt = -1
for i, s := range samples {
if s.status == 0 {
refusedAt = i
break
}
}
if refusedAt < 0 {
t.Fatalf("the child never stopped accepting; saw:\n%s", describe(samples))
}
var keptAliveBefore, drainingInside, closedInside bool
for _, s := range samples[:refusedAt] {
switch s.path {
case readyPath:
if s.at.Before(signalAt) && !s.draining && !s.willClose {
keptAliveBefore = true
}
if s.at.After(signalAt) && s.draining {
drainingInside = true
if s.willClose {
closedInside = true
}
}
case healthPath, pingPath:
if s.status != http.StatusOK {
t.Errorf("%s answered %d before the listener closed, want 200;\n%s",
s.path, s.status, describe(samples))
}
}
}
if !keptAliveBefore {
t.Fatalf("no answer before the signal kept the connection alive, so the header assertion below proves nothing;\n%s",
describe(samples))
}
if !drainingInside {
t.Errorf("no answer inside the window reported draining; the flip and the closed listener were not far enough apart to observe;\n%s",
describe(samples))
}
if !closedInside {
t.Errorf("answers inside the window still kept the connection alive, so a pooled connection survives the whole window and is cut at the end of it anyway;\n%s",
describe(samples))
}
if err := cmd.Wait(); err != nil {
t.Fatalf("child exited with %v, want a clean exit", err)
}
})
}
}
// The default has to be no window at all: a process that configures no
// extend.shutdown section must shut down the way it did before the section
// existed.
//
// Asserted as a sequence rather than as a duration. How long a shutdown takes
// is decided by how much the cleanup callbacks have to do, so "as fast as
// before" is not falsifiable; "nothing was inserted between the signal and the
// listener closing" is.
func TestAnUnconfiguredShutdownAddsNoWindow(t *testing.T) {
cmd, lines := startChild(t, false)
await(t, lines, markerReady, 30*time.Second)
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatalf("signal: %v", err)
}
await(t, lines, markerSignal, 10*time.Second)
spent := took(t, lines, 10*time.Second)
if spent > 100*time.Millisecond {
t.Errorf("an unconfigured shutdown spent %s between the signal and exiting; "+
"with no drain window and no cleanup callbacks it must be immediate", spent)
}
if err := cmd.Wait(); err != nil {
t.Fatalf("child exited with %v, want a clean exit", err)
}
}
// A second signal during the window ends it early rather than killing the
// process. Somebody sending another kill wants this over with sooner, and the
// answer to that is to stop draining - not to skip the cleanup, which is what
// the default disposition would do.
//
// This is the pair to TestASecondSignalStillKillsAStuckShutdown: the escape
// hatch has to be closed for the length of the window and open after it.
func TestASecondSignalEndsTheDrainWindowEarly(t *testing.T) {
// Long enough that the shutdown cannot plausibly have taken this long on
// its own, short enough that the test does not sit out the whole window
// when the early exit is missing - it fails on the reported duration
// instead of on a timeout, which says which of the two broke.
const window = 10 * time.Second
cmd, lines := startChild(t, false,
fmt.Sprintf("%s=%d", childDrainMS, window.Milliseconds()))
addr := childAddr(t, lines)
await(t, lines, markerReady, 30*time.Second)
w := watch(addr, readyPath)
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatalf("first signal: %v", err)
}
deadline := time.Now().Add(15 * time.Second)
for !w.sawDraining() {
if time.Now().After(deadline) {
t.Fatal("the child never reported draining, so the second signal below would not land inside the window")
}
time.Sleep(20 * time.Millisecond)
}
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatalf("second signal: %v", err)
}
spent := took(t, lines, window+20*time.Second)
if spent >= window {
t.Errorf("the window ran its full %s despite a second signal (%s); the signal was ignored", window, spent)
}
await(t, lines, markerExiting, 10*time.Second)
if err := cmd.Wait(); err != nil {
t.Fatalf("child exited with %v; a second signal inside the window must end the window, not the process", err)
}
}
+49
View File
@@ -0,0 +1,49 @@
package migrate
import (
"strings"
"testing"
"go-admin/cmd/migrate/migration"
)
// A mistyped --app used to be indistinguishable from an up-to-date database on
// all three paths: `migrate` printed that the app was unknown and exited 0,
// while `--dry-run` and `status` printed "nothing to apply" and "none
// recorded" - the same words a database with nothing pending produces. An
// operator scripting `migrate --app crmm && deploy` therefore deployed against
// a database the migrations never touched.
func TestAppRegistrationErrorRejectsAnUnknownCode(t *testing.T) {
restore := appCode
t.Cleanup(func() { appCode = restore })
appCode = "doesnotexist"
err := appRegistrationError()
if err == nil {
t.Fatal("an unregistered app code must be an error, not an empty run")
}
if !strings.Contains(err.Error(), `"doesnotexist"`) {
t.Errorf("the message must quote what was typed; got %q", err)
}
// Listing what is registered is what turns the error into a fix: the typo
// is usually one letter away from something in this list.
if !strings.Contains(err.Error(), migration.FrameworkAppCode) {
t.Errorf("the message must list the registered codes; got %q", err)
}
}
func TestAppRegistrationErrorAcceptsWhatIsRegistered(t *testing.T) {
restore := appCode
t.Cleanup(func() { appCode = restore })
for _, code := range []string{
"", // no --app at all: every migration runs
migration.FrameworkAppCode, // "core", the framework's own
strings.ToUpper(migration.FrameworkAppCode), // codes normalize to lower case
} {
appCode = code
if err := appRegistrationError(); err != nil {
t.Errorf("appCode %q must be accepted; got %v", code, err)
}
}
}
+312 -15
View File
@@ -1,21 +1,46 @@
package migration
import (
"fmt"
"log"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"gorm.io/gorm"
contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration"
common "go-admin/common/models"
)
var Migrate = &Migration{
version: make(map[string]func(db *gorm.DB, version string) error),
var Migrate = newMigration()
// contractSnapshot is contractmigration.Snapshot, indirected through a
// package-level variable so tests can substitute an isolated
// *contractmigration.Registry's Snapshot instead of reaching into
// go-admin-core's single process-wide registry, which every *Migration in
// this process - test-local or the package-level Migrate - reads through the
// same call. See mergedEntries.
var contractSnapshot = contractmigration.Snapshot
func newMigration() *Migration {
return &Migration{version: make(map[string]versionEntry)}
}
// versionEntry is one registered migration plus the app it belongs to. The
// empty app code means the framework itself, which is also what the
// sys_migration.app_code column defaults to, so history written before this
// field existed reads back correctly with no backfill.
type versionEntry struct {
appCode string
fn func(db *gorm.DB, version string) error
}
type Migration struct {
db *gorm.DB
version map[string]func(db *gorm.DB, version string) error
version map[string]versionEntry
mutex sync.Mutex
}
@@ -27,20 +52,285 @@ func (e *Migration) SetDb(db *gorm.DB) {
e.db = db
}
// SetVersion registers a migration owned by the framework. Signature and
// behaviour are unchanged: every existing call site in version/*.go keeps
// compiling and keeps writing common.Migration{Version: version} with no app
// code, which is the correct meaning of "framework".
func (e *Migration) SetVersion(k string, f func(db *gorm.DB, version string) error) {
e.mutex.Lock()
defer e.mutex.Unlock()
e.version[k] = f
e.setVersion(k, "", f)
}
func (e *Migration) Migrate() {
versions := make([]string, 0)
for k := range e.version {
func (e *Migration) setVersion(k, appCode string, f func(db *gorm.DB, version string) error) {
e.mutex.Lock()
defer e.mutex.Unlock()
e.version[k] = versionEntry{appCode: appCode, fn: f}
}
// AppMigrationFunc is the signature of a migration registered through ForApp.
//
// It receives appCode explicitly because the migration - not the framework -
// writes its own completion row, normally as the last statement inside its own
// transaction. That is what makes "the schema change and the record of it
// commit together" true, and the framework cannot insert the row on the
// migration's behalf without giving that up. Handing the code to the function
// is what stops an app's migrations from silently recording themselves as the
// framework's.
type AppMigrationFunc func(db *gorm.DB, version, appCode string) error
// AppRegistrar is a per-app view over a registry.
type AppRegistrar struct {
m *Migration
appCode string
}
// FrameworkAppCode is the name migrate status prints for migrations that belong
// to the framework rather than to an app, and the name --app accepts to select
// them. The stored app code for those is the empty string; this is only the
// spelling humans use. It is reserved - ForApp rejects it - so that every group
// heading status prints is also a value --app understands.
const FrameworkAppCode = "core"
// ForApp returns a registrar that records migrations under code.
//
// The code is lower-cased: sys_migration.version sorts as ASCII, so mixed case
// would order MyApp before crm for no reason a reader could guess, and the two
// spellings would group as two different apps in migrate status.
//
// An empty or reserved code panics rather than falling back to the framework.
// Registration happens in init(), so this fires the first time the binary runs
// anywhere, which is the point: an app whose migrations quietly file themselves
// under the framework is exactly the class of silent failure this work is meant
// to remove. Framework migrations call Migrate.SetVersion directly.
func ForApp(code string) *AppRegistrar { return Migrate.ForApp(code) }
// ForApp is the same on an explicit registry, which is what tests use.
func (e *Migration) ForApp(code string) *AppRegistrar {
code = NormalizeAppCode(code)
switch code {
case "":
panic("migration.ForApp: empty app code; framework migrations use Migrate.SetVersion")
case FrameworkAppCode:
panic("migration.ForApp: app code " + FrameworkAppCode + " is reserved for the framework")
}
return &AppRegistrar{m: e, appCode: code}
}
// AppCode reports the code this registrar files migrations under, after
// normalisation.
func (r *AppRegistrar) AppCode() string { return r.appCode }
// SetVersion registers an app-owned migration under k, which is the bare
// timestamp taken from the file name exactly as framework migrations do.
//
// What reaches sys_migration.version is the namespaced form; the version string
// handed to f is that same namespaced string, so a migration that writes
// common.Migration{Version: version, AppCode: appCode} records the key the
// registry will look for next time.
func (r *AppRegistrar) SetVersion(k string, f AppMigrationFunc) {
key := namespacedKey(r.appCode, k)
r.m.setVersion(key, r.appCode, func(db *gorm.DB, version string) error {
return f(db, version, r.appCode)
})
}
// namespacedKey scopes k to appCode so two apps cannot collide on the
// sys_migration.version primary key by minting the same millisecond timestamp.
// Framework migrations (appCode == "") stay bare, matching every version string
// already in production.
func namespacedKey(appCode, k string) string {
if appCode == "" {
return k
}
return appCode + "-" + k
}
// mergedEntries returns every migration this process knows about: the
// host's own registry (e.version, filled by version/*.go and
// version-local/*.go through SetVersion/ForApp) plus whatever a third-party
// application registered through go-admin-core's sdk/contract/migration
// package (PRD 006, F9's host wiring).
//
// That package keeps its own process-wide registry, entirely separate from
// e.version, because a third-party application cannot reach into this
// process to call an unexported method on *Migration - contract/migration's
// package-level ForApp/Snapshot are the only door open to it. Without this
// merge, migrate/status/--dry-run would only ever see the host's own
// migrations: an application's ForApp("crm").SetVersion(...) would compile,
// register successfully into contract/migration's registry, and then never
// run, with no error anywhere - the exact silent gap this method closes.
//
// Entry and versionEntry are structurally identical (an app code plus a
// func(db, version) error); the conversion below exists only because they
// are two distinct named types, one per package, not because the data
// differs.
func (e *Migration) mergedEntries() map[string]versionEntry {
e.mutex.Lock()
out := make(map[string]versionEntry, len(e.version))
for k, v := range e.version {
out[k] = v
}
e.mutex.Unlock()
for k, entry := range contractSnapshot() {
if _, exists := out[k]; exists {
// contract/migration.ForApp namespaces every app-owned key as
// appCode + "-" + k, and appCode is reserved from ""/"core", so
// this should never collide with a host-registered key. If it
// somehow does, the host's own registration wins rather than
// silently overwriting it.
continue
}
out[k] = versionEntry{appCode: entry.AppCode, fn: entry.Fn}
}
return out
}
// StatusEntry is one row of migrate status.
type StatusEntry struct {
AppCode string
Version string
Registered bool
Applied bool
ApplyTime *time.Time
}
// Status merges the in-process registry with sys_migration, so it reports all
// three shapes at once: registered but not applied, registered and applied, and
// applied while nothing registers it any more - a row left behind by a
// migration file that was deleted, or by an app that was uninstalled.
//
// It only reads. Nothing here creates or alters a table, which is what lets
// both `status` and `--dry-run` run against a database without touching it.
func (e *Migration) Status() ([]StatusEntry, error) {
if e.db == nil {
return nil, fmt.Errorf("migration: no database configured")
}
all := e.mergedEntries()
registered := make(map[string]string, len(all))
for k, v := range all {
registered[k] = v.appCode
}
applied := make(map[string]common.Migration)
// A database that has never been migrated has no sys_migration table.
// Reporting everything as pending is the honest answer there; erroring out
// would make status useless in exactly the case it is most wanted.
if e.db.Migrator().HasTable(&common.Migration{}) {
var rows []common.Migration
if err := e.db.Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
applied[r.Version] = r
}
}
versions := make(map[string]struct{}, len(registered)+len(applied))
for k := range registered {
versions[k] = struct{}{}
}
for k := range applied {
versions[k] = struct{}{}
}
list := make([]string, 0, len(versions))
for k := range versions {
list = append(list, k)
}
sort.Strings(list)
out := make([]StatusEntry, 0, len(list))
for _, v := range list {
entry := StatusEntry{Version: v}
if code, ok := registered[v]; ok {
entry.Registered = true
entry.AppCode = code
}
if row, ok := applied[v]; ok {
entry.Applied = true
t := row.ApplyTime
entry.ApplyTime = &t
if !entry.Registered {
// Nothing registers this version any more, so the database is
// the only source left for what it belonged to.
entry.AppCode = row.AppCode
}
}
out = append(out, entry)
}
return out, nil
}
// Migrate applies every registered migration that has not been applied yet,
// across all apps. Existing callers are unaffected.
func (e *Migration) Migrate() { e.run(allApps) }
// MigrateApp applies only the migrations registered under appCode. Pass
// FrameworkAppCode for the framework's own migrations.
func (e *Migration) MigrateApp(appCode string) { e.run(AppFilter(appCode)) }
// NormalizeAppCode applies the same rule ForApp does, so a code typed on the
// command line matches one written in an init().
func NormalizeAppCode(code string) string {
return strings.ToLower(strings.TrimSpace(code))
}
// AppFilter turns a code as typed into the code stored in the registry, so
// "core" selects the framework's migrations, whose stored code is empty.
func AppFilter(code string) string {
code = NormalizeAppCode(code)
if code == FrameworkAppCode {
return ""
}
return code
}
// DisplayAppCode is the inverse: what to print for a stored code.
func DisplayAppCode(code string) string {
if code == "" {
return FrameworkAppCode
}
return code
}
// AppCodes lists the app codes with at least one registered migration, framework
// included under its display name, sorted.
func (e *Migration) AppCodes() []string {
all := e.mergedEntries()
seen := map[string]struct{}{}
for _, v := range all {
seen[DisplayAppCode(v.appCode)] = struct{}{}
}
out := make([]string, 0, len(seen))
for code := range seen {
out = append(out, code)
}
sort.Strings(out)
return out
}
func (e *Migration) run(appCode string) {
all := e.mergedEntries()
versions := make([]string, 0, len(all))
entries := make(map[string]versionEntry, len(all))
for k, v := range all {
if appCode != allApps && v.appCode != appCode {
continue
}
versions = append(versions, k)
entries[k] = v
}
if !sort.StringsAreSorted(versions) {
sort.Strings(versions)
sort.Strings(versions)
// A mistyped --app would otherwise select nothing and report "no
// migrations to apply", which reads exactly like "already up to date".
if appCode != allApps && len(versions) == 0 {
log.Printf("no migrations are registered for app %q; registered: %s",
DisplayAppCode(appCode), strings.Join(e.AppCodes(), ", "))
return
}
var err error
var count int64
applied := 0
@@ -56,7 +346,7 @@ func (e *Migration) Migrate() {
continue
}
log.Printf("applying migration %s", v)
if err = (e.version[v])(e.db.Debug(), v); err != nil {
if err = entries[v].fn(e.db.Debug(), v); err != nil {
log.Fatalf("migration %s failed: %v", v, err)
}
applied++
@@ -68,7 +358,14 @@ func (e *Migration) Migrate() {
}
}
// allApps is the sentinel run() takes to mean "do not filter". It is distinct
// from the empty app code, which selects the framework's own migrations.
const allApps = "\x00all"
// GetFilename derives a migration's version from its file name. The rule
// lives in contract/migration, because an application registering through
// that package names its files by the same convention and must land on the
// same version string; a second copy here is a second thing to keep in step.
func GetFilename(s string) string {
s = filepath.Base(s)
return s[:13]
return contractmigration.GetFilename(s)
}
+582
View File
@@ -0,0 +1,582 @@
package migration
import (
"bytes"
"log"
"os"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration"
common "go-admin/common/models"
)
// withContractRegistry points contractSnapshot at an isolated
// *contractmigration.Registry for the duration of one test, instead of
// go-admin-core's single process-wide one - see contractSnapshot's doc
// comment for why that indirection exists. Restored on cleanup so other
// tests in this package keep seeing an empty contract registry regardless of
// run order.
func withContractRegistry(t *testing.T) *contractmigration.Registry {
t.Helper()
reg := contractmigration.NewRegistry()
orig := contractSnapshot
contractSnapshot = reg.Snapshot
t.Cleanup(func() { contractSnapshot = orig })
return reg
}
func newTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err = db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
return db
}
// recordFor is what an app's migration is expected to do: write its own
// completion row, with the version it was handed and the app code it was told
// it belongs to.
func recordFor(db *gorm.DB, version, appCode string) error {
return db.Create(&common.Migration{Version: version, AppCode: appCode}).Error
}
func rowsByVersion(t *testing.T, db *gorm.DB) map[string]common.Migration {
t.Helper()
var rows []common.Migration
if err := db.Find(&rows).Error; err != nil {
t.Fatalf("read sys_migration: %v", err)
}
out := make(map[string]common.Migration, len(rows))
for _, r := range rows {
out[r.Version] = r
}
return out
}
// Acceptance 9: a migration registered through ForApp("x") lands in
// sys_migration with app_code "x".
//
// The registry cannot write that row for the migration, because the row is the
// migration's own last statement inside its own transaction. So the only thing
// that can make this true is handing the code to the function - which is why
// AppMigrationFunc takes three parameters.
func TestForAppRecordsItsAppCode(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
m.ForApp("x").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
return recordFor(db, version, appCode)
})
m.Migrate()
rows := rowsByVersion(t, db)
row, ok := rows["x-1786800001000"]
if !ok {
t.Fatalf("no row for x-1786800001000; got %v", rows)
}
if row.AppCode != "x" {
t.Errorf("app_code = %q, want %q", row.AppCode, "x")
}
}
// The framework path is untouched: same signature, and an empty app code, which
// is what the column defaults to and what every row written before this field
// existed reads back as.
func TestSetVersionStillRecordsTheFrameworkAsEmpty(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
return db.Create(&common.Migration{Version: version}).Error
})
m.Migrate()
rows := rowsByVersion(t, db)
row, ok := rows["1786700009000"]
if !ok {
t.Fatalf("no row for 1786700009000; got %v", rows)
}
if row.AppCode != "" {
t.Errorf("app_code = %q, want empty (framework)", row.AppCode)
}
}
// Acceptance 12: --app x runs x's migrations and touches nothing else.
func TestMigrateAppRunsOnlyThatApp(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
ran := map[string]bool{}
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
ran["core"] = true
return db.Create(&common.Migration{Version: version}).Error
})
m.ForApp("x").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
ran["x"] = true
return recordFor(db, version, appCode)
})
m.ForApp("y").SetVersion("1786800002000", func(db *gorm.DB, version, appCode string) error {
ran["y"] = true
return recordFor(db, version, appCode)
})
m.MigrateApp("x")
if !ran["x"] {
t.Error("x did not run")
}
if ran["y"] || ran["core"] {
t.Errorf("MigrateApp(x) also ran %v", ran)
}
rows := rowsByVersion(t, db)
if len(rows) != 1 {
t.Fatalf("sys_migration has %d rows, want 1: %v", len(rows), rows)
}
}
// "core" is what status prints for the framework, so --app core has to select
// it. The stored code is the empty string; AppFilter is the translation.
func TestMigrateAppCoreSelectsTheFramework(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
ran := map[string]bool{}
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
ran["core"] = true
return db.Create(&common.Migration{Version: version}).Error
})
m.ForApp("x").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
ran["x"] = true
return recordFor(db, version, appCode)
})
m.MigrateApp(FrameworkAppCode)
if !ran["core"] {
t.Error("framework migration did not run")
}
if ran["x"] {
t.Error("--app core also ran x")
}
}
// Zero-argument Migrate keeps meaning "everything", which is what every
// existing caller relies on.
func TestMigrateRunsEveryApp(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
var order []string
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
order = append(order, version)
return db.Create(&common.Migration{Version: version}).Error
})
m.ForApp("bbb").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
order = append(order, version)
return recordFor(db, version, appCode)
})
m.ForApp("aaa").SetVersion("1786800002000", func(db *gorm.DB, version, appCode string) error {
order = append(order, version)
return recordFor(db, version, appCode)
})
m.Migrate()
// Namespacing puts every framework migration - bare digits - ahead of every
// app migration, and orders apps by code rather than by whose timestamp
// happened to be smaller. aaa's file is the newer of the two and still runs
// first. Cross-app order is not promised, but this is the order, and it is
// the one to notice changed.
want := []string{"1786700009000", "aaa-1786800002000", "bbb-1786800001000"}
if len(order) != len(want) {
t.Fatalf("ran %v, want %v", order, want)
}
for i := range want {
if order[i] != want[i] {
t.Fatalf("ran %v, want %v", order, want)
}
}
}
// Two apps minting the same millisecond timestamp used to mean one of them was
// read as already applied and silently skipped. The namespace prefix is what
// makes that impossible without changing the primary key.
func TestNamespacingKeepsTwoAppsWithTheSameTimestampApart(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
const sameTimestamp = "1786800001000"
ran := 0
for _, app := range []string{"crm", "oms"} {
m.ForApp(app).SetVersion(sameTimestamp, func(db *gorm.DB, version, appCode string) error {
ran++
return recordFor(db, version, appCode)
})
}
m.Migrate()
if ran != 2 {
t.Errorf("ran %d migrations, want 2", ran)
}
rows := rowsByVersion(t, db)
for _, want := range []string{"crm-" + sameTimestamp, "oms-" + sameTimestamp} {
if _, ok := rows[want]; !ok {
t.Errorf("missing %s; got %v", want, rows)
}
}
}
func TestNamespacedKeyLeavesFrameworkVersionsBare(t *testing.T) {
if got := namespacedKey("", "1786700009000"); got != "1786700009000" {
t.Errorf("framework version was rewritten to %q", got)
}
if got := namespacedKey("crm", "1786800001000"); got != "crm-1786800001000" {
t.Errorf("namespacedKey = %q", got)
}
}
// An app code differing only in case would group as two apps in status and sort
// before every lower-case one, for no reason a reader could guess.
func TestForAppNormalisesTheCode(t *testing.T) {
m := newMigration()
if got := m.ForApp(" CRM ").AppCode(); got != "crm" {
t.Errorf("AppCode = %q, want crm", got)
}
}
func TestForAppRejectsReservedCodes(t *testing.T) {
for _, code := range []string{"", " ", FrameworkAppCode, "CORE"} {
t.Run("code="+code, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Errorf("ForApp(%q) did not panic", code)
}
}()
newMigration().ForApp(code)
})
}
}
func TestStatusReportsPendingAppliedAndOrphaned(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
applied := time.Date(2026, 8, 25, 14, 3, 11, 0, time.UTC)
if err := db.Create(&common.Migration{Version: "1786700009000", ApplyTime: applied}).Error; err != nil {
t.Fatal(err)
}
// Recorded, but nothing registers it any more.
if err := db.Create(&common.Migration{Version: "gone-1786800000000", ApplyTime: applied, AppCode: "gone"}).Error; err != nil {
t.Fatal(err)
}
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error { return nil })
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error { return nil })
entries, err := m.Status()
if err != nil {
t.Fatal(err)
}
byVersion := map[string]StatusEntry{}
for _, e := range entries {
byVersion[e.Version] = e
}
if e := byVersion["1786700009000"]; !e.Applied || !e.Registered || e.AppCode != "" {
t.Errorf("framework entry = %+v", e)
} else if e.ApplyTime == nil || !e.ApplyTime.Equal(applied) {
t.Errorf("framework apply time = %v, want %v", e.ApplyTime, applied)
}
if e := byVersion["crm-1786800001000"]; e.Applied || !e.Registered || e.AppCode != "crm" {
t.Errorf("crm entry = %+v", e)
}
if e := byVersion["gone-1786800000000"]; !e.Applied || e.Registered || e.AppCode != "gone" {
t.Errorf("orphaned entry = %+v", e)
}
}
// Acceptance 11 rests on this: status and --dry-run both go through Status, and
// Status must not create the table it reads.
func TestStatusDoesNotCreateItsTable(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
m := newMigration()
m.SetDb(db)
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error { return nil })
entries, err := m.Status()
if err != nil {
t.Fatalf("Status on a database with no sys_migration: %v", err)
}
if len(entries) != 1 || entries[0].Applied {
t.Errorf("entries = %+v, want one pending", entries)
}
if db.Migrator().HasTable(&common.Migration{}) {
t.Error("Status created sys_migration; it must only read")
}
}
// The completion row is the migration's own last statement, inside its own
// transaction. A migration that fails must leave no record of having run, or
// the next run skips it and the schema stays half-changed with nothing to say
// so.
func TestFailedMigrationLeavesNoRecord(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := recordFor(tx, version, appCode); err != nil {
return err
}
return errTestMigrationFailed
})
})
// run() calls log.Fatal on failure, which would take the test binary with
// it, so drive the registered function directly - the point here is the
// transaction boundary, not the scheduler.
entry := m.version["crm-1786800001000"]
if err := entry.fn(db, "crm-1786800001000"); err == nil {
t.Fatal("migration reported success")
}
if rows := rowsByVersion(t, db); len(rows) != 0 {
t.Errorf("sys_migration has %v after a failed migration", rows)
}
}
var errTestMigrationFailed = &testError{"boom"}
type testError struct{ s string }
func (e *testError) Error() string { return e.s }
// A mistyped --app used to select nothing and print "no migrations to apply",
// which reads as "already up to date" - the command reports success and does
// nothing, which is the failure mode this whole batch exists to remove.
func TestMigrateAppOnAnUnknownCodeSaysSo(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
return db.Create(&common.Migration{Version: version}).Error
})
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
return recordFor(db, version, appCode)
})
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
m.MigrateApp("crmm")
if !strings.Contains(buf.String(), `no migrations are registered for app "crmm"`) {
t.Errorf("output = %q", buf.String())
}
if !strings.Contains(buf.String(), "registered: core, crm") {
t.Errorf("the message must list what is registered; got %q", buf.String())
}
if rows := rowsByVersion(t, db); len(rows) != 0 {
t.Errorf("a typo ran %v", rows)
}
}
// This is the acceptance test for PRD 006's host-wiring gap: a migration
// registered through contract/migration.ForApp - the only door open to a
// third-party application - must actually run, be recorded under its app
// code, and show up in AppCodes/Status/--app the same as one registered
// through the host's own m.ForApp. Before mergedEntries existed, m.Migrate()
// never looked at contract/migration's registry at all, so this compiled,
// registered, and silently never ran.
func TestMergedEntriesRunsAContractRegisteredAppMigration(t *testing.T) {
reg := withContractRegistry(t)
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
ran := false
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error {
ran = true
return recordFor(db, version, appCode)
})
m.Migrate()
if !ran {
t.Fatal("contract-registered migration did not run")
}
rows := rowsByVersion(t, db)
row, ok := rows["order-1793800000000"]
if !ok {
t.Fatalf("no row for order-1793800000000; got %v", rows)
}
if row.AppCode != "order" {
t.Errorf("app_code = %q, want %q", row.AppCode, "order")
}
}
// migrate status and --dry-run both read Status; a contract-registered
// migration has to appear there under its app code exactly like a
// host-registered one, both before and after it is applied.
func TestMergedEntriesStatusIncludesContractRegisteredMigrations(t *testing.T) {
reg := withContractRegistry(t)
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error {
return recordFor(db, version, appCode)
})
entries, err := m.Status()
if err != nil {
t.Fatal(err)
}
byVersion := map[string]StatusEntry{}
for _, e := range entries {
byVersion[e.Version] = e
}
e, ok := byVersion["order-1793800000000"]
if !ok || !e.Registered || e.Applied || e.AppCode != "order" {
t.Fatalf("pending contract entry = %+v (ok=%v)", e, ok)
}
m.Migrate()
entries, err = m.Status()
if err != nil {
t.Fatal(err)
}
byVersion = map[string]StatusEntry{}
for _, e := range entries {
byVersion[e.Version] = e
}
if e := byVersion["order-1793800000000"]; !e.Applied {
t.Errorf("applied contract entry = %+v", e)
}
}
// AppCodes feeds both --app's typo detection (appRegistrationError) and the
// group headings status prints; a contract-registered app has to appear
// there or a real "go-admin migrate --app order" would be told the app does
// not exist.
func TestMergedEntriesAppCodesIncludesContractRegisteredApps(t *testing.T) {
reg := withContractRegistry(t)
m := newMigration()
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error { return nil })
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error { return nil })
got := m.AppCodes()
want := []string{"core", "order"}
if len(got) != len(want) {
t.Fatalf("AppCodes = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("AppCodes = %v, want %v", got, want)
}
}
}
// --app order has to actually run only order's migrations - the same
// per-app isolation MigrateApp already gives host-registered apps - even
// though order is registered in a different registry entirely.
func TestMergedEntriesMigrateAppRunsOnlyThatContractApp(t *testing.T) {
reg := withContractRegistry(t)
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
ran := map[string]bool{}
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
ran["core"] = true
return db.Create(&common.Migration{Version: version}).Error
})
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error {
ran["order"] = true
return recordFor(db, version, appCode)
})
m.MigrateApp("order")
if !ran["order"] {
t.Error("order did not run")
}
if ran["core"] {
t.Errorf("MigrateApp(order) also ran %v", ran)
}
}
// A host-registered key is not supposed to collide with a namespaced
// contract key (see mergedEntries' doc comment), but if it somehow did, the
// host's own registration must win rather than a third-party application
// silently overwriting a framework migration under the same key.
func TestMergedEntriesHostRegistrationWinsOnKeyCollision(t *testing.T) {
reg := withContractRegistry(t)
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
hostRan, contractRan := false, false
m.ForApp("dup").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
hostRan = true
return recordFor(db, version, appCode)
})
reg.ForApp("dup").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
contractRan = true
return recordFor(db, version, appCode)
})
m.Migrate()
if !hostRan {
t.Error("host registration did not run")
}
if contractRan {
t.Error("contract registration ran; host registration should have won the collision")
}
}
// GetFilename must stay the same rule the contract package applies, since an
// application registering through contract/migration names its files by that
// convention and has to land on the same version string. Pinning the reject
// case is what catches a re-divergence: a local copy that only sliced would
// return "add_orders.go" here and register a migration under a key that never
// matches anything.
func TestGetFilenameDelegatesToTheContractRule(t *testing.T) {
if got := GetFilename("version/1786700001000_demo_menu.go"); got != "1786700001000" {
t.Fatalf("GetFilename = %q, want %q", got, "1786700001000")
}
defer func() {
if recover() == nil {
t.Fatal("a file name carrying no version did not panic")
}
}()
GetFilename("version/add_orders.go")
}
+8
View File
@@ -15,6 +15,14 @@ type Model struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement;comment:主键编码"`
}
// ModelTime is frozen at the schema shape these tables had before
// 1786700003000 converted deleted_at to a NOT NULL millisecond marker. That is
// correct for the migrations ordered before the conversion, and wrong for any
// added after it: writes put NULL into a NOT NULL column, and reads are scoped
// "WHERE deleted_at IS NULL" and match nothing.
//
// Migrations after that version seed through the runtime models in app/.
// TestPostConversionMigrationsAvoidFrozenSeedModels enforces this.
type ModelTime struct {
CreatedAt time.Time `json:"createdAt" gorm:"comment:创建时间"`
UpdatedAt time.Time `json:"updatedAt" gorm:"comment:最后更新时间"`
@@ -0,0 +1,54 @@
package version
import (
"runtime"
"gorm.io/gorm"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
// Normalize sys_role.data_scope to one of the five values
// actions.Permission recognizes, ahead of PRD 006 F14/H2 making its
// unrecognized-scope branch fail closed instead of fail open.
//
// Before that change, an empty or unrecognized data_scope fell into
// Permission's default branch, which returned the query untouched - exactly
// the same SQL as data_scope "1" (全部数据权限). The seed data shipped
// precisely that: config/db.sql's built-in admin role (role_id 1) carries an
// empty data_scope rather than "1". Once the default starts matching no
// rows instead, that role would silently lose all visibility everywhere
// actions.Permission is used, the moment a deployment turns EnableDP on.
//
// Rewriting every value outside {1,2,3,4,5} to "1" keeps each such role's
// effective visibility exactly what it already was - a role that intended a
// tighter scope was never getting it under the old fail-open default either,
// so this does not tighten anything a deployment was relying on. Whether to
// tighten it further is left to whoever owns that role.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700005000NormalizeRoleDataScope)
}
func _1786700005000NormalizeRoleDataScope(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := normalizeRoleDataScope(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
// normalizeRoleDataScope is split out so tests can run it against a database
// that only has sys_role, without also standing up sys_migration.
//
// The explicit "IS NULL OR" matters: sys_role.data_scope has no NOT NULL
// constraint, and SQL's three-valued logic makes `NULL NOT IN (...)`
// evaluate to NULL rather than TRUE, so a bare NOT IN clause silently skips
// NULL rows instead of normalizing them.
func normalizeRoleDataScope(tx *gorm.DB) error {
return tx.Exec(
"UPDATE sys_role SET data_scope = '1' WHERE data_scope IS NULL OR data_scope NOT IN ('1', '2', '3', '4', '5')",
).Error
}
@@ -0,0 +1,146 @@
package version
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
type roleDataScopeRow struct {
RoleId int `gorm:"column:role_id;primaryKey;autoIncrement"`
DataScope string `gorm:"column:data_scope"`
}
func (roleDataScopeRow) TableName() string { return "sys_role" }
func openRoleTable(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.AutoMigrate(&roleDataScopeRow{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
// The migration exists because the shipped admin role is exactly this case:
// config/db.sql's role_id 1 carries an empty data_scope. Reproduces the seed
// data literally rather than a made-up example.
func TestNormalizesTheEmptyDataScopeTheSeedDataShips(t *testing.T) {
db := openRoleTable(t)
if err := db.Create(&roleDataScopeRow{RoleId: 1, DataScope: ""}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
if err := normalizeRoleDataScope(db); err != nil {
t.Fatalf("migrate: %v", err)
}
var row roleDataScopeRow
if err := db.First(&row, 1).Error; err != nil {
t.Fatalf("read back: %v", err)
}
if row.DataScope != "1" {
t.Fatalf("data_scope = %q, want %q", row.DataScope, "1")
}
}
// The five recognized values must survive untouched - this migration
// normalizes what Permission cannot make sense of, not what it already can.
func TestLeavesRecognizedScopesAlone(t *testing.T) {
db := openRoleTable(t)
valid := []string{"1", "2", "3", "4", "5"}
for i, scope := range valid {
if err := db.Create(&roleDataScopeRow{RoleId: i + 1, DataScope: scope}).Error; err != nil {
t.Fatalf("seed %d: %v", i, err)
}
}
if err := normalizeRoleDataScope(db); err != nil {
t.Fatalf("migrate: %v", err)
}
var rows []roleDataScopeRow
if err := db.Order("role_id").Find(&rows).Error; err != nil {
t.Fatalf("read back: %v", err)
}
for i, row := range rows {
if row.DataScope != valid[i] {
t.Errorf("role %d: data_scope = %q, want %q (untouched)", row.RoleId, row.DataScope, valid[i])
}
}
}
// A garbage value (not just empty) must be normalized the same way as empty -
// both are "not one of the five", and the migration's WHERE clause has to
// catch both.
func TestNormalizesGarbageScopesToo(t *testing.T) {
db := openRoleTable(t)
if err := db.Create(&roleDataScopeRow{RoleId: 1, DataScope: "6"}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
if err := normalizeRoleDataScope(db); err != nil {
t.Fatalf("migrate: %v", err)
}
var row roleDataScopeRow
if err := db.First(&row, 1).Error; err != nil {
t.Fatalf("read back: %v", err)
}
if row.DataScope != "1" {
t.Fatalf("data_scope = %q, want %q", row.DataScope, "1")
}
}
// A NULL data_scope must be normalized too. sys_role.data_scope has no NOT
// NULL constraint, and `NULL NOT IN (...)` evaluates to NULL rather than
// TRUE under SQL's three-valued logic, so a bare NOT IN clause would leave
// this row untouched - the exact gap that let a NULL-scoped role go blind
// once Permission's default branch starts fail-closing.
func TestNormalizesNullDataScope(t *testing.T) {
db := openRoleTable(t)
if err := db.Exec("INSERT INTO sys_role (role_id, data_scope) VALUES (1, NULL)").Error; err != nil {
t.Fatalf("seed: %v", err)
}
if err := normalizeRoleDataScope(db); err != nil {
t.Fatalf("migrate: %v", err)
}
var row roleDataScopeRow
if err := db.First(&row, 1).Error; err != nil {
t.Fatalf("read back: %v", err)
}
if row.DataScope != "1" {
t.Fatalf("data_scope = %q, want %q", row.DataScope, "1")
}
}
// Running it twice must be safe: it is a plain UPDATE, not DDL, but
// sys_migration only records success once, and an operator who reruns
// `migrate` on a partially-applied database has to be able to trust that.
func TestNormalizeRoleDataScopeIsRepeatable(t *testing.T) {
db := openRoleTable(t)
if err := db.Create(&roleDataScopeRow{RoleId: 1, DataScope: ""}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
for i := 0; i < 3; i++ {
if err := normalizeRoleDataScope(db); err != nil {
t.Fatalf("migrate %d: %v", i, err)
}
}
var row roleDataScopeRow
if err := db.First(&row, 1).Error; err != nil {
t.Fatalf("read back: %v", err)
}
if row.DataScope != "1" {
t.Fatalf("data_scope = %q, want %q", row.DataScope, "1")
}
}
@@ -0,0 +1,46 @@
package version
import (
"runtime"
"gorm.io/gorm"
adminmodels "go-admin/app/admin/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
// Add sys_menu.app_code and sys_api.app_code ahead of PRD 006 F9's Seeder.
//
// Every row a third-party application's migration writes through
// seed.SeedMenus must be attributable to the app that wrote it, so
// installing, auditing, or removing one application does not require
// guessing which rows belong to it - see go-admin-core's docs/contract.md,
// "Application-supplied menu and API entries", for the requirement this
// satisfies.
//
// Ordered after 1786700003000, so importing cmd/migrate/migration/models is
// banned here (see schema_coverage_test.go's
// TestPostConversionMigrationsAvoidFrozenSeedModels): AddColumn instead
// reads the runtime models' own gorm tags directly, which is also what
// makes the column this adds match the one the admin Seeder writes through
// those same structs.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700006000AppCodeColumns)
}
func _1786700006000AppCodeColumns(db *gorm.DB, version string) error {
m := db.Migrator()
if !m.HasColumn(&adminmodels.SysMenu{}, "AppCode") {
if err := m.AddColumn(&adminmodels.SysMenu{}, "AppCode"); err != nil {
return err
}
}
if !m.HasColumn(&adminmodels.SysApi{}, "AppCode") {
if err := m.AddColumn(&adminmodels.SysApi{}, "AppCode"); err != nil {
return err
}
}
return db.Create(&common.Migration{Version: version}).Error
}
@@ -9,6 +9,8 @@ import (
"strconv"
"strings"
"testing"
"go-admin/cmd/migrate/migration"
)
// The repository carries two ModelTime types. The one in
@@ -79,9 +81,13 @@ func runtimeSoftDeleteTables(t *testing.T) map[string]string {
}
func importsRuntimeModels(f *ast.File) bool {
return importsPackage(f, "go-admin/common/models")
}
func importsPackage(f *ast.File, pkg string) bool {
for _, imp := range f.Imports {
p, err := strconv.Unquote(imp.Path.Value)
if err == nil && p == "go-admin/common/models" {
if err == nil && p == pkg {
return true
}
}
@@ -171,3 +177,89 @@ func repoRoot(t *testing.T) string {
t.Fatal("go.mod not found above the test directory")
return ""
}
// softDeleteConversion is the version at which sys_api, sys_menu and the rest
// stop storing deleted_at as a nullable timestamp and start storing the NOT
// NULL millisecond marker.
const softDeleteConversion = 1786700003000
// versionPrefixLen is the width migration.GetFilename slices off a filename.
const versionPrefixLen = 13
// Migrations ordered after the conversion must not seed rows through
// cmd/migrate/migration/models.
//
// That package's ModelTime still declares a nullable gorm.DeletedAt, which is
// correct for the migrations that predate the conversion - it is the shape the
// column had when they ran. Reusing it afterwards writes NULL into a NOT NULL
// column and the migration fails on its first insert:
//
// NOT NULL constraint failed: sys_api.deleted_at
//
// A fresh database never catches this, because every migration using that
// package today is ordered before the conversion and so runs while the column
// is still nullable. Only a migration added afterwards hits it, which in
// practice means the next person adding a business module - the reference
// they copy, 1786700001000_demo_menu.go, is itself one of the safe ones.
//
// Reads through that package are worse than writes, which is why the whole
// import is banned rather than just the inserts. gorm scopes a nullable
// DeletedAt as "WHERE deleted_at IS NULL", and after the conversion live rows
// hold 0, so the row is simply not there:
//
// frozen SysRole -> record not found
// runtime SysRole -> roleId=1
//
// 1786700001000_demo_menu.go looks the admin role up that way and treats
// ErrRecordNotFound as "roles are not seeded yet, skip authorisation". A
// post-conversion copy that switched its inserts to the runtime models but
// kept this lookup would seed the menu, grant nothing, and still record the
// migration as applied - the menu appears, its buttons do nothing, and no
// error is reported anywhere.
func TestPostConversionMigrationsAvoidFrozenSeedModels(t *testing.T) {
const frozenModels = "go-admin/cmd/migrate/migration/models"
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
checked := 0
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
// GetFilename is what every migration uses to derive its own version,
// so the two stay in step if the filename convention ever changes.
if len(name) < versionPrefixLen {
continue
}
version, err := strconv.ParseInt(migration.GetFilename(name), 10, 64)
if err != nil || version <= softDeleteConversion {
continue // not a versioned migration, or one that predates the change
}
checked++
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, parser.ImportsOnly)
if err != nil {
t.Fatalf("parse %s: %v", name, err)
}
if importsPackage(f, frozenModels) {
t.Errorf("%s is ordered after the soft-delete conversion but seeds through %s;\n"+
" that package writes a nullable deleted_at and will fail with\n"+
" \"NOT NULL constraint failed\" on its first insert.\n"+
" Use the runtime models under app/ instead - they carry the marker.",
name, frozenModels)
}
}
if checked == 0 {
t.Fatal("no post-conversion migrations found; the scan is broken, not the code")
}
}
+141 -8
View File
@@ -3,12 +3,16 @@ package migrate
import (
"bytes"
"fmt"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"os"
"strconv"
"strings"
"text/template"
"time"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
"github.com/go-admin-team/go-admin-core/v2/config/source/file"
"github.com/spf13/cobra"
@@ -25,6 +29,8 @@ var (
generate bool
goAdmin bool
host string
appCode string
dryRun bool
StartCmd = &cobra.Command{
Use: "migrate",
Short: "Initialize the database",
@@ -33,14 +39,31 @@ var (
run()
},
}
statusCmd = &cobra.Command{
Use: "status",
Short: "List applied and pending migrations, grouped by app",
Example: "go-admin migrate status -c config/settings.yml",
Run: func(cmd *cobra.Command, args []string) {
runStatus()
},
}
)
// fixme 在您看不见代码的时候运行迁移,我觉得是不安全的,所以编译后最好不要去执行迁移
func init() {
StartCmd.PersistentFlags().StringVarP(&configYml, "config", "c", "config/settings.yml", "Start server with provided configuration file")
StartCmd.PersistentFlags().BoolVarP(&generate, "generate", "g", false, "generate migration file")
StartCmd.PersistentFlags().BoolVarP(&goAdmin, "goAdmin", "a", false, "generate go-admin migration file")
StartCmd.PersistentFlags().BoolVarP(&goAdmin, "goAdmin", "a", false, "with -g, write the generated file to version/ instead of version-local/ (does not affect which migrations run)")
StartCmd.PersistentFlags().StringVarP(&host, "domain", "d", "*", "select tenant host")
// --app is deliberately long-only. -a already means "generate into
// version/ rather than version-local/", which is about writing a template
// file, not about which migrations run; giving the two the same letter
// would be a trap.
StartCmd.PersistentFlags().StringVar(&appCode, "app", "", "limit to the migrations of one app (\""+migration.FrameworkAppCode+"\" for the framework's own)")
StartCmd.Flags().BoolVar(&dryRun, "dry-run", false, "list what would be applied, in order, and write nothing")
StartCmd.AddCommand(statusCmd)
}
func run() {
@@ -58,7 +81,12 @@ func run() {
}
}
func migrateModel() error {
// resolveDB picks the tenant database and hands it to the registry.
//
// It creates and alters nothing, which is what lets status and --dry-run share
// it: those two must be able to run against a production database without
// leaving a trace.
func resolveDB() (*gorm.DB, error) {
if host == "" {
host = "*"
}
@@ -73,29 +101,134 @@ func migrateModel() error {
}
}
if db == nil {
return fmt.Errorf("未找到数据库配置")
return nil, fmt.Errorf("未找到数据库配置")
}
if config.DatabasesConfig[host].Driver == "mysql" {
//初始化数据库时候用
db.Set("gorm:table_options", "ENGINE=InnoDB CHARSET=utf8mb4")
}
err := db.Debug().AutoMigrate(&models.Migration{})
return db, nil
}
// exitUnlessAppRegistered ends the command when --app names something no
// migration was registered under.
//
// Every path took a typo as "nothing matched" and reported success: `migrate`
// printed that the app was unknown and still exited 0, while `--dry-run` and
// `status` said "nothing to apply" and "none recorded" - which is what an
// up-to-date database says too, so the output does not even hint at the typo.
// An operator running `go-admin migrate --app crmm && deploy` gets the deploy.
//
// Checked against the registry, which init() has already filled, so this runs
// before any database work and costs nothing. It lives in the command layer
// because the exit code does: the migration package stays callable from a test
// without taking the process down with it.
func exitUnlessAppRegistered() {
if err := appRegistrationError(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// appRegistrationError carries the decision on its own so it can be tested;
// exitUnlessAppRegistered is only the os.Exit around it. Nil means --app was
// either empty or names a registered app.
func appRegistrationError() error {
if appCode == "" {
return nil
}
want := migration.DisplayAppCode(migration.AppFilter(appCode))
registered := migration.Migrate.AppCodes()
for _, c := range registered {
if c == want {
return nil
}
}
return fmt.Errorf("no migrations are registered for app %q; registered: %s",
want, strings.Join(registered, ", "))
}
func migrateModel() error {
db, err := resolveDB()
if err != nil {
return err
}
// sys_migration is the one table that never goes through a versioned
// migration - it is the table that records them. AutoMigrate realigns it
// on every run, which is how the app_code column reaches an existing
// database without anyone writing a migration for it.
if err = db.Debug().AutoMigrate(&models.Migration{}); err != nil {
return err
}
migration.Migrate.SetDb(db.Debug())
if appCode != "" {
migration.Migrate.MigrateApp(appCode)
return nil
}
migration.Migrate.Migrate()
return err
return nil
}
func initDB() {
// Before the database is touched, so a typo cannot get as far as looking
// like a successful no-op on either path below.
exitUnlessAppRegistered()
//3. 初始化数据库链接
database.Setup()
if dryRun {
db, err := resolveDB()
if err != nil {
fmt.Println(err)
return
}
migration.Migrate.SetDb(db)
entries, err := migration.Migrate.Status()
if err != nil {
fmt.Println(err)
return
}
if err = printPending(os.Stdout, entries, appCode); err != nil {
fmt.Println(err)
}
return
}
//4. 数据库迁移
fmt.Println("数据库迁移开始")
_ = migrateModel()
if err := migrateModel(); err != nil {
fmt.Println(err)
return
}
fmt.Println(`数据库基础数据初始化成功`)
}
func runStatus() {
config.Setup(
file.NewSource(file.WithPath(configYml)),
func() {
exitUnlessAppRegistered()
database.Setup()
db, err := resolveDB()
if err != nil {
fmt.Println(err)
return
}
migration.Migrate.SetDb(db)
entries, err := migration.Migrate.Status()
if err != nil {
fmt.Println(err)
return
}
if err = printStatus(os.Stdout, entries, appCode); err != nil {
fmt.Println(err)
}
},
)
}
func genFile() error {
t1, err := template.ParseFiles("template/migrate.template")
if err != nil {
+160
View File
@@ -0,0 +1,160 @@
package migrate
import (
"fmt"
"io"
"sort"
"strings"
"time"
"go-admin/cmd/migrate/migration"
)
const applyTimeLayout = "2006-01-02 15:04:05"
// printStatus lists every migration this binary knows about together with every
// row already in sys_migration, grouped by app.
//
// filter is an app code as typed on the command line; empty means every app.
func printStatus(w io.Writer, entries []migration.StatusEntry, filter string) error {
entries = filterByApp(entries, filter)
groups, order := groupByApp(entries)
if len(order) == 0 {
_, err := fmt.Fprintln(w, "no migrations registered and none recorded")
return err
}
// One width for the whole listing rather than one per group: the versions
// of two apps line up, so a long list can be read down the column.
width := versionWidth(entries)
var applied, pending, orphaned int
for i, app := range order {
if i > 0 {
fmt.Fprintln(w)
}
fmt.Fprintf(w, "[%s]\n", app)
for _, e := range groups[app] {
state := "pending"
switch {
case e.Applied && !e.Registered:
state = "orphaned"
orphaned++
case e.Applied:
state = "applied"
applied++
default:
pending++
}
fmt.Fprintln(w, strings.TrimRight(
fmt.Sprintf(" %-*s%-*s%s", stateWidth, state, width, e.Version, formatApplyTime(e.ApplyTime)), " "))
}
}
fmt.Fprintf(w, "\n%d applied, %d pending across %d app(s)\n", applied, pending, len(order))
if orphaned > 0 {
fmt.Fprintf(w, "%d orphaned: recorded in sys_migration, but nothing in this binary registers them.\n"+
"Expected after a migration file is removed or an app is uninstalled; they will not run again.\n", orphaned)
}
return nil
}
// printPending is --dry-run: the same data as status, narrowed to what an
// actual run would do and printed in the order it would do it.
//
// It reads and prints. Every write path - AutoMigrate on sys_migration
// included - is on the other branch in initDB, so a dry run leaves the database
// byte for byte as it found it.
func printPending(w io.Writer, entries []migration.StatusEntry, filter string) error {
entries = filterByApp(entries, filter)
fmt.Fprintln(w, "dry-run: nothing will be written")
pending := make([]migration.StatusEntry, 0, len(entries))
for _, e := range entries {
// An orphaned row is recorded and unregistered; a real run cannot
// apply it, so a dry run must not offer to.
if !e.Applied && e.Registered {
pending = append(pending, e)
}
}
if len(pending) == 0 {
_, err := fmt.Fprintln(w, "nothing to apply")
return err
}
appWidth := 0
for _, e := range pending {
if n := len(migration.DisplayAppCode(e.AppCode)) + 2; n > appWidth {
appWidth = n
}
}
fmt.Fprintln(w, "would apply, in this order:")
for _, e := range pending {
fmt.Fprintf(w, " %-*s%s\n", appWidth+2, "["+migration.DisplayAppCode(e.AppCode)+"]", e.Version)
}
fmt.Fprintf(w, "\n%d migration(s) pending\n", len(pending))
return nil
}
// stateWidth is the width of the applied/pending/orphaned column, sized to the
// longest of the three plus a gap.
const stateWidth = len("orphaned") + 2
func versionWidth(entries []migration.StatusEntry) int {
width := 0
for _, e := range entries {
if n := len(e.Version) + 2; n > width {
width = n
}
}
return width
}
// filterByApp keeps the entries of one app. The filter is matched after the
// same normalisation ForApp applies, so --app CRM finds crm.
func filterByApp(entries []migration.StatusEntry, filter string) []migration.StatusEntry {
if filter == "" {
return entries
}
want := migration.AppFilter(filter)
out := make([]migration.StatusEntry, 0, len(entries))
for _, e := range entries {
if e.AppCode == want {
out = append(out, e)
}
}
return out
}
// groupByApp buckets entries by display name and returns the buckets plus the
// order to print them in: the framework first, then apps alphabetically. That
// is also the order a full run executes them in, because version strings sort
// as ASCII and the framework's are bare digits.
func groupByApp(entries []migration.StatusEntry) (map[string][]migration.StatusEntry, []string) {
groups := make(map[string][]migration.StatusEntry)
for _, e := range entries {
app := migration.DisplayAppCode(e.AppCode)
groups[app] = append(groups[app], e)
}
order := make([]string, 0, len(groups))
for app := range groups {
order = append(order, app)
}
sort.Slice(order, func(i, j int) bool {
if (order[i] == migration.FrameworkAppCode) != (order[j] == migration.FrameworkAppCode) {
return order[i] == migration.FrameworkAppCode
}
return order[i] < order[j]
})
return groups, order
}
func formatApplyTime(t *time.Time) string {
if t == nil {
return ""
}
return t.Format(applyTimeLayout)
}
+170
View File
@@ -0,0 +1,170 @@
package migrate
import (
"bytes"
"strings"
"testing"
"time"
"go-admin/cmd/migrate/migration"
)
func at(s string) *time.Time {
t, err := time.Parse(applyTimeLayout, s)
if err != nil {
panic(err)
}
return &t
}
// The order Status returns: version strings sorted as ASCII.
func sampleEntries() []migration.StatusEntry {
return []migration.StatusEntry{
{Version: "1786700001000", AppCode: "", Registered: true, Applied: true, ApplyTime: at("2026-08-20 10:00:00")},
{Version: "1786700005000", AppCode: "", Registered: true},
{Version: "crm-1786800001000", AppCode: "crm", Registered: true, Applied: true, ApplyTime: at("2026-08-25 14:03:11")},
{Version: "crm-1786800002000", AppCode: "crm", Registered: true},
}
}
func TestPrintStatusGroupsByApp(t *testing.T) {
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), ""); err != nil {
t.Fatal(err)
}
got := buf.String()
for _, want := range []string{
"[core]",
"[crm]",
"applied 1786700001000 2026-08-20 10:00:00",
"pending 1786700005000",
"applied crm-1786800001000 2026-08-25 14:03:11",
"pending crm-1786800002000",
"2 applied, 2 pending across 2 app(s)",
} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q:\n%s", want, got)
}
}
// The framework heads the list, because that is the order a full run
// executes in.
if strings.Index(got, "[core]") > strings.Index(got, "[crm]") {
t.Errorf("core is not listed first:\n%s", got)
}
}
// A row nobody registers any more is neither applied-and-current nor pending.
// Calling it applied would say the migration is in this binary, which is what
// sends someone looking for a file that was deleted.
func TestPrintStatusMarksOrphanedRows(t *testing.T) {
entries := append(sampleEntries(), migration.StatusEntry{
Version: "gone-1786800000000", AppCode: "gone", Applied: true, ApplyTime: at("2026-08-01 09:00:00"),
})
var buf bytes.Buffer
if err := printStatus(&buf, entries, ""); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.Contains(got, "orphaned gone-1786800000000") {
t.Errorf("orphaned row not marked:\n%s", got)
}
if !strings.Contains(got, "nothing in this binary registers them") {
t.Errorf("orphaned rows need an explanation:\n%s", got)
}
if !strings.Contains(got, "2 applied, 2 pending") {
t.Errorf("orphaned rows must not be counted as applied:\n%s", got)
}
}
func TestPrintStatusFiltersByApp(t *testing.T) {
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), "crm"); err != nil {
t.Fatal(err)
}
got := buf.String()
if strings.Contains(got, "[core]") {
t.Errorf("--app crm listed the framework:\n%s", got)
}
if !strings.Contains(got, "across 1 app(s)") {
t.Errorf("output = %s", got)
}
}
// status prints [core]; --app core has to mean the same thing.
func TestPrintStatusAppCoreSelectsTheFramework(t *testing.T) {
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), migration.FrameworkAppCode); err != nil {
t.Fatal(err)
}
got := buf.String()
if strings.Contains(got, "[crm]") {
t.Errorf("--app core listed crm:\n%s", got)
}
if !strings.Contains(got, "[core]") {
t.Errorf("--app core listed nothing:\n%s", got)
}
}
func TestPrintStatusOnAnEmptyRegistry(t *testing.T) {
var buf bytes.Buffer
if err := printStatus(&buf, nil, ""); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "no migrations registered and none recorded") {
t.Errorf("output = %s", buf.String())
}
}
func TestPrintPendingListsOnlyPendingInOrder(t *testing.T) {
var buf bytes.Buffer
if err := printPending(&buf, sampleEntries(), ""); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.Contains(got, "dry-run: nothing will be written") {
t.Errorf("dry-run must say it writes nothing:\n%s", got)
}
if strings.Contains(got, "1786700001000\n") || strings.Contains(got, "crm-1786800001000") {
t.Errorf("dry-run listed already applied migrations:\n%s", got)
}
if !strings.Contains(got, "[core] 1786700005000") || !strings.Contains(got, "[crm] crm-1786800002000") {
t.Errorf("dry-run is missing pending migrations:\n%s", got)
}
if !strings.Contains(got, "2 migration(s) pending") {
t.Errorf("output = %s", got)
}
if strings.Index(got, "1786700005000") > strings.Index(got, "crm-1786800002000") {
t.Errorf("dry-run order does not match run order:\n%s", got)
}
}
// An orphaned row is applied and unregistered; a dry run must not offer to
// apply it, because a real run cannot.
func TestPrintPendingSkipsOrphanedRows(t *testing.T) {
entries := []migration.StatusEntry{
{Version: "gone-1786800000000", AppCode: "gone", Applied: true, ApplyTime: at("2026-08-01 09:00:00")},
}
var buf bytes.Buffer
if err := printPending(&buf, entries, ""); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "nothing to apply") {
t.Errorf("output = %s", buf.String())
}
}
func TestPrintPendingFiltersByApp(t *testing.T) {
var buf bytes.Buffer
if err := printPending(&buf, sampleEntries(), "CRM"); err != nil {
t.Fatal(err)
}
got := buf.String()
if strings.Contains(got, "[core]") {
t.Errorf("--app CRM listed the framework:\n%s", got)
}
if !strings.Contains(got, "1 migration(s) pending") {
t.Errorf("output = %s", got)
}
}
+135
View File
@@ -0,0 +1,135 @@
package actions_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"go-admin/common/actions"
"go-admin/common/dto"
"go-admin/common/models"
)
// capturingLogger records every SQL statement GORM actually executes, so a
// test can inspect it the way inspecting a *gorm.DB's own Statement cannot:
// IndexAction builds and executes its query in one unbroken chain
// (db.Model(...).Scopes(...).Find(...)...Count(...)) and never hands the
// built statement back to its caller.
type capturingLogger struct {
gormlogger.Interface
mu sync.Mutex
stmts []string
}
func (l *capturingLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
sql, _ := fc()
l.mu.Lock()
l.stmts = append(l.stmts, sql)
l.mu.Unlock()
}
func (l *capturingLogger) all() string {
l.mu.Lock()
defer l.mu.Unlock()
return strings.Join(l.stmts, "\n")
}
// probeRow is a minimal model satisfying models.ActiveRecord through the
// same embeds a real app/admin model uses, so IndexAction sees exactly the
// shape it is written against.
type probeRow struct {
models.Model
models.ControlBy
Name string
}
func (probeRow) TableName() string { return "action_probe_row" }
func (e *probeRow) Generate() models.ActiveRecord { o := *e; return &o }
func (e *probeRow) GetId() interface{} { return e.Id }
// probeIndexReq is a minimal dto.Index: no search tags, page defaults.
type probeIndexReq struct {
dto.Pagination `search:"-"`
}
// Generate returns a copy, the way every dto.Index in this repository does:
// IndexAction closes over one instance and serves every request to the route
// from it, so returning the receiver would share one struct across them. The
// probe has to model that faithfully or it is not the shape IndexAction is
// written against.
func (p *probeIndexReq) Generate() dto.Index { o := *p; return &o }
func (p *probeIndexReq) Bind(*gin.Context) error { return nil }
func (p *probeIndexReq) GetNeedSearch() interface{} { return *p }
type pageEnvelope struct {
Code int32 `json:"code"`
}
// TestIndexActionAppliesDataPermission is an end-to-end guard core's own
// test suite cannot provide. The five generic CRUD actions in this package
// (create/delete/index/update/view.go) were not lowered to core (PRD 006
// F3) - they still call actions.Permission directly, in this repository, on
// a code path core knows nothing about. core's tests pin down what
// Permission does for a given scope; nothing pinned down whether this
// package's own Actions still remember to call it at all. This runs
// IndexAction exactly as a real request would, against a real in-memory
// database, and inspects the SQL GORM actually executed - not just that
// the handler returned success, which it would just as happily do with no
// filter applied at all.
func TestProbeIndexReqGenerateReturnsAFreshInstance(t *testing.T) {
p := &probeIndexReq{}
got := p.Generate()
if got == dto.Index(p) {
t.Fatal("Generate returned the receiver; IndexAction would share one instance across every request to the route")
}
}
func TestIndexActionAppliesDataPermission(t *testing.T) {
previous := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = true
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
cl := &capturingLogger{Interface: gormlogger.Default.LogMode(gormlogger.Silent)}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: cl})
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.AutoMigrate(&probeRow{}); err != nil {
t.Fatalf("AutoMigrate: %v", err)
}
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.Set("db", db)
c.Set(actions.PermissionKey, &actions.DataPermission{DataScope: actions.DataScopeSelf, UserId: 7})
actions.IndexAction(&probeRow{}, &probeIndexReq{}, func() interface{} { return &[]probeRow{} })(c)
var body pageEnvelope
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decoding response body %q: %v", w.Body.String(), err)
}
if body.Code != http.StatusOK {
t.Fatalf("response code = %d, want %d; body=%s", body.Code, http.StatusOK, w.Body.String())
}
sql := cl.all()
const wantFragment = "action_probe_row.create_by = "
if !strings.Contains(sql, wantFragment) {
t.Fatalf("IndexAction did not apply the data-permission scope to its query; want SQL containing %q, got:\n%s", wantFragment, sql)
}
}
+32 -122
View File
@@ -1,138 +1,48 @@
package actions
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
"gorm.io/gorm"
contractactions "github.com/go-admin-team/go-admin-core/v2/sdk/contract/actions"
)
type DataPermission struct {
DataScope string
UserId int
DeptId int
RoleId int
}
// DataPermission is a thin alias of go-admin-core's sdk/contract/actions
// (PRD 006 F3/F5).
type DataPermission = contractactions.DataPermission
// The five values sys_role.data_scope can hold, referenced directly from
// go-admin-core's sdk/contract/actions rather than restated as literals -
// see that package's DataScope* doc comment and PRD 006's hard constraint 4.
const (
DataScopeAll = contractactions.DataScopeAll
DataScopeCustom = contractactions.DataScopeCustom
DataScopeDept = contractactions.DataScopeDept
DataScopeDeptTree = contractactions.DataScopeDeptTree
DataScopeSelf = contractactions.DataScopeSelf
)
// PermissionAction, Permission, GetPermissionFromContext and
// IsValidDataScope forward to go-admin-core's sdk/contract/actions (PRD 006
// F3/F5). create.go/delete.go/index.go/update.go/view.go in this package
// (the generic CRUD actions, which do not move to core) call Permission and
// GetPermissionFromContext by these same names and are unchanged by the
// move: the names now resolve to forwards instead of local definitions, and
// the behaviour is identical either way.
func PermissionAction() gin.HandlerFunc {
return func(c *gin.Context) {
// Permission() below returns the query untouched when data permission
// is off, so the lookup that feeds it has nothing to feed. It used to
// run anyway: a sys_user join on every list, detail, update and delete,
// with the result discarded.
if !config.ApplicationConfig.EnableDP {
c.Set(PermissionKey, new(DataPermission))
c.Next()
return
}
userId := user.GetUserIdStr(c)
if userId == "" {
c.Set(PermissionKey, new(DataPermission))
c.Next()
return
}
// The token already carries what the scope is decided by. Reading it
// there costs nothing, and goes no more stale than rolekey does - which
// Casbin has always read from the token.
if p, ok := permissionFromClaims(c); ok {
c.Set(PermissionKey, p)
c.Next()
return
}
db, err := pkg.GetOrm(c)
if err != nil {
log.Error(err)
return
}
msgID := pkg.GenerateMsgIDFromContext(c)
p, err := newDataPermission(db, userId)
if err != nil {
log.Errorf("MsgID[%s] PermissionAction error: %s", msgID, err)
response.Error(c, 500, err, "权限范围鉴定错误")
c.Abort()
return
}
c.Set(PermissionKey, p)
c.Next()
}
}
// permissionFromClaims builds the scope from the token, reporting false when
// the token predates deptid being carried. Such a token still exists until it
// expires, and it has to keep working.
func permissionFromClaims(c *gin.Context) (*DataPermission, bool) {
claims := user.ExtractClaims(c)
if claims["deptid"] == nil || claims["datascope"] == nil {
return nil, false
}
scope, ok := claims["datascope"].(string)
if !ok {
return nil, false
}
return &DataPermission{
DataScope: scope,
UserId: user.GetUserId(c),
DeptId: user.GetDeptId(c),
RoleId: user.GetRoleId(c),
}, true
}
func newDataPermission(tx *gorm.DB, userId interface{}) (*DataPermission, error) {
var err error
p := &DataPermission{}
err = tx.Table("sys_user").
Select("sys_user.user_id", "sys_role.role_id", "sys_user.dept_id", "sys_role.data_scope").
Joins("left join sys_role on sys_role.role_id = sys_user.role_id").
Where("sys_user.user_id = ?", userId).
Scan(p).Error
if err != nil {
err = errors.New("获取用户数据出错 msg:" + err.Error())
return nil, err
}
return p, nil
return contractactions.PermissionAction()
}
func Permission(tableName string, p *DataPermission) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if !config.ApplicationConfig.EnableDP {
return db
}
switch p.DataScope {
case "2":
return db.Where(tableName+".create_by in (select sys_user.user_id from sys_role_dept left join sys_user on sys_user.dept_id=sys_role_dept.dept_id where sys_role_dept.role_id = ?)", p.RoleId)
case "3":
return db.Where(tableName+".create_by in (SELECT user_id from sys_user where dept_id = ? )", p.DeptId)
case "4":
return db.Where(tableName+".create_by in (SELECT user_id from sys_user where sys_user.dept_id in(select dept_id from sys_dept where dept_path like ? ))", "%/"+pkg.IntToString(p.DeptId)+"/%")
case "5":
return db.Where(tableName+".create_by = ?", p.UserId)
default:
return db
}
}
return contractactions.Permission(tableName, p)
}
func getPermissionFromContext(c *gin.Context) *DataPermission {
p := new(DataPermission)
if pm, ok := c.Get(PermissionKey); ok {
switch pm.(type) {
case *DataPermission:
p = pm.(*DataPermission)
}
}
return p
}
// GetPermissionFromContext 提供非action写法数据范围约束
func GetPermissionFromContext(c *gin.Context) *DataPermission {
return getPermissionFromContext(c)
return contractactions.GetPermissionFromContext(c)
}
// IsValidDataScope reports whether s is one of the five values Permission
// recognizes. See go-admin-core's sdk/contract/actions.IsValidDataScope.
func IsValidDataScope(s string) bool {
return contractactions.IsValidDataScope(s)
}
+73 -48
View File
@@ -1,4 +1,4 @@
package actions
package actions_test
import (
"net/http"
@@ -6,76 +6,101 @@ import (
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"go-admin/common/actions"
)
// No database is placed in the context on purpose. The middleware needs one
// only to run the sys_user join, so reaching the handler proves it did not.
func runPermission(t *testing.T, claims jwt.MapClaims) (*DataPermission, bool) {
t.Helper()
gin.SetMode(gin.TestMode)
// The detailed data-permission regression suite (claims parsing, the
// GetOrm-unavailable abort, the SQL each data scope produces) now lives in
// go-admin-core's sdk/contract/actions, alongside the logic itself (PRD 006
// F3). What is left to test here is the shim's own wiring: that this
// package's exported names still round-trip through the same *gin.Context
// key core's PermissionAction and GetPermissionFromContext use.
//
// This file lives in package actions_test, an external test, deliberately:
// it exercises PermissionAction and GetPermissionFromContext exactly as an
// app/admin Service does, through this package's public API only, not
// through anything internal a forward could paper over.
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
if claims != nil {
c.Set(jwt.JwtPayloadKey, claims)
}
PermissionAction()(c)
value, exists := c.Get(PermissionKey)
if !exists {
return nil, false
}
p, _ := value.(*DataPermission)
return p, true
}
// Permission() returns the query untouched when data permission is off, so the
// lookup feeding it has nothing to feed. It used to run regardless: a sys_user
// join on every list, detail, update and delete, discarded immediately.
func TestNoLookupWhenDataPermissionIsOff(t *testing.T) {
previous := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = false
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
if _, ok := runPermission(t, jwt.MapClaims{"identity": float64(7)}); !ok {
t.Fatal("the request needed a database even though data permission is off")
}
}
func TestScopeComesFromTheTokenWhenItCarriesOne(t *testing.T) {
// TestPermissionKeyMatchesWhatPermissionActionSets guards PRD 006's hard
// constraint 4: PermissionKey must be declared as
// `const PermissionKey = contractactions.PermissionKey`, a direct
// reference, never a restated literal (see type.go). PermissionAction is
// core's middleware and always writes under core's own key. This test reads
// the value back with actions.PermissionKey exactly as code outside
// GetPermissionFromContext would - c.Get(actions.PermissionKey) is a real,
// if uncommon, way to read the value go-admin has always allowed, and it is
// the one call site where an independently declared PermissionKey would
// stop working without GetPermissionFromContext's own forward hiding it.
//
// If PermissionKey were ever re-declared as an independent literal in this
// package, a later edit to core's copy would make this test fail without a
// single byte of this package having changed - which is the silent-failure
// mode hard constraint 4 exists to rule out (evaluation S2).
func TestPermissionKeyMatchesWhatPermissionActionSets(t *testing.T) {
previous := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = true
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
p, ok := runPermission(t, jwt.MapClaims{
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{
"identity": float64(7),
"roleid": float64(3),
"deptid": float64(5),
"datascope": "4",
"datascope": actions.DataScopeDeptTree,
})
actions.PermissionAction()(c)
value, ok := c.Get(actions.PermissionKey)
if !ok {
t.Fatal("the token carried the scope and a database was still needed")
t.Fatal("PermissionAction did not set the key actions.PermissionKey names; the two have diverged")
}
if p.DataScope != "4" || p.UserId != 7 || p.DeptId != 5 || p.RoleId != 3 {
t.Fatalf("scope read as %+v", p)
p, ok := value.(*actions.DataPermission)
if !ok || p.DataScope != actions.DataScopeDeptTree || p.DeptId != 5 {
t.Fatalf("value under actions.PermissionKey = %#v, want a DataPermission carrying the token's scope", value)
}
}
// A token minted before deptid was carried is still valid until it expires, and
// has to keep working - by falling back to the query, which needs a database.
func TestATokenWithoutDeptIdFallsBackToTheQuery(t *testing.T) {
// TestGetPermissionFromContextRoundTrips is the same guard from the other
// exported entry point: GetPermissionFromContext must read back exactly
// what PermissionAction wrote, both reached through this package's own
// forwards rather than core's directly.
func TestGetPermissionFromContextRoundTrips(t *testing.T) {
previous := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = true
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
if _, ok := runPermission(t, jwt.MapClaims{
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{
"identity": float64(7),
"roleid": float64(3),
"datascope": "4",
}); ok {
t.Fatal("an old token was served from claims it does not have")
"deptid": float64(5),
"datascope": actions.DataScopeSelf,
})
actions.PermissionAction()(c)
p := actions.GetPermissionFromContext(c)
if p.DataScope != actions.DataScopeSelf || p.UserId != 7 {
t.Fatalf("GetPermissionFromContext() = %+v, want DataScope=%q UserId=7", p, actions.DataScopeSelf)
}
}
func TestIsValidDataScope(t *testing.T) {
for _, s := range []string{actions.DataScopeAll, actions.DataScopeCustom, actions.DataScopeDept, actions.DataScopeDeptTree, actions.DataScopeSelf} {
if !actions.IsValidDataScope(s) {
t.Errorf("IsValidDataScope(%q) = false, want true", s)
}
}
if actions.IsValidDataScope("6") {
t.Error(`IsValidDataScope("6") = true, want false`)
}
}
+11 -3
View File
@@ -1,5 +1,13 @@
package actions
const (
PermissionKey = "dataPermission"
)
import contractactions "github.com/go-admin-team/go-admin-core/v2/sdk/contract/actions"
// PermissionKey is a direct reference to go-admin-core's sdk/contract/actions
// constant, not a restated literal - see that package's PermissionKey doc
// comment. PRD 006's hard constraint 4 requires this form for exactly this
// symbol: PermissionAction (below) sets the gin context key it owns, and
// GetPermissionFromContext reads it back; an independently declared literal
// here would let the two silently drift apart if core's copy ever changed
// without this one following. common/actions/shim_test.go carries the
// regression test for that failure mode.
const PermissionKey = contractactions.PermissionKey
+12 -71
View File
@@ -1,74 +1,15 @@
package dto
type AutoForm struct {
Fields []Field `json:"fields"`
FormRef string `json:"formRef"`
FormModel string `json:"formModel"`
Size string `json:"size"`
LabelPosition string `json:"labelPosition"`
LabelWidth int `json:"labelWidth"`
FormRules string `json:"formRules"`
Gutter int `json:"gutter"`
Disabled bool `json:"disabled"`
Span int `json:"span"`
FormBtns bool `json:"formBtns"`
}
import contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
type Config struct {
Label string `json:"label"`
LabelWidth interface{} `json:"labelWidth"`
ShowLabel bool `json:"showLabel"`
ChangeTag bool `json:"changeTag"`
Tag string `json:"tag"`
TagIcon string `json:"tagIcon"`
Required bool `json:"required"`
Layout string `json:"layout"`
Span int `json:"span"`
Document string `json:"document"`
RegList []interface{} `json:"regList"`
FormId int `json:"formId"`
RenderKey int64 `json:"renderKey"`
DefaultValue interface{} `json:"defaultValue"`
ShowTip bool `json:"showTip,omitempty"`
ButtonText string `json:"buttonText,omitempty"`
FileSize int `json:"fileSize,omitempty"`
SizeUnit string `json:"sizeUnit,omitempty"`
}
type Option struct {
Label string `json:"label"`
Value string `json:"value"`
}
type Slot struct {
Prepend string `json:"prepend,omitempty"`
Append string `json:"append,omitempty"`
ListType bool `json:"list-type,omitempty"`
Options []Option `json:"options,omitempty"`
}
type Field struct {
Config Config `json:"__config__"`
Slot Slot `json:"__slot__"`
Placeholder string `json:"placeholder,omitempty"`
Style Style `json:"style,omitempty"`
Clearable bool `json:"clearable,omitempty"`
PrefixIcon string `json:"prefix-icon,omitempty"`
SuffixIcon string `json:"suffix-icon,omitempty"`
Maxlength interface{} `json:"maxlength"`
ShowWordLimit bool `json:"show-word-limit,omitempty"`
Readonly bool `json:"readonly,omitempty"`
Disabled bool `json:"disabled"`
VModel string `json:"__vModel__"`
Action string `json:"action,omitempty"`
Accept string `json:"accept,omitempty"`
Name string `json:"name,omitempty"`
AutoUpload bool `json:"auto-upload,omitempty"`
ListType string `json:"list-type,omitempty"`
Multiple bool `json:"multiple,omitempty"`
Filterable bool `json:"filterable,omitempty"`
}
type Style struct {
Width string `json:"width"`
}
// AutoForm and the types below describe a form built by go-admin-ui's form
// designer. They are thin aliases of go-admin-core's sdk/contract/dto (PRD
// 006 F2/F5).
type (
AutoForm = contractdto.AutoForm
Config = contractdto.Config
Option = contractdto.Option
Slot = contractdto.Slot
Field = contractdto.Field
Style = contractdto.Style
)
+7 -102
View File
@@ -1,106 +1,11 @@
package dto
import (
vd "github.com/bytedance/go-tagexpr/v2/validator"
"net/http"
import contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
// ObjectById, ObjectGetReq and ObjectDeleteReq are thin aliases of
// go-admin-core's sdk/contract/dto (PRD 006 F2/F5).
type (
ObjectById = contractdto.ObjectById
ObjectGetReq = contractdto.ObjectGetReq
ObjectDeleteReq = contractdto.ObjectDeleteReq
)
type ObjectById struct {
Id int `uri:"id"`
Ids []int `json:"ids"`
}
func (s *ObjectById) Bind(ctx *gin.Context) error {
var err error
log := api.GetRequestLogger(ctx)
err = ctx.ShouldBindUri(s)
if err != nil {
log.Warnf("ShouldBindUri error: %s", err.Error())
return err
}
if ctx.Request.Method == http.MethodDelete {
err = ctx.ShouldBind(&s)
if err != nil {
log.Warnf("ShouldBind error: %s", err.Error())
return err
}
if len(s.Ids) > 0 {
return nil
}
if s.Ids == nil {
s.Ids = make([]int, 0)
}
if s.Id != 0 {
s.Ids = append(s.Ids, s.Id)
}
}
if err = vd.Validate(s); err != nil {
log.Errorf("Validate error: %s", err.Error())
return err
}
return err
}
func (s *ObjectById) GetId() interface{} {
if len(s.Ids) > 0 {
s.Ids = append(s.Ids, s.Id)
return s.Ids
}
return s.Id
}
type ObjectGetReq struct {
Id int `uri:"id"`
}
func (s *ObjectGetReq) Bind(ctx *gin.Context) error {
var err error
log := api.GetRequestLogger(ctx)
err = ctx.ShouldBindUri(s)
if err != nil {
log.Warnf("ShouldBindUri error: %s", err.Error())
return err
}
if err = vd.Validate(s); err != nil {
log.Errorf("Validate error: %s", err.Error())
return err
}
return err
}
func (s *ObjectGetReq) GetId() interface{} {
return s.Id
}
type ObjectDeleteReq struct {
Ids []int `json:"ids"`
}
func (s *ObjectDeleteReq) Bind(ctx *gin.Context) error {
var err error
log := api.GetRequestLogger(ctx)
err = ctx.ShouldBind(&s)
if err != nil {
log.Warnf("ShouldBind error: %s", err.Error())
return err
}
if len(s.Ids) > 0 {
return nil
}
if s.Ids == nil {
s.Ids = make([]int, 0)
}
if err = vd.Validate(s); err != nil {
log.Errorf("Validate error: %s", err.Error())
return err
}
return err
}
func (s *ObjectDeleteReq) GetId() interface{} {
return s.Ids
}
+6 -4
View File
@@ -2,11 +2,13 @@ package dto
import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
)
// OrderDest forwards to go-admin-core's sdk/contract/dto (PRD 006 F2/F5). A
// function cannot be aliased the way a type can, so this is a pure
// pass-through rather than a `func X = pkg.X` form Go does not have.
func OrderDest(sort string, bl bool) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Order(clause.OrderByColumn{Column: clause.Column{Name: sort}, Desc: bl})
}
return contractdto.OrderDest(sort, bl)
}
+4 -17
View File
@@ -1,20 +1,7 @@
package dto
type Pagination struct {
PageIndex int `form:"pageIndex"`
PageSize int `form:"pageSize"`
}
import contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
func (m *Pagination) GetPageIndex() int {
if m.PageIndex <= 0 {
m.PageIndex = 1
}
return m.PageIndex
}
func (m *Pagination) GetPageSize() int {
if m.PageSize <= 0 {
m.PageSize = 10
}
return m.PageSize
}
// Pagination is a thin alias of go-admin-core's sdk/contract/dto (PRD 006
// F2/F5).
type Pagination = contractdto.Pagination
+19 -68
View File
@@ -1,80 +1,31 @@
package dto
import (
"github.com/go-admin-team/go-admin-core/v2/tools/search"
"go-admin/common/global"
"gorm.io/gorm"
contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
)
type GeneralDelDto struct {
Id int `uri:"id" json:"id" validate:"required"`
Ids []int `json:"ids"`
}
func (g GeneralDelDto) GetIds() []int {
ids := make([]int, 0)
// Id 此前在 else 分支里被重复追加:仅传 Id 时会得到 [5 5],
// 同一条记录被执行两次删除
if g.Id > 0 {
ids = append(ids, g.Id)
}
for _, id := range g.Ids {
if id > 0 {
ids = append(ids, id)
}
}
if len(ids) == 0 {
//方式全部删除
ids = append(ids, 0)
}
return ids
}
type GeneralGetDto struct {
Id int `uri:"id" json:"id" validate:"required"`
}
// GeneralDelDto and GeneralGetDto are thin aliases of go-admin-core's
// sdk/contract/dto (PRD 006 F2/F5).
type (
GeneralDelDto = contractdto.GeneralDelDto
GeneralGetDto = contractdto.GeneralGetDto
)
// MakeCondition and Paginate forward to go-admin-core's sdk/contract/dto
// (PRD 006 F2/F5). This file used to read go-admin/common/global.Driver to
// pick the SQL dialect MakeCondition resolves search tags against; the
// lowered version instead reads db.Dialector.Name() from inside the closure
// it returns, which is always the driver the caller's own *gorm.DB is bound
// to - correct even when a multi-tenant host has more than one database
// open with different drivers, which a single package-level variable could
// never be. global.Driver itself is untouched and still readable, but
// nothing in this package reads it anymore.
func MakeCondition(q interface{}) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
condition := &search.GormCondition{
GormPublic: search.GormPublic{},
Join: make([]*search.GormJoin, 0),
}
search.ResolveSearchQuery(global.Driver, q, condition)
for _, join := range condition.Join {
if join == nil {
continue
}
db = db.Joins(join.JoinOn)
for k, v := range join.Where {
db = db.Where(k, v...)
}
for k, v := range join.Or {
db = db.Or(k, v...)
}
for _, o := range join.Order {
db = db.Order(o)
}
}
for k, v := range condition.Where {
db = db.Where(k, v...)
}
for k, v := range condition.Or {
db = db.Or(k, v...)
}
for _, o := range condition.Order {
db = db.Order(o)
}
return db
}
return contractdto.MakeCondition(q)
}
func Paginate(pageSize, pageIndex int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
offset := (pageIndex - 1) * pageSize
if offset < 0 {
offset = 0
}
return db.Offset(offset).Limit(pageSize)
}
return contractdto.Paginate(pageSize, pageIndex)
}
+7 -18
View File
@@ -1,21 +1,10 @@
package dto
import (
"github.com/gin-gonic/gin"
"go-admin/common/models"
import contractdto "github.com/go-admin-team/go-admin-core/v2/sdk/contract/dto"
// Index and Control are thin aliases of go-admin-core's sdk/contract/dto
// (PRD 006 F2/F5).
type (
Index = contractdto.Index
Control = contractdto.Control
)
type Index interface {
Generate() Index
Bind(ctx *gin.Context) error
GetPageIndex() int
GetPageSize() int
GetNeedSearch() interface{}
}
type Control interface {
Generate() Control
Bind(ctx *gin.Context) error
GenerateM() (models.ActiveRecord, error)
GetId() interface{}
}
+7
View File
@@ -7,5 +7,12 @@ const (
var (
// Driver 数据库驱动
//
// Deprecated: common/dto.MakeCondition stopped reading this after PRD
// 006 F2/F5 - it now takes the dialect from the *gorm.DB passed to the
// scope it returns instead of this process-wide variable. Driver is
// still set (common/database/initialize.go) and still readable for fork
// code that reads it directly, but it is no longer this framework's own
// path to the current SQL dialect.
Driver string
)
+14
View File
@@ -0,0 +1,14 @@
package global
// Status values written to sys_opera_log.status.
//
// They live here rather than in app/admin/service/dto because
// common/middleware/logger.go writes the operation-log message and needs them.
// A package promised as a stable contract must not compile-depend on a
// business module: a fork that replaces or drops app/admin would otherwise
// stop compiling common/middleware, which is not something a contract package
// is allowed to do. See docs/contract.md.
const (
OperaStatusEnabled = "1"
OperaStatusDisabled = "2"
)
+177
View File
@@ -0,0 +1,177 @@
// Package health answers whether this process should be sent traffic.
//
// The two questions an orchestrator asks are not the same one, and go-admin
// answers them at two endpoints:
//
// - /health is liveness: is the process there at all. It stays a bare 200,
// because the honest answer to "should I restart you" is almost always no.
// Restarting a process because its database is unreachable turns one
// outage into a crash loop that also loses the connection pool, the cache
// and every in-flight request.
// - /ready is readiness: should this instance receive requests now. It fails
// while the dependencies are unreachable, and - the part that only exists
// because of the life-cycle phases - it fails as soon as shutdown begins,
// before the server stops accepting.
//
// # What the draining answer is worth
//
// Order alone does not produce a window. Answering before the server stops
// accepting is the right order - the reverse reports the state after the
// connections are already cut - but with nothing between the two they are
// microseconds apart, and a poller on a multi-second interval never sees the
// 503.
//
// The delay between them is extend.shutdown.drain, which is zero unless it is
// configured. On the shipped defaults this is therefore still an answer that
// can be read rather than one anything acts on; a deployment that sets a drain
// window is the one that gets a window to act in.
//
// What acts on it depends on who does the removing. A load balancer that polls
// /ready takes this instance out when it reads the 503, and the window has to
// cover its check interval times its failure threshold, plus however long the
// removal takes to apply. On Kubernetes the endpoint is withdrawn when the Pod
// receives a deletionTimestamp, concurrently with SIGTERM and regardless of
// what the probe returns - there the window covers the delay in that removal
// reaching every node, and the 503 is what makes the state observable.
package health
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"sync/atomic"
"time"
"github.com/go-admin-team/go-admin-core/v2/sdk"
)
// draining is set when the process starts shutting down.
//
// It is kept here rather than read back from core: BeginShutdown sets a flag on
// the Application, but nothing exports it, and one host wanting to know is not
// yet a reason to widen that interface.
var draining atomic.Bool
// BeginDraining records that shutdown has started, so readiness fails from now
// on. It is called with BeginShutdown, before anything is taken apart.
func BeginDraining() { draining.Store(true) }
// Draining reports whether shutdown has begun.
func Draining() bool { return draining.Load() }
// Check is one dependency and what asking it produced.
type Check struct {
Name string `json:"name"`
OK bool `json:"ok"`
Err string `json:"error,omitempty"`
}
// Ready asks every dependency this process cannot serve a request without.
//
// The queue is deliberately absent. 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 - which
// is a reason to alert, not a reason to leave the load balancer pool.
func Ready(ctx context.Context) []Check {
return []Check{
safely("database", func() error { return pingDB(ctx) }),
safely("cache", probeCache),
}
}
// safely turns a panic into a failed check.
//
// Not defensive habit: the accessors hand back wrappers, not the resources.
// sdk.Runtime.GetCacheAdapter builds a runtime.Cache around whatever is
// configured and returns it even when nothing is - so the value is not nil, the
// cache inside it is, and the first call dereferences it. A nil check cannot
// see that, and the same is true of GetQueueAdapter.
//
// Whatever the reason, 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 to answer is the wrong reply.
func safely(name string, fn func() error) (c Check) {
c = Check{Name: name}
defer func() {
if r := recover(); r != nil {
c.OK, c.Err = false, fmt.Sprintf("the check panicked: %v", r)
}
}()
if err := fn(); err != nil {
c.Err = err.Error()
return c
}
c.OK = true
return c
}
// Healthy reports whether every check passed.
func Healthy(checks []Check) bool {
for _, c := range checks {
if !c.OK {
return false
}
}
return true
}
func pingDB(ctx context.Context) error {
db := sdk.Runtime.GetDb()
if db == nil {
return errors.New("no database configured")
}
sqlDB, err := db.DB()
if err != nil {
return err
}
return sqlDB.PingContext(ctx)
}
// cacheProbePrefix names the probe's keys. The key itself is per probe, not
// fixed: two /ready requests arriving together - or two instances sharing one
// redis, which is the normal deployment - would otherwise overwrite each
// other's value between the write and the read and each conclude the cache was
// broken. A readiness probe that reports false negatives under load takes
// healthy instances out of the pool, which is worse than not probing.
const cacheProbePrefix = "go-admin:health:"
// cacheProbeTTL is short because these keys are write-once and never read
// again by anyone else; it only has to outlive the read that follows.
const cacheProbeTTL = 30
func probeCache() error {
adapter := sdk.Runtime.GetCacheAdapter()
if adapter == nil {
return errors.New("no cache configured")
}
suffix := make([]byte, 8)
if _, err := rand.Read(suffix); err != nil {
return fmt.Errorf("could not build a probe key: %w", err)
}
key := cacheProbePrefix + hex.EncodeToString(suffix)
// Written and read back rather than only read: 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.
want := time.Now().Format(time.RFC3339Nano)
if err := adapter.Set(key, want, cacheProbeTTL); err != nil {
return err
}
// Best effort, and its error is deliberately dropped: the verdict is
// already decided by the read below, and a cache that cannot delete a key
// it just wrote is not a reason to refuse traffic. The TTL is the real
// cleanup.
defer func() { _ = adapter.Del(key) }()
got, err := adapter.Get(key)
if err != nil {
return err
}
if got != want {
return errors.New("the cache returned a different value than was written")
}
return nil
}
+244
View File
@@ -0,0 +1,244 @@
package health
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
corestorage "github.com/go-admin-team/go-admin-core/v2/storage"
)
func freshRuntime(t *testing.T) {
t.Helper()
previous := sdk.Runtime
t.Cleanup(func() { sdk.Runtime = previous })
sdk.Runtime = runtime.NewConfig()
}
// fakeCache answers whatever the test needs it to.
type fakeCache struct {
mu sync.Mutex
setErr error
getErr error
getBack string // returned instead of what was written, when non-empty
stored map[string]string
// oneSlot makes the cache keep a single value however many keys are
// written, which is what a shared probe key turns any cache into.
oneSlot bool
slot string
// setBarrier, when set, holds every writer until all of them have written.
// Without it the probes are short enough that the scheduler usually runs
// them one after another, and a shared key survives by luck rather than by
// design - which would leave the test below asserting nothing.
setBarrier *barrier
}
// barrier releases every waiter once n of them have arrived.
type barrier struct {
n int
mu sync.Mutex
got int
ch chan struct{}
}
func newBarrier(n int) *barrier { return &barrier{n: n, ch: make(chan struct{})} }
func (b *barrier) wait() {
b.mu.Lock()
b.got++
if b.got == b.n {
close(b.ch)
}
b.mu.Unlock()
<-b.ch
}
func (c *fakeCache) String() string { return "fake" }
func (c *fakeCache) Set(key string, val interface{}, _ int) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.setErr != nil {
return c.setErr
}
v, _ := val.(string)
if c.oneSlot {
c.slot = v
return nil
}
if c.stored == nil {
c.stored = map[string]string{}
}
c.stored[key] = v
c.mu.Unlock()
if c.setBarrier != nil {
// Outside the lock on purpose: waiting while holding it would deadlock
// every other writer before the barrier could fill.
c.setBarrier.wait()
}
c.mu.Lock()
return nil
}
func (c *fakeCache) Get(key string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.getErr != nil {
return "", c.getErr
}
if c.getBack != "" {
return c.getBack, nil
}
if c.oneSlot {
return c.slot, nil
}
return c.stored[key], nil
}
func (c *fakeCache) Del(key string) error {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.stored, key)
return nil
}
func (c *fakeCache) HashGet(_, _ string) (string, error) { return "", nil }
func (c *fakeCache) HashDel(_, _ string) error { return nil }
func (c *fakeCache) Increase(string) error { return nil }
func (c *fakeCache) Decrease(string) error { return nil }
func (c *fakeCache) Expire(string, time.Duration) error { return nil }
var _ corestorage.AdapterCache = (*fakeCache)(nil)
func named(checks []Check, name string) Check {
for _, c := range checks {
if c.Name == name {
return c
}
}
return Check{Name: name, Err: "check not reported at all"}
}
// A cache that accepts writes and answers every read with a different value is
// the failure this probe exists for - a client pointed at the wrong server, or
// one that silently drops everything. A read alone cannot tell that apart from
// a healthy cache with a cold key, which is why the probe writes first.
func TestCacheProbeFailsWhenTheValueDoesNotComeBack(t *testing.T) {
freshRuntime(t)
sdk.Runtime.SetCacheAdapter(&fakeCache{getBack: "something else"})
got := named(Ready(context.Background()), "cache")
if got.OK {
t.Error("the cache check passed although the value written was not the value read back")
}
if got.Err == "" {
t.Error("the failing check reported no reason")
}
}
func TestCacheProbePassesWhenTheValueComesBack(t *testing.T) {
freshRuntime(t)
sdk.Runtime.SetCacheAdapter(&fakeCache{})
if got := named(Ready(context.Background()), "cache"); !got.OK {
t.Errorf("the cache check failed for a cache that works: %s", got.Err)
}
}
func TestCacheProbeReportsAWriteFailure(t *testing.T) {
freshRuntime(t)
sdk.Runtime.SetCacheAdapter(&fakeCache{setErr: errors.New("connection refused")})
got := named(Ready(context.Background()), "cache")
if got.OK {
t.Error("the cache check passed although the write failed")
}
}
// Nothing configured is the case that used to take the process down rather
// than answer. 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 probe that panics is the worst
// possible answer to "are you well".
//
// Every check has to be reported, passing or not. A probe that omits what it
// could not reach reads as a shorter list of healthy things.
func TestEveryDependencyIsReportedEvenWithNothingConfigured(t *testing.T) {
freshRuntime(t)
checks := Ready(context.Background())
for _, name := range []string{"database", "cache"} {
c := named(checks, name)
if c.Err == "check not reported at all" {
t.Errorf("%s was not reported", name)
}
if c.OK {
t.Errorf("%s passed with nothing configured", name)
}
}
if Healthy(checks) {
t.Error("Healthy said yes for a process with no database and no cache")
}
}
// BeginDraining sets the flag and Draining reports it, before anything else is
// taken apart. That is the whole of what can be checked from inside the
// process: whether anyone outside gets to read it depends on
// extend.shutdown.drain, which is zero unless it is configured, and on who is
// routing traffic here - the package comment has both. The subprocess tests in
// cmd/api are where a reader on the other end of a socket sees the 503.
func TestDrainingIsObservableOnceItBegins(t *testing.T) {
previous := draining.Load()
t.Cleanup(func() { draining.Store(previous) })
draining.Store(false)
if Draining() {
t.Fatal("Draining reported true before shutdown began")
}
BeginDraining()
if !Draining() {
t.Error("Draining still reported false after BeginDraining")
}
}
// Two probes at once must both pass. With one fixed key they overwrite each
// other's value between the write and the read, and a readiness probe that
// reports false negatives under load takes healthy instances out of the pool -
// which is worse than not probing at all.
func TestConcurrentProbesDoNotOverwriteEachOther(t *testing.T) {
freshRuntime(t)
const probes = 16
sdk.Runtime.SetCacheAdapter(&fakeCache{setBarrier: newBarrier(probes)})
var wg sync.WaitGroup
failures := make(chan string, probes)
for i := 0; i < probes; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if c := named(Ready(context.Background()), "cache"); !c.OK {
failures <- c.Err
}
}()
}
wg.Wait()
close(failures)
var n int
var first string
for err := range failures {
if n == 0 {
first = err
}
n++
}
if n > 0 {
t.Errorf("%d of %d concurrent probes called a healthy cache broken; first: %s", n, probes, first)
}
}
+29 -2
View File
@@ -3,11 +3,19 @@ package middleware
import (
"time"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"go-admin/common/middleware/handler"
)
// authMiddleware is the single JWT middleware instance the whole process
// shares. InitMiddleware builds it once, before any module registers its
// routes; GetAuthMiddleware is how a module gets it back instead of calling
// AuthInit itself and building another, functionally-equivalent-but-distinct
// instance.
var authMiddleware *jwt.GinJWTMiddleware
// AuthInit jwt验证new
func AuthInit() (*jwt.GinJWTMiddleware, error) {
timeout := time.Hour
@@ -33,4 +41,23 @@ func AuthInit() (*jwt.GinJWTMiddleware, error) {
TimeFunc: time.Now,
})
}
}
// GetAuthMiddleware returns the shared JWT middleware instance InitMiddleware
// built at startup. Application modules (app/admin, app/jobs, app/other,
// app/demo) call this instead of AuthInit so their router chains - which
// still need the instance itself for authMiddleware.MiddlewareFunc() and
// authMiddleware.LoginHandler, not just the bound closure registered under
// sdk.Runtime's JwtTokenCheck key - end up using the same instance the host
// registered, rather than one each.
//
// It fails loudly instead of returning nil: an InitRouter that runs before
// InitMiddleware has a real startup-ordering bug, not a case to paper over
// with a nil *jwt.GinJWTMiddleware that would panic much further down the
// call chain with a far less useful stack trace.
func GetAuthMiddleware() *jwt.GinJWTMiddleware {
if authMiddleware == nil {
log.Fatal("JWT middleware not initialized; InitMiddleware must run before any module's InitRouter")
}
return authMiddleware
}
+91 -17
View File
@@ -1,29 +1,103 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"net/http"
)
// defaultDemoMsg is what a refused request is told when nothing is configured.
//
// It is the string this middleware used to carry hard-coded, kept verbatim so
// that a deployment which never set application.demomsg is answered exactly as
// it was before.
const defaultDemoMsg = "谢谢您的参与,但为了大家更好的体验,所以本次提交就算了吧!\U0001F600\U0001F600\U0001F600"
// demoWriteRoutes are routes that change something despite being registered as
// GET, so the method alone does not say whether they are safe to serve.
//
// All three belong to the code generator: two write Go source files onto the
// server's filesystem and the third inserts menus, APIs and casbin rules into
// the database. They are registered under a group whose own name says it does
// no role check, and a demo deployment lets anybody log in - so on a demo host
// they were reachable by any visitor, and the menus one had in fact been used.
//
// Spelled as gin route patterns, which is what Context.FullPath returns, so a
// path parameter matches whatever value it is given.
//
// This list cannot be checked from here: common/ may not import app/, so this
// package cannot see which routes exist. What keeps it honest is a test beside
// the routes themselves - see app/other/router - which registers them and
// fails if any entry here has stopped being a real route.
//
// It also does not close the general hole. Nothing stops the next GET handler
// that writes something from being added without an entry here, and no static
// check can tell a handler that writes from one that reads. Demo mode refuses
// the routes it has been told about; that is the whole of the guarantee.
var demoWriteRoutes = map[string]bool{
"/api/v1/gen/toproject/:tableId": true,
"/api/v1/gen/apitofile/:tableId": true,
"/api/v1/gen/todb/:tableId": true,
}
// DemoWriteRoutes returns the routes demo mode refuses despite their method.
//
// Exported only so the test that lives beside the route registrations can
// check every one of them still exists; nothing else should need it.
func DemoWriteRoutes() []string {
out := make([]string, 0, len(demoWriteRoutes))
for route := range demoWriteRoutes {
out = append(out, route)
}
return out
}
// demoAllows reports whether demo mode lets a request through.
//
// route is the matched gin route pattern and uri the raw request target; the
// two are different things and both are needed. The route is what identifies a
// handler regardless of the values in its path parameters, and it is empty for
// a request that matched nothing - which is why the login and logout checks
// still read the raw target, as they always did.
func demoAllows(method, route, uri string) bool {
if demoWriteRoutes[route] {
return false
}
return method == http.MethodGet ||
method == http.MethodOptions ||
uri == "/api/v1/login" ||
uri == "/api/v1/logout"
}
// demoMessage is the answer a refused request gets.
//
// application.demomsg has been in the configuration all along and nothing read
// it: the message was hard-coded here, and the demo host's configured string
// happened to be identical, so the setting looked like it worked. An empty
// value falls back rather than answering with nothing.
func demoMessage() string {
if msg := config.ApplicationConfig.DemoMsg; msg != "" {
return msg
}
return defaultDemoMsg
}
// DemoEvn refuses anything that would change state while mode is demo.
func DemoEvn() gin.HandlerFunc {
return func(c *gin.Context) {
method := c.Request.Method
if config.ApplicationConfig.Mode == "demo" {
if method == "GET" ||
method == "OPTIONS" ||
c.Request.RequestURI == "/api/v1/login" ||
c.Request.RequestURI == "/api/v1/logout" {
c.Next()
} else {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "谢谢您的参与,但为了大家更好的体验,所以本次提交就算了吧!\U0001F600\U0001F600\U0001F600",
})
c.Abort()
return
}
if config.ApplicationConfig.Mode != "demo" {
c.Next()
return
}
c.Next()
if demoAllows(c.Request.Method, c.FullPath(), c.Request.RequestURI) {
c.Next()
return
}
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": demoMessage(),
})
c.Abort()
}
}
+144
View File
@@ -0,0 +1,144 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
)
// demoMode puts the process in demo mode for one test and puts it back.
func demoMode(t *testing.T, mode, msg string) {
t.Helper()
previousMode, previousMsg := config.ApplicationConfig.Mode, config.ApplicationConfig.DemoMsg
t.Cleanup(func() {
config.ApplicationConfig.Mode = previousMode
config.ApplicationConfig.DemoMsg = previousMsg
})
config.ApplicationConfig.Mode, config.ApplicationConfig.DemoMsg = mode, msg
}
// The method is not enough on its own. Three of the generator's routes are
// registered as GET and write anyway - two of them onto the server's
// filesystem, one into the database - so a guard that reads only the method
// serves them to anybody who can log in, which on a demo host is everybody.
func TestDemoRefusesTheWritesThatAreServedOverGET(t *testing.T) {
const login = "/api/v1/login"
for _, tc := range []struct {
name string
method string
route, uri string
wantThrough bool
}{
{"a plain read", http.MethodGet, "/api/v1/dept", "/api/v1/dept", true},
{"a write, by method", http.MethodPost, "/api/v1/dept", "/api/v1/dept", false},
{"login is how a visitor gets in", http.MethodPost, login, login, true},
{"logout", http.MethodPost, "/api/v1/logout", "/api/v1/logout", true},
{"preflight", http.MethodOptions, "/api/v1/dept", "/api/v1/dept", true},
// A request that matched no route has an empty pattern, and the guard
// still has to refuse it by method - this is what a POST to a path
// that does not exist looks like from in here.
{"a write to nothing at all", http.MethodPost, "", "/api/v1/__probe__", false},
// The three this change is about.
{"generator writes the database", http.MethodGet,
"/api/v1/gen/todb/:tableId", "/api/v1/gen/todb/3", false},
{"generator writes source files", http.MethodGet,
"/api/v1/gen/toproject/:tableId", "/api/v1/gen/toproject/3", false},
{"generator writes an api file", http.MethodGet,
"/api/v1/gen/apitofile/:tableId", "/api/v1/gen/apitofile/3", false},
// The read-only half of the generator has to keep working, or the demo
// host cannot demonstrate the feature at all. Refusing too much is as
// much of a defect as refusing too little.
{"generator preview stays available", http.MethodGet,
"/api/v1/gen/preview/:tableId", "/api/v1/gen/preview/3", true},
{"generator table tree stays available", http.MethodGet,
"/api/v1/gen/tabletree", "/api/v1/gen/tabletree", true},
{"table list stays available", http.MethodGet,
"/api/v1/db/tables/page", "/api/v1/db/tables/page", true},
{"column list stays available", http.MethodGet,
"/api/v1/db/columns/page", "/api/v1/db/columns/page", true},
} {
t.Run(tc.name, func(t *testing.T) {
if got := demoAllows(tc.method, tc.route, tc.uri); got != tc.wantThrough {
t.Errorf("demoAllows(%s %s) = %v, want %v", tc.method, tc.route, got, tc.wantThrough)
}
})
}
}
// Everything above is about demo mode only. A deployment that is not a demo
// runs the generator for real, and a guard that reached it there would have
// taken the feature away from every production install.
func TestOutsideDemoModeNothingIsRefused(t *testing.T) {
demoMode(t, "prod", "")
gin.SetMode(gin.TestMode)
for _, route := range append(DemoWriteRoutes(), "/api/v1/dept") {
t.Run(route, func(t *testing.T) {
r := gin.New()
r.Use(DemoEvn())
r.GET(route, func(c *gin.Context) { c.String(http.StatusOK, "served") })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, requestFor(route), nil))
if w.Body.String() != "served" {
t.Errorf("answered %q; outside demo mode the handler must run", w.Body.String())
}
})
}
}
// The refusal has to come back as the demo message rather than a 403 or a 404:
// the front end shows it to the visitor, and the point of a demo host is that
// being turned away is explained.
func TestDemoRefusalCarriesTheConfiguredMessage(t *testing.T) {
gin.SetMode(gin.TestMode)
const route = "/api/v1/gen/todb/:tableId"
for _, tc := range []struct {
name, configured, want string
}{
{"configured", "come back tomorrow", "come back tomorrow"},
// A deployment that never set application.demomsg keeps the answer it
// already had; an empty setting must not become an empty message.
{"not configured", "", defaultDemoMsg},
} {
t.Run(tc.name, func(t *testing.T) {
demoMode(t, "demo", tc.configured)
r := gin.New()
r.Use(DemoEvn())
r.GET(route, func(c *gin.Context) { c.String(http.StatusOK, "served") })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, requestFor(route), nil))
if w.Code != http.StatusOK {
t.Errorf("answered %d, want 200 so the front end reads the body", w.Code)
}
if body := w.Body.String(); !strings.Contains(body, tc.want) {
t.Errorf("body %q does not carry %q", body, tc.want)
}
if strings.Contains(w.Body.String(), "served") {
t.Error("the handler ran; the request was supposed to be refused")
}
})
}
}
// requestFor turns a route pattern into a request target by giving every path
// parameter a value.
func requestFor(route string) string {
segments := strings.Split(route, "/")
for i, segment := range segments {
if strings.HasPrefix(segment, ":") {
segments[i] = "1"
}
}
return strings.Join(segments, "/")
}
+24 -13
View File
@@ -1,7 +1,6 @@
package handler
import (
"go-admin/app/admin/models"
"go-admin/common"
"net/http"
@@ -163,19 +162,31 @@ func LogOut(c *gin.Context) {
}
// Authorizator decides whether a parsed identity may proceed. It authorizes
// every identity IdentityHandler was able to build, which is what it has always
// done.
//
// It used to also assert data["user"] and data["role"] into app/admin/models
// types and copy five fields onto the context. Those two keys are not in the
// map: IdentityHandler builds it from the token claims and puts in
// IdentityKey / UserName / RoleKey / UserId / RoleIds / DataScope. Both
// assertions therefore failed on every request, and because the ok result was
// discarded, the five c.Set calls stored zero values and the function returned
// true regardless.
//
// Nothing in this repository or in go-admin-core reads role / roleIds /
// userId / userName / dataScope off the context - the open-source data
// permission path reads the JWT claims through
// common/actions.Permission -> user.GetUserIdStr(c). Dropping the block
// therefore removes five zero values nobody read, and with them the last
// import of app/admin from a contract package.
//
// Anything maintaining its own copy of this file must check its own consumers
// before taking this change: a codebase that does read those keys off the
// context needs Authorizator to keep setting them.
func Authorizator(data interface{}, c *gin.Context) bool {
if v, ok := data.(map[string]interface{}); ok {
u, _ := v["user"].(models.SysUser)
r, _ := v["role"].(models.SysRole)
c.Set("role", r.RoleName)
c.Set("roleIds", r.RoleId)
c.Set("userId", u.UserId)
c.Set("userName", u.Username)
c.Set("dataScope", r.DataScope)
return true
}
return false
_, ok := data.(map[string]interface{})
return ok
}
func Unauthorized(c *gin.Context, code int, message string) {
+29 -5
View File
@@ -2,15 +2,20 @@ package middleware
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
log "github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
"go-admin/common/actions"
)
// These alias core's own constants (see sdk/runtime.GetHandlerFunc's contract
// doc, section 9) rather than redeclaring the same three strings, so a typo
// here can no longer split registration and lookup into two different keys
// that both happen to compile.
const (
JwtTokenCheck string = "JwtToken"
RoleCheck string = "AuthCheckRole"
PermissionCheck string = "PermissionAction"
JwtTokenCheck = runtime.JwtTokenCheck
RoleCheck = runtime.RoleCheck
PermissionCheck = runtime.PermissionCheck
)
func InitMiddleware(r *gin.Engine) {
@@ -29,7 +34,26 @@ func InitMiddleware(r *gin.Engine) {
r.Use(Secure)
// 链路追踪
//r.Use(middleware.Trace())
sdk.Runtime.SetMiddleware(JwtTokenCheck, (*jwt.GinJWTMiddleware).MiddlewareFunc)
// Build the shared JWT middleware instance here, before any module
// registers routes (initRouter runs ahead of runStartupHooks, which is
// what invokes each module's InitRouter - see cmd/api/server.go). Doing
// it once here, instead of once per module via AuthInit, is what makes
// GetAuthMiddleware and sdk.Runtime.GetHandlerFunc(JwtTokenCheck) both
// resolve to a single, meaningful instance instead of "whichever module
// happened to initialize last".
//
// SetMiddleware must be given a bound closure (authMiddleware.MiddlewareFunc()),
// not the unbound method expression (*jwt.GinJWTMiddleware).MiddlewareFunc:
// the latter has no receiver bound to it, so GetHandlerFunc's type
// assertion to gin.HandlerFunc always fails for it.
var err error
authMiddleware, err = AuthInit()
if err != nil {
// A process with no JWT middleware must not start serving requests.
log.Fatalf("JWT Init Error, %s", err.Error())
}
sdk.Runtime.SetMiddleware(JwtTokenCheck, authMiddleware.MiddlewareFunc())
sdk.Runtime.SetMiddleware(RoleCheck, AuthCheckRole())
sdk.Runtime.SetMiddleware(PermissionCheck, actions.PermissionAction())
}
+77
View File
@@ -0,0 +1,77 @@
package middleware
import (
"testing"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
)
// freshRuntime hands the test its own Runtime and puts the old one back, the
// same pattern cmd/api/server_test.go uses: sdk.Runtime is a process-wide
// singleton, and a test that registers into it would otherwise leak state
// into every other test in the binary.
func freshRuntime(t *testing.T) {
t.Helper()
previous := sdk.Runtime
t.Cleanup(func() { sdk.Runtime = previous })
sdk.Runtime = runtime.NewConfig()
}
// TestInitMiddlewareRegistersUsableJwtHandlerFunc is the reverse proof for
// hoisting the JWT instance's construction into InitMiddleware:
// sdk.Runtime.GetHandlerFunc(JwtTokenCheck) must hand back ok=true and a
// non-nil gin.HandlerFunc, not just something GetMiddleware can return as an
// untyped interface{}.
//
// Before this change, InitMiddleware registered the unbound method
// expression (*jwt.GinJWTMiddleware).MiddlewareFunc under this key - a value
// with no receiver bound to it, which is not a gin.HandlerFunc no matter how
// a caller asserts its type. Reverting the registration below to that
// expression makes GetHandlerFunc report ok=false; it does not fail to
// compile, because (*jwt.GinJWTMiddleware).MiddlewareFunc has a well-formed,
// unrelated method-expression type that SetMiddleware's interface{} param
// happily accepts.
func TestInitMiddlewareRegistersUsableJwtHandlerFunc(t *testing.T) {
freshRuntime(t)
previousSecret := config.JwtConfig.Secret
config.JwtConfig.Secret = "test-secret-key"
t.Cleanup(func() { config.JwtConfig.Secret = previousSecret })
gin.SetMode(gin.TestMode)
InitMiddleware(gin.New())
h, ok := sdk.Runtime.GetHandlerFunc(JwtTokenCheck)
if !ok {
t.Fatal("GetHandlerFunc(JwtTokenCheck) reported ok=false after InitMiddleware ran")
}
if h == nil {
t.Fatal("GetHandlerFunc(JwtTokenCheck) reported ok=true but returned a nil handler")
}
}
// TestInitMiddlewareBuildsOneSharedJwtInstance locks down the fix for the
// four-instances problem: GetAuthMiddleware must return the very instance
// InitMiddleware built and handed to sdk.Runtime, not a lookalike built
// separately by whichever caller asks first.
func TestInitMiddlewareBuildsOneSharedJwtInstance(t *testing.T) {
freshRuntime(t)
previousSecret := config.JwtConfig.Secret
config.JwtConfig.Secret = "test-secret-key"
t.Cleanup(func() { config.JwtConfig.Secret = previousSecret })
gin.SetMode(gin.TestMode)
InitMiddleware(gin.New())
shared := GetAuthMiddleware()
if shared == nil {
t.Fatal("GetAuthMiddleware returned nil after InitMiddleware ran")
}
if shared != authMiddleware {
t.Error("GetAuthMiddleware did not return the package-level instance InitMiddleware built")
}
}
+67 -20
View File
@@ -1,19 +1,18 @@
package middleware
import (
"bufio"
"bytes"
"encoding/json"
"go-admin/app/admin/service/dto"
"errors"
"go-admin/common"
"io"
"io/ioutil"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
"github.com/go-admin-team/go-admin-core/v2/logger"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
@@ -28,19 +27,14 @@ func LoggerToFile() gin.HandlerFunc {
// 开始时间
startTime := time.Now()
// 处理请求
//
// The body is only read when it has a destination. operParam below is
// the only consumer, and it is written when logger.enableddb is on -
// off in the shipped configuration, where reading the body was a copy
// of every request made and discarded.
var body string
switch c.Request.Method {
case http.MethodPost, http.MethodPut, http.MethodGet, http.MethodDelete:
bf := bytes.NewBuffer(nil)
wt := bufio.NewWriter(bf)
_, err := io.Copy(wt, c.Request.Body)
if err != nil {
log.Warnf("copy body error, %s", err.Error())
err = nil
}
rb, _ := ioutil.ReadAll(bf)
c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(rb))
body = string(rb)
if config.LoggerConfig.EnabledDB {
body = readOperParam(c, log)
}
c.Next()
@@ -100,10 +94,55 @@ func LoggerToFile() gin.HandlerFunc {
}
}
// SetDBOperLog 写入操作日志表 fixme 该方法后续即将弃用
func SetDBOperLog(c *gin.Context, clientIP string, statusCode int, reqUri string, reqMethod string, latencyTime time.Duration, body string, result string, status int) {
// operParamLimit caps what is copied out of a request body for the operation
// log. A file upload is a POST like any other and reaches this middleware
// before any handler, so without a limit the whole upload is held in memory to
// write a log row - a 16MB upload allocated about 67MB. The limit also keeps
// the value inside the column, which is TEXT.
const operParamLimit = 32 << 10
log := api.GetRequestLogger(c)
// readOperParam copies the start of the request body for the operation log and
// leaves the request readable by the handler.
//
// The body is not buffered whole: the handler reads the part copied here from
// memory and the rest straight from the connection, so what this holds is
// bounded by operParamLimit however large the request is.
func readOperParam(c *gin.Context, log *logger.Helper) string {
switch c.Request.Method {
case http.MethodPost, http.MethodPut, http.MethodGet, http.MethodDelete:
default:
return ""
}
if c.Request.Body == nil {
return ""
}
rest := c.Request.Body
head := make([]byte, operParamLimit)
n, err := io.ReadFull(rest, head)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
log.Warnf("read body for the operation log: %s", err)
}
head = head[:n]
c.Request.Body = readCloser{
Reader: io.MultiReader(bytes.NewReader(head), rest),
Closer: rest,
}
return string(head)
}
type readCloser struct {
io.Reader
io.Closer
}
// operaLogFields builds the message written to the operation log queue.
//
// Split out of SetDBOperLog so the field set can be asserted in a test: the
// consumer on the other end of the queue reads these keys by name, so a
// dropped or renamed key costs a column in sys_opera_log and reports nothing.
func operaLogFields(c *gin.Context, clientIP string, statusCode int, reqUri string, reqMethod string, latencyTime time.Duration, body string, result string, status int) map[string]interface{} {
l := make(map[string]interface{})
l["_fullPath"] = c.FullPath()
l["operUrl"] = reqUri
@@ -120,10 +159,18 @@ func SetDBOperLog(c *gin.Context, clientIP string, statusCode int, reqUri string
l["createBy"] = user.GetUserId(c)
l["updateBy"] = user.GetUserId(c)
if status == http.StatusOK {
l["status"] = dto.OperaStatusEnabel
l["status"] = global.OperaStatusEnabled
} else {
l["status"] = dto.OperaStatusDisable
l["status"] = global.OperaStatusDisabled
}
return l
}
// SetDBOperLog 写入操作日志表 fixme 该方法后续即将弃用
func SetDBOperLog(c *gin.Context, clientIP string, statusCode int, reqUri string, reqMethod string, latencyTime time.Duration, body string, result string, status int) {
log := api.GetRequestLogger(c)
l := operaLogFields(c, clientIP, statusCode, reqUri, reqMethod, latencyTime, body, result, status)
q := sdk.Runtime.GetQueuePrefix(c.Request.Host)
message, err := sdk.Runtime.GetStreamMessage("", global.OperateLog, l)
if err != nil {
+124
View File
@@ -0,0 +1,124 @@
package middleware
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
)
// serveWithLogger runs one request through the logger middleware and returns
// what the handler saw, with logger.enableddb set as given.
func serveWithLogger(t testing.TB, enabledDB bool, method, body string) string {
t.Helper()
prev := config.LoggerConfig.EnabledDB
config.LoggerConfig.EnabledDB = enabledDB
t.Cleanup(func() { config.LoggerConfig.EnabledDB = prev })
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(LoggerToFile())
var seen string
handler := func(c *gin.Context) {
b, err := io.ReadAll(c.Request.Body)
if err != nil {
t.Errorf("handler could not read the body: %v", err)
}
seen = string(b)
c.Status(http.StatusOK)
}
r.Handle(method, "/probe", handler)
req := httptest.NewRequest(method, "/probe", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(httptest.NewRecorder(), req)
return seen
}
// The middleware rewrites Request.Body so it can log the parameters. Whatever
// else it does, the handler has to receive the request the client sent - all
// of it, whether or not the operation log is on, and whether or not the body
// is longer than what gets logged.
func TestHandlerStillSeesTheWholeBody(t *testing.T) {
cases := []struct {
name string
enabledDB bool
body string
}{
{"log off, short body", false, `{"username":"admin"}`},
{"log on, short body", true, `{"username":"admin"}`},
{"log off, empty body", false, ""},
{"log on, empty body", true, ""},
// Longer than operParamLimit: the logged copy is truncated, the body is not.
{"log on, body past the limit", true, strings.Repeat("x", operParamLimit+4096)},
{"log off, body past the limit", false, strings.Repeat("y", operParamLimit+4096)},
// Exactly at the boundary, where a fencepost error would show.
{"log on, body exactly at the limit", true, strings.Repeat("z", operParamLimit)},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
if got := serveWithLogger(t, c.enabledDB, method, c.body); got != c.body {
t.Errorf("%s: handler saw %d bytes, the client sent %d",
method, len(got), len(c.body))
}
}
})
}
}
// The body is read for one reason - operParam on the operation log row - and
// that row is only written when logger.enableddb is on. With it off, reading
// the body is a copy of every request made and thrown away, and a file upload
// is a POST like any other: 16MB of upload allocated about 67MB here.
//
// Allocation counts are deterministic across machines; wall-clock is not.
func TestBodyIsNotCopiedWhenTheOperationLogIsOff(t *testing.T) {
const size = 1 << 20
body := strings.Repeat("x", size)
prev := config.LoggerConfig.EnabledDB
config.LoggerConfig.EnabledDB = false
t.Cleanup(func() { config.LoggerConfig.EnabledDB = prev })
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(LoggerToFile())
r.POST("/probe", func(c *gin.Context) { c.Status(http.StatusOK) })
payload := []byte(body)
run := func() {
req := httptest.NewRequest(http.MethodPost, "/probe", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(httptest.NewRecorder(), req)
}
var before, after uint64
before = heapAllocs()
run()
after = heapAllocs()
// The handler never reads the body, so a request that does not copy it
// should allocate far less than the body's size. The old middleware
// allocated about four times the body.
if grew := after - before; grew > size/2 {
t.Errorf("a %d-byte request allocated %d bytes with the operation log off; "+
"the body should not be read when nothing consumes it", size, grew)
}
}
func heapAllocs() uint64 {
var m runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&m)
return m.TotalAlloc
}
+73
View File
@@ -0,0 +1,73 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"go-admin/common/global"
)
// The operation-log consumer reads these keys by name off the queue message.
// Losing one costs a column in sys_opera_log and reports nothing - the request
// still succeeds, the log row is just wrong.
//
// This locks the set down across the move of the status constants out of
// app/admin/service/dto, which touched every request path.
var operaLogKeys = []string{
"_fullPath", "operUrl", "operIp", "operLocation", "operName",
"requestMethod", "operParam", "operTime", "jsonResult", "latencyTime",
"statusCode", "userAgent", "createBy", "updateBy", "status",
}
func TestOperaLogFieldsAreComplete(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/sys-user", nil)
c.Request.Header.Set("User-Agent", "go-test")
l := operaLogFields(c, "127.0.0.1", http.StatusOK, "/api/v1/sys-user", http.MethodPost,
12*time.Millisecond, `{"a":1}`, `{"code":200}`, http.StatusOK)
for _, k := range operaLogKeys {
if _, ok := l[k]; !ok {
t.Errorf("operation log is missing %q", k)
}
}
if len(l) != len(operaLogKeys) {
t.Errorf("operation log has %d fields, expected %d; update operaLogKeys deliberately, not to make this pass",
len(l), len(operaLogKeys))
}
if got := l["operUrl"]; got != "/api/v1/sys-user" {
t.Errorf("operUrl = %v", got)
}
if got := l["userAgent"]; got != "go-test" {
t.Errorf("userAgent = %v", got)
}
}
// status is what tells a failed request from a successful one in the log table.
// It is a string, and it is the one field whose source package changed.
func TestOperaLogStatusMapping(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name string
status int
want string
}{
{"ok", http.StatusOK, global.OperaStatusEnabled},
{"error", http.StatusInternalServerError, global.OperaStatusDisabled},
} {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
l := operaLogFields(c, "127.0.0.1", tc.status, "/", http.MethodGet, 0, "", "", tc.status)
if l["status"] != tc.want {
t.Fatalf("status = %v, want %v", l["status"], tc.want)
}
})
}
}
+27
View File
@@ -59,6 +59,33 @@ func AuthCheckRole() gin.HandlerFunc {
}
}
// EnforceRoleFor reports whether the caller's role has explicit Casbin
// permission to act on path with method.
//
// AuthCheckRole never calls Enforce for a route CasbinExclude lists - that
// is the whole point of the list. A handler on such a route can still need
// the real answer for part of what it does: sys_user.go's Update shares its
// excluded route between the personal-center screen editing the caller's own
// record (which is why the route is excluded at all) and an admin editing
// someone else's, and only the second case is meant to require a policy
// grant. That handler asks here instead of assuming the middleware already
// checked.
func EnforceRoleFor(c *gin.Context, path, method string) (bool, error) {
data, ok := c.Get(jwtauth.JwtPayloadKey)
if !ok {
return false, nil
}
v, ok := data.(jwtauth.MapClaims)
if !ok {
return false, nil
}
if v["rolekey"] == "admin" {
return true, nil
}
e := sdk.Runtime.GetCasbinByTenant(c.Request.Host)
return e.Enforce(v["rolekey"], path, method)
}
// excludedFromCasbin reports whether the route skips the permission check.
//
// It runs for every non-admin request, so the order matters: the method rules
+1
View File
@@ -34,6 +34,7 @@ var CasbinExclude = []UrlInfo{
{Url: "/api/v1/user/pwd", Method: "PUT"},
{Url: "/api/v1/metrics", Method: "GET"},
{Url: "/api/v1/health", Method: "GET"},
{Url: "/api/v1/ready", Method: "GET"},
{Url: "/", Method: "GET"},
{Url: "/api/v1/server-monitor", Method: "GET"},
{Url: "/api/v1/public/uploadFile", Method: "POST"},
+9 -37
View File
@@ -1,41 +1,13 @@
package models
import (
"time"
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
"gorm.io/plugin/soft_delete"
// ControlBy, Model and ModelTime are thin aliases of go-admin-core's
// sdk/contract/models (PRD 006 F1/F5). A type alias is the same type, not a
// new one, so every model that embeds these keeps its GORM tags, JSON tags
// and method set untouched.
type (
ControlBy = contractmodels.ControlBy
Model = contractmodels.Model
ModelTime = contractmodels.ModelTime
)
type ControlBy struct {
CreateBy int `json:"createBy" gorm:"index;comment:创建者"`
UpdateBy int `json:"updateBy" gorm:"index;comment:更新者"`
}
// SetCreateBy 设置创建人id
func (e *ControlBy) SetCreateBy(createBy int) {
e.CreateBy = createBy
}
// SetUpdateBy 设置修改人id
func (e *ControlBy) SetUpdateBy(updateBy int) {
e.UpdateBy = updateBy
}
type Model struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement;comment:主键编码"`
}
type ModelTime struct {
CreatedAt time.Time `json:"createdAt" gorm:"comment:创建时间"`
UpdatedAt time.Time `json:"updatedAt" gorm:"comment:最后更新时间"`
// DeletedAt is milliseconds since the epoch, zero while the row is live,
// and never null.
//
// A nullable marker cannot take part in a unique index. Two live rows are
// (name, NULL) and (name, NULL), and NULL is not equal to NULL, so the
// index permits both — it looks like a constraint and enforces nothing.
// With zero for live rows the pair collides, while two deletions of the
// same name differ by their timestamps and both remain.
DeletedAt soft_delete.DeletedAt `json:"-" gorm:"softDelete:milli;index;comment:删除时间"`
}
+11 -7
View File
@@ -1,11 +1,15 @@
package models
// Menu 菜单中的类型枚举值
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
// Directory, Menu and Button are the menu type enum values used by
// sys_menu.menu_type, referenced directly from go-admin-core's
// sdk/contract/models rather than restated as literals: PRD 006's hard
// constraint 4 requires `const X = pkg.X` for exactly this reason - two
// independently written copies of the same value can be edited out of step,
// where a direct reference cannot.
const (
// Directory 目录
Directory string = "M"
// Menu 菜单
Menu string = "C"
// Button 按钮
Button string = "F"
Directory = contractmodels.Directory
Menu = contractmodels.Menu
Button = contractmodels.Button
)
+7 -9
View File
@@ -1,12 +1,10 @@
package models
import "time"
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
type Migration struct {
Version string `gorm:"primaryKey"`
ApplyTime time.Time `gorm:"autoCreateTime"`
}
func (Migration) TableName() string {
return "sys_migration"
}
// Migration is the sys_migration row model (data). It is unrelated to
// cmd/migrate/migration.Migration, the in-process registration table this
// package's TableName has nothing to do with - see
// go-admin-core's sdk/contract/models.Migration doc comment for why the two
// share a name.
type Migration = contractmodels.Migration
+7 -27
View File
@@ -1,30 +1,10 @@
package models
type Response struct {
// 代码
Code int `json:"code" example:"200"`
// 数据集
Data interface{} `json:"data"`
// 消息
Msg string `json:"msg"`
RequestId string `json:"requestId"`
}
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
type Page struct {
List interface{} `json:"list"`
Count int `json:"count"`
PageIndex int `json:"pageIndex"`
PageSize int `json:"pageSize"`
}
// ReturnOK 正常返回
func (res *Response) ReturnOK() *Response {
res.Code = 200
return res
}
// ReturnError 错误返回
func (res *Response) ReturnError(code int) *Response {
res.Code = code
return res
}
// Response and Page are thin aliases of go-admin-core's sdk/contract/models
// (PRD 006 F1/F5).
type (
Response = contractmodels.Response
Page = contractmodels.Page
)
+9 -8
View File
@@ -1,11 +1,12 @@
package models
import "gorm.io/gorm/schema"
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
type ActiveRecord interface {
schema.Tabler
SetCreateBy(createBy int)
SetUpdateBy(updateBy int)
Generate() ActiveRecord
GetId() interface{}
}
// ActiveRecord is self-referencing (Generate() ActiveRecord), which is why
// it must stay a type alias rather than a defined type: aliasing preserves
// identity with go-admin-core's sdk/contract/models.ActiveRecord, so a
// model whose Generate() returns that interface still satisfies this one. A
// defined type here would break every implementer's method set - see
// go-admin-core's sdk/contract/models package tests for the counterproof
// (PRD 006 counterproof A).
type ActiveRecord = contractmodels.ActiveRecord
+4 -39
View File
@@ -1,42 +1,7 @@
package models
import (
"gorm.io/gorm"
import contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
)
// BaseUser 密码登录基础用户
type BaseUser struct {
Username string `json:"username" gorm:"type:varchar(100);comment:用户名"`
Salt string `json:"-" gorm:"type:varchar(255);comment:加盐;<-"`
PasswordHash string `json:"-" gorm:"type:varchar(128);comment:密码hash;<-"`
Password string `json:"password" gorm:"-"`
}
// SetPassword 设置密码
func (u *BaseUser) SetPassword(value string) {
u.Password = value
u.generateSalt()
u.PasswordHash = u.GetPasswordHash()
}
// GetPasswordHash 获取密码hash
func (u *BaseUser) GetPasswordHash() string {
passwordHash, err := pkg.SetPassword(u.Password, u.Salt)
if err != nil {
return ""
}
return passwordHash
}
// generateSalt 生成加盐值
func (u *BaseUser) generateSalt() {
u.Salt = pkg.GenerateRandomKey16()
}
// Verify 验证密码
func (u *BaseUser) Verify(db *gorm.DB, tableName string) bool {
db.Table(tableName).Where("username = ?", u.Username).First(u)
return u.GetPasswordHash() == u.PasswordHash
}
// BaseUser is a thin alias of go-admin-core's sdk/contract/models (PRD 006
// F1/F5).
type BaseUser = contractmodels.BaseUser
+64 -5
View File
@@ -9,10 +9,11 @@ package storage
import (
"log"
"sync"
"github.com/go-admin-team/go-admin-core/v2/captcha"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/captcha"
)
// Setup 配置storage组件
@@ -34,17 +35,75 @@ func setupCaptcha() {
captcha.SetStore(captcha.NewCacheStore(sdk.Runtime.GetCacheAdapter(), 600))
}
var (
queueMu sync.Mutex
// installed is the adapter setupQueue built, kept so the next reload can
// shut it down, and counted so a consumer can tell one from the next.
installed interface{ Shutdown() }
installedGen uint64
)
// QueueGeneration reports how many times this package has installed a queue
// adapter. It changes every time setupQueue builds a new one, which is on
// every configuration reload, and stays 0 for as long as the configuration has
// no queue section at all - in which case nothing is installed and callers are
// working with the runtime's own fallback queue.
//
// It exists because there is no way to ask for the adapter's identity from the
// outside. sdk.Runtime.GetQueueAdapter and GetQueuePrefix build a fresh
// runtime.Queue wrapper on every call, so comparing what two calls return
// compares two wrappers and never matches, however many times the underlying
// adapter has been replaced. This package creates the adapter, so this is the
// only place that knows. A counter rather than the adapter itself keeps the
// comparison on a uint64: an adapter type that is not comparable would panic
// an `==` between two interface values.
func QueueGeneration() uint64 {
queueMu.Lock()
defer queueMu.Unlock()
return installedGen
}
func setupQueue() {
if config.QueueConfig.Empty() {
return
}
if q := sdk.Runtime.GetQueueAdapter(); q != nil {
q.Shutdown()
}
queueMu.Lock()
defer queueMu.Unlock()
queueAdapter, err := config.QueueConfig.Setup()
if err != nil {
log.Fatalf("queue setup error, %s\n", err.Error())
}
previous := installed
sdk.Runtime.SetQueueAdapter(queueAdapter)
go queueAdapter.Run()
installed = queueAdapter
installedGen++
// The previous adapter goes down after the new one is installed, not
// before. Shutdown waits for its consumers to deliver what it still holds,
// and for that whole wait the runtime would otherwise be handing producers
// a queue that has stopped accepting: every Append in the window comes back
// ErrQueueClosed, and both call sites in common/middleware log it. Swapping
// first leaves no such window - a producer gets the new queue or the old
// one, and both work.
//
// Only an adapter this package installed. GetQueueAdapter never returns
// nil - with nothing configured the runtime falls back to its own memory
// queue and wraps that - so the `if q := GetQueueAdapter(); q != nil` this
// replaces was always true, and shut down the fallback queue on the very
// first start, before anything had used it.
if previous != nil {
previous.Shutdown()
}
// Deliberately not started here. Run has to come after the consumers have
// registered: the contract implementations refuse a registration once the
// queue is running (storage.ErrQueueAlreadyStarted), and the legacy
// adapter this repository still goes through swallows that error rather
// than reporting it - its own comment says the interface gives it no way
// to tell the caller. Starting here and registering afterwards is
// therefore a race that loses consumers in silence. Whoever registers is
// the one that starts it.
}
+130
View File
@@ -0,0 +1,130 @@
package storage
import (
"errors"
"os"
"testing"
"time"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
corestorage "github.com/go-admin-team/go-admin-core/v2/storage"
"github.com/go-admin-team/go-admin-core/v2/storage/queue"
)
// redisAddrEnv points these tests at a server. They are skipped without it, so
// a developer with no redis running still gets a green run - and CI sets it,
// which is the point: the ordering rule they cover is invisible on the memory
// backend, and memory is the default. A suite that only ever exercised the
// default would report success for a queue that silently drops every consumer.
const redisAddrEnv = "GO_ADMIN_TEST_REDIS_ADDR"
func redisAddr(t *testing.T) string {
t.Helper()
addr := os.Getenv(redisAddrEnv)
if addr != "" {
return addr
}
// Skipping locally is the point; skipping in CI is the failure this whole
// file exists to prevent. A workflow that renamed the variable, or dropped
// the service, would otherwise go green while these two tests quietly did
// nothing - which is the same shape as the defect they cover.
if os.Getenv("CI") != "" {
t.Fatalf("%s is not set while CI is: the redis-backed queue tests must not skip here", redisAddrEnv)
}
t.Skipf("%s is not set; skipping the redis-backed queue tests", redisAddrEnv)
return ""
}
// newRedisQueue builds the queue the same way setupQueue does - through
// config.QueueConfig.Setup - so that what is under test is the adapter this
// repository actually gets, LegacyQueueAdapter and all, rather than a redis
// client wired up by the test.
func newRedisQueue(t *testing.T, prefix string) corestorage.AdapterQueue {
t.Helper()
previous := config.QueueConfig
t.Cleanup(func() { config.QueueConfig = previous })
config.QueueConfig = &config.Queue{
Redis: &config.RedisQueue{
RedisOptions: config.RedisOptions{Addr: redisAddr(t)},
Group: prefix,
KeyPrefix: prefix,
},
}
q, err := config.QueueConfig.Setup()
if err != nil {
t.Fatalf("queue setup: %v", err)
}
t.Cleanup(q.Shutdown)
return q
}
func message(t *testing.T, stream string) corestorage.Messager {
t.Helper()
m := &queue.Message{}
m.SetStream(stream)
m.SetValues(map[string]interface{}{"hello": "world"})
return m
}
// Registered first, then started: the consumer gets the message. This is the
// order setupQueue and attachQueueConsumers now produce between them.
func TestRedisQueueDeliversToAConsumerRegisteredBeforeTheStart(t *testing.T) {
stream := "t-ordered"
q := newRedisQueue(t, "gotest-ordered")
got := make(chan struct{}, 1)
q.Register(stream, func(corestorage.Messager) error {
select {
case got <- struct{}{}:
default:
}
return nil
})
go q.Run()
// Give Start a moment to reach its read loop before publishing.
time.Sleep(500 * time.Millisecond)
if err := q.Append(message(t, stream)); err != nil {
t.Fatalf("append: %v", err)
}
select {
case <-got:
case <-time.After(15 * time.Second):
t.Fatal("the consumer never received the message")
}
}
// Started first, then registered: the registration is refused and every
// publish afterwards fails.
//
// Subscribe answers ErrQueueAlreadyStarted, and LegacyQueueAdapter.Register
// returns nothing, so the caller cannot know - that part is silent. What is not
// silent is the consequence: no consumer group was created, so Publish refuses
// the topic with ErrNoHandler on every single request, and go-admin's call
// sites log that at error level while the login and operation log rows are
// never written.
//
// This is the test the memory backend cannot provide. queue.Memory's Register
// starts another consumer goroutine whatever the state, so the same code passes
// there - which is how the defect survived, memory being the default.
func TestRedisQueueRefusesAConsumerRegisteredAfterTheStart(t *testing.T) {
stream := "t-late"
q := newRedisQueue(t, "gotest-late")
go q.Run()
time.Sleep(500 * time.Millisecond)
q.Register(stream, func(corestorage.Messager) error { return nil })
err := q.Append(message(t, stream))
if err == nil {
t.Fatal("a message was accepted for a topic whose registration came after Start; " +
"if the backend now accepts late registration, the ordering rule in setupQueue can be revisited")
}
if !errors.Is(err, corestorage.ErrNoHandler) {
t.Fatalf("append failed with %v, want %v - the test is meant to pin the "+
"missing-consumer path, not any error at all", err, corestorage.ErrNoHandler)
}
}
+161
View File
@@ -0,0 +1,161 @@
package storage
import (
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
corestorage "github.com/go-admin-team/go-admin-core/v2/storage"
"github.com/go-admin-team/go-admin-core/v2/storage/queue"
)
// sampleSize is how many publishes have to land inside the reload before the
// measurement is taken. Waiting on the count rather than on wall clock keeps
// the window the test covers the same on a loaded runner as on an idle one.
const sampleSize = 200
func swapMsg() corestorage.Messager {
m := new(queue.Message)
m.SetStream("t")
m.SetValues(map[string]interface{}{"a": "b"})
return m
}
// A reload must never leave producers holding a queue that has stopped
// accepting.
//
// Shutdown waits for its consumers to deliver what the queue still holds. Taking
// the old adapter down before installing the new one meant the runtime pointed
// at a closed queue for that entire wait: every Append in the window came back
// ErrQueueClosed, and both call sites in common/middleware log it at error
// level. Installing first leaves no window - a producer gets the new queue or
// the old one, and both accept.
//
// The difference is only visible during that wait, which is why the test holds
// a consumer rather than checking the state after Setup has returned: by then
// the two orders look identical.
//
// One refusal survives the fix and is not something this ordering can reach.
// GetQueuePrefix hands back 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 one. That window is one call wide and 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. The test publishes from a single goroutine, so at most one of its
// calls can straddle the swap - which is what makes "more than one" the line
// between the two orders rather than a tolerance.
func TestAReloadNeverPointsProducersAtAClosedQueue(t *testing.T) {
prevQ, prevC := config.QueueConfig, config.CacheConfig
prevRuntime := sdk.Runtime
prevInstalled, prevGen := installed, installedGen
t.Cleanup(func() {
config.QueueConfig, config.CacheConfig = prevQ, prevC
sdk.Runtime = prevRuntime
queueMu.Lock()
installed, installedGen = prevInstalled, prevGen
queueMu.Unlock()
})
sdk.Runtime = runtime.NewConfig()
config.CacheConfig = &config.Cache{Memory: struct{}{}}
// Sized so the buffer cannot fill while the consumer is held: a full queue
// returns an error of its own, and this test needs every error other than
// ErrQueueClosed to mean something it does not model has happened.
config.QueueConfig = &config.Queue{Memory: &config.QueueMemory{PoolSize: 4096}}
Setup()
// A consumer that will not finish until this test lets it, so the reload's
// Shutdown has something to wait for.
release := make(chan struct{})
consuming := make(chan struct{})
var picked sync.Once
first := sdk.Runtime.GetQueuePrefix("")
first.Register("t", func(corestorage.Messager) error {
picked.Do(func() { close(consuming) })
<-release
return nil
})
go first.Run()
for i := 0; i < 4; i++ {
if err := first.Append(swapMsg()); err != nil {
t.Fatalf("seed append %d: %v", i, err)
}
}
select {
case <-consuming:
case <-time.After(10 * time.Second):
t.Fatal("the consumer never picked a message up, so the reload has nothing to wait for")
}
reloaded := make(chan struct{})
go func() { Setup(); close(reloaded) }()
// Publish continuously while the reload is in progress.
var refused atomic.Int64
var attempts atomic.Int64
unexpected := make(chan error, 1)
stop := make(chan struct{})
// publishing is closed by the producer on its way out. The test joins on it
// before returning: t.Cleanup restores sdk.Runtime, and a producer still in
// flight would be reading the variable that restore writes.
publishing := make(chan struct{})
go func() {
defer close(publishing)
for {
select {
case <-stop:
return
default:
}
attempts.Add(1)
err := sdk.Runtime.GetQueuePrefix("").Append(swapMsg())
switch {
case err == nil:
case errors.Is(err, corestorage.ErrQueueClosed):
refused.Add(1)
default:
// Kept rather than counted: an Append refused for some other
// reason would otherwise leave refused at zero and the test
// green while nothing was reaching a queue at all.
select {
case unexpected <- err:
default:
}
}
time.Sleep(time.Millisecond)
}
}()
deadline := time.After(30 * time.Second)
for attempts.Load() < sampleSize {
select {
case <-deadline:
t.Fatalf("only %d publishes landed inside the reload; the window was never sampled", attempts.Load())
case <-time.After(time.Millisecond):
}
}
close(release)
select {
case <-reloaded:
case <-time.After(30 * time.Second):
t.Fatal("the reload never finished")
}
close(stop)
<-publishing
select {
case err := <-unexpected:
t.Fatalf("a publish failed for a reason this test does not model: %v", err)
default:
}
if n := refused.Load(); n > 1 {
t.Errorf("%d of %d publishes during the reload were refused: producers were pointed at the closed queue",
n, attempts.Load())
}
}
+53
View File
@@ -0,0 +1,53 @@
package storage
import (
"testing"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
)
// Issue #892: a configuration reload replaces the queue adapter and the
// consumers registered against the previous one are attached to a queue nobody
// publishes to any more.
//
// The fix has two halves. attachQueueConsumers gives a new queue its own
// consumers and the same queue none, which cmd/api covers against a queue the
// test controls. This is the other half: that a reload actually produces a new
// queue for it to notice. Setup is what config re-runs on every change, so
// calling it twice is what a reload does to this package.
func TestSetupBumpsTheQueueGenerationOnEveryReload(t *testing.T) {
// Setup writes the process-wide sdk.Runtime - the cache and queue adapters -
// and this package's own record of what it installed. Restoring all of it
// keeps the test from deciding what a later test in this binary sees,
// which is the same isolation cmd/api's freshRuntime provides.
prevQ, prevC := config.QueueConfig, config.CacheConfig
prevRuntime := sdk.Runtime
prevInstalled, prevGen := installed, installedGen
t.Cleanup(func() {
config.QueueConfig, config.CacheConfig = prevQ, prevC
sdk.Runtime = prevRuntime
queueMu.Lock()
installed, installedGen = prevInstalled, prevGen
queueMu.Unlock()
})
sdk.Runtime = runtime.NewConfig()
config.CacheConfig = &config.Cache{Memory: struct{}{}}
config.QueueConfig = &config.Queue{Memory: &config.QueueMemory{PoolSize: 10}}
before := QueueGeneration()
Setup()
first := QueueGeneration()
Setup()
second := QueueGeneration()
t.Logf("before=%d first=%d second=%d", before, first, second)
if first == before {
t.Fatal("the first Setup did not install a queue")
}
if second == first {
t.Fatal("a second Setup - which is what a configuration reload does - did not install a new one")
}
}
+1 -1
View File
@@ -336,6 +336,6 @@ INSERT INTO sys_post (post_id, post_name, post_code, sort, status, remark, creat
(2, '首席技术执行官', 'CTO', 2, '2','首席技术执行官', 1, 1,'2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL),
(3, '首席运营官', 'COO', 3, '2','测试工程师', 1, 1,'2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
INSERT INTO sys_role (role_id, role_name, status, role_key, role_sort, flag, remark, admin, data_scope, create_by, update_by, created_at, updated_at, deleted_at)VALUES
(1, '系统管理员', '2', 'admin', 1, '', '', 1, '', 1, 1, '2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
(1, '系统管理员', '2', 'admin', 1, '', '', 1, '1', 1, 1, '2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
INSERT INTO sys_user VALUES (1, 'admin', '$2a$10$/Glr4g9Svr6O0kvjsRJCXu3f0W8/dsP3XZyVNi1019ratWpSPMyw.', 'zhangwj', '13818888888', 1, '', '', '1', '1@qq.com', 1, 1, '', '2', 1, 1, '2021-05-13 19:56:37.914', '2021-05-13 19:56:40.205', NULL);
-- 数据完成 ;
+1 -1
View File
@@ -318,6 +318,6 @@ INSERT INTO sys_menu_api_rule VALUES (46, 156);
INSERT INTO sys_post VALUES (1, '首席执行官', 'CEO', 0, '2','首席执行官', 1, 1, '2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
INSERT INTO sys_post VALUES (2, '首席技术执行官', 'CTO', 2, '2','首席技术执行官', 1, 1,'2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
INSERT INTO sys_post VALUES (3, '首席运营官', 'COO', 3, '2','测试工程师', 1, 1,'2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
INSERT INTO sys_role VALUES (1, '系统管理员', '2', 'admin', 1, '', '', true, '', 1, 1, '2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
INSERT INTO sys_role VALUES (1, '系统管理员', '2', 'admin', 1, '', '', true, '1', 1, 1, '2021-05-13 19:56:37.913', '2021-05-13 19:56:37.913', NULL);
INSERT INTO sys_user VALUES (1, 'admin', '$2a$10$/Glr4g9Svr6O0kvjsRJCXu3f0W8/dsP3XZyVNi1019ratWpSPMyw.', 'zhangwj', '13818888888', 1, '', '', '1', '1@qq.com', 1, 1, '', '2', 1, 1, '2021-05-13 19:56:37.914', '2021-05-13 19:56:40.205', NULL);
-- 数据完成 ;
+136
View File
@@ -1,5 +1,10 @@
package config
import (
"fmt"
"strings"
)
var ExtConfig Extend
// Extend 扩展配置
@@ -13,6 +18,7 @@ type Extend struct {
AMap AMap // 这里配置对应配置文件的结构即可
FileStore FileStore
RateLimit RateLimit
Shutdown Shutdown
}
// DefaultInboundQPS is the limit applied when nothing is configured. It is the
@@ -72,3 +78,133 @@ type ObjectStore struct {
func (o ObjectStore) Configured() bool {
return o.Endpoint != "" && o.AccessKeyID != "" && o.AccessKeySecret != "" && o.BucketName != ""
}
// Default budgets for a graceful shutdown, in seconds. Each applies to the
// matching field of extend.shutdown when that field is absent, and together
// they are what the process spent before the section existed - so a deployment
// that configures nothing keeps the shutdown it already had.
//
// The drain default is zero deliberately. The three budgets are spent one
// after the other, and once their sum reaches the orchestrator's stop grace
// period the process is killed part-way through its cleanup callbacks, which
// is worse than not draining at all. `docker stop` allows ten seconds by
// default and 5+3 already leaves little room, so a non-zero default here would
// slow down every existing shutdown to buy something only a load balancer that
// polls /ready can collect.
const (
DefaultDrainSeconds = 0
DefaultServerSeconds = 5
DefaultCleanupSeconds = 3
)
// Shutdown is how long a graceful shutdown may spend, stage by stage.
//
// extend:
// shutdown:
// drain: 0
// server: 5
// cleanup: 3
// grace: 30
//
// Every field is a pointer for the reason RateLimit.InboundQPS is: nil means
// "not configured" and takes the default, while a value that was written down
// is taken literally, zero included. Without that separation `server: 0` - do
// not wait for in-flight requests, which is a reasonable thing to ask under a
// very short grace period - could not be said at all, and `drain: 0` would
// have to mean something different from `server: 0` in the same section.
type Shutdown struct {
// Drain is how long to keep serving normally after a stop signal arrives.
// Throughout it /ready answers 503 and keep-alive is switched off, which
// is what gives whatever routes traffic here time to stop routing it
// before the listener closes. Zero is no window: the readiness flip and
// the listener closing are then microseconds apart and nothing observes
// the first.
//
// What the window is worth depends on who does the removing and on what
// basis; the package comment in common/health has the two cases, and they
// do not want the same value.
Drain *int
// Server is how long the server waits for in-flight requests once the
// listener is closed.
Server *int
// Cleanup is how long the BeforeExit callbacks get after that.
Cleanup *int
// Grace is the stop grace period the orchestrator gives this process -
// `docker stop --timeout`, or terminationGracePeriodSeconds. Nothing reads
// it during a shutdown; it exists so start-up can say whether the budget
// fits inside it. Absent means no comparison is made, because the
// reference values differ threefold between runtimes and a fixed threshold
// would warn about configurations that are correct.
Grace *int
}
// ShutdownBudget is what a shutdown will actually spend, in seconds, after the
// fallbacks have been applied.
type ShutdownBudget struct {
Drain int
Server int
Cleanup int
// Grace is zero when extend.shutdown.grace was not configured.
Grace int
}
// Budget resolves the configured section into the values that will be spent.
//
// A negative is refused rather than corrected. A wait cannot be negative, so
// there is no reading of one to honour, and quietly turning it into zero would
// be the failure this whole section exists to remove: written down, accepted,
// and not what happens. It is returned as an error rather than reported here
// so that the rule can be checked without ending the process.
func (s Shutdown) Budget() (ShutdownBudget, error) {
var negative []string
for _, f := range []struct {
name string
value *int
}{
{"drain", s.Drain},
{"server", s.Server},
{"cleanup", s.Cleanup},
{"grace", s.Grace},
} {
if f.value != nil && *f.value < 0 {
negative = append(negative, fmt.Sprintf("%s: %d", f.name, *f.value))
}
}
if len(negative) > 0 {
return ShutdownBudget{}, fmt.Errorf(
"extend.shutdown was given a negative number of seconds (%s); "+
"a wait cannot be negative, and 0 is how to say \"do not wait\"",
strings.Join(negative, ", "))
}
return ShutdownBudget{
Drain: budgetSeconds(s.Drain, DefaultDrainSeconds),
Server: budgetSeconds(s.Server, DefaultServerSeconds),
Cleanup: budgetSeconds(s.Cleanup, DefaultCleanupSeconds),
Grace: budgetSeconds(s.Grace, 0),
}, nil
}
func budgetSeconds(configured *int, fallback int) int {
if configured != nil {
return *configured
}
return fallback
}
// Total is the whole of the shutdown, since the three stages run one after the
// other.
func (b ShutdownBudget) Total() int { return b.Drain + b.Server + b.Cleanup }
// Overrun reports how many seconds have to be found for the budget to fit
// inside the configured grace period. It is zero when no grace period was
// configured and when the budget already fits.
//
// Fitting means strictly less: the grace period is when SIGKILL is sent, so a
// budget that ends exactly then leaves the last callback no time to return.
func (b ShutdownBudget) Overrun() int {
if b.Grace <= 0 || b.Total() < b.Grace {
return 0
}
return b.Total() - b.Grace + 1
}
+167 -1
View File
@@ -1,6 +1,9 @@
package config
import "testing"
import (
"strings"
"testing"
)
func TestObjectStoreConfigured(t *testing.T) {
if (ObjectStore{}).Configured() {
@@ -32,3 +35,166 @@ func TestRateLimitThreshold(t *testing.T) {
t.Errorf("configured limit = %v, want %v", got, custom)
}
}
func ptr(v int) *int { return &v }
// The zero-value rule is the same for all four fields, and it is the one the
// section would otherwise need a paragraph of documentation to survive: nil
// takes the default, a number that was written down is spent literally. A
// `server: 0` that quietly became five seconds would be the same class of
// failure this whole batch is about - configuration accepted and not applied.
func TestShutdownBudgetFallbacks(t *testing.T) {
for _, tc := range []struct {
name string
in Shutdown
want ShutdownBudget
}{
{
// What an existing settings.yml hits after an upgrade: no
// extend.shutdown section at all, and therefore the shutdown it
// already had.
name: "nothing configured",
in: Shutdown{},
want: ShutdownBudget{Drain: 0, Server: 5, Cleanup: 3},
},
{
name: "all four configured",
in: Shutdown{Drain: ptr(10), Server: ptr(8), Cleanup: ptr(4), Grace: ptr(30)},
want: ShutdownBudget{Drain: 10, Server: 8, Cleanup: 4, Grace: 30},
},
{
// The case a plain int could not express: do not wait for
// in-flight requests, which is a reasonable thing to ask for when
// the grace period is very short.
name: "explicit zeros are spent, not replaced",
in: Shutdown{Drain: ptr(0), Server: ptr(0), Cleanup: ptr(0)},
want: ShutdownBudget{Drain: 0, Server: 0, Cleanup: 0},
},
{
name: "one field configured, the rest default",
in: Shutdown{Drain: ptr(15)},
want: ShutdownBudget{Drain: 15, Server: 5, Cleanup: 3},
},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.in.Budget()
if err != nil {
t.Fatalf("Budget() = %v", err)
}
if got != tc.want {
t.Errorf("Budget() = %+v, want %+v", got, tc.want)
}
})
}
}
// A negative is refused, not corrected. Turning it into zero would be the
// failure this section exists to remove - written down, accepted, and not what
// happens - and there is no reading of a negative wait to honour.
//
// The last row is what makes the other four mean anything: an implementation
// that refused every value would pass them all.
func TestShutdownBudgetRefusesNegativeSeconds(t *testing.T) {
for _, tc := range []struct {
name string
in Shutdown
wantErr bool
}{
{name: "negative drain", in: Shutdown{Drain: ptr(-1)}, wantErr: true},
{name: "negative server", in: Shutdown{Server: ptr(-1)}, wantErr: true},
{name: "negative cleanup", in: Shutdown{Cleanup: ptr(-1)}, wantErr: true},
{name: "negative grace", in: Shutdown{Grace: ptr(-1)}, wantErr: true},
{name: "explicit zeros are not negative", in: Shutdown{Drain: ptr(0), Server: ptr(0), Cleanup: ptr(0)}},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := tc.in.Budget()
if tc.wantErr && err == nil {
t.Fatal("Budget() accepted a negative number of seconds")
}
if !tc.wantErr && err != nil {
t.Fatalf("Budget() = %v, want the zeros taken literally", err)
}
})
}
}
// The message has to name every field that is wrong, not the first one: a
// caller who fixes one and gets the same error back learns to distrust it.
func TestShutdownBudgetNamesEveryNegativeField(t *testing.T) {
_, err := Shutdown{Drain: ptr(-1), Server: ptr(-30), Cleanup: ptr(-3), Grace: ptr(-9)}.Budget()
if err == nil {
t.Fatal("Budget() accepted four negative values")
}
for _, name := range []string{"drain", "server", "cleanup", "grace"} {
if !strings.Contains(err.Error(), name) {
t.Errorf("%q does not name %s", err, name)
}
}
}
// The sum is what has to fit inside the orchestrator's grace period, and the
// verdict is only reached when a grace period was configured. A fixed
// threshold instead would warn about the manifest this repository ships.
func TestShutdownBudgetOverrun(t *testing.T) {
resolved := func(s Shutdown) ShutdownBudget {
b, err := s.Budget()
if err != nil {
t.Fatalf("Budget() = %v", err)
}
return b
}
for _, tc := range []struct {
name string
budget ShutdownBudget
wantTotal int
wantOverrun int
}{
{
name: "defaults, no grace period to judge against",
budget: resolved(Shutdown{}),
wantTotal: 8,
},
{
name: "fits with room to spare",
budget: resolved(Shutdown{Drain: ptr(10), Grace: ptr(30)}),
wantTotal: 18,
},
{
// Equal is not a fit. The grace period is when SIGKILL is sent, so
// a budget that ends exactly then leaves the last callback no time
// to return.
name: "exactly equal still overruns",
budget: resolved(Shutdown{Drain: ptr(22), Grace: ptr(30)}),
wantTotal: 30,
wantOverrun: 1,
},
{
name: "over by five",
budget: resolved(Shutdown{Drain: ptr(26), Grace: ptr(30)}),
wantTotal: 34,
wantOverrun: 5,
},
{
// The reason the threshold is a configured value rather than a
// constant: the same budget is wrong under `docker stop` and right
// under a Kubernetes default.
name: "the docker default is the tighter one",
budget: resolved(Shutdown{Drain: ptr(10), Grace: ptr(10)}),
wantTotal: 18,
wantOverrun: 9,
},
} {
t.Run(tc.name, func(t *testing.T) {
if got := tc.budget.Total(); got != tc.wantTotal {
t.Errorf("Total() = %d, want %d", got, tc.wantTotal)
}
if got := tc.budget.Overrun(); got != tc.wantOverrun {
t.Errorf("Overrun() = %d, want %d", got, tc.wantOverrun)
}
if over := tc.budget.Overrun(); over > 0 && tc.budget.Total()-over >= tc.budget.Grace {
t.Errorf("Overrun() = %d does not bring %d under the %d grace period",
over, tc.budget.Total(), tc.budget.Grace)
}
})
}
}
+38
View File
@@ -0,0 +1,38 @@
package config
import (
"os"
"regexp"
"testing"
)
// The seed files write the built-in admin role's data_scope inline in a SQL
// INSERT, not through Go code, so nothing else in the test suite exercises
// this value. It has to be one of the five scopes actions.Permission
// recognizes: PRD 006 F14/H2 made every other value match no rows, and an
// empty string - which is what these files shipped before that fix - is one
// such value. Without this the shipped admin account would silently lose
// all visibility the moment a deployment turns EnableDP on.
func TestSeedAdminRoleHasAValidDataScope(t *testing.T) {
cases := map[string]*regexp.Regexp{
"db.sql": regexp.MustCompile(
`INSERT INTO sys_role VALUES \(1, '系统管理员', '2', 'admin', 1, '', '', true, '([^']*)'`),
"db-sqlserver.sql": regexp.MustCompile(
`\(1, '系统管理员', '2', 'admin', 1, '', '', 1, '([^']*)'`),
}
valid := map[string]bool{"1": true, "2": true, "3": true, "4": true, "5": true}
for file, pattern := range cases {
data, err := os.ReadFile(file)
if err != nil {
t.Fatalf("%s: %v", file, err)
}
m := pattern.FindSubmatch(data)
if m == nil {
t.Fatalf("%s: admin role INSERT not found; the regex may be out of date", file)
}
if scope := string(m[1]); !valid[scope] {
t.Errorf("%s: admin role data_scope = %q, want one of 1-5", file, scope)
}
}
}
+34
View File
@@ -82,6 +82,40 @@ settings:
# 会被负载均衡、监控和压测统计成成功)。
rateLimit:
inboundQPS: 200
# shutdown budgets, in seconds. The three are spent one after the other,
# and their sum has to stay inside the stop grace period the orchestrator
# allows - once it is up, SIGKILL arrives part-way through the cleanup
# callbacks, which is worse than not draining at all.
shutdown:
# How long to keep serving normally after a stop signal arrives. For that
# long /ready answers 503 and keep-alive is switched off, which is what
# gives a load balancer time to take this instance out of rotation before
# the listener closes.
#
# What to set depends on who removes this instance and on what basis. A
# load balancer that polls /ready itself needs at least "check interval x
# failure threshold + however long removal takes to apply". A Kubernetes
# Service removes the endpoint when the Pod is deleted, concurrently with
# SIGTERM and regardless of what the probe returns, so here this covers
# the delay in that removal reaching every node.
#
# 0 by default: a deployment that leaves this alone shuts down exactly as
# it did before this section existed. It also means /ready never reports
# draining - the flip and the closed listener are microseconds apart, and
# no poller reads anything in between.
drain: 0
# How long to wait for in-flight requests once the listener is closed.
server: 5
# How long the BeforeExit cleanup callbacks get after that.
cleanup: 3
# The stop grace period the orchestrator gives this process - `docker stop
# --timeout`, or terminationGracePeriodSeconds. Nothing reads it during a
# shutdown; start-up uses it to say whether the three budgets above fit
# inside it, and warns when they do not. Left out, nothing is compared:
# the reference values are 10s for docker and 30s for Kubernetes, three
# times apart, and a fixed threshold would warn about correct
# configurations.
#grace: 30
# fileStore 对象存储。上传接口的 source 参数决定走哪一家:
# source=1 只存本地,source=2 阿里云 OSS,source=3 七牛 Kodo
# 没有填的那一家在被请求时会返回明确错误,不会静默存到别处。
+34
View File
@@ -66,6 +66,40 @@ settings:
# 会被负载均衡、监控和压测统计成成功)。
rateLimit:
inboundQPS: 200
# shutdown budgets, in seconds. The three are spent one after the other,
# and their sum has to stay inside the stop grace period the orchestrator
# allows - once it is up, SIGKILL arrives part-way through the cleanup
# callbacks, which is worse than not draining at all.
shutdown:
# How long to keep serving normally after a stop signal arrives. For that
# long /ready answers 503 and keep-alive is switched off, which is what
# gives a load balancer time to take this instance out of rotation before
# the listener closes.
#
# What to set depends on who removes this instance and on what basis. A
# load balancer that polls /ready itself needs at least "check interval x
# failure threshold + however long removal takes to apply". A Kubernetes
# Service removes the endpoint when the Pod is deleted, concurrently with
# SIGTERM and regardless of what the probe returns, so here this covers
# the delay in that removal reaching every node.
#
# 0 by default: a deployment that leaves this alone shuts down exactly as
# it did before this section existed. It also means /ready never reports
# draining - the flip and the closed listener are microseconds apart, and
# no poller reads anything in between.
drain: 0
# How long to wait for in-flight requests once the listener is closed.
server: 5
# How long the BeforeExit cleanup callbacks get after that.
cleanup: 3
# The stop grace period the orchestrator gives this process - `docker stop
# --timeout`, or terminationGracePeriodSeconds. Nothing reads it during a
# shutdown; start-up uses it to say whether the three budgets above fit
# inside it, and warns when they do not. Left out, nothing is compared:
# the reference values are 10s for docker and 30s for Kubernetes, three
# times apart, and a fixed threshold would warn about correct
# configurations.
#grace: 30
cache:
# redis:
# addr: 127.0.0.1:6379
+68
View File
@@ -0,0 +1,68 @@
package config
import (
"testing"
coreconfig "github.com/go-admin-team/go-admin-core/v2/config"
"github.com/go-admin-team/go-admin-core/v2/config/source/file"
)
// shippedSettings is the shape the loader fills in, cut down to the part under
// test. The reader is JSON-based, so the keys are matched against field names
// case-insensitively - which is exactly the matching that silently drops a
// section the struct has no field for.
type shippedSettings struct {
Settings struct {
Extend Extend
}
}
func (*shippedSettings) OnChange() {}
// The shutdown section has to arrive where it is read from, and with the
// values the documentation claims.
//
// This is the failure this batch exists to remove, one level up: the loader
// discards keys no field matches, without an error and without a log line, so
// a section put in the wrong place is written, accepted, and never applied.
// Nothing but loading the shipped file through the real loader can tell the
// two apart - the struct compiles either way.
//
// The values are asserted as well as the arrival. A settings.yml that shipped
// a different default from config.Default*Seconds would give two answers to
// "what does an unconfigured deployment spend", and the file is the one people
// read.
func TestTheShippedSettingsReachTheShutdownStruct(t *testing.T) {
for _, name := range []string{"settings.yml", "settings.full.yml"} {
t.Run(name, func(t *testing.T) {
var loaded shippedSettings
c, err := coreconfig.NewConfig(
coreconfig.WithSource(file.NewSource(file.WithPath(name))),
coreconfig.WithEntity(&loaded),
)
if err != nil {
t.Fatalf("load %s: %v", name, err)
}
t.Cleanup(func() { _ = c.Close() })
s := loaded.Settings.Extend.Shutdown
if s.Drain == nil || s.Server == nil || s.Cleanup == nil {
t.Fatalf("%s left extend.shutdown unfilled (%+v); the section is written but nothing reads it",
name, s)
}
want := ShutdownBudget{
Drain: DefaultDrainSeconds,
Server: DefaultServerSeconds,
Cleanup: DefaultCleanupSeconds,
}
got, err := s.Budget()
if err != nil {
t.Fatalf("%s does not resolve: %v", name, err)
}
if got != want {
t.Errorf("%s ships %+v, want the documented defaults %+v", name, got, want)
}
})
}
}
+7
View File
@@ -7,6 +7,13 @@ services:
restart: always
ports:
- 8000:8000
# Compose allows 10 seconds by default, and this process spends
# drain + server + cleanup from extend.shutdown before it exits - 8 out of
# the box, more for anyone who configures a drain window. Past the deadline
# it is sent SIGKILL and the cleanup callbacks are cut off part-way
# through. checksilent's docker-stop-cuts-shutdown-short check compares
# this against config/settings.yml.
stop_grace_period: 30s
volumes:
- ./config/:/go-admin-api/config/
- ./static/:/go-admin-api/static/
+808
View File
@@ -0,0 +1,808 @@
# 公共契约面
> 本文写给**第三方应用作者**:你写一个装进 go-admin 的业务模块,可以依赖什么、
> 怎么接进来、哪些约定不遵守会**不报错地出错**。
>
> 主仓贡献者的编码约定见根目录 `AGENTS.md`,设计取舍见 `docs/architecture.md`。
---
## 契约面在 core,不在 go-admin
这份文档以前列的是 go-admin 自己的四个包(`common/actions` 等),依据写的是
「把 `app/demo` 的 import 去重之后恰好就是这四个」。
**那个依据是错的,而且错的方向是把人引向依赖宿主。**
go-admin 的使用方式是 clone / fork:每个使用者拿到的是一整份代码,然后**改它**。
应用如果依赖 `go-admin/common/actions`,它依赖的是一个**每个使用者都不一样、
而且随时在变**的东西——你没有办法测试自己的应用在别人改过的 fork 上能不能编译。
还有一条更硬的:`go-admin` 这个 module path 没有点号,
按 Go 的规则**不是合法的可解析模块路径**:
```
$ go get go-admin/common/models
go: malformed module path "go-admin/common/models": missing dot in first path element
```
想 import 它就必须写 `replace`,而**非主模块的 `replace` 会被忽略**——
你在自己应用里写的 replace 对使用者不生效。所以「应用 require go-admin」
这条路不是不优雅,是走不通。
契约面因此落在 **go-admin-core**:那是唯一一个大家都一样、有版本号、
不会被使用者随手改的东西。
---
## 承诺稳定的包
全部在 `github.com/go-admin-team/go-admin-core/v2` 下:
| 包 | 用途 |
|---|---|
| `sdk/contract/models` | `Model` / `ControlBy` / `ModelTime` / `ActiveRecord` / `BaseUser` / `Migration`、`sys_menu.menu_type` 的三个枚举值 |
| `sdk/contract/dto` | `Pagination` / `MakeCondition` / `Paginate` / `OrderDest` / `ObjectById`、`Index` 与 `Control` 接口 |
| `sdk/contract/actions` | 数据权限设施:`DataPermission` / `Permission` / `PermissionAction` / `GetPermissionFromContext`、五个 `DataScope*` 常量与 `IsValidDataScope` |
| `sdk/contract/migration` | `Registry` / `AppRegistrar` / `ForApp` / `SetVersion` / `GetFilename` |
| `sdk/contract/seed` | `MenuSpec` / `ApiSpec` / `Seeder` / `SeedMenus`——往侧边栏和接口表里登记自己 |
| `sdk/pkg` | `GetOrm(c)`:从请求上下文取本租户的数据库连接 |
| `sdk/api`、`sdk/service` | 可选的 Api / Service 基类 |
| `response` | `OK` / `Error` / `PageOK`:响应格式 |
| `jwtauth/user` | 从 token 取当前用户身份 |
| `sdk/runtime` | 中间件 key 常量与 `GetHandlerFunc`:复用宿主已注册的鉴权链 |
`sdk/contract/` 这个前缀的含义就是「**承诺对应用稳定**的那一面」。core 里
`sdk/` 下的其他包是框架基础设施,语义不同——上表逐个列了名字,
**不要因为「都在 core 里」就认为是契约面**。
"稳定"的含义:**在 core 的 `v2.x` 内不做破坏性变更**。新增导出符号不算破坏;
改签名、改语义、删除导出符号算,会走 major 版本并在 release note 里单列。
准确的语义以 core 那份文档为准:
[go-admin-core `docs/contract.md`](https://github.com/go-admin-team/go-admin-core/blob/main/docs/contract.md)。
本文写的是宿主这一侧——它管不着的那些。
### go-admin 自己的包
`go-admin/common/models`、`common/dto`、`common/actions` 里的契约类型现在是
**指向 core 的类型别名**(`type X = corepkg.X`),主仓和所有 fork 的存量代码
一行不用改。别名在编译期就是同一个类型,不是"兼容层"。
但**新写的应用不要 import 它们**——那样就又依赖上宿主了。
---
## 契约面是三层,不是一层
划分依据不是"应用会 import 哪些包",而是**"哪一条不遵守会静默出错"**:
| 层 | 内容 | 判据 |
|---|---|---|
| **一 · 必须遵守** | 路由注册、从 context 取库、响应 shape、`ControlBy`/`ModelTime`、鉴权、数据权限、事务范式 | 不遵守 → **不报错,行为悄悄不对** |
| **二 · 可选便利** | `api.Api`、`service.Service`、CRUD Action、`MakeCondition` | 用不用都对 |
| **三 · 今天空白** | 应用间调用、领域事件、缓存租户隔离 | **没有。别自己发明** |
**框架不强制任何一层抽象。** 一个不用任何便利层的 handler 完全合法:
```go
func handler(c *gin.Context) {
db, err := pkg.GetOrm(c)
if err != nil {
response.Error(c, 500, err, "")
return
}
var list []MyModel
if err := db.Find(&list).Error; err != nil {
response.Error(c, 500, err, "")
return
}
response.OK(c, list, "")
}
```
第一层则是不管你用不用便利层都要遵守的,逐条写在下面,每条都附**不遵守会怎样**。
---
## 第一层:不遵守就静默出错
### 1. 路由注册
见下方「注册路由」一节。
**不遵守会怎样**:注册表在 `RunAppRouters()` 之后就封闭了,晚到的注册被丢弃,
只记一条 ERROR 日志。包级 `AppRouters` 连这个都没有——它就是一个普通 slice,
什么时候 append 都"成功",启动钩子之后 append 的那些永远不会执行,**且不出声**。
### 2. 数据库连接从 context 取,不用全局变量
```go
db, err := pkg.GetOrm(c) // 唯一正确的取法
```
`common/middleware/db.go` 在每个请求上按 `c.Request.Host` 挑出本租户的连接
放进 context:
```go
c.Set("db", sdk.Runtime.GetDbByTenant(c.Request.Host).WithContext(c))
```
**不遵守会怎样**:连接是**按租户注册**的(`SetDbByTenant(host, db)`),
`GetOrm(c)` 按 `c.Request.Host` 挑。你要是在启动时把某个连接存进包级变量再一直用,
多租户部署下所有租户的读写就都落到那一个库上——不报错、不告警,数据串了才发现。
这个坑在本仓库真踩过:`common/global.Driver` 取的是启动循环
**迭代到的第一个**库的驱动(`common/database/initialize.go`),
而 Go 的 map 迭代顺序是随机的——两个库用不同驱动时,那个值每次启动都可能不一样。
所以「一个进程一个库」这个假设不要写进任何一行代码。
### 3. 响应 shape
一律用 `response.OK` / `response.Error` / `response.PageOK`,不要自己
`c.JSON`。它们发出去的形状是:
```jsonc
// 成功
{"requestId": "...", "code": 200, "data": {...}}
// 分页:data 里再套一层
{"requestId": "...", "code": 200, "data": {"count": 42, "pageIndex": 1, "pageSize": 10, "list": [...]}}
// 失败
{"requestId": "...", "code": 500, "msg": "...", "status": "error"}
```
**HTTP 状态码永远是 200**,业务码在 body 的 `code` 里——这是既定行为,
`response.Error` 走的是 `c.AbortWithStatusJSON(http.StatusOK, res)`。
**不遵守会怎样**:前端 `src/utils/request.ts` 的响应拦截器只读 body 的 `code`,
`code !== 200` 就弹一条 `msg` 内容的 error toast 并 reject。你自己
`c.JSON(200, myThing)` 的话 `code` 是 `undefined`,界面上弹出来的是**一条空的
错误提示**,数据到不了页面。列表更安静:`useTable.ts` 读的是
`page?.list ?? []` 和 `page?.count ?? 0`,形状对不上就是**一张空表,零报错**。
### 4. `ControlBy` 与 `ModelTime`
每张业务表的 model 都嵌这三个:
```go
type Order struct {
models.Model // Id
// ... 你的字段 ...
models.ControlBy // CreateBy / UpdateBy
models.ModelTime // CreatedAt / UpdatedAt / DeletedAt
}
func (Order) TableName() string { return "app_order" } // 必须显式声明
```
`ControlBy` 提供 `create_by` 列,**数据权限的每一条 SQL 都 join 在它上面**。
`ModelTime` 的 `DeletedAt` 是 `soft_delete.DeletedAt`(毫秒时间戳,活行为 0,
永不为 NULL),不是 `gorm.DeletedAt`。
**不遵守会怎样**:
- 嵌了 `ControlBy` 但写入时忘了 `SetCreateBy(user.GetUserId(c))`,
`create_by` 就是 0。除「全部数据权限」外的每一档都**查不到任何数据**,
而且不报错——看起来像"这个用户还没建过数据"。
- 用错 `ModelTime` 版本(可空的 `gorm.DeletedAt`):gorm 按
`deleted_at IS NULL` 过滤,而活行里存的是 0,于是**整张表一行都查不出来**。
主仓的 `sys_columns` / `sys_tables` 真在这个状态下待过——代码生成器
一张表都列不出来,没有任何报错。`make checksilent` 的 `modeltime-mix`
就是为这条加的。
- `TableName()` 忘了写:GORM 配了 `SingularTable`,不会推导复数,表名会是
你没预料的那个。
### 5. 鉴权:用宿主已注册的中间件,不要自己造
```go
jwtCheck, ok := sdk.Runtime.GetHandlerFunc(runtime.JwtTokenCheck)
if !ok {
log.Fatal("JwtTokenCheck is not registered; is the host started via cmd/api?")
}
roleCheck, _ := sdk.Runtime.GetHandlerFunc(runtime.RoleCheck)
permCheck, _ := sdk.Runtime.GetHandlerFunc(runtime.PermissionCheck)
g := v1.Group("/order").Use(jwtCheck).Use(roleCheck).Use(permCheck)
```
三个 key 的常量在 `sdk/runtime`,宿主启动时把三个中间件注册进去。
**不遵守会怎样**:`GetHandlerFunc` 在"没注册"和"注册成了别的类型"两种情况下
都返回 `ok=false` 而不是 panic——**因为路由注册跑在 core 的 panic 护栏里面,
裸类型断言 panic 之后日志报的是"这个模块一条路由都没注册上",跟真实原因对不上**。
所以 `ok` 必须自己判,判出来要**大声失败**:一个跳过鉴权继续注册的路由,
就是一条静默的匿名可访问接口。
**宿主必须注册绑定过的闭包。** 三个 key 存的都得是 `gin.HandlerFunc`——
比如 `authMiddleware.MiddlewareFunc()`,**不是** `(*jwt.GinJWTMiddleware).MiddlewareFunc`。
后者是方法表达式,没有接收者绑在上面,取回来断言不成 `gin.HandlerFunc`,
怎么断言都做不成一个能用的 handler。
> **当前状态**:`common/middleware/init.go` 里 `RoleCheck` 与 `PermissionCheck`
> 注册的是 `AuthCheckRole()` 和 `actions.PermissionAction()`,都是绑定过的闭包,
> 取回来就能用;**`JwtTokenCheck` 注册的还是那个方法表达式**,所以今天对它
> `GetHandlerFunc` 拿到的是 `ok=false`。上面那段 `log.Fatal` 会在启动时打出来——
> 这是有意的,宁可起不来也不要一条没鉴权的路由。主仓这一处的修复见 F10,
> 修完之后本段可以删掉。
还有一条**不影响行为但影响理解**的:主仓今天四个模块各自调一次 `AuthInit()`
(`app/admin`、`app/jobs`、`app/other`、`app/demo`),也就是有四个 JWT 实例。
这不产生行为差异——配置同源(`config.JwtConfig`),JWT 校验是无状态的,
不看实例身份。但它意味着 `GetHandlerFunc(runtime.JwtTokenCheck)` 取回来的是
**最后注册进去的那一个**。要让应用拿到一个有意义的共享实例,宿主应当在注册路由
之前构造一次,而不是每个模块构造一次。
**测的时候别用 `admin` 账号。** `AuthCheckRole` 里 `rolekey == "admin"` 直接
`c.Next()`,**完全跳过 Casbin**。拿 admin 压任何鉴权路径都测不到东西。
### 6. 数据权限
两件事都要做:
```go
// 路由上挂中间件(上一节的 permCheck 就是它)
g := v1.Group("/order").Use(permCheck)
// 查询里组合 scope
p := actions.GetPermissionFromContext(c)
db.Scopes(actions.Permission(Order{}.TableName(), p)).Find(&list)
```
`sys_role.data_scope` 有五档,`Permission()` 按它拼 WHERE 条件:
| 值 | 常量 | 含义 | 条件 |
|---|---|---|---|
| `1` | `DataScopeAll` | 全部数据权限 | 不加条件 |
| `2` | `DataScopeCustom` | 自定义数据权限 | `create_by` 属于 `sys_role_dept` 关联到的部门 |
| `3` | `DataScopeDept` | 本部门 | `create_by` 属于本部门 |
| `4` | `DataScopeDeptTree` | 本部门及以下 | `create_by` 属于 `dept_path` 匹配的子树 |
| `5` | `DataScopeSelf` | 仅本人 | `create_by = 当前用户` |
自己往 `sys_role.data_scope` 写值的话先过一遍 `IsValidDataScope`——
写进去的非法值不会在写入时报错,只会在**每一次查询**里静默地什么都查不到。
**不遵守会怎样**,两种漏法的方向相反,值得分清:
- **查询里忘了组合 `Permission()`** —— 就是**全量可见**,每个角色都看得到所有人
的数据,不报错、不记日志。**这是本框架里最贵的一类静默失败**,所以那一行
`db.Scopes(...)` 不是"最佳实践",是契约。
- **组合了 `Permission()` 但路由上漏挂中间件** —— 上下文里没有 `PermissionKey`,
拿到的是零值,`DataScope` 是空串,落进下面那个 fail-closed 的 default,
结果是**一行都查不到**。方向反了,至少还看得见。
五档之外的值(空串、拼错的、还没迁移的老数据)落到 `default` 分支,
那里是 **fail closed**:加一条 `1 = 0`,什么都不返回。注意 `1`(全部数据权限)
是**显式列出的一个 case**,不是"落到 default"——两者曾经是同一条路,
于是"没配置"和"配置成看全部"产出的 SQL 一个字都不差。
`3` / `4` 两档在 `DeptId <= 0` 时同样 fail closed。原因是
`sys_dept.dept_path` 一律以 `/0/` 开头,`dept_id=0` 会把 LIKE 模式变成
`'%/0/%'`,**命中全表**——本来想表达"没有部门",实际表达的是"全部部门"。
数据权限还有一个**全局开关** `application.enabledp`,默认是 `false`。
关掉时 `Permission()` 原样返回查询、`PermissionAction()` 直接放行——
**你的应用在默认配置下测不出数据权限的任何行为**,要验证得先把它打开。
**不要自己重写这段 SQL。** 那 20 行里埋着 8 项内部知识:JWT claims 的私有键名
(`datascope` / `deptid`)、`sys_user`↔`sys_role` 的 join、`sys_role_dept`
关联表、`sys_dept.dept_path` 的 `/0/1/2/` 编码、`create_by` 的归属约定、
`enabledp` 开关、老 token 的回落逻辑。**而且写错的方向是越权。**
仓库里有过一份第二实现,`dept_path` 的匹配写成 `"%"+id+"%"` 少了两个斜杠,
`dept_id=1` 会匹配上 `/11/`、`/21/`、`/100/`——写它的人比第三方更懂这套约定,
仍然写错了。那份实现已经删掉了。
### 7. 事务范式
**业务层的事务一律用 `Transaction()` 闭包形式**:
```go
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&order).Error; err != nil {
return err // rolled back
}
return tx.Model(&stock).Where("qty >= ?", n).
UpdateColumn("qty", gorm.Expr("qty - ?", n)).Error
})
```
GORM 自己处理提交、回滚,以及 **panic 时的回滚**。
**不要照抄 `app/admin/service/sys_role.go`。** 那里有 5 处手写的
`Begin` / `defer` 写法,三个缺陷都是静默的:
```go
tx := e.Orm
if config.DatabaseConfig.Driver != "sqlite3" { // 缺陷 2
tx = e.Orm.Begin()
defer func() {
if err != nil { tx.Rollback() } else { tx.Commit() } // 缺陷 1
}()
}
```
1. **panic 时提交半截事务**——defer 只看 `err`,panic 时 `err` 仍是 nil,走的是
`Commit()`
2. **sqlite 下根本不开事务**——那一整个特判让 `tx` 就是 `e.Orm` 本身,
写一半失败留一半
3. **读 `config.DatabaseConfig.Driver`**——那是全局单库配置,多租户下不是
当前租户的驱动
缺陷 1 不止那一处:`app/admin/service/sys_dept.go`、`sys_menu.go`、
`app/other/models/tools/sys_tables.go` 用的是同一个 `defer` 写法
(没有 sqlite 特判,所以只有缺陷 1)。**整个 `Begin`/`defer` 家族都别照抄。**
同一个仓库里就有正确的参照:`cmd/migrate/migration/version/` 下 7 个迁移里
5 个用的是闭包形式(另外两个是纯 DDL 标记,DDL 在 MySQL 下本来就不进事务),
且这条路在 sqlite 下实测跑得通(`make build-sqlite`)。
主仓那些写法本批次不改,单独跟。
**并发保护用条件更新 + `RowsAffected`**,不要"先查后改":
```go
res := tx.Model(&Order{}).Where("id = ? AND status = ?", id, StatusPending).
Update("status", StatusPaid)
if res.Error != nil { return res.Error }
if res.RowsAffected == 0 { return ErrAlreadyPaid } // 别人先改了
```
---
## 第二层:可选便利
用不用都对,**不用不会出任何问题**:
| 东西 | 在哪 | 是什么 |
|---|---|---|
| `api.Api` | core `sdk/api` | 一条链式糖:`MakeContext` / `Bind` / `MakeOrm` / `OK` / `PageOK` / `Error` |
| `service.Service` | core `sdk/service` | 一个装 `Orm` / `Log` / `Cache` / `Error` 的结构体加一个 `AddError` |
| `MakeCondition` / `search` tag | core `sdk/contract/dto` | 把 DTO 上的 `search:"type:exact;column:name;table:xx"` 翻成 WHERE |
| 通用 CRUD Action | go-admin `common/actions` | `IndexAction` 等五个。**留在 go-admin,没有下沉** |
最后一行是有意的:CRUD Action 是最需要演进的一类东西(分页参数、批量操作、
软删语义、字段级权限),而 core 的每一个导出都是永久承诺——放进去容易,
拿出来不可能。想用就把那 294 行抄走,抄走的那份还能按你自己的需要改。
主仓唯一的真实业务模块 `app/admin` **一个 CRUD Action 都没用**,全是手写 Service。
`MakeCondition` 返回的是 `func(db *gorm.DB) *gorm.DB` 闭包,方言从闭包里那个
`db.Dialector.Name()` 读,**必然是本租户那个库的驱动**,不需要你设置任何东西。
---
## 第三层:今天没有的
**明说没有,别自己发明**:
| 能力 | 现状 |
|---|---|
| 应用间调用 | 零定义。A 应用要调 B 应用只能直接 import 对方的包,循环依赖就回来了 |
| 领域事件 / EventBus | 无 |
| 缓存的租户隔离 | `service.Service` 有 `Cache` 字段,**是否按租户隔离未验证**。当作没隔离来写 |
| 生命周期钩子之外的时点 | 只有下面那四个。没有「路由装好之后、开始监听之前」这一档 |
这几条留给后续批次,按真实需求补——现在凭空设计只会设计错。
如果你的应用卡在这里,在 issue 里说一声,那正是我们要的输入。
---
## 装一个应用要接两处线
后端**两处**,漏掉第二处是**静默失败**:
```go
// 1. 路由:cmd/api/<name>.go
import _ "github.com/acme/go-admin-app-order/router"
// 2. 迁移:cmd/migrate/server.go 的 import 块里
import _ "github.com/acme/go-admin-app-order/migration"
```
两个都是空导入,作用只是让那个包的 `init()` 跑起来。
**漏了第二处会怎样**:不报错。`migrate` 命令照常跑完、照常打印成功,
你的建表和种子数据**就是不执行**。等到第一个请求打过来才会看到
"表不存在",而那时排查方向已经跑偏了。
`migrate --dry-run` 是确认接线成功的最快方式——它只读,可以直接对生产库跑:
```bash
go-admin migrate --dry-run -c config/settings.yml # 你的迁移应该出现在列表里
```
带界面的应用还有第三处,在前端仓库,见下一节。
---
## 前端:菜单 `component` 必须以 `apps/` 开头
前端那一处接线是 `go-admin-ui` 的 `apps.config.mjs`——加一条
`{ code: 'order', source: '...' }`,`source` 指到你的页面目录
(兄弟目录的相对路径,或 `./node_modules/@scope/app-order/views/order`)。
`scripts/sync-apps.mjs` 会在 `pnpm dev` 与 `pnpm build` 之前把它复制进
`src/apps/<code>/`,不需要手工跑。
`src/stores/permission.ts` 的 `appPath()` **只认路径第一段是 `apps`**,
其余一律当成主仓内置视图去 `src/views/` 下找。
所以你的菜单种子里 `Component` 必须写成:
```
apps/<code>/<该应用内的相对路径>/index
```
比如 `code` 是 `order` 的应用写 `apps/order/index`(开头带不带 `/` 都行,
只看第一段)。**不能**写成 `/order/index`。
**写错会怎样**:第一段是 `order` 而不是 `apps`,前端会去找一个不存在的
`src/views/order/index.vue`,页面摔到 `AppNotInstalled` 占位组件。
但控制台打印的是 `no component at src/views/order/index.vue`——
**跟真实原因(漏了 `apps/` 前缀)对不上**,排查时很容易被这条日志带偏。
对应的前端约定写在 go-admin-ui 的 `AGENTS.md`。另外一条:`source` 目录的内容
**原样**搬进 `src/apps/<code>/`,不会在 `code` 之外再自动插一层——想要
`apps/order/index` 这种最短形式,`source` 就要直接指到该应用**这一个页面模块**
的目录,而不是应用仓库的 `views` 根目录。
---
## 注册路由
写一个 `func()` 签名的 `InitRouter`(照抄 `app/demo/router/router.go`),
然后二选一接进来:
```go
// 方式一(历史写法,仍然有效):在主仓 cmd/api/<name>.go 里
AppRouters = append(AppRouters, router.InitRouter)
// 方式二(推荐):不需要 import go-admin/cmd/api
sdk.Runtime.SetAppRouters(router.InitRouter)
```
**第三方应用只能走方式二**——方式一要求 `import "go-admin/cmd/api"`,
那就又依赖上宿主了。
走方式二还多拿到两样东西,都在 core 那边实现:
- **panic 护栏**——你的 `InitRouter` panic 了,其余模块照常注册、进程不退出,
日志里会写明是哪一行注册的
- **失败分级**——`sdk.Runtime.SetAppRoutersWith(f, runtime.WithFatal())`
声明「我起不来就别启动」
方式一(包级 `AppRouters`)没有护栏,panic 直接掀桌。
**执行顺序**:先跑完包级 `AppRouters`,再由 `sdk.Runtime.RunAppRouters()`
跑 core 自己的注册表,各自内部保持注册顺序。别依赖跨来源的相对顺序。
`InitRouter()` 内部:自己拿 `sdk.Runtime.GetEngine()`,按需建
`gin.RouterGroup`,通过 `init()` 自注册到你自己包内的
`routerCheckRole` / `routerNoCheckRole` 列表,不在任何中心文件手工列举。
---
## 注册数据库迁移
框架自身的迁移用 `SetVersion`;应用的迁移走 `ForApp`:
```go
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.ForApp("crm").SetVersion(migration.GetFilename(fileName), initCrmTables)
}
func initCrmTables(db *gorm.DB, version, appCode string) error {
return db.Transaction(func(tx *gorm.DB) error {
// ... schema / data changes ...
return tx.Create(&models.Migration{Version: version, AppCode: appCode}).Error
})
}
```
注册面(`ForApp` / `SetVersion` / `GetFilename`)在 core 的
`sdk/contract/migration`,是一个**进程级的包级注册表**——`ForApp` 直接当包级函数
调,不需要从宿主手里接过什么句柄。**执行面**——读 `sys_migration`、排序、跑事务、
`migrate` 与 `migrate status` 两个命令——留在宿主,它通过 `Snapshot()` 读那张表。
仓库内的模块继续经 `go-admin/cmd/migrate/migration` 走,那个包现在是薄壳,
导入路径不变;外置应用直接 import core 的那个包,**两边写法一模一样**。
五条必须知道的规则:
1. **完成记录由迁移函数自己写**,而且要写在自己的事务里。框架的调度循环只做
"这个 version 在 `sys_migration` 里有没有" 的判断,从不代你插入 —— 这样
"数据改完了"和"标记成已完成"才是同一个事务,不会出现改了一半却被记成成功。
2. **`AppCode` 必须写进去**。签名多带一个 `appCode` 参数就是为此 —— 忘了写,
schema 上那一列等于白加,你的迁移会被记成框架的。
3. **落库的 `version` 是加了前缀的**。`ForApp("crm")` 注册 `1786800001000`,
实际写进 `sys_migration.version` 的是 `crm-1786800001000`,函数收到的
`version` 参数已经是这个带前缀的值,照抄进 `models.Migration{Version: version}`
即可。前缀的意义是:两个来源不同的应用哪怕碰巧生成同一个毫秒时间戳,也不会撞主键、
不会有一方被误判为"已应用"。
4. **应用 code 一律小写**,`ForApp` 会自己 `strings.ToLower` 一遍。`core` 是保留字
(`migrate status` 用它表示框架自身,`--app core` 选中框架),`ForApp("core")`
会 panic。
5. **文件名前 13 位必须是毫秒时间戳**,`GetFilename` 就是从这里取版本号的。
不合规的名字会 panic,并把违规文件名报出来 —— 这是**故意的**:调用点全在
`init()` 里,没有 error 可返回,而另一条路是把文件名本身注册成"版本号"
(`add_orders.go` 恰好 13 个字符,只查长度是拦不住的),那样这条迁移
永远不会被执行,且不会有任何提示。宁可启动失败。
顺序保证:**同一应用内按版本号严格有序**。跨应用顺序不做承诺 —— 由于前缀的存在,
今天的实际顺序是"先跑完全部框架迁移,再按 appCode 字母序逐个应用跑完",
但这是实现细节,不要依赖它。跨应用依赖(应用 A 的迁移要求应用 B 先跑完)
需要依赖拓扑排序,属于后续阶段。
看当前状态、看这次会跑什么,不用猜:
```bash
go-admin migrate status -c config/settings.yml # 按应用分组列出已应用 / 待应用
go-admin migrate --dry-run -c config/settings.yml # 列出会执行什么、什么顺序,不写库
go-admin migrate --app crm -c config/settings.yml # 只跑 crm 的迁移
```
`status` 与 `--dry-run` 是纯只读的,不建表、不改表结构,可以直接对生产库执行。
---
## 菜单与接口种子
一个带界面的应用要在侧边栏里出现,需要往四类数据里写东西:`sys_api`、
`sys_menu`、`sys_menu_api_rule`(菜单与接口的关联)、以及角色授权与 Casbin
策略(`sys_role_menu` / `casbin_rule`)。
**你不需要知道这些表长什么样。** `sdk/contract/seed` 让你只描述"我要什么",
由宿主决定"怎么写进它自己的表":
```go
// 在你自己的迁移里,用它自己的那个事务
err := seed.SeedMenus(tx, "order", []seed.MenuSpec{
{Code: "root", Kind: models.Directory, Title: "订单"},
{Code: "list", Parent: "root", Kind: models.Menu, Title: "订单列表",
Path: "/order", Component: "apps/order/index", ApiCodes: []string{"list"}},
}, []seed.ApiSpec{
{Code: "list", Title: "订单列表", Path: "/api/v1/order", Method: "GET"},
})
```
`Kind` 用的就是 `sdk/contract/models` 里 `sys_menu.menu_type` 的那三个值
(`Directory` / `Menu` / `Button`),不是另一套同值的常量。
`Component` 的写法见上面「前端」一节——**这里是最容易写错的一个字段**。
core 里**没有** `SysMenu`、没有 `SysApi`、没有任何表名。这是刻意划的边界:
这个框架的宿主里本来就已经有两份 `SysMenu`(一份冻结在迁移期、一份运行期),
两者在软删语义上不一致,害过人,为此专门建了一个仓库内的工具来守。
往 core 里再放第三份表结构,就等于在**唯一没有工具守着**的地方重造同一类 bug。
### `Sort` 有上界,越界会中断整场迁移
`sys_menu.sort` 声明为 `gorm:"size:4"`,MySQL 据此建成 **tinyint,取值 -128..127**。
sqlite 忽略宽度,所以越界值在本地测试里一路绿灯,到真实安装时是 Error 1264 ——
而且发生在一次迁移的**中途**,后面的迁移全部不再执行。
`make checksilent` 的 `menu-sort-overflow` 会扫出仓库树里的越界字面量,
**但它扫不到 module cache 里的应用**。外置应用只有宿主 Seeder 的运行期校验兜底。
### `MenuSpec` 没有菜单名字段,名字由宿主合成
前端用菜单名做 keep-alive 的缓存键。两个应用如果都取 `Code: "list"`,
缓存键就会撞在一起 —— 后打开的那个页面会拿到前一个的缓存实例。
所以宿主的 Seeder 不直接用 `Code` 当菜单名,而是用
**PascalCase(appCode) + PascalCase(Code)** 合成(`order` + `list` → `OrderList`)。
你不需要做什么,但要知道两件事:
- 菜单名不是你能指定的,也不必与 `Title` 一致 —— `Title` 才是界面上显示的文字
- 前端组件的 `name` 若要与菜单名对齐(`checksilent` 的 `menu-name-mismatch` 会比对),
按合成后的名字写,不是按 `Code`
---
## 应用配置节
不要改宿主的源码去加配置。`sdk/config.RegisterExtend` 让你认领
`extend:` 下自己那一节:
```go
type orderConfig struct {
PaymentEndpoint string
Timeout int
}
// 在 init() 里调,与 SetAppRouters / ForApp 同一约定
var getOrderConfig = config.RegisterExtend[orderConfig]("order")
func handler(c *gin.Context) {
cfg := getOrderConfig()
_ = cfg.PaymentEndpoint
}
```
```yaml
extend:
order:
PaymentEndpoint: https://payment.internal
Timeout: 30
```
每个 key 各自解码,互不覆盖。**同一个 key 注册两次会立刻 panic**——
注册期没有"封闭时刻"可以用来拒绝迟到的注册,所以重复只能在注册的那一刻
大声报出来,而不是让第二个人静默顶掉第一个人的配置节。
配置文件是被监听的,改动会触发重载。`RegisterExtend` 每次重载解码进一个全新的
`T` 再原子换指针,所以访问器拿到的永远是一个自洽的快照,请求路径上读它不需要加锁。
唯一要注意的:**不要跨两次调用拼一个视图**——从同一个返回值上读两个字段是一致的,
调两次访问器各读一个字段,中间夹一次重载就不是了。
---
## 硬约束:注册要赶在启动钩子之前
三个注册入口——`AppRouters`、`sdk.Runtime.SetAppRouters`、`migration.ForApp`——
都必须在 `cmd/api/server.go` 的 `runStartupHooks()` 执行之前调用完。
`init()` 是最省事的位置:Go 规范保证包级变量初始化与 `init()` 在 `main()` 之前
**单 goroutine 顺序执行**,注册期天然没有并发写。但它不是唯一合法位置——
在 `run()` 之类早于启动钩子的地方注册同样成立。**这条规则约束的是顺序,
不是你写在哪个函数里。**
想在代码里判断注册窗口是否还开着:
```go
if sdk.Runtime.AppRoutersSealed() { /* RunAppRouters 已经跑过了 */ }
```
主仓这边补三条 core 那份文档管不着的:
1. **`RunAppRouters()` 跑过之后,core 的注册表就封闭了**,再调
`sdk.Runtime.SetAppRouters` 会被丢弃并记一条 ERROR 日志。包级 `AppRouters`
没有这个机制——它就是一个普通 slice,什么时候 append 都"成功",
但 `runStartupHooks()` 之后 append 的那些永远不会被执行,且不出声。
这是继续推荐方式二的理由之一。
2. **封闭是黏性的,而 `sdk.Runtime` 是包级单例。** 写测试时若会触发启动钩子,
必须换掉它再还原,否则同一个测试二进制里后面的测试会静默丢注册:
```go
previous := sdk.Runtime
t.Cleanup(func() { sdk.Runtime = previous })
sdk.Runtime = runtime.NewConfig()
```
`cmd/api/server_test.go` 里的 `freshRuntime` 就是这个。
3. **迁移的调度循环是主仓的东西**,core 只有注册面。迁移注册的约束仍然是
"赶在调度循环跑起来之前",实践上就是 `init()`。
---
## 生命周期挂载点
除了注册路由和迁移,应用还可以把工作挂在进程生命的四个时点上,不必等宿主按名字来调自己。
契约本身在 core,见
[go-admin-core `docs/contract.md`](https://github.com/go-admin-team/go-admin-core/blob/main/docs/contract.md)
的「Life-cycle phases」一节。这里只写**在本仓里它们分别落在哪一行**。
| 阶段 | 在 `cmd/api/server.go` 的位置 | 此时可用 |
|---|---|---|
| `AfterResource` | `bootstrap.SetupConfig` 跑完 `database.Setup` / `storage.Setup` 之后 | 配置、库、缓存、队列、casbin |
| `BeforeRouter` | `initRouter()` **之前** | 以上,加引擎尚未构建这一事实 |
| `AfterListen` | `startServing()` 里,`net.Listen` 返回之后 | 全部,端口**已绑定**、连接进得来 |
| `BeforeExit` | `srv.Shutdown` 返回之后(无论它是否报错) | 全部,正在被拆掉 |
`AfterListen` 承诺的是**端口已绑定**,不是「`Serve` 已经在 accept 循环里」——
`srv.Serve` 在另一个 goroutine 上。这个区别是真实的:绑定成功之后内核就会把连接排进
backlog,所以钩子里去连自己的端口不会被拒;但此刻 `Serve` 可能还没跑到第一次 `Accept`。
绑定失败则**根本不会有这个阶段**:`net.Listen` 的错误直接从 `run()` 返回,
横幅不打印,进程非零退出。
```go
sdk.Runtime.SetPhase(runtime.AfterResource, func() { /* ... */ })
sdk.Runtime.SetShutdown(func(ctx context.Context) { /* ... */ })
```
### `BeforeRouter` 不等于 `before` 注册表
**这两个不是同一个时点,文档里别混着写。** `SetBefore` 的回调由
`runStartupHooks()` 执行,而那是在 `initRouter()` **之后**——引擎已经建好了。
`BeforeRouter` 在它之前。
顺带:`BeforeRouter` 是「硬约束:注册要赶在启动钩子之前」那一节所说的合法注册窗口之一。
它早于 `runStartupHooks()`,所以在这里调 `sdk.Runtime.SetAppRouters` 仍然来得及。
### `AfterResource` 会跑很多次,回调必须扛得住
它在**每次配置热更新之后**都会再跑一遍,因为热更新会重建它所命名的那些资源。
所以这里的回调要求是**「对同一个资源幂等」,不是「第二次什么都不做」**。
本仓自己的队列消费者就是这条规则的样板,也是它存在的理由
(`cmd/api/server.go` 的 `attachQueueConsumers`):
- 热更新重建了队列适配器,挂在旧适配器上的消费者连着一个**再没人往里发消息**的队列,
登录日志和操作日志就此停写且不出声。所以新适配器**必须**重新注册。
- 但同一个适配器不能注册两次,否则每条消息有两个消费者,每行日志写两遍。
**身份不能从访问器取。** `sdk.Runtime.GetQueueAdapter()` 与 `GetQueuePrefix()`
每次调用都新造一个 `runtime.Queue` 包装,比较两次返回等于比较两个包装,
**底层适配器换过多少次都不相等**。要在**创建资源的地方**记身份——
本仓是 `common/storage.QueueGeneration()`。
### 注册消费者要赶在队列启动之前
走哪条实现,取决于配置里有没有 `redis:` 段(`config.QueueConfig.Setup()`):
| 配置 | 实际类型 | 启动后还能注册吗 |
|---|---|---|
| `queue: memory:` | `queue.NewMemory` | **能**。它的 `Register` 每次起一个消费 goroutine,不看是否已 `Run` |
| `queue: redis:` | `storage.LegacyQueueAdapter` 包着新契约实现 | **不能**。`Register` 内部调 `Subscribe`,启动后返回 `storage.ErrQueueAlreadyStarted` |
而 `LegacyQueueAdapter.Register` **没有返回值**——它只能把这个错误写进 slog,
core 里那行注释自己写着「The interface has no way to report this to the caller」。
**静默的是注册这一步,不是之后。** 没有建立消费组,redis 会用
`storage.ErrNoHandler` 拒绝**之后的每一次投递**,而本仓两个调用点
(`common/middleware/logger.go`、`common/middleware/handler/auth.go`)
都把它记为 error——于是日志行一条都不落库,同时每个请求刷一条错误日志。
所以顺序是硬的:**先 `Register` 完,再由注册方 `Run()`。**
`common/storage` 的 `setupQueue` 有意不启动队列。
默认配置选的是 memory 后端,它不在乎顺序——**这个缺陷在默认部署里看不见,
只在配了 redis 的部署上发作**,而丢掉的正是登录日志、操作日志和 api 检查。
### `BeforeExit` 反序执行,预算约束的是等待
清理按**注册的逆序**执行。`SetShutdown` 拿到宿主剩余的预算,
但**它约束的是等待,不是工作**:预算用尽时 `RunShutdown` 停止等待并返回,
而不检查 context 的回调会一直跑到进程退出。Go 没法取消一个不检查取消的函数。
本仓的样板是 cron(`app/jobs/jobbase.go` 的 `startCrontab`):
`cron.Stop()` 返回一个在**已经在跑的任务结束时**关闭的 context,
钩子在它和预算之间二选一。
---
## 安全边界:装一个应用等于信任它
**这一层划不出安全边界,本文不假装划得出。**
第三方应用的代码在**宿主进程内**运行,与宿主**同权限**。它持有的是裸的
`*gorm.DB`——`seed.SeedMenus` 用的就是你自己迁移里那个 `tx`,绕开 `Seeder`
直写 `sys_menu`、`sys_api`、甚至 `casbin_rule` 一直都做得到,Go 的类型系统
拦不住,本框架的任何一层也拦不住。
还有一条**不碰 `casbin_rule` 也能走通**的间接路径:把自己的菜单通过
`ApiCodes` 关联到别人的接口,然后等管理员在后台把这个菜单授权给某个角色——
策略是后台自己生成的,记在管理员头上。
所以:
> **装一个应用,等于信任它。** 这和 `import _` 一个 Go 库是同一量级的信任。
> `Seeder` 这类设计的目的是让**守规矩的应用不必知道宿主的表结构**,
> 不是把不守规矩的应用关起来。
给使用者的实际建议只有一条:**按信任 Go 依赖的标准来审应用**——看源码、
钉版本、认作者。不要因为它叫"应用"就以为它跑在沙箱里。
---
## 边界由 CI 守着
`tools/checksilent` 里有两条盯契约面的检查,`make checksilent` 在 CI 里跑,
命中 ERROR 即失败:
| 检查 | 盯的是 |
|---|---|
| `contract-import-boundary` | `common/`、`core/` 不得 import `app/`——否则一个删掉 `app/admin` 的 fork 就编译不了它被告知可以依赖的那一面 |
| `contract-shim-alias` | 从 core 契约包声明出来的类型必须是**别名**(`type X = pkg.Y`),不能是 defined type。判据是右手边,不是一份包名清单,所以谁在哪加的都算 |
第二条守的是一条一个字符的差别。`type X = pkg.Y` 和 `type X pkg.Y`
看着几乎一样,但后者只拿走底层结构、**丢掉整个方法集**,于是嵌了它的 model
不再满足 `ActiveRecord`。麻烦在于这**不一定在本仓编译失败**——本仓只用接口
使唤其中一部分类型,没被使唤到的那些在这里编译得好好的,
**到第三方应用或某个 fork 里才炸**,而那里没人看着。
测试文件同样算——一个删掉 `app/admin` 的 fork 也应该能跑 `go test ./...`。
**这两条工具都只扫仓库树。** 装在 module cache 里的第三方应用,
`checksilent` 一个文件都看不到。所以它保的是**这个仓库和它的 fork**,
不是你的应用——你的应用要自己跑自己的检查。
`checksilent` 还检查其他几类"不出声的失败",写模块时值得先看一眼
`AGENTS.md` 的「静默失败校验」一节,或者 `tools/checksilent/checks.go` 里的
`runChecks`(`-h` 只打印命令行参数,不列检查)。
+167
View File
@@ -0,0 +1,167 @@
// Package apis is app-order's HTTP layer: four hand-written gin handlers,
// none of them a wrapper around core's generic CRUD Actions. See
// service/order.go's package doc for why.
package apis
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/actions"
// models.Response in the @Success annotations below resolves to
// go-admin-core's sdk/contract/models.Response, not to this package -
// swaggo finds it through --parseDependency. It is the envelope with a
// data field; core's response.Response, which the framework's own
// handlers name, has no data field and would document these endpoints
// as returning none.
"github.com/go-admin-team/example-app-order/models"
"github.com/go-admin-team/example-app-order/service"
orderdto "github.com/go-admin-team/example-app-order/service/dto"
)
// Order embeds api.Api the same way every hand-written go-admin handler
// does (see app/admin/apis/sys_post.go): MakeContext/MakeOrm/Bind/
// MakeService/OK/Error/PageOK are all core, imported with no dependency on
// go-admin itself.
type Order struct {
api.Api
}
// GetPage
// @Summary List orders visible to the caller's data scope
// @Tags order
// @Param status query string false "status"
// @Param orderNo query string false "orderNo"
// @Param pageIndex query int false "pageIndex"
// @Param pageSize query int false "pageSize"
// @Success 200 {object} models.Response
// @Router /api/v1/order [get]
// @Security Bearer
func (e Order) GetPage(c *gin.Context) {
s := service.Order{}
req := orderdto.OrderSearchReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, binding.Form).
MakeService(&s.Service).
Errors
if err != nil {
e.Error(http.StatusUnprocessableEntity, err, err.Error())
return
}
p := actions.GetPermissionFromContext(c)
list := make([]models.Order, 0)
count, err := s.GetPage(&req, p, &list)
if err != nil {
e.Logger.Error(err)
e.Error(http.StatusInternalServerError, err, "failed to list orders")
return
}
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "ok")
}
// Get
// @Summary Get one order and its items
// @Tags order
// @Param id path int true "order id"
// @Success 200 {object} models.Response
// @Router /api/v1/order/{id} [get]
// @Security Bearer
func (e Order) Get(c *gin.Context) {
s := service.Order{}
req := orderdto.OrderIdReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, nil).
MakeService(&s.Service).
Errors
if err != nil {
e.Error(http.StatusUnprocessableEntity, err, err.Error())
return
}
p := actions.GetPermissionFromContext(c)
var order models.Order
if err = s.Get(req.Id, p, &order); err != nil {
e.Error(http.StatusNotFound, err, "order not found")
return
}
e.OK(order, "ok")
}
// Create
// @Summary Place a new order
// @Tags order
// @Accept application/json
// @Param data body orderdto.OrderCreateReq true "data"
// @Success 200 {object} models.Response
// @Router /api/v1/order [post]
// @Security Bearer
func (e Order) Create(c *gin.Context) {
s := service.Order{}
req := orderdto.OrderCreateReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, binding.JSON).
MakeService(&s.Service).
Errors
if err != nil {
e.Error(http.StatusUnprocessableEntity, err, err.Error())
return
}
order, err := s.Create(&req, user.GetUserId(c))
if err != nil {
if errors.Is(err, service.ErrOrderEmpty) {
e.Error(http.StatusBadRequest, err, err.Error())
return
}
e.Logger.Error(err)
e.Error(http.StatusInternalServerError, err, "failed to create order")
return
}
e.OK(order, "created")
}
// Pay
// @Summary Mark a pending order as paid
// @Tags order
// @Param id path int true "order id"
// @Success 200 {object} models.Response
// @Router /api/v1/order/{id}/pay [put]
// @Security Bearer
func (e Order) Pay(c *gin.Context) {
s := service.Order{}
req := orderdto.OrderIdReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, nil).
MakeService(&s.Service).
Errors
if err != nil {
e.Error(http.StatusUnprocessableEntity, err, err.Error())
return
}
p := actions.GetPermissionFromContext(c)
if err = s.Pay(req.Id, p); err != nil {
if errors.Is(err, service.ErrOrderNotPending) {
// Deliberately the same response whether the order does not
// exist, is already paid, or is outside p's data scope - see
// service.Order.Pay's doc comment.
e.Error(http.StatusConflict, err, err.Error())
return
}
e.Logger.Error(err)
e.Error(http.StatusInternalServerError, err, "payment failed")
return
}
e.OK(nil, "paid")
}
+81
View File
@@ -0,0 +1,81 @@
module github.com/go-admin-team/example-app-order
go 1.25.13
require (
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/go-admin-team/go-admin-core/v2 v2.5.0
gorm.io/gorm v1.31.2
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/andeya/ameda v1.5.3 // indirect
github.com/andeya/goutil v1.0.1 // indirect
github.com/bitly/go-simplejson v0.5.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/bytedance/go-tagexpr/v2 v2.9.11 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/casbin/casbin/v3 v3.8.1 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99 // indirect
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nyaruka/phonenumbers v1.2.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/redis/go-redis/v9 v9.22.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.1 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.39.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gorm.io/plugin/soft_delete v1.2.1 // indirect
modernc.org/libc v1.67.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.42.2 // indirect
)
+252
View File
@@ -0,0 +1,252 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/andeya/ameda v1.5.3 h1:SvqnhQPZwwabS8HQTRGfJwWPl2w9ZIPInHAw9aE1Wlk=
github.com/andeya/ameda v1.5.3/go.mod h1:FQDHRe1I995v6GG+8aJ7UIUToEmbdTJn/U26NCPIgXQ=
github.com/andeya/goutil v1.0.1 h1:eiYwVyAnnK0dXU5FJsNjExkJW4exUGn/xefPt3k4eXg=
github.com/andeya/goutil v1.0.1/go.mod h1:jEG5/QnnhG7yGxwFUX6Q+JGMif7sjdHmmNVjn7nhJDo=
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/go-tagexpr/v2 v2.9.11 h1:jJgmoDKPKacGl0llPYbYL/+/2N+Ng0vV0ipbnVssXHY=
github.com/bytedance/go-tagexpr/v2 v2.9.11/go.mod h1:UAyKh4ZRLBPGsyTRFZoPqTni1TlojMdOJXQnEIPCX84=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/casbin/casbin/v3 v3.8.1 h1:D4dEY4knePPR4YgNP5WZtWNaOxD0UK0LpPy9+zxtBwo=
github.com/casbin/casbin/v3 v3.8.1/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99 h1:K62Lb6bsgLOB++z/VAvRvtiEBdNCuMfmQGTGGWMdPpM=
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99/go.mod h1:9+sJ9zvvkXC5sPjPEZM3Jpb9n2Q2VtcrGZly0UHYF5I=
github.com/chanxuehong/util v0.0.0-20200304121633-ca8141845b13/go.mod h1:XEYt99iTxMqkv+gW85JX/DdUINHUe43Sbe5AtqSaDAQ=
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd h1:v3JNsFZmplLO/Cmiyr/rGvR7lW1ld9lB+d5h4yR0MTI=
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd/go.mod h1:mysjrtCs9MmN8hqDf4/mc4eQ26Rt9s1p5oO+fhJlLB4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-admin-team/go-admin-core/v2 v2.5.0 h1:aD1SALklBxizGB9u8cOgm4OT8z656FM83F4fD6dMz9g=
github.com/go-admin-team/go-admin-core/v2 v2.5.0/go.mod h1:LG/XvEfOplbuadKrPTPm0Nu5pN06aQUNZZC3ao4B4gs=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nyaruka/phonenumbers v1.0.55/go.mod h1:sDaTZ/KPX5f8qyV9qN+hIm+4ZBARJrupC6LuhshJq1U=
github.com/nyaruka/phonenumbers v1.2.2 h1:OwVjf7Y4uHoK9VJUrA8ebR0ha2yc6sEYbfrwkq0asCY=
github.com/nyaruka/phonenumbers v1.2.2/go.mod h1:wzk2qq7qwsaBKrfbkWKdgHYOOH+QFTesSpIq53ELw8M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.23.0/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/plugin/soft_delete v1.2.1 h1:qx9D/c4Xu6w5KT8LviX8DgLcB9hkKl6JC9f44Tj7cGU=
gorm.io/plugin/soft_delete v1.2.1/go.mod h1:Zv7vQctOJTGOsJ/bWgrN1n3od0GBAZgnLjEx+cApLGk=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+89
View File
@@ -0,0 +1,89 @@
// Package migration registers app-order's one migration: create its two
// tables and seed the menu/API entries the admin UI needs to expose them.
//
// It registers through contract/migration.ForApp - the package-level
// facade, not a private NewRegistry() - because that is the only registry a
// third-party app, which cannot reach into the host process, can register
// against and have any hope of the host's own execution engine picking up.
// Whether it actually does, today, is a different question: see this
// package's test file and the gap list in the accompanying report.
package migration
import (
"gorm.io/gorm"
contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration"
contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed"
"github.com/go-admin-team/example-app-order/models"
)
// AppCode is app-order's migration.ForApp / seed.SeedMenus identity.
const AppCode = "order"
// version is this migration's sys_migration key before ForApp namespaces
// it (see contract/migration.ForApp's doc comment: the stored key becomes
// "order-" + version). It follows the framework's own 13-digit millisecond
// timestamp convention purely so a human reading sys_migration.version
// alongside the framework's own rows can still eyeball roughly when it was
// authored; contract/migration.ForApp does not require that shape, just
// uniqueness within this app's own namespace.
const version = "1793800000000"
func init() {
contractmigration.ForApp(AppCode).SetVersion(version, createOrderSchema)
}
// createOrderSchema creates app_order/app_order_item and seeds the menu and
// API entries a host's Seeder turns into sys_menu/sys_api/sys_menu_api_rule
// rows (and, once an administrator grants the menu to a role through the
// ordinary admin UI, casbin_rule). See seed.Seeder's security note: this
// call does not sandbox anything, it only saves app-order from needing to
// know go-admin's own schema.
func createOrderSchema(db *gorm.DB, migrationVersion, appCode string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&models.Order{}, &models.OrderItem{}); err != nil {
return err
}
menus := []seed.MenuSpec{
{
Code: "dir", Kind: contractmodels.Directory,
Title: "Order Example", Path: "/apps/order", Component: "Layout",
Icon: "shopping", Sort: 20,
},
{
Code: "list", Parent: "dir", Kind: contractmodels.Menu,
Title: "Orders", Path: "list",
// Component must start with "apps/<code>/" - see
// seed.MenuSpec.Component's doc comment. This is the one
// concrete rule the report's gap list has nothing bad to
// say about: it is documented exactly where a caller
// building a MenuSpec would look.
Component: "apps/order/order/index",
Sort: 1,
ApiCodes: []string{"list", "get", "create", "pay"},
},
{
Code: "btn-create", Parent: "list", Kind: contractmodels.Button,
Title: "Create", Permission: "order:order:create", Sort: 1,
},
{
Code: "btn-pay", Parent: "list", Kind: contractmodels.Button,
Title: "Pay", Permission: "order:order:pay", Sort: 2,
},
}
apis := []seed.ApiSpec{
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
{Code: "get", Title: "Order detail", Path: "/api/v1/order/:id", Method: "GET", Handle: "apis.Order.Get-fm"},
{Code: "create", Title: "Create order", Path: "/api/v1/order", Method: "POST", Handle: "apis.Order.Create-fm"},
{Code: "pay", Title: "Pay order", Path: "/api/v1/order/:id/pay", Method: "PUT", Handle: "apis.Order.Pay-fm"},
}
if err := seed.SeedMenus(tx, appCode, menus, apis); err != nil {
return err
}
return tx.Create(&contractmodels.Migration{Version: migrationVersion, AppCode: appCode}).Error
})
}
@@ -0,0 +1,153 @@
package migration
import (
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration"
contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed"
"github.com/go-admin-team/example-app-order/models"
)
// fakeSeeder stands in for a host's real Seeder (the one wt-shim, as of
// this writing, never registers - see the accompanying report's gap list).
// It records what it received instead of writing to any table, which is
// enough to check app-order's own MenuSpec/ApiSpec assembly without
// depending on go-admin's sys_menu/sys_api schema.
type fakeSeeder struct {
appCode string
menus []seed.MenuSpec
apis []seed.ApiSpec
}
func (f *fakeSeeder) SeedMenus(tx *gorm.DB, appCode string, menus []seed.MenuSpec, apis []seed.ApiSpec) error {
f.appCode = appCode
f.menus = menus
f.apis = apis
return nil
}
// seed.RegisterSeeder panics on a second call in the same process (see its
// doc comment) - by design, there is no public way to unregister one. This
// package's tests share the one registration below rather than each
// registering their own.
var fake = &fakeSeeder{}
func init() {
seed.RegisterSeeder(fake)
}
// TestRegistersUnderContractMigrationForApp is this package's core claim:
// that createOrderSchema is reachable through contract/migration's
// package-level Snapshot, the only registry a third-party module can
// register against. It does not confirm any host actually calls Snapshot
// today - see the report.
func TestRegistersUnderContractMigrationForApp(t *testing.T) {
entries := contractmigration.Snapshot()
entry, ok := entries[AppCode+"-"+version]
if !ok {
t.Fatalf("no entry for %s-%s; registered: %v", AppCode, version, keysOf(entries))
}
if entry.AppCode != AppCode {
t.Errorf("Entry.AppCode = %q, want %q", entry.AppCode, AppCode)
}
}
func keysOf(m map[string]contractmigration.Entry) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// TestMigrationCreatesTablesSeedsMenusAndRecordsItself runs the registered
// migration function directly against a fresh sqlite database - standing in
// for the host's execution engine, which (see the report) does not exist
// yet for an externally-registered app. It is the closest thing to an
// end-to-end run this example can do without wt-shim's cooperation.
func TestMigrationCreatesTablesSeedsMenusAndRecordsItself(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
// sys_migration itself is created by the framework's own first
// migration (go-admin's cmd/migrate/migration/version/*_tables.go),
// which by the time any app's migration runs has always already run -
// simulate that precondition rather than app-order's own migration
// creating a table it does not own.
if err := db.AutoMigrate(&contractmodels.Migration{}); err != nil {
t.Fatalf("automigrate sys_migration: %v", err)
}
entries := contractmigration.Snapshot()
entry, ok := entries[AppCode+"-"+version]
if !ok {
t.Fatalf("no entry for %s-%s", AppCode, version)
}
if err := entry.Fn(db, AppCode+"-"+version); err != nil {
t.Fatalf("running the registered migration: %v", err)
}
if !db.Migrator().HasTable(&models.Order{}) {
t.Error("app_order was not created")
}
if !db.Migrator().HasTable(&models.OrderItem{}) {
t.Error("app_order_item was not created")
}
var migrationRow contractmodels.Migration
if err := db.Where("version = ?", AppCode+"-"+version).First(&migrationRow).Error; err != nil {
t.Fatalf("sys_migration row: %v", err)
}
if migrationRow.AppCode != AppCode {
t.Errorf("sys_migration.app_code = %q, want %q", migrationRow.AppCode, AppCode)
}
if fake.appCode != AppCode {
t.Errorf("Seeder saw appCode %q, want %q", fake.appCode, AppCode)
}
assertMenuGraphIsConsistent(t, fake.menus, fake.apis)
}
// assertMenuGraphIsConsistent checks the two rules that would otherwise
// only surface as a broken admin UI at install time: every Parent
// reference resolves to a Code in the same batch, and the frontend's
// apps/<code>/ convention for a packaged page's Component (documented on
// MenuSpec.Component, enforced by nothing - see the report) is actually
// followed.
func assertMenuGraphIsConsistent(t *testing.T, menus []seed.MenuSpec, apis []seed.ApiSpec) {
t.Helper()
codes := make(map[string]seed.MenuSpec, len(menus))
for _, m := range menus {
codes[m.Code] = m
}
apiCodes := make(map[string]bool, len(apis))
for _, a := range apis {
apiCodes[a.Code] = true
}
for _, m := range menus {
if m.Parent != "" {
if _, ok := codes[m.Parent]; !ok {
t.Errorf("menu %q has Parent %q, which is not a Code in this batch", m.Code, m.Parent)
}
}
for _, ac := range m.ApiCodes {
if !apiCodes[ac] {
t.Errorf("menu %q references ApiCode %q, which is not in this batch's apis", m.Code, ac)
}
}
if m.Kind == contractmodels.Menu && m.Component != "" {
if !strings.HasPrefix(m.Component, "apps/"+AppCode+"/") {
t.Errorf("menu %q has Component %q, want it to start with apps/%s/", m.Code, m.Component, AppCode)
}
}
}
}
+52
View File
@@ -0,0 +1,52 @@
// Package models holds app-order's two GORM row models.
package models
import (
contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
)
// The two values Order.Status can hold. Kept as narrow strings rather than
// an int enum to match sys_role.data_scope's own convention in core, and to
// leave room for a future status without a schema change.
const (
StatusPending = "1" // awaiting payment
StatusPaid = "2" // paid; set only by a successful Pay
)
// orderTable is passed to actions.Permission and repeated as
// Order.TableName's return value. It is not literally the word "order":
// that is a reserved SQL keyword, and actions.Permission builds its WHERE
// clause by string-concatenating tableName straight into raw SQL
// (`tableName+".create_by = ?"`, see permission.go) with no quoting at all.
// A table named exactly "order" would make every data-scope query a syntax
// error on MySQL's default (non-ANSI-quotes) mode. This is not something
// core enforces or even mentions - Permission's tableName parameter is an
// opaque string as far as it is concerned - so avoiding reserved words is
// entirely on the caller.
const orderTable = "app_order"
// Order is one customer order. ControlBy is required, not decorative:
// actions.Permission's data-scope SQL joins against create_by, so an Order
// without it would make every data-scope rule silently match nothing.
type Order struct {
contractmodels.Model
OrderNo string `json:"orderNo" gorm:"type:varchar(64);uniqueIndex;comment:order number"`
UserId int `json:"userId" gorm:"index;comment:buyer user id"`
Status string `json:"status" gorm:"type:varchar(4);index;comment:order status: 1 pending, 2 paid"`
TotalCents int64 `json:"totalCents" gorm:"comment:total amount in cents, sum of item price*quantity at creation time"`
// Items is populated by Preload; it is never set by Order's own migrator
// column set (OrderItem.OrderId is the foreign key, not a column here).
Items []OrderItem `json:"items,omitempty" gorm:"foreignKey:OrderId"`
contractmodels.ControlBy
contractmodels.ModelTime
}
// TableName pins the row model to app_order regardless of any global
// singular/plural table naming strategy the host configures. See orderTable
// above for why this is not simply "order".
func (Order) TableName() string {
return orderTable
}

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