Compare commits

..
Author SHA1 Message Date
wenjianzhang c463f3696d Merge pull request #949 from go-admin-team/chore/core-v2.10.0
Take go-admin-core v2.10.0, and fix the log call it exposes
2026-09-24 20:48:20 +08:00
zhangwenjian 60db31e0e8 chore⬆️: go-admin-core v2.8.0 -> v2.10.0
Picks up two releases at once:

  - v2.9.0: app.Register normalizes Manifest.Requires the way it already
    normalized Code, so the dependency list stored in sys_app.requires is
    spelled the same way as the app codes beside it; and a version
    component beginning with '+' is reported as a signed component rather
    than as a build-metadata suffix.
  - v2.10.0: builds with Go 1.27, which this repository already declares,
    and fixes a %S verb in the listener's shutdown log.

All three modules move together: example/app-order requires core directly
and test/e2e-apporder does indirectly. No other requirement changes.
2026-09-24 18:14:51 +08:00
zhangwenjian e3e5d3e550 fix🐛: format the dept-list error instead of printing its verb
log.Error is the unformatted entry point, so "find dept list error, %s"
went out with the %s intact and the error text glued to it:

    find dept list error, %sconnection refused

Errorf is what the line meant. go vet reports this once go-admin-core's
logger is recognised as a print-style wrapper, which it is from v2.10.0.
2026-09-24 18:14:38 +08:00
wenjianzhang f427a42b4f Merge pull request #948 from go-admin-team/chore/go-1.27
Build with Go 1.27
2026-09-23 20:26:02 +08:00
zhangwenjian 1169cb3e87 chore⬆️: build with Go 1.27
govulncheck reports seven standard-library vulnerabilities against go1.26.5
that this code actually reaches, and none against go1.27.1.

It also clears the way for the next go-admin-core release, which declares
go 1.27.1: a module cannot require a dependency whose language version is
newer than its own. Doing it in its own commit keeps that upgrade to a
one-line dependency bump.

All three modules in the tree move together. test/e2e-apporder requires the
main module, so leaving it behind fails the moment the main module declares a
newer version - "updates to go.mod needed", before a test runs.
example/app-order only requires go-admin-core and would still have resolved,
but it was declaring 1.25.13, two releases back and out of support.

The workflows pin the Go version explicitly instead of reading go.mod, and
the four READMEs state it under environmental requirements, so those move
with it too.

No dependency changes - go mod tidy leaves every go.sum untouched.
2026-09-23 17:25:51 +08:00
wenjianzhang ec10917272 Merge pull request #947 from go-admin-team/fix/915-scheduler-lease
fix🐛: make the job scheduler single-writer with a database lease
2026-09-22 22:57:21 +08:00
wenjianzhang b8af16baf0 Merge pull request #946 from go-admin-team/test/job-remove-goroutine-leak
test✅: fail when Remove parks a goroutine on an abandoned channel
2026-09-22 22:54:55 +08:00
wenjianzhang 92987cdee3 Merge pull request #943 from go-admin-team/feat/010-generator-vue3
feat✨: generate Vue 3 pages from the code generator (PRD 010)
2026-09-22 21:42:43 +08:00
zhangwenjian df98ffb5c5 docs📝: say what a second replica now does to the scheduler
The note told the reader the scheduler stood in the way of raising the
replica count. It no longer does: one enabled job fires once however many
pods there are, a pod that loses the lease stops scheduling, and one that
exits hands the lease back rather than making its successor wait it out.

The shared log volume still does stand in the way, and that is now the only
thing the note asks for before the number goes up.
2026-09-20 18:31:44 +08:00
zhangwenjian acc9378283 fix🐛: schedule jobs only while this instance holds the lease
A second instance pointed at the same database did not divide the work, it
overwrote it. Every instance registered the whole enabled list into its own
cron.Cron, and startup ran `UPDATE sys_job SET entry_id = 0 WHERE entry_id
> 0` across the entire table, so the newest process erased the ids the
previous one wrote and put its own over the top. Neither symptom logged
anything: an enabled job fired once per instance, and stopping one from the
UI removed an entry from the wrong process and answered 200 either way.

A supervisor per tenant now keeps the scheduler in step with the lease.
Holding it is not decided once at startup, because both of the other
answers are wrong for longer than a moment:

  - an instance that never got the lease keeps asking, so the death of the
    holder does not stop the jobs until somebody restarts a process by hand;
  - an instance that holds it stops scheduling as soon as its lease has
    lapsed, because a holder still scheduling after the lease has gone
    elsewhere is the two-schedulers defect reached from the other side.

A failed renewal is not a lost lease. The scheduler keeps running until the
lease could actually have expired: a database that is briefly unreachable
must not stop the jobs, and cannot have handed them to anyone else, because
nobody else can reach it to take the lease either.

Stopping is no longer arranged by startCrontab. A scheduler now stops for
two different reasons and only the supervisor knows which - and since a
start happens every time the lease is taken, registering a shutdown
callback there would add one per leadership change for the life of the
process, because SetShutdown appends.

sys_job.entry_id keeps its meaning. This does not distribute the jobs and is
not meant to: the HTTP side scales, the scheduler stays single-writer.

Fixes #915.
2026-09-20 18:31:44 +08:00
zhangwenjian b4b5bc5b3a feat✨: a database lease the schedulers compete for
One row per database, taken and renewed by single UPDATE statements whose
RowsAffected the database decides. Nothing uses it yet; the scheduler is
wired to it next.

The two timestamps are epoch milliseconds in a BIGINT rather than timestamp
columns, which is the one decision here worth explaining. A timestamp does
not survive the trip through a driver unchanged: read over go-admin's own
`parseTime=True&loc=Local` DSN, MySQL's UTC_TIMESTAMP arrives relabelled as
local time, and on a UTC+8 host every lease is eight hours out. It is
invisible to any test that compares the lease against itself, because each
instance's own arithmetic stays self-consistent - only the comparison
between two instances is wrong, which is the only comparison that matters.
An integer has no timezone for a driver to apply.

The migration seeds the row free. There is no insert path at runtime, so two
instances starting together cannot race to create the row they are both
trying to claim, and neither has to tell a duplicate-key error apart from a
real one in whichever driver it is running against. A missing row is
therefore reported rather than recovered from: silently never scheduling
anywhere is the worse failure.

The current-time expression differs per dialect and all four are covered:
MySQL, PostgreSQL and SQL Server against real servers, SQLite by default.
2026-09-20 18:31:44 +08:00
zhangwenjian 27ad988fd7 ci👷: run the tests against MySQL too
MySQL is the dialect most installations run and the only registered driver
with no service here. The scheduler lease that follows reads the database's
clock, and the first implementation read it as a timestamp: over go-admin's
own `parseTime=True&loc=Local` DSN, MySQL's UTC_TIMESTAMP comes back
relabelled as local time, so on any host that is not UTC every lease was one
zone offset out. Every assertion that compared the lease only against itself
still passed, and the three dialects already here could not see it.

The DSN keeps loc=Local on purpose: it is what config/settings.yml ships. A
DSN here that quietly differed would test a configuration nobody runs.
2026-09-20 18:31:44 +08:00
zhangwenjian effc3a3e69 Merge branches 'feat/010-gen-plumbing' and 'feat/010-vue3-template' into integ/010-all 2026-09-19 21:21:25 +08:00
zhangwenjian 08f789737f fix🐛: escape the literal T in the datetime value-format
value-format="YYYY-MM-DDTHH:mm:ssZ" works today only because dayjs does
not currently give T a format-token meaning, so it passes through as a
literal character -- an accident of the current token table, not
something this string declares. Escaped it to YYYY-MM-DD[T]HH:mm:ssZ,
dayjs's own syntax for "this character, verbatim, not a token": produces
byte-for-byte the same output today (confirmed below) and stops
depending on T staying meaningless in a future dayjs version.

Re-verified both directions against the escaped string, and did so
against the real el-date-picker component this time rather than dayjs
alone: mounted element-plus's actual ElDatePicker with
value-format="YYYY-MM-DD[T]HH:mm:ssZ" (copied from a real rendering of
this fixed template, not retyped) and confirmed it renders a real,
non-blank date -- not "Invalid Date" -- when its modelValue is set to
either shape Go's encoding/json actually sends (a numeric offset or a
literal Z for UTC), and that both render identically since they are the
same instant. Submission was re-checked through the same dayjs call the
component itself makes to format a picked value. A fourth check formats
the same instant with both the old and the escaped string and asserts
they are equal, so this suite would have caught the difference if the
escape had changed anything instead of just hardening it.
2026-09-19 21:21:01 +08:00
zhangwenjian f57bf5d61d fix🐛: query-less table's Query type broke FK dropdown fetches (P0)
3625ce8 fixed the no-empty-object-type lint error by switching a
zero-IsQuery table's {ClassName}Query to `Record<string, never>`, the
same default useTable.ts's own `TQuery extends object = Record<string,
never>` uses. That default is safe there only because useTable.ts's one
internal `TQuery & PageQuery` goes through an `as` cast rather than a
structural check. Code that builds the object literal directly does not
get that protection - and vue.go.template's foreign-key dropdown fetch
does exactly that: `list{FkClass}({ pageIndex: 1, pageSize: 100 })`.

Record<string, never> is a mapped type over every string key, each
mapped to never, so `Record<string, never> & PageQuery` does not leave
PageQuery's own properties alone: pageIndex becomes `never & number`,
i.e. never, and no value can be passed for it - a straight type error,
not a lint warning, so the previous fix's pnpm lint pass did not catch
it. Trips on any query-less table referenced by a foreign key - a plain
lookup/dict table used as a dropdown source, an ordinary shape, not a
rare one.

Switched to Record<never, never>: a mapped type over the empty key
set, which behaves as the empty object type `{}` under intersection
(PageQuery's properties come through unchanged) while still satisfying
`TQuery extends object` and not tripping no-empty-object-type (it is a
generic instantiation, not a literal `{}` type annotation) - confirmed
all three separately before touching the template.

Verified with node 24.11.0: generated a real zero-IsQuery table, copied
its .ts into go-admin-ui alongside a throwaway file reproducing
vue.go.template's exact FK call site
(`await list{Class}({ pageIndex: 1, pageSize: 100 })`), and ran both
eslint and vue-tsc --noEmit - the type-check step lint alone cannot
cover, which is what let this through the first time. Confirmed red
first (swapped the generated file's Record<never, never> back to
Record<string, never>): vue-tsc reported the exact "Property 'pageIndex'
is incompatible with index signature" error. Restored the fix - both
clean.
2026-09-19 21:20:41 +08:00
zhangwenjian 143dbf19a2 Merge branches 'feat/010-gen-plumbing' and 'feat/010-vue3-template' into integ/010-all 2026-09-19 20:51:39 +08:00
zhangwenjian f6bd306d6d fix🐛: send datetime fields as RFC3339, not space-separated local time
Both date-pickers -- the search filter and the insert/edit form -- used
value-format="YYYY-MM-DD HH:mm:ss", which formats a picked instant as
e.g. "2026-09-19 12:30:00": no T separator, no offset. dto.go.template
declares every datetime column's InsertReq/UpdateReq field as
time.Time with a plain `json:"..."` tag (R6 leaves that file alone, so
there is no time_format tag to reach for instead), and encoding/json's
default (Un)MarshalJSON for time.Time only accepts RFC3339. The
generated form would submit new and edited datetime values in a shape
Go's JSON decoder cannot parse -- a runtime failure on every create and
update, with nothing in `pnpm type-check` or `pnpm lint` positioned to
see it: both check the request is well-typed TypeScript, not that the
string it produces is a string Go can read.

Changed both to value-format="YYYY-MM-DDTHH:mm:ssZ" -- dayjs's Z token
renders the picker's own local offset, which is what a zero-nanosecond
time.Time (anything without a database column storing sub-second
precision) round-trips to on either side of the wire; confirmed
separately against Go's actual json.Marshal/Unmarshal, not assumed from
the RFC.

The search filter needed the same fix, not just the form: GetPageReq
binds a `time.Time` query field via `form:"..."` (dto.go.template),
and gin's own default for an untagged time.Time binding is also
RFC3339 -- the same failure mode on the query side, one call the
report didn't name but the same root cause reaches.

This is a runtime behaviour change no compiler catches, so it was
verified as one: a Go program exercising encoding/json directly (not
assumed from reading the RFC) confirmed a zero-nanosecond time.Time
marshals to plain RFC3339 with no fractional seconds, and unmarshals
correctly from both a numeric offset and a literal Z. Separately, a
Node script loaded go-admin-ui's own installed dayjs 1.11.21 with the
customParseFormat plugin -- the same plugin element-plus's date-picker
extends dayjs with -- and called the same parseDate path date-picker
panel.mjs uses (time-picker/src/utils.ts, no strict flag passed, so
lenient parsing): formatting with this value-format produced a valid
submission string, and parsing either an offset or a literal-Z string
back with the same format produced a valid, correctly-valued date --
covering create, edit prefill, and update in the two directions that
matter (browser to Go, Go to browser) without needing a live backend.
2026-09-19 20:50:13 +08:00
zhangwenjian 3beb00143a fix🐛: give FK and dict options separate variable names
A column configured with both FkTableName and DictType -- and reaching
a branch of each, e.g. required + IsQuery=1 + IsInsert=1 with
HtmlType=radio -- had both blocks declare `const {JsonField}Options`:
the dict branch from useDict, the FK branch from ref(). TS2451,
Cannot redeclare block-scoped variable, and the page does not compile.

This is reachable precisely because FkTableName and DictType do not
exclude each other consistently: search, list and the form's select
branch check FK first and fall back to dict, but the form's radio
branch never looks at FK at all -- it was already established (the
$dictUsed/$fkUsed audit two commits back) that a radio column's dict
options are used regardless of whatever FkTableName says. A column
that is both radio and query-or-insert-select can legitimately need
both sources at once, under one shared name.

Renamed to {JsonField}DictOptions and {JsonField}FkOptions and updated
every consuming branch to the name that matches what it was already
branching on: FK branches (search select, form select, the list
column's Label function) read FkOptions; dict branches (search select,
form select, form radio, the list column's dictLabel call) read
DictOptions. Mechanical rename, no new conditions -- each site already
knew which source it wanted from its own if/else-if.

Verified with team-lead's exact repro (FkTableName + DictType +
IsQuery=1 + IsInsert=1 + HtmlType=radio) rendered through the real
template.Execute and checked against a throwaway go-admin-ui worktree
(deleted afterwards): TS2451 fired twice before this change, zero
after -- and the rendered file confirms the search select actually
reads kindFkOptions (FK wins search's priority) while the insert radio
reads kindDictOptions (radio never checks FK), so both sources are
live, not just declared. Re-ran every fixture from every previous
round alongside it; all stayed green. pnpm type-check and pnpm lint
both zero error, on Node 24.11.0.
2026-09-19 20:49:41 +08:00
zhangwenjian 7a52a50964 fix🐛: dedupe FK imports by target table and filter by actual use
Two columns pointing at the same foreign table -- owner and approver
both selecting from the same users table, say -- each triggered their
own `import { listX } from ...` / `import type { X } from ...` line,
which is a duplicate ES module import once both fire: TS2300. Nothing
here ever asked whether a target table had already been imported by an
earlier column, because nothing tracked target tables at all -- only
source columns, and "one FK-configured column" was never the same
thing as "one distinct target table".

The same import was also gated on the column having FkTableName set,
not on $fkUsed -- the condition the previous fix already applies to the
const declarations that read the import. A column carrying FK metadata
but reaching no query, list or insert-select branch imported a module
nothing in the file references.

text/template has no set to check membership in, so the dedup is a
nested range: a column only imports its target if no earlier,
equally-used column already claimed the same FkTableNameClass. $fkUsed
is recomputed for both the outer and the inner column rather than
factored out, since text/template has no way to carry a per-column
value computed in one range into a second, later range over the same
data.

Verified with a fixture carrying three columns pointing at the same
target table -- one read only from search, one only from the list, one
from neither -- rendered through the real template.Execute and checked
against a throwaway go-admin-ui worktree (deleted afterwards): before
this change, TS2300 fired six times (the function and the type, three
times over); after, exactly one import of each, and the unused third
column contributes neither. Re-ran the previous rounds' fixtures
alongside it; all stayed green. pnpm type-check and pnpm lint both zero
error, on Node 24.11.0.
2026-09-19 20:48:58 +08:00
zhangwenjian 630e13686c fix🐛: gate optional imports on where they're actually used, not on raw metadata
Every $hasX flag controlling an optional import matched "this column
carries the metadata" rather than "some rendered branch actually reads
it" -- necessary but not sufficient, since FkTableName/DictType lose to
each other by priority (FK wins search, list and the form's select
branch; the form's radio branch never checks FK at all) and a column
can carry either while being neither queryable, listed, nor an
insertable select/radio.

$hasDatetime was the reachable case integration testing found:
sys_tables.go assigns HtmlType "datetime" to any timestamp/datetime
column on import regardless of IsList, because GetList's audit-column
exclusion is a separate, later step editTable.vue never surfaces
created_at/updated_at through anyway. Nearly every real table has both,
so nearly every table imported DateCell without using it.

$hasFk and $hasDict had the identical shape one level down: the
per-column ref/onMounted/useDict declarations were gated on "this
column has FkTableName/DictType", not on whether the column reaches a
branch that reads the resulting Options ref -- an FK column used only
via search (no IsList, no insert-select) still declared a Label
function nothing calls, and a dict column used only in an insert radio
(no IsQuery, no IsList) still would have, had the two flags controlling
its import stayed as wide as the per-column check they were meant to
gate.

Rewrote both to a shared $dictUsed/$fkUsed condition, matching each
consuming branch's own guard term for term, and split the FK block's
Label function under its own IsList check -- Options can be needed for
search or the form's select without List ever being true. $hasDictList
already had this shape from the previous fix and needed no change.

Verified with two new fixtures, rendered through the real
template.Execute and checked against a throwaway go-admin-ui worktree
(deleted afterwards) with hand-written API-module stubs: a bare table
carrying only the standard created_at/updated_at pair -- confirmed red
on DateCell before this change, green after -- and a table exercising
every optional import through a path distinct from the ones the two
earlier verification rounds covered (a dict column read only from an
insert radio, an FK column read only from search, and a business
datetime column that IS listed, so DateCell still has to import when
the real thing needs it). Re-ran the three fixtures from the previous
two rounds alongside these two; all five stayed green. pnpm type-check
and pnpm lint both zero error, on Node 24.11.0.
2026-09-19 20:40:29 +08:00
zhangwenjian 05661e2f3e fix🐛: jsonField format check relaxed to any legal identifier
jsonFieldPattern copied businessName's rule (^[a-z][A-Za-z]+$: at least
two letters, no digits) on the theory that jsonField should tighten to
the same identifier shape. That does not hold: businessName is typed
by a person on genInfoForm.vue, so a strict pattern is a reasonable
guardrail on human input. jsonField is computed by the importer from
the column name (sys_tables.go's namelist/JsonField loop) - nobody
types it, so the same pattern only rejected names the importer
legitimately produces: a single-letter column ("x") or one whose last
segment ends in a digit ("address2", "a1") both collapse to a single
camelCase word with nothing left to re-capitalize, and both failed the
old check.

The blast radius is wider than "this one column can't be edited":
validateAndSanitizeColumns runs over every column on every Update, so
a table that merely contains one such column could not save any
config change at all, including edits with nothing to do with that
column.

Relaxed to ^[a-z][A-Za-z0-9]*$ - any legal JS/TS identifier starting
with a lowercase letter. Still rejects what has to be rejected: empty,
whitespace/punctuation, and leading-digit names, since those cannot be
unquoted object keys in the generated interface/lang file at all.
Uniqueness and the expression-content check on defaultValue are
unchanged - defaultValue is genuinely user-typed (F6's config page),
so tightening it was the right call to begin with; this was the only
place a human-input rule had been copied onto machine-generated data.

Verified against the real import path, not hand-typed jsonField values:
built a table with columns id/x/address2/a1 in a fake information_schema,
ran it through the real SysTable.Insert, confirmed the importer computes
exactly jsonField x/address2/a1, then submitted an update through the
real SysTable.Update changing only tableComment (nothing about those
columns). Confirmed red first - 500, "jsonField 格式不合法:\"x\"" - a
change unrelated to any of the three columns was rejected solely because
they existed on the table. Restored the fix - 200, "修改成功".
2026-09-19 20:37:01 +08:00
zhangwenjian 3625ce851b fix🐛: empty Query interface for zero-IsQuery tables trips no-empty-object-type
A plain display table with no search form (zero columns marked
IsQuery=1) is a normal shape, not an edge case - but ts.go.template's
Query interface only had a body when the range over .Columns found a
match, so it rendered `export interface {ClassName}Query {}`, which
@typescript-eslint/no-empty-object-type flags and pnpm lint fails on.

Falls back to `export type {ClassName}Query = Record<string, never>`
when no column qualifies - the same answer go-admin-ui's own
useTable.ts already gives this shape (`TQuery extends object =
Record<string, never>`), so it intersects with PageQuery the same way
an empty interface would have and callers do not need to special-case
a query-less table.

Audited the rest of this template for the same "zero of some optional
feature" shape while in here, since this is the third time a query-less/
select-less/whatever-less table has slipped through (dictLabel, DateCell,
this one):

  - a zero-column table: cannot occur - a table without at least a
    primary key column cannot exist to be imported from information_schema
    in the first place, so .Columns is never empty here.
  - a table with only its primary key column: the row interface still
    has one property (the pk); not empty, no lint issue.
  - a table where every column has IsInsert=0: does not affect this
    template - add{Class}/update{Class} both take the full row interface
    unconditionally (every column, not just IsInsert ones), so there is
    no column-count-dependent shape to go empty here.

Verified with node 24.11.0 (not the machine default): generated a
zero-IsQuery table and a with-IsQuery table, copied both into
go-admin-ui and ran eslint + vue-tsc --noEmit. Confirmed red first -
`export interface VerifyNoQueryQuery {}` failed eslint with exactly the
no-empty-object-type error. Restored the fix - clean on both, plus a
throwaway call site instantiating useTable<VerifyNoQuery,
VerifyNoQueryQuery> to prove the type satisfies useTable's `TQuery
extends object` constraint, not just that it parses in isolation.
2026-09-19 20:36:24 +08:00
zhangwenjian 8b312bed1d Merge branches 'feat/010-gen-plumbing' and 'feat/010-vue3-template' into integ/010-all 2026-09-19 15:22:03 +08:00
zhangwenjian 30bcb57f41 fix🐛: put the rules computed's comma at the end of the previous line
The rules block joined entries with a $first-flag comma the same way
defaultQuery and defaultModel do, but on its own line rather than all
on one -- so the comma for every entry but the first sat at the start
of its line instead of the end of the one before it. @stylistic/comma-style
requires the opposite, and pnpm lint fails on any table with two or
more required insert fields (one comma is enough to trip it; a table
with 0-1 required fields never renders a second entry to get it wrong).
Reproduced first: rendering a four-required-field fixture reported
three comma-style errors, matching what integration testing found on
qa010_widget and qa010_natural.

Fixed by moving the separator so it trails the previous entry instead
of leading the next one: the else branch now emits ",\n  " -- comma,
then the newline and indent -- rather than "\n  , ", so the comma lands
on the line that already has content instead of opening a fresh one.

Also fixed while reproducing, found by the same fixture: dictLabel was
imported whenever any column had a DictType, but it is only called from
the list column's dictionary branch. A table using a dictionary solely
in its insert form (no such column in the list) imported dictLabel and
never called it, tripping no-unused-vars. $hasDict now only gates
useDict; a new $hasDictList gates dictLabel specifically.

Verified against three fixtures via a throwaway go-admin-ui worktree
(deleted afterwards) with hand-written API-module stubs standing in for
F5: the regression fixture (four required fields, confirmed red before
the fix, green after), and the two fixtures from the original F4
verification round (full branch coverage, and the all-flags-off edge
case), all of which stayed green. pnpm type-check and pnpm lint both
ran clean with zero errors, on Node 24.11.0 (this machine's default
node is 20.19.0; the project's engines field wants >=22).
2026-09-19 15:21:30 +08:00
zhangwenjian 5bb211afcd fix🐛: Preview now sets tab.MLTBName before rendering, matching NOActionsGen
MLTBName (table_name with underscores turned to dashes, e.g. "user_profile"
-> "user-profile") is a gorm:"-" field - table.Get never fills it in, the
caller has to. NOActionsGen has done so since it existed; Preview never
did, so every template's import path that reads it
(from '@/api/{PackageName}/{MLTBName}' in vue.go.template, present in
both the pre-Vue3 template and F4's rewrite) rendered with the module
segment missing - "from '@/api/admin/'" - in the preview dialog only.
The real generated file was always correct.

Pre-existing, not introduced by this PRD, but worth fixing now: F3/F9
added two more Preview panes (the language packs) on top of an assumption
- that Preview's output stands in for what NOActionsGen actually writes -
that was never true for this field. Checked the rest of gen.go for the
same "only set on the write path" shape; MLTBName is the only one -
every other field Preview's templates read comes straight off the
sys_tables/sys_columns rows table.Get already loads.

Verified with a throwaway harness driving Gen.Preview through a real gin
Context and sqlite-backed db, parsing the JSON response and reading back
template/vue.go.template's import line. Confirmed red first (temporarily
removed the added line): "from '@/api/verify010/'". Restored it: "from
'@/api/verify010/verify-widget'".
2026-09-19 15:18:29 +08:00
zhangwenjian d102c4b7c1 Merge branch 'feat/010-vue3-template' into integ/010-all 2026-09-19 14:17:14 +08:00
zhangwenjian 545453c93e feat✨: infer column width from column_type before rendering (PRD 010 R2)
vue.go.template (F4) only had a flat fallback for an unconfigured
colWidth: 110 for datetime, 120 for everything else. R2 asks for a
precise per-type inference instead - varchar(n) tiered by n, tinyint(1)
narrower than a general integer, text/blob wide - sized so a typical
table's list columns land inside go-admin-ui's ~580px text-column
budget. text/template cannot parse "varchar(255)" itself, so this has
to run in gen.go before the template executes, not in the template.

Judgment is columnType, not goType, same as F1/F2's earlier reversal
(API契约.md §1.1): sys_tables.go:323-338 gives every non-primary-key
int/tinyint/bigint/decimal column goType "string", so goType alone
cannot tell a boolean flag from a bigint from a name column.

InferColumnWidth(columnType string) int is exported and pure per
测试用例.md §2.5's own request, so it can be pinned with an exact
input/output table rather than only asserting "the rendered page
happens not to overflow" - see column_width_test.go, including the
tinyint(1)-vs-tinyint(4) and the varchar tier-boundary cases.
applyInferredColumnWidths only touches a column already at the 0
sentinel, wired into both Preview and NOActionsGen ahead of the
template execute calls; a column already configured (by the user or F6)
is left untouched, also pinned by a test.

Verified against the real NOActionsGen call path (not just the unit
test): generated a table mixing every branch (tinyint(1), datetime,
decimal, varchar at three tiers, text, and one pre-configured column)
and printed the resulting tab.Columns[i].ColWidth after generation -
every value matched InferColumnWidth's own table, and the
pre-configured column's 333 was left untouched.
2026-09-19 14:16:37 +08:00
zhangwenjian c3d2a5952b fix🐛: getVerifyXxx's pk parameter type follows GoType, not always number
ts.go.template hardcoded the pk parameter as `: number`. vue.go.template
(F4, landed after this file) derives $pkType per table - "number" unless
the pk column's GoType is "string" (natural keys do exist; sys_tables.go
gives a pk column GoType "string" whenever its ColumnType is not
int-shaped), then types useForm on that same $pkType. A string-pk table
therefore generated a page calling get{Class}(id: string) against an api
module whose get{Class} only accepted number - a TS compile error, not
a runtime bug, and exactly the kind of gap each side's own template-only
verification could not see (F4 was checked against a hand-written .ts
stub before this template existed; this template was checked against a
numeric-pk fixture only).

Copies vue.go.template's $pkType derivation verbatim so both templates
agree by construction rather than by convention.

Verified: generated both a numeric-pk and a string-pk table's api module,
copied both into go-admin-ui and ran vue-tsc --noEmit - clean. Added a
throwaway call-site file exercising getVerifyStrPk with a string and (via
@ts-expect-error) confirming a number argument is now rejected, so the
type is actually enforced rather than having quietly widened to any.
2026-09-19 14:12:01 +08:00
zhangwenjian d65b21baf6 feat✨: field-level validation for sys_tables.go:357's Update (PRD 010 F10)
That bind-and-save path had zero field-level validation (API契约.md
§1.2/§2.1, decision D6), which left four ways to silently corrupt the
generator's own metadata or the language packs it writes:

  - jsonField had no format rule at all, unlike tableName/className/
    businessName, which all have a pattern check. A jsonField with a
    dot, a leading digit, or anything not matching lower camelCase
    still saved, and would land verbatim as a key segment in
    gen/{PackageName}/{BusinessName}.ts.
  - jsonField had no per-table uniqueness check. Two columns retitled
    to the same jsonField overwrite each other's generated i18n key
    with no warning.
  - businessName had no cross-table uniqueness check within a
    packageName. Two tables sharing one write the same language pack
    path and the second's generation silently clobbers the first's.
  - defaultValue is spliced into the generated defaultModel() as a
    literal, never evaluated (API契约.md §2.1) - so content shaped
    like a function call or block does not do what it looks like it
    does, and previously saved without complaint either way.

colWidth is handled differently on purpose: API契约.md §2.1 says an
out-of-range value should fall back to the inferred width rather than
be rejected, so it is reset to the 0 sentinel in place instead of
failing the request - same outcome as if it had never been set.

G10 (colliding with the built-in admin/* i18n namespace) is not one of
the four: D9 already moved generated keys into their own gen/
namespace, so that collision no longer exists.

All four checks and colWidth's sanitize-in-place path are covered by
sys_tables_validate_test.go. Confirmed red first: swapped in a no-op
stand-in for both validators and reran - 13 sub-tests that should now
be rejected or sanitized passed straight through instead (jsonField
format x5, jsonField uniqueness x1, colWidth range x2, defaultValue
expression x4, businessName uniqueness x1). Restored the real
implementation and reran - all green.
2026-09-19 14:08:05 +08:00
zhangwenjian 0f31feae6f feat✨: rewrite the generator's Vue template for Vue 3 (PRD 010 F4)
template/v4/vue.go.template produced Vue 2 syntax -- slot-scope, .sync,
.native -- all removed outright in Vue 3, so every generated page
failed to render (PRD 010 G1). Rewritten from scratch to match
go-admin-ui's reference page (src/views/demo/product/index.vue):
PageContainer + ProTable + useTable/useForm/useRemove,
<script setup lang="ts">.

Four constraints from the phase-3 review, all load-bearing:

- An HtmlType this template doesn't recognise (the old "file" branch,
  or anything future work adds) renders as a plain input rather than
  nothing. sys_tables.go still assigns "datetime" automatically on
  import, and a row can carry "file" from before F7 disabled it in the
  UI, so "unknown" is a real, reachable state, not a hypothetical one.
- Every column width is min-width, never width -- a rigid width
  repeats G5's overflow bug on any table with enough columns. colWidth
  (F1) backs it when set, a flat per-column-kind number otherwise.
- Not one Chinese character anywhere in the output, comments included:
  D10's CJK scan is a bare regex with no exception for "it's only a
  comment". Every label goes through
  $t('gen.{PackageName}.{BusinessName}.{JsonField}'), read from the
  language pack F9 wired into src/lang/{locale}/gen/.
- defineOptions carries the ClassName + "Manage" suffix gen.go writes
  into sys_menu.menu_name (R4) -- the old template's bare ClassName
  was a latent mismatch masked by loadView()'s runtime rewrite (G7).

Verified by rendering two fixtures through the real template.Execute --
one exercising every optional import (dictionary, foreign key,
datetime, required-field rules, search filters) and one with none of
them, a string primary key, and a select/radio with neither a
dictionary nor a foreign key configured, plus an html_type this enum
has never had. Both scan clean for CJK, and both pass pnpm type-check
and pnpm lint with zero errors against a throwaway go-admin-ui
worktree (deleted afterwards) seeded with hand-written API-module
stubs standing in for F5, which lands in template/v4/js.go.template
separately.
2026-09-19 14:04:29 +08:00
zhangwenjian 0494a27d6c fix🐛: SysColumns.Update can now clear colWidth/defaultValue back to 0/""
Updates(&e) uses GORM's struct form, which skips zero-value fields -
but 0/"" is exactly the sentinel PRD 010 F1/F2 chose for "unconfigured"
(数据库变更.md §1.1). A caller resetting colWidth or defaultValue back
to that sentinel was therefore silently ignored: the row kept its old
value, the API reported success, and reopening the edit form showed the
stale number/string again.

Not fixed by switching to Select("*") - that would also start writing
this struct's other zero-valued fields (Sort, the Pk/Required/... bools),
none of which are part of this change. A second, map-form Updates
scoped to just these two columns writes the sentinel without touching
anything else; map-form Updates does not skip zero values.

TestSysColumnsUpdateClearsSentinelFields pins the regression: seed
150/"active", update to 0/"", read back and assert it stuck. Confirmed
red before this change (got 150/"active" back) and green after.
2026-09-19 13:59:27 +08:00
zhangwenjian e2b6ddb290 feat✨: generate per-table zh-CN/en-US language packs (PRD 010 F3/F9)
Add lang-zh.go.template and lang-en.go.template, one key per column
keyed by JsonField, valued from ColumnComment (R3) with a fallback to
JsonField when ColumnComment is blank (G11) - both locales resolve to
the same zh-CN text today since D3 rules out machine translation and
go-admin-ui's parity test rejects blank values.

NOActionsGen writes both files to lang/{locale}/gen/{PackageName}/
{BusinessName}.ts, two levels deep so go-admin-ui's gen-namespace.ts
glob (./*/*.ts under each locale's gen/) picks them up; nesting under
PackageName (rather than a flat gen/{BusinessName}.ts) avoids two
tables in different packages silently overwriting each other's
translations, since BusinessName has no uniqueness check.

Output has to be single-quoted with no trailing comma to satisfy
go-admin-ui's eslint config, which text/template's builtin `printf "%q"`
does not produce, hence the small parseGenTemplate/singleQuote helper
shared by the two templates.

Verified end to end: generated a real table's output (including a
blank-ColumnComment column, to exercise the G11 fallback), copied the
two language packs into go-admin-ui and ran vue-tsc --noEmit and eslint
against them - both clean, comma-dangle/quotes included.
2026-09-19 13:56:34 +08:00
zhangwenjian 28eba92077 feat✨: generate a typed .ts API module instead of .js (PRD 010 F5)
Rewrite template/v4/js.go.template (renamed to ts.go.template) to emit
TypeScript matching go-admin-ui's src/api/demo/product.ts: an interface
per row and per query params, typed CRUD functions, and a del{Class}
that takes Id[] like useRemove expects. GoType maps to number for
int/int64/float32/float64 and string otherwise, since go-admin-ui's
request() types the {code,data,msg} envelope rather than the payload.

Preview's response map key changes from template/js.go.template to
template/api.ts.template so its Tab label matches the new content;
NOActionsGen writes the file with a .ts extension.

Verified end to end: generated a real table's output, copied it into
go-admin-ui and ran vue-tsc --noEmit and eslint against it - both clean.
2026-09-19 13:55:56 +08:00
zhangwenjian aa7a92664d feat✨: add sys_columns.col_width and default_value (PRD 010 F1/F2)
Back the code generator's Vue 3 template migration: col_width lets R2's
column-width inference be overridden per field, and default_value lets
R1/A6's "unconfigured rows still generate a usable page" guarantee hold
for generated forms. Both use a sentinel default (0 / "") rather than
NULL so "unconfigured" has exactly one representation - see
docs-prd/010-代码生成器前端模板迁移Vue3/数据库变更.md §1.1.

The migration and the model change land together: AddColumn reads the
column definition off tools.SysColumns's own gorm tag, so splitting them
across commits would leave one of them failing to compile.
2026-09-19 13:33:42 +08:00
wenjianzhang 68ecc5f9a8 Merge pull request #942 from go-admin-team/ci/gofmt-gate
chore🔧: gate gofmt in CI
2026-09-18 23:06:48 +08:00
zhangwenjian 8341044251 chore🔧: gate gofmt in CI
The tree is at zero unformatted files for the first time. Nothing holds it
there: gofmt drift is invisible in review because the common form of it is a
missing newline at the end of a file, which no diff reader notices and no
existing check looks at. That is how the batch cleaned up just now reached 26
files.

`make fmt-check` reports rather than rewrites. CI cannot commit, and a target
that silently reformats hides what it touched; `gofmt -l` names the files and
says nothing when there are none, so its output is both the failure message and
the fix.

The step runs before the tests rather than beside checksilent at the end. A
formatting miss is a one-command fix, and learning about it after five minutes
of tests and an end-to-end install wastes five minutes for nothing.
2026-09-18 22:57:17 +08:00
wenjianzhang 8b03400ecc Merge pull request #941 from go-admin-team/docs/agents-api-terminology
docs📝: call the layer Api in the three places that still said Handler
2026-09-18 21:15:40 +08:00
zhangwenjian 8115c3a737 docs📝: call the layer Api in the three places that still said Handler
The diagram at the top reads Router -> Api -> Service -> Model, the section is
headed "Api 层", and the directory is app/admin/apis/. Three sentences in the
body still called that layer Handler -- the name it went by before the rename,
and one nothing in the tree answers to now. A reader following the document has
no way to tell the two are the same layer.

Wording only. No rule changes, and no other file is touched.
2026-09-18 21:07:32 +08:00
wenjianzhang c01307202d Merge pull request #939 from go-admin-team/style/gofmt-tree
style💄: run gofmt over the tree
2026-09-18 21:03:46 +08:00
zhangwenjian 5ecb1e6e4c style💄: run gofmt over the tree
`gofmt -l` listed 26 files. Seventeen of them were missing the newline at the
end of the file; the rest are indentation that used spaces where the file uses
tabs, a handful of call sites written `f(a,b)`, and the doc comment spacing
gofmt has rewritten since 1.19 (`//X` to `// X`).

Nothing here changes behaviour: `go build ./...` and `go vet ./...` are clean
and `go test ./common/...` passes, which is the half of the tree these files
are concentrated in.

Only the files gofmt named are touched, so the diff reads line by line rather
than as a reflow of the whole repository. `gofmt -l` is now empty, which is the
precondition for gating it in CI -- worth doing, but a separate change.
2026-09-18 18:55:19 +08:00
wenjianzhang 9c299805c1 Merge pull request #938 from go-admin-team/fix/cache-control-stray-token
fix🐛: drop the stray token from the Cache-Control header
2026-09-18 14:53:52 +08:00
zhangwenjian 2a900c9876 fix🐛: drop the stray token from the Cache-Control header
NoCache sent `no-cache, no-store, max-age=0, must-revalidate, value`. The
trailing `, value` is not a directive; it is a leftover token that has been on
every response this middleware touches since the file was written. Unknown
directives are ignored, so nothing misbehaved because of it, but it went out on
the wire and read as a mistake to anyone looking.

The assertion added in #937 pins the old value, so it moves with the source:
removing the token from the middleware alone turns TestNoCache red, which is
the whole point of that test and the reason both lines change together here.
2026-09-18 14:47:28 +08:00
wenjianzhang 0008b943a3 Merge pull request #937 from Tuoxie423/test/header-middleware
test✅: add unit tests for NoCache/Options/Secure middleware
2026-09-18 14:41:31 +08:00
拖鞋423 50c74b1f96 test✅: add unit tests for NoCache/Options/Secure middleware 2026-09-17 23:41:21 +08:00
wenjianzhang ae1eef6d4f Merge pull request #936 from go-admin-team/fix/gen-import-accepts-json-body
fix🐛: accept the import table list from a JSON body as well
2026-09-16 20:53:03 +08:00
zhangwenjian 1cd39b80b3 test✅: fail when Remove parks a goroutine on an abandoned channel
#924 buffered the channel Remove returns, so the goroutine it starts can
finish when the caller has already timed out and walked away. That half of
the patch was not covered: reverting the buffer on its own left the whole
suite green, while the timeout assertion it shipped with stayed red only for
the error-return half.

This test abandons twenty channels and then requires every goroutine to be
gone. It waits for the scheduler to empty first, so a goroutine that has not
yet reached its send cannot make the test pass while proving nothing.

Without the buffer: 20 of 20 parked on `chan send` at jobbase.go:216.
2026-09-16 08:27:24 +08:00
66 changed files with 2650 additions and 657 deletions
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: 1.26.5
go-version: 1.27.1
- name: Tidy
run: go mod tidy
+35 -2
View File
@@ -68,6 +68,28 @@ jobs:
--health-retries 20
--health-start-period 20s
# The dialect most installations actually run, and until the
# scheduler lease (#915) the only one with no service here. The lease
# reads the database's clock, and the first implementation read it as
# a timestamp: over go-admin's own `parseTime=True&loc=Local` DSN,
# MySQL's UTC_TIMESTAMP comes back relabelled as local time, so on any
# host that is not UTC every lease was one zone offset out - and every
# assertion that compared the lease only against itself still passed.
# The three dialects that were here could not see it.
mysql:
image: mysql:8
env:
MYSQL_ROOT_PASSWORD: GoAdmin_Test1
MYSQL_DATABASE: goadmin_test
ports:
- 3306:3306
options: >-
--health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -pGoAdmin_Test1"
--health-interval 10s
--health-timeout 5s
--health-retries 20
--health-start-period 20s
env:
GO_ADMIN_TEST_REDIS_ADDR: 127.0.0.1:6379
# The soft-delete conversion drops an index, and gorm's PostgreSQL driver
@@ -77,13 +99,18 @@ jobs:
# See go-admin#919.
GO_ADMIN_TEST_POSTGRES_DSN: "host=127.0.0.1 port=5432 user=postgres password=postgres dbname=goadmin_test sslmode=disable"
GO_ADMIN_TEST_SQLSERVER_DSN: "sqlserver://sa:GoAdmin_Test1@127.0.0.1:1433?database=goadmin_test"
# loc=Local on purpose: it is what config/settings.yml ships and what
# made the timezone defect above reachable. A DSN here that quietly
# differed from the one installations use would test a configuration
# nobody runs.
GO_ADMIN_TEST_MYSQL_DSN: "root:GoAdmin_Test1@tcp(127.0.0.1:3306)/goadmin_test?charset=utf8mb4&parseTime=True&loc=Local"
steps:
- name: Set up Go 1.26
- name: Set up Go 1.27
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: 1.26.5
go-version: 1.27.1
id: go
- name: Check out code into the Go module directory
@@ -100,6 +127,12 @@ jobs:
- name: Get dependencies
run: go mod tidy
# Before the tests rather than beside checksilent at the end: a formatting
# miss is a one-command fix, and finding out about it after five minutes of
# tests and an end-to-end install is five minutes nobody gets back.
- name: Formatting
run: make fmt-check
# 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.
+3 -3
View File
@@ -20,7 +20,7 @@ Router → Api → Service → Model
## 优先使用通用 Action
单表 CRUD **不要手写 Handler 与 Service**。`common/actions` 提供的五个
单表 CRUD **不要手写 Api 与 Service**。`common/actions` 提供的五个
Action 已覆盖参数绑定、数据权限过滤、操作人注入、分页与错误响应:
```go
@@ -49,7 +49,7 @@ r := v1.Group("/demo-product").Use(authMiddleware.MiddlewareFunc()).Use(middlewa
就地返回会串数据(`app/demo` 的测试锁定了这一点)
- 详情/删除 DTO 内嵌 `dto.ObjectById` 即可继承 `Bind` 与 `GetId`,无需重写
仅当业务超出单表 CRUD(跨表事务、外部调用、复杂校验)时才自行编写 Handler
仅当业务超出单表 CRUD(跨表事务、外部调用、复杂校验)时才自行编写 Api
与 Service,写法见下。
## Api 层(仅在通用 Action 不适用时)
@@ -167,7 +167,7 @@ sys_menu / sys_menu_api_rule / casbin_rule 四张表如何配齐,用的是幂
## Swagger
Handler 必须带完整注解,`go generate` 会据此生成文档:
Api 必须带完整注解,`go generate` 会据此生成文档:
```go
// @Summary 岗位列表
+17
View File
@@ -80,6 +80,23 @@ else
go run ./tools/checksilent
endif
# gofmt as a gate, not a rewrite. CI cannot commit, and a target that quietly
# reformats hides what it touched, so this reports and fails instead. `gofmt -l`
# prints the files it would rewrite and nothing at all when there are none, so
# that list is both the failure message and the instructions for fixing it.
#
# The tree reached zero unformatted files once; without something holding it
# there it drifts back, which is how the previous batch grew to 26 files -
# mostly a missing newline at the end of the file, which no reviewer notices.
.PHONY: fmt-check
fmt-check:
@unformatted=$$(gofmt -l .); \
if [ -n "$$unformatted" ]; then \
echo "gofmt would rewrite these files. Run 'gofmt -w .' and commit the result:"; \
echo "$$unformatted"; \
exit 1; \
fi
#.PHONY: docker
#docker:
# docker build . -t go-admin:latest
+1 -1
View File
@@ -106,7 +106,7 @@ antd 体验(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admi
### 环境要求
go 1.26.5
go 1.27.1
node版本: v22+(推荐 v24 LTS)
+1 -1
View File
@@ -106,7 +106,7 @@ antd デモ(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admi
### 動作要件
go 1.26.5
go 1.27.1
node バージョン: v22 以上(v24 LTS 推奨)
+1 -1
View File
@@ -104,7 +104,7 @@ At the same time, a series of tutorials including videos and documents are provi
### Environmental requirements
go 1.26.5
go 1.27.1
nodejs: v22+ (v24 LTS recommended)
+1 -1
View File
@@ -106,7 +106,7 @@ antd 體驗(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admi
### 環境需求
go 1.26.5
go 1.27.1
node 版本: v22+(建議 v24 LTS)
+2 -2
View File
@@ -3,9 +3,9 @@ package apis
import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
@@ -145,4 +145,4 @@ func (e SysApi) DeleteSysApi(c *gin.Context) {
return
}
e.OK(req.GetId(), "删除成功")
}
}
+2 -2
View File
@@ -3,9 +3,9 @@ package apis
import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
@@ -216,5 +216,5 @@ func (e SysDictData) GetAll(c *gin.Context) {
l = append(l, d)
}
e.OK(l,"查询成功")
e.OK(l, "查询成功")
}
+10 -10
View File
@@ -4,9 +4,9 @@ import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
@@ -31,7 +31,7 @@ type SysDictType struct {
// @Security Bearer
func (e SysDictType) GetPage(c *gin.Context) {
s := service.SysDictType{}
req :=dto.SysDictTypeGetPageReq{}
req := dto.SysDictTypeGetPageReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, binding.Form).
@@ -62,7 +62,7 @@ func (e SysDictType) GetPage(c *gin.Context) {
// @Security Bearer
func (e SysDictType) Get(c *gin.Context) {
s := service.SysDictType{}
req :=dto.SysDictTypeGetReq{}
req := dto.SysDictTypeGetReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, nil).
@@ -82,7 +82,7 @@ func (e SysDictType) Get(c *gin.Context) {
e.OK(object, "查询成功")
}
//Insert 字典类型创建
// Insert 字典类型创建
// @Summary 添加字典类型
// @Description 获取JSON
// @Tags 字典类型
@@ -94,7 +94,7 @@ func (e SysDictType) Get(c *gin.Context) {
// @Security Bearer
func (e SysDictType) Insert(c *gin.Context) {
s := service.SysDictType{}
req :=dto.SysDictTypeInsertReq{}
req := dto.SysDictTypeInsertReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, binding.JSON).
@@ -109,7 +109,7 @@ func (e SysDictType) Insert(c *gin.Context) {
err = s.Insert(&req)
if err != nil {
e.Logger.Error(err)
e.Error(500, err,fmt.Sprintf(" 创建字典类型失败,详情:%s", err.Error()))
e.Error(500, err, fmt.Sprintf(" 创建字典类型失败,详情:%s", err.Error()))
return
}
e.OK(req.GetId(), "创建成功")
@@ -127,7 +127,7 @@ func (e SysDictType) Insert(c *gin.Context) {
// @Security Bearer
func (e SysDictType) Update(c *gin.Context) {
s := service.SysDictType{}
req :=dto.SysDictTypeUpdateReq{}
req := dto.SysDictTypeUpdateReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, binding.JSON, nil).
@@ -157,7 +157,7 @@ func (e SysDictType) Update(c *gin.Context) {
// @Security Bearer
func (e SysDictType) Delete(c *gin.Context) {
s := service.SysDictType{}
req :=dto.SysDictTypeDeleteReq{}
req := dto.SysDictTypeDeleteReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, binding.JSON, nil).
@@ -189,7 +189,7 @@ func (e SysDictType) Delete(c *gin.Context) {
// @Security Bearer
func (e SysDictType) GetAll(c *gin.Context) {
s := service.SysDictType{}
req :=dto.SysDictTypeGetPageReq{}
req := dto.SysDictTypeGetPageReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, binding.Form).
@@ -207,4 +207,4 @@ func (e SysDictType) GetAll(c *gin.Context) {
return
}
e.OK(list, "查询成功")
}
}
+4 -4
View File
@@ -29,7 +29,7 @@ type SysLoginLog struct {
// @Security Bearer
func (e SysLoginLog) GetPage(c *gin.Context) {
s := service.SysLoginLog{}
req :=dto.SysLoginLogGetPageReq{}
req := dto.SysLoginLogGetPageReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, binding.Form).
@@ -60,7 +60,7 @@ func (e SysLoginLog) GetPage(c *gin.Context) {
// @Security Bearer
func (e SysLoginLog) Get(c *gin.Context) {
s := service.SysLoginLog{}
req :=dto.SysLoginLogGetReq{}
req := dto.SysLoginLogGetReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req).
@@ -90,7 +90,7 @@ func (e SysLoginLog) Get(c *gin.Context) {
// @Security Bearer
func (e SysLoginLog) Delete(c *gin.Context) {
s := service.SysLoginLog{}
req :=dto.SysLoginLogDeleteReq{}
req := dto.SysLoginLogDeleteReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, binding.JSON, nil).
@@ -107,4 +107,4 @@ func (e SysLoginLog) Delete(c *gin.Context) {
return
}
e.OK(req.GetId(), "删除成功")
}
}
+3 -3
View File
@@ -65,7 +65,7 @@ func (e SysOperaLog) GetPage(c *gin.Context) {
// @Security Bearer
func (e SysOperaLog) Get(c *gin.Context) {
s := new(service.SysOperaLog)
req :=dto.SysOperaLogGetReq{}
req := dto.SysOperaLogGetReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, nil).
@@ -96,7 +96,7 @@ func (e SysOperaLog) Get(c *gin.Context) {
// @Security Bearer
func (e SysOperaLog) Delete(c *gin.Context) {
s := new(service.SysOperaLog)
req :=dto.SysOperaLogDeleteReq{}
req := dto.SysOperaLogDeleteReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, binding.JSON).
@@ -111,7 +111,7 @@ func (e SysOperaLog) Delete(c *gin.Context) {
err = s.Remove(&req)
if err != nil {
e.Logger.Error(err)
e.Error(500,err, fmt.Sprintf("删除失败!错误详情:%s", err.Error()))
e.Error(500, err, fmt.Sprintf("删除失败!错误详情:%s", err.Error()))
return
}
e.OK(req.GetId(), "删除成功")
+8 -8
View File
@@ -2,12 +2,12 @@ package apis
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"go-admin/app/admin/models"
"go-admin/app/admin/service"
@@ -31,7 +31,7 @@ type SysPost struct {
// @Security Bearer
func (e SysPost) GetPage(c *gin.Context) {
s := service.SysPost{}
req :=dto.SysPostPageReq{}
req := dto.SysPostPageReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, binding.Form).
@@ -65,7 +65,7 @@ func (e SysPost) GetPage(c *gin.Context) {
// @Security Bearer
func (e SysPost) Get(c *gin.Context) {
s := service.SysPost{}
req :=dto.SysPostGetReq{}
req := dto.SysPostGetReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, nil).
@@ -99,7 +99,7 @@ func (e SysPost) Get(c *gin.Context) {
// @Security Bearer
func (e SysPost) Insert(c *gin.Context) {
s := service.SysPost{}
req :=dto.SysPostInsertReq{}
req := dto.SysPostInsertReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, binding.JSON).
@@ -131,7 +131,7 @@ func (e SysPost) Insert(c *gin.Context) {
// @Security Bearer
func (e SysPost) Update(c *gin.Context) {
s := service.SysPost{}
req :=dto.SysPostUpdateReq{}
req := dto.SysPostUpdateReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, binding.JSON, nil).
@@ -163,7 +163,7 @@ func (e SysPost) Update(c *gin.Context) {
// @Security Bearer
func (e SysPost) Delete(c *gin.Context) {
s := service.SysPost{}
req :=dto.SysPostDeleteReq{}
req := dto.SysPostDeleteReq{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, binding.JSON).
@@ -181,4 +181,4 @@ func (e SysPost) Delete(c *gin.Context) {
return
}
e.OK(req.GetId(), "删除成功")
}
}
+1 -1
View File
@@ -29,4 +29,4 @@ func registerSysDeptRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
r1.GET("/deptTree", api.Get2Tree)
}
}
}
+1 -1
View File
@@ -21,4 +21,4 @@ func registerSysLoginLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
r.GET("/:id", api.Get)
r.DELETE("", api.Delete)
}
}
}
+1 -1
View File
@@ -30,4 +30,4 @@ func registerSysMenuRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
//r1.GET("/menuids", api.GetMenuIDS)
}
}
}
+1 -1
View File
@@ -20,4 +20,4 @@ func registerSysOperaLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
r.GET("/:id", api.Get)
r.DELETE("", api.Delete)
}
}
}
+1 -1
View File
@@ -22,4 +22,4 @@ func registerSyPostRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
r.PUT("/:id", api.Update)
r.DELETE("", api.Delete)
}
}
}
+1 -1
View File
@@ -36,4 +36,4 @@ func registerSysUserRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
{
v1auth.GET("/getinfo", api.GetInfo)
}
}
}
+9 -9
View File
@@ -7,15 +7,15 @@ import (
// SysDeptGetPageReq 列表或者搜索使用结构体
type SysDeptGetPageReq struct {
DeptId int `form:"deptId" search:"type:exact;column:dept_id;table:sys_dept" comment:"id"` //id
ParentId int `form:"parentId" search:"type:exact;column:parent_id;table:sys_dept" comment:"上级部门"` //上级部门
DeptPath string `form:"deptPath" search:"type:exact;column:dept_path;table:sys_dept" comment:""` //路径
DeptName string `form:"deptName" search:"type:exact;column:dept_name;table:sys_dept" comment:"部门名称"` //部门名称
Sort int `form:"sort" search:"type:exact;column:sort;table:sys_dept" comment:"排序"` //排序
Leader string `form:"leader" search:"type:exact;column:leader;table:sys_dept" comment:"负责人"` //负责人
Phone string `form:"phone" search:"type:exact;column:phone;table:sys_dept" comment:"手机"` //手机
Email string `form:"email" search:"type:exact;column:email;table:sys_dept" comment:"邮箱"` //邮箱
Status string `form:"status" search:"type:exact;column:status;table:sys_dept" comment:"状态"` //状态
DeptId int `form:"deptId" search:"type:exact;column:dept_id;table:sys_dept" comment:"id"` //id
ParentId int `form:"parentId" search:"type:exact;column:parent_id;table:sys_dept" comment:"上级部门"` //上级部门
DeptPath string `form:"deptPath" search:"type:exact;column:dept_path;table:sys_dept" comment:""` //路径
DeptName string `form:"deptName" search:"type:exact;column:dept_name;table:sys_dept" comment:"部门名称"` //部门名称
Sort int `form:"sort" search:"type:exact;column:sort;table:sys_dept" comment:"排序"` //排序
Leader string `form:"leader" search:"type:exact;column:leader;table:sys_dept" comment:"负责人"` //负责人
Phone string `form:"phone" search:"type:exact;column:phone;table:sys_dept" comment:"手机"` //手机
Email string `form:"email" search:"type:exact;column:email;table:sys_dept" comment:"邮箱"` //邮箱
Status string `form:"status" search:"type:exact;column:status;table:sys_dept" comment:"状态"` //状态
}
func (m *SysDeptGetPageReq) GetNeedSearch() interface{} {
+1 -1
View File
@@ -54,4 +54,4 @@ type SysLoginLogDeleteReq struct {
func (s *SysLoginLogDeleteReq) GetId() interface{} {
return s.Ids
}
}
+1 -1
View File
@@ -258,7 +258,7 @@ func (e *SysDept) SetDeptLabel() (m []dto.DeptLabel, err error) {
list := make([]models.SysDept, 0)
err = e.Orm.Find(&list).Error
if err != nil {
log.Error("find dept list error, %s", err.Error())
log.Errorf("find dept list error, %s", err.Error())
return
}
m = make([]dto.DeptLabel, 0)
+1 -1
View File
@@ -107,4 +107,4 @@ type SysRoleMenu struct {
// return nil, err
// }
// return r, nil
//}
//}
+35 -16
View File
@@ -97,13 +97,20 @@ LOOP:
}
// Setup 初始化
// Setup gives every tenant a scheduler and a supervisor to decide whether
// this instance is the one that fills it.
//
// One owner id for the whole process, not one per tenant: the thing holding
// the leases is this process, and a log line naming it should name the same
// thing in every database it appears in.
func Setup(dbs map[string]*gorm.DB) {
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore Starting...")
owner := newOwnerID()
for k, db := range dbs {
sdk.Runtime.SetCrontabByTenant(k, cronjob.NewWithSeconds())
setup(k, db)
newSupervisor(k, db, owner).start()
}
}
@@ -149,7 +156,7 @@ func setup(key string, db *gorm.DB) {
startCrontab(crontab)
}
// startCrontab starts c and arranges for it to be stopped on the way out.
// startCrontab starts c.
//
// 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
@@ -158,24 +165,36 @@ func setup(key string, db *gorm.DB) {
// 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.
// Stopping is no longer arranged here. A scheduler now stops for two
// different reasons - the process is going down, or this instance lost the
// lease (#915) - and only the supervisor knows which. Registering a shutdown
// callback per start, when a start happens every time the lease is taken,
// would also add one callback per leadership change for the life of the
// process: SetShutdown appends.
func startCrontab(c *cron.Cron) {
c.Start()
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore start success.")
}
// 关闭任务
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")
}
})
// stopCrontab stops one tenant's scheduler and waits for the jobs already
// running to finish, bounded by ctx.
//
// cron.Stop returns a context that closes once those jobs have finished.
// That is the wait the shutdown budget exists to bound: giving up on it
// leaves them running until the process exits, which is better than holding
// the whole shutdown open for one job that will not end.
func stopCrontab(ctx context.Context, key string) {
c := sdk.Runtime.GetCrontabByTenant(key)
if c == nil {
return
}
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 -4
View File
@@ -6,26 +6,45 @@ import (
"testing"
"time"
glebarez "github.com/glebarez/sqlite"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/cronjob"
"gorm.io/gorm"
models2 "go-admin/app/jobs/models"
)
// 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.
//
// It now goes through the supervisor, which is what production does and what
// owns the shutdown callback since the lease landed (#915): a scheduler stops
// either because the process is going down or because this instance lost the
// lease, and only the supervisor can tell those apart.
//
// 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) {
// registration once it has run: a second RunShutdown in this binary would
// find an empty registry and pass while proving nothing. The lease-release
// assertion is folded in here for the same reason.
func TestTheSchedulerIsStoppedAndTheLeaseHandedBackOnTheWayOut(t *testing.T) {
const tenant = "*"
db := leaseDB(t)
var ticks atomic.Int64
c := cronjob.NewWithSeconds()
if _, err := c.AddFunc("* * * * * *", func() { ticks.Add(1) }); err != nil {
t.Fatalf("AddFunc: %v", err)
}
sdk.Runtime.SetCrontabByTenant(tenant, c)
startCrontab(c)
s := newSupervisor(tenant, db, "instance-under-test")
s.start()
if !s.holdsLease() {
t.Fatal("the supervisor did not take a free lease, so this test would prove nothing about giving it back")
}
// It has to be running before stopping it can mean anything.
deadline := time.Now().Add(5 * time.Second)
@@ -49,4 +68,33 @@ func TestTheSchedulerIsStoppedOnTheWayOut(t *testing.T) {
if n := ticks.Load() - at; n > 0 {
t.Errorf("the job fired %d more times after shutdown: the scheduler is still running", n)
}
// And the lease is free, so a successor takes it immediately instead of
// waiting out a TTL held by a process that has exited.
var row models2.SysJobLease
if err := db.Where("name = ?", models2.SchedulerLeaseName).First(&row).Error; err != nil {
t.Fatalf("reading the lease row: %v", err)
}
if row.Owner != "" {
t.Errorf("the lease is still owned by %q after shutdown; a successor would wait out the TTL", row.Owner)
}
}
// leaseDB is a database with the two tables jobs.setup touches and one free
// lease row, which is the shape migration 1786700009000 leaves behind.
func leaseDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(glebarez.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("opening sqlite: %v", err)
}
if err := db.AutoMigrate(&models2.SysJob{}, &models2.SysJobLease{}); err != nil {
t.Fatalf("migrating: %v", err)
}
row := models2.SysJobLease{Name: models2.SchedulerLeaseName}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seeding the lease row: %v", err)
}
return db
}
+177
View File
@@ -0,0 +1,177 @@
package jobs
import (
"fmt"
"os"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
models2 "go-admin/app/jobs/models"
)
// nowExprMs is the dialect's expression for the current time as
// milliseconds since the Unix epoch.
//
// The lease compares one instance's idea of "expired" against another
// instance's idea of "still mine", so both have to come from the same clock.
// Two processes whose wall clocks differ by more than the lease TTL would
// otherwise both hold it and both schedule - the exact situation the lease
// exists to prevent, and it would look like it was working, because each
// instance's own arithmetic is self-consistent.
//
// Milliseconds rather than a timestamp, because a timestamp does not survive
// the trip through a driver unchanged. MySQL's UTC_TIMESTAMP read over
// go-admin's own `parseTime=True&loc=Local` DSN arrives labelled as local
// time: on a UTC+8 host every lease is eight hours out, and a test that only
// checked the lease logic against itself passes anyway. An epoch integer has
// no timezone for a driver to apply.
func nowExprMs(dialect string) (string, error) {
switch dialect {
case "mysql":
// UNIX_TIMESTAMP reads its argument in the session timezone and
// NOW(3) is in the session timezone, so the two cancel and the
// result is the absolute epoch regardless of what that zone is.
return "CAST(ROUND(UNIX_TIMESTAMP(NOW(3)) * 1000) AS SIGNED)", nil
case "postgres":
return "CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)", nil
case "sqlite":
// julianday is the portable millisecond clock here: strftime('%s')
// truncates to the second, and unixepoch('now','subsec') needs
// SQLite 3.42.
return "CAST((julianday('now') - 2440587.5) * 86400000.0 AS INTEGER)", nil
case "sqlserver":
return "DATEDIFF_BIG(millisecond, '1970-01-01T00:00:00', SYSUTCDATETIME())", nil
}
return "", fmt.Errorf("no epoch-milliseconds expression for dialect %q", dialect)
}
// dbNowMs reads the clock from the database rather than from this process.
//
// The read and the UPDATE that uses it are two statements, so the value is
// already slightly stale by the time it is compared - and that is the safe
// direction in both places it is used:
//
// - as the expiry cutoff, a stale-old now makes this instance *less*
// likely to decide another instance's lease has expired;
// - as the basis for a new expiry, it makes this instance's own lease
// expire sooner, so it renews sooner.
//
// Neither error makes two instances hold the lease at once.
//
// Zero is rejected rather than returned. It is what a failed conversion
// looks like, it is before every expiry there will ever be, and an
// implementation that passed it on would read every lease as expired, hand
// it to every instance, and restore the defect this lease fixes with a lease
// table sitting on top of it.
func dbNowMs(db *gorm.DB) (int64, error) {
expr, err := nowExprMs(db.Dialector.Name())
if err != nil {
return 0, err
}
var ms int64
if err := db.Raw("SELECT " + expr).Row().Scan(&ms); err != nil {
return 0, fmt.Errorf("reading the database clock: %w", err)
}
if ms <= 0 {
return 0, fmt.Errorf("the database clock read as %d from %q", ms, expr)
}
return ms, nil
}
// newOwnerID identifies this process in the lease row.
//
// Hostname and pid make a log line answer "which one is it" without a lookup;
// the random suffix is what actually makes it unique, because a container
// restarted under the same name can come back with the same hostname and the
// same pid 1.
func newOwnerID() string {
host, err := os.Hostname()
if err != nil || host == "" {
host = "unknown"
}
return fmt.Sprintf("%s-%d-%s", host, os.Getpid(), uuid.New().String()[:8])
}
// lease is one instance's claim on scheduling one database's jobs.
type lease struct {
db *gorm.DB
owner string
ttl time.Duration
}
// acquire takes the lease or renews one this instance already holds, and
// reports whether this instance holds it when it returns.
//
// Renewal is tried first and is scoped to this owner, so it cannot take a
// lease another instance has meanwhile claimed. Only if that matches nothing
// does it try to take an expired one. Both are single UPDATE statements
// decided by RowsAffected: the database, not this process, arbitrates
// between two instances running this at the same moment.
//
// There is no insert path. The migration seeds the row, so a missing row is
// a broken installation rather than a state to recover from - and it is
// reported as one, instead of being papered over by an insert that two
// instances would race to win.
func (l *lease) acquire() (bool, error) {
nowMs, err := dbNowMs(l.db)
if err != nil {
return false, err
}
expiresMs := nowMs + l.ttl.Milliseconds()
renewed := l.db.Model(&models2.SysJobLease{}).
Where("name = ? AND owner = ?", models2.SchedulerLeaseName, l.owner).
Update("expires_at_ms", expiresMs)
if renewed.Error != nil {
return false, fmt.Errorf("renewing the scheduler lease: %w", renewed.Error)
}
if renewed.RowsAffected > 0 {
return true, nil
}
taken := l.db.Model(&models2.SysJobLease{}).
Where("name = ? AND expires_at_ms <= ?", models2.SchedulerLeaseName, nowMs).
Updates(map[string]any{
"owner": l.owner,
"acquired_at_ms": nowMs,
"expires_at_ms": expiresMs,
})
if taken.Error != nil {
return false, fmt.Errorf("taking the scheduler lease: %w", taken.Error)
}
if taken.RowsAffected > 0 {
return true, nil
}
// Neither statement matched. Either another instance holds an
// unexpired lease - the ordinary case, and not an error - or the row
// the migration seeds is gone, which is, and which would otherwise
// present as jobs silently never running anywhere.
var rows int64
if err := l.db.Model(&models2.SysJobLease{}).
Where("name = ?", models2.SchedulerLeaseName).
Count(&rows).Error; err != nil {
return false, fmt.Errorf("checking for the scheduler lease row: %w", err)
}
if rows == 0 {
return false, fmt.Errorf("the %q lease row is missing from %s; run the migrations",
models2.SchedulerLeaseName, (&models2.SysJobLease{}).TableName())
}
return false, nil
}
// release hands the lease back so a successor can take it now instead of
// waiting out the TTL. It is scoped to this owner: an instance that already
// lost the lease must not clear the row its successor is holding.
func (l *lease) release() error {
res := l.db.Model(&models2.SysJobLease{}).
Where("name = ? AND owner = ?", models2.SchedulerLeaseName, l.owner).
Updates(map[string]any{"owner": "", "expires_at_ms": 0})
if res.Error != nil {
return fmt.Errorf("releasing the scheduler lease: %w", res.Error)
}
return nil
}
+268
View File
@@ -0,0 +1,268 @@
package jobs
import (
"os"
"testing"
"time"
glebarez "github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlserver"
"gorm.io/gorm"
models2 "go-admin/app/jobs/models"
)
// The lease is the one thing in this package whose correctness is a property
// of the database rather than of this process, so these run against every
// dialect that can be reached. SQLite always; the others when their DSN is
// set, and they must be set in CI - a suite that quietly skipped them would
// report success for a lease that cannot be taken at all on the dialect most
// installations actually run.
const (
mysqlDSNEnv = "GO_ADMIN_TEST_MYSQL_DSN"
postgresDSNEnv = "GO_ADMIN_TEST_POSTGRES_DSN"
sqlserverDSNEnv = "GO_ADMIN_TEST_SQLSERVER_DSN"
)
type dialectDB struct {
name string
open func(string) gorm.Dialector
env string
}
var optionalDialects = []dialectDB{
{"mysql", func(dsn string) gorm.Dialector { return mysql.Open(dsn) }, mysqlDSNEnv},
{"postgres", func(dsn string) gorm.Dialector { return postgres.Open(dsn) }, postgresDSNEnv},
{"sqlserver", func(dsn string) gorm.Dialector { return sqlserver.Open(dsn) }, sqlserverDSNEnv},
}
// eachDialect runs body against SQLite and against every optional dialect
// whose DSN is set.
func eachDialect(t *testing.T, body func(t *testing.T, db *gorm.DB)) {
t.Helper()
t.Run("sqlite", func(t *testing.T) {
db, err := gorm.Open(glebarez.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("opening sqlite: %v", err)
}
body(t, seedLeaseTable(t, db))
})
for _, d := range optionalDialects {
t.Run(d.name, func(t *testing.T) {
dsn := os.Getenv(d.env)
if dsn == "" {
if os.Getenv("CI") != "" {
t.Fatalf("%s is not set while CI is: the lease must not go untested on %s", d.env, d.name)
}
t.Skipf("%s is not set; skipping %s", d.env, d.name)
}
db, err := gorm.Open(d.open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("connecting to %s: %v", d.env, err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("sql.DB: %v", err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
body(t, seedLeaseTable(t, db))
})
}
}
// seedLeaseTable builds the shape 1786700009000 leaves behind: the table,
// and exactly one free row.
func seedLeaseTable(t *testing.T, db *gorm.DB) *gorm.DB {
t.Helper()
if err := db.Migrator().DropTable(&models2.SysJobLease{}); err != nil {
t.Fatalf("dropping sys_job_lease: %v", err)
}
if err := db.AutoMigrate(&models2.SysJobLease{}); err != nil {
t.Fatalf("creating sys_job_lease: %v", err)
}
row := models2.SysJobLease{Name: models2.SchedulerLeaseName, AcquiredAtMs: 0, ExpiresAtMs: 0}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seeding the lease row: %v", err)
}
return db
}
func TestTheDatabaseClockIsReadableAndIsNotTheZeroTime(t *testing.T) {
eachDialect(t, func(t *testing.T, db *gorm.DB) {
nowMs, err := dbNowMs(db)
if err != nil {
t.Fatalf("dbNowMs: %v", err)
}
if nowMs <= 0 {
t.Fatal("the database clock read as zero, which would read every lease as expired")
}
// Not an assertion about either clock's accuracy - a container's
// clock and this one can drift. An hour is far wider than drift
// and far narrower than a timezone offset, which is the mistake
// this catches: reading MySQL's UTC_TIMESTAMP over a loc=Local
// DSN lands exactly one zone offset away and is invisible to
// every assertion that only compares the lease against itself.
drift := time.Duration(time.Now().UnixMilli()-nowMs) * time.Millisecond
if drift > time.Hour || drift < -time.Hour {
t.Errorf("the database clock is %v away from this process's; a timezone mistake looks exactly like this", drift)
}
})
}
func TestOnlyOneOfTwoInstancesTakesTheLease(t *testing.T) {
eachDialect(t, func(t *testing.T, db *gorm.DB) {
a := &lease{db: db, owner: "instance-a", ttl: time.Minute}
b := &lease{db: db, owner: "instance-b", ttl: time.Minute}
heldA, err := a.acquire()
if err != nil {
t.Fatalf("a.acquire: %v", err)
}
if !heldA {
t.Fatal("the first instance did not take a free lease")
}
heldB, err := b.acquire()
if err != nil {
t.Fatalf("b.acquire: %v", err)
}
if heldB {
t.Error("the second instance took a lease the first one holds: both would schedule")
}
})
}
func TestTheHolderRenewsAndTheOtherStillCannotTakeIt(t *testing.T) {
eachDialect(t, func(t *testing.T, db *gorm.DB) {
a := &lease{db: db, owner: "instance-a", ttl: time.Minute}
b := &lease{db: db, owner: "instance-b", ttl: time.Minute}
if held, err := a.acquire(); err != nil || !held {
t.Fatalf("a.acquire: held=%v err=%v", held, err)
}
before := readLease(t, db)
if held, err := a.acquire(); err != nil || !held {
t.Fatalf("a renewing: held=%v err=%v", held, err)
}
after := readLease(t, db)
if after.ExpiresAtMs < before.ExpiresAtMs {
t.Errorf("renewal moved the expiry backwards: %d then %d", before.ExpiresAtMs, after.ExpiresAtMs)
}
if after.AcquiredAtMs != before.AcquiredAtMs {
t.Errorf("renewal moved acquired_at_ms (%d then %d); it must say when the lease was taken, not when it was last renewed",
before.AcquiredAtMs, after.AcquiredAtMs)
}
if held, err := b.acquire(); err != nil || held {
t.Errorf("the other instance took a renewed lease: held=%v err=%v", held, err)
}
})
}
func TestAnExpiredLeaseIsTakenOver(t *testing.T) {
eachDialect(t, func(t *testing.T, db *gorm.DB) {
a := &lease{db: db, owner: "instance-a", ttl: time.Minute}
b := &lease{db: db, owner: "instance-b", ttl: time.Minute}
if held, err := a.acquire(); err != nil || !held {
t.Fatalf("a.acquire: held=%v err=%v", held, err)
}
// What a dead leader leaves behind: its row, unrenewed, past its
// expiry. Forced rather than waited out, so the test does not
// trade a second of sleep for the same assertion.
expire(t, db)
if held, err := b.acquire(); err != nil || !held {
t.Fatalf("the successor did not take an expired lease: held=%v err=%v", held, err)
}
if got := readLease(t, db).Owner; got != "instance-b" {
t.Errorf("owner is %q after takeover, want instance-b", got)
}
// And the instance that lost it must not get it back by renewing:
// renewal is scoped to the owner column it no longer matches.
if held, err := a.acquire(); err != nil || held {
t.Errorf("the dead leader renewed a lease it had lost: held=%v err=%v", held, err)
}
})
}
func TestReleaseHandsTheLeaseOnWithoutWaitingOutTheTTL(t *testing.T) {
eachDialect(t, func(t *testing.T, db *gorm.DB) {
a := &lease{db: db, owner: "instance-a", ttl: time.Hour}
b := &lease{db: db, owner: "instance-b", ttl: time.Minute}
if held, err := a.acquire(); err != nil || !held {
t.Fatalf("a.acquire: held=%v err=%v", held, err)
}
if held, err := b.acquire(); err != nil || held {
t.Fatalf("precondition: b must not hold it yet (held=%v err=%v)", held, err)
}
if err := a.release(); err != nil {
t.Fatalf("a.release: %v", err)
}
if held, err := b.acquire(); err != nil || !held {
t.Errorf("a released a lease with an hour left and the successor still could not take it: held=%v err=%v", held, err)
}
})
}
func TestReleasingALeaseSomebodyElseHoldsDoesNothing(t *testing.T) {
eachDialect(t, func(t *testing.T, db *gorm.DB) {
a := &lease{db: db, owner: "instance-a", ttl: time.Minute}
stale := &lease{db: db, owner: "instance-gone", ttl: time.Minute}
if held, err := a.acquire(); err != nil || !held {
t.Fatalf("a.acquire: held=%v err=%v", held, err)
}
if err := stale.release(); err != nil {
t.Fatalf("stale.release: %v", err)
}
if got := readLease(t, db).Owner; got != "instance-a" {
t.Errorf("owner is %q; an instance that already lost the lease cleared its successor's row", got)
}
})
}
func TestAMissingLeaseRowIsReportedRatherThanSilentlyNeverScheduling(t *testing.T) {
eachDialect(t, func(t *testing.T, db *gorm.DB) {
if err := db.Where("name = ?", models2.SchedulerLeaseName).
Delete(&models2.SysJobLease{}).Error; err != nil {
t.Fatalf("deleting the lease row: %v", err)
}
a := &lease{db: db, owner: "instance-a", ttl: time.Minute}
held, err := a.acquire()
if held {
t.Fatal("acquire reported the lease held with no row to hold")
}
if err == nil {
t.Error("a missing lease row was reported as an ordinary 'someone else holds it': jobs would never run anywhere and nothing would say why")
}
})
}
func readLease(t *testing.T, db *gorm.DB) models2.SysJobLease {
t.Helper()
var row models2.SysJobLease
if err := db.Where("name = ?", models2.SchedulerLeaseName).First(&row).Error; err != nil {
t.Fatalf("reading the lease row: %v", err)
}
return row
}
func expire(t *testing.T, db *gorm.DB) {
t.Helper()
if err := db.Model(&models2.SysJobLease{}).
Where("name = ?", models2.SchedulerLeaseName).
Update("expires_at_ms", 0).Error; err != nil {
t.Fatalf("expiring the lease: %v", err)
}
}
+58
View File
@@ -0,0 +1,58 @@
package models
// SchedulerLeaseName is the name of the one lease row per database.
//
// One row, not one per tenant: a tenant is a separate database with its own
// sys_job table and its own scheduler, so the row that decides who schedules
// it lives in that database alongside the jobs it governs.
const SchedulerLeaseName = "scheduler"
// SysJobLease is the scheduler's single-writer lease over one database.
//
// app/jobs registers every enabled job into an in-process cron.Cron and keeps
// each job's scheduler handle in sys_job.entry_id. The scheduler is per
// process and entry_id is one shared column, so a second instance pointed at
// the same database does not divide the work - it overwrites it, and nothing
// logs that it did (issue #915). Only the holder of this lease calls
// jobs.Setup, which keeps the scheduler single-writer while the HTTP side
// still scales.
//
// It deliberately embeds neither models.ModelTime nor models.ControlBy. A
// lease is machine state, not a record a person creates, edits or
// soft-deletes: there is no author to attribute it to, and a deleted-but-
// present lease row would be a row that both does and does not hold the
// scheduler.
type SysJobLease struct {
// Name is the lease being held. The migration seeds exactly one row,
// SchedulerLeaseName, and the runtime only ever updates it - there is
// no insert path, so two instances starting at once cannot race to
// create the row they are both trying to claim.
Name string `json:"name" gorm:"type:varchar(64);primaryKey"`
// Owner identifies the process that holds the lease. Empty means the
// lease is free, which is what the migration seeds.
Owner string `json:"owner" gorm:"type:varchar(191);not null"`
// AcquiredAtMs is when the current owner took the lease, not when it
// last renewed: a leader that has held it for an hour and one that took
// over a second ago are different situations, and only this column
// tells them apart. Renewal moves ExpiresAtMs and leaves this alone.
AcquiredAtMs int64 `json:"acquiredAtMs" gorm:"column:acquired_at_ms;not null"`
// ExpiresAtMs is when another instance may take the lease.
//
// Milliseconds since the Unix epoch, in a BIGINT, rather than a
// timestamp column. A timestamp crossing the driver boundary carries
// timezone semantics that the driver applies on the way through: with
// go-admin's own `parseTime=True&loc=Local` DSN, MySQL's UTC_TIMESTAMP
// comes back labelled as local time, and a lease written in Asia/
// Shanghai is then eight hours out - in whichever direction makes every
// other instance's lease look expired. An integer has no timezone for
// anything to apply, and the comparison that decides who schedules
// becomes integer arithmetic that no DSN setting can reinterpret.
ExpiresAtMs int64 `json:"expiresAtMs" gorm:"column:expires_at_ms;not null"`
}
func (*SysJobLease) TableName() string {
return "sys_job_lease"
}
+97
View File
@@ -0,0 +1,97 @@
package jobs
import (
"runtime"
"strings"
"testing"
"time"
"github.com/robfig/cron/v3"
)
// The frame the leaked goroutines park in. Remove starts it, and it is the
// only goroutine in this package that sends on a channel the caller may have
// walked away from.
const removeSenderFrame = "go-admin/app/jobs.Remove.func1"
// Remove hands the caller a channel it is free to abandon: RemoveJob stops
// waiting after a second and returns a timeout error. The send therefore has
// to complete with nobody receiving, or every stop that times out parks a
// goroutine on it for the life of the process.
//
// The order matters. Counting parked goroutines straight after calling Remove
// would pass while proving nothing, because the goroutine may not have reached
// the send yet. So the entries are waited out first: an empty scheduler means
// every goroutine is at or past its send, and only then is a survivor a leak.
func TestRemoveLetsItsGoroutineFinishWithNobodyReceiving(t *testing.T) {
const jobs = 20
c := cron.New()
ids := make([]cron.EntryID, 0, jobs)
for i := 0; i < jobs; i++ {
id, err := c.AddFunc("@every 1h", func() {})
if err != nil {
t.Fatalf("AddFunc: %v", err)
}
ids = append(ids, id)
}
for _, id := range ids {
// The returned channel is dropped on purpose: this is what a caller
// that has already timed out leaves behind.
_ = Remove(c, int(id))
}
if err := waitFor(3*time.Second, func() bool { return len(c.Entries()) == 0 }); err != nil {
t.Fatalf("the scheduler still holds %d entries, so the goroutines never reached their send "+
"and this test cannot show anything", len(c.Entries()))
}
if err := waitFor(3*time.Second, func() bool { return parkedInRemove() == 0 }); err != nil {
t.Errorf("%d of %d goroutines are still parked sending on an abandoned channel:\n%s",
parkedInRemove(), jobs, oneParkedStack())
}
}
func waitFor(d time.Duration, done func() bool) error {
deadline := time.Now().Add(d)
for {
if done() {
return nil
}
if time.Now().After(deadline) {
return errTimeout
}
time.Sleep(10 * time.Millisecond)
}
}
var errTimeout = timeoutError{}
type timeoutError struct{}
func (timeoutError) Error() string { return "timed out" }
func parkedInRemove() int {
return strings.Count(goroutineDump(), removeSenderFrame)
}
func oneParkedStack() string {
for _, block := range strings.Split(goroutineDump(), "\n\n") {
if strings.Contains(block, removeSenderFrame) {
return block
}
}
return "(none)"
}
func goroutineDump() string {
buf := make([]byte, 1<<20)
for {
n := runtime.Stack(buf, true)
if n < len(buf) {
return string(buf[:n])
}
buf = make([]byte, 2*len(buf))
}
}
+212
View File
@@ -0,0 +1,212 @@
package jobs
import (
"context"
"sync"
"time"
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/pkg/cronjob"
"gorm.io/gorm"
)
// leaseTTL is how long a lease stays valid without being renewed, and
// leaseHeartbeat is how often the holder renews it.
//
// The gap between them is the point: at a third of the TTL, two consecutive
// renewals can fail - a restarting database, a paused container - and the
// third still lands before anything else may take the lease. Making them
// equal would hand the scheduler to another instance on the first missed
// beat.
//
// The TTL is also the longest the jobs can be stopped everywhere: an
// instance killed without running its shutdown leaves its lease behind, and
// the successor waits this long before taking it.
const (
leaseTTL = 30 * time.Second
leaseHeartbeat = 10 * time.Second
)
// supervisor keeps one tenant's scheduler in step with one lease.
//
// It exists because holding the lease is not a decision made once at
// startup. An instance that never gets the lease has to keep asking, or the
// death of the current holder would stop the jobs until somebody restarted a
// process by hand; and an instance that holds it has to stop scheduling the
// moment it can no longer prove it still does, or a network partition turns
// into the two-schedulers-at-once defect (#915) that the lease exists to
// prevent.
type supervisor struct {
key string
db *gorm.DB
lease *lease
mu sync.Mutex
running bool
// lastRenew is when this instance last proved it holds the lease. It
// is compared only against this process's own later readings, never
// against another instance's, so the monotonic clock is the right one
// here - the reason the lease itself reads the database's clock does
// not apply to measuring how long ago something happened locally.
lastRenew time.Time
stop chan struct{}
stopOnce sync.Once
done chan struct{}
}
func newSupervisor(key string, db *gorm.DB, owner string) *supervisor {
return &supervisor{
key: key,
db: db,
lease: &lease{db: db, owner: owner, ttl: leaseTTL},
stop: make(chan struct{}),
done: make(chan struct{}),
}
}
// start takes the lease if it is free, schedules this tenant's jobs if it
// got it, and then keeps both facts true for the life of the process.
//
// The first attempt is synchronous so that a single-instance deployment -
// which is nearly all of them - has its jobs registered by the time Setup
// returns, exactly as it did before there was a lease.
func (s *supervisor) start() {
s.tick()
go s.heartbeat()
sdk.Runtime.SetShutdown(func(ctx context.Context) {
s.shutdown(ctx)
})
}
func (s *supervisor) heartbeat() {
defer close(s.done)
t := time.NewTicker(leaseHeartbeat)
defer t.Stop()
for {
select {
case <-s.stop:
return
case <-t.C:
s.tick()
}
}
}
// tick asks for the lease and makes the scheduler match the answer.
func (s *supervisor) tick() {
held, err := s.lease.acquire()
if err != nil {
// Not knowing is not the same as having lost it. The lease is
// still ours until it expires, so the scheduler keeps running
// and this instance keeps trying - a database that is briefly
// unreachable must not stop the jobs, and must not hand them to
// anyone else either, because nobody else can reach it to take
// the lease.
log.Errorf("[Job] scheduler lease for %s: %v", s.key, err)
if s.heldFor() > leaseTTL {
log.Errorf("[Job] scheduler lease for %s has not been renewed in %v; stopping the scheduler before anything else takes it",
s.key, leaseTTL)
s.stopScheduling()
}
return
}
if !held {
s.stopScheduling()
return
}
s.mu.Lock()
s.lastRenew = time.Now()
already := s.running
s.mu.Unlock()
if !already {
s.startScheduling()
}
}
// heldFor reports how long it has been since this instance last proved it
// holds the lease. A zero lastRenew means it never has, which is not a lease
// that has gone stale.
func (s *supervisor) heldFor() time.Duration {
s.mu.Lock()
defer s.mu.Unlock()
if s.lastRenew.IsZero() {
return 0
}
return time.Since(s.lastRenew)
}
func (s *supervisor) startScheduling() {
s.mu.Lock()
if s.running {
s.mu.Unlock()
return
}
s.running = true
s.mu.Unlock()
log.Infof("[Job] holding the scheduler lease for %s; registering its jobs", s.key)
setup(s.key, s.db)
}
// stopScheduling stops this tenant's scheduler and puts a fresh one in its
// place.
//
// Fresh, rather than reusing the stopped one, because taking the lease back
// runs setup again and setup adds every enabled job to whatever scheduler is
// registered. Reusing it would leave the previous registration in place and
// fire every job twice - the symptom this whole change is here to remove,
// reintroduced one layer down.
func (s *supervisor) stopScheduling() {
s.mu.Lock()
if !s.running {
s.mu.Unlock()
return
}
s.running = false
s.mu.Unlock()
log.Infof("[Job] no longer holding the scheduler lease for %s; stopping its jobs", s.key)
ctx, cancel := context.WithTimeout(context.Background(), leaseHeartbeat)
defer cancel()
stopCrontab(ctx, s.key)
sdk.Runtime.SetCrontabByTenant(s.key, cronjob.NewWithSeconds())
}
// shutdown stops the heartbeat, stops the scheduler and hands the lease back
// so a successor can take it now rather than waiting out the TTL.
func (s *supervisor) shutdown(ctx context.Context) {
s.stopOnce.Do(func() { close(s.stop) })
select {
case <-s.done:
case <-ctx.Done():
}
s.mu.Lock()
wasRunning := s.running
s.running = false
s.mu.Unlock()
if wasRunning {
stopCrontab(ctx, s.key)
if err := s.lease.release(); err != nil {
log.Errorf("[Job] releasing the scheduler lease for %s: %v", s.key, err)
}
}
}
// holdsLease reports whether this instance is currently scheduling. It exists
// for the tests: everything else acts on the answer inside tick.
func (s *supervisor) holdsLease() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
}
+94
View File
@@ -0,0 +1,94 @@
package jobs
import (
"testing"
"time"
"github.com/go-admin-team/go-admin-core/v2/sdk"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/cronjob"
models2 "go-admin/app/jobs/models"
)
// An instance that starts while another one holds the lease must not
// register the jobs. This is the whole point: every instance registering the
// whole enabled list into its own scheduler is what made one job fire once
// per instance (#915).
func TestASecondInstanceDoesNotScheduleWhileTheFirstHoldsTheLease(t *testing.T) {
const tenant = "second-instance"
db := leaseDB(t)
sdk.Runtime.SetCrontabByTenant(tenant, cronjob.NewWithSeconds())
first := newSupervisor(tenant, db, "instance-a")
first.tick()
if !first.holdsLease() {
t.Fatal("the first instance did not take a free lease")
}
second := newSupervisor(tenant, db, "instance-b")
second.tick()
if second.holdsLease() {
t.Error("a second instance scheduled while the first holds the lease: the job would fire twice per tick")
}
}
// Losing the lease has to stop the scheduler, not merely stop it from being
// taken again. A holder that keeps scheduling after its lease has gone to
// somebody else is two schedulers at once - the defect the lease exists to
// prevent, reached from the other direction.
func TestTheSupervisorStopsSchedulingWhenItLosesTheLease(t *testing.T) {
const tenant = "loses-lease"
db := leaseDB(t)
sdk.Runtime.SetCrontabByTenant(tenant, cronjob.NewWithSeconds())
holder := newSupervisor(tenant, db, "instance-a")
holder.tick()
if !holder.holdsLease() {
t.Fatal("the supervisor did not take a free lease, so losing it cannot be observed")
}
// What a partition looks like from the database's side: the lease
// lapsed and somebody else took it while this instance was away.
expire(t, db)
successor := &lease{db: db, owner: "instance-b", ttl: time.Minute}
if held, err := successor.acquire(); err != nil || !held {
t.Fatalf("the successor could not take the expired lease: held=%v err=%v", held, err)
}
holder.tick()
if holder.holdsLease() {
t.Error("the supervisor kept scheduling after the lease went to another instance")
}
if got := readLease(t, db).Owner; got != "instance-b" {
t.Errorf("owner is %q; the instance that lost the lease wrote over its successor", got)
}
}
// A database that cannot be reached is not the same as a lease that has been
// lost. Stopping on the first failed renewal would stop the jobs every time
// the database blinked - and hand them to nobody, because no other instance
// can reach it to take the lease either.
func TestABrieflyUnreachableDatabaseDoesNotStopTheScheduler(t *testing.T) {
const tenant = "db-blip"
db := leaseDB(t)
sdk.Runtime.SetCrontabByTenant(tenant, cronjob.NewWithSeconds())
s := newSupervisor(tenant, db, "instance-a")
s.tick()
if !s.holdsLease() {
t.Fatal("the supervisor did not take a free lease")
}
// The table going missing is how an unreachable database presents to
// acquire: every statement against it returns an error.
if err := db.Migrator().DropTable(&models2.SysJobLease{}); err != nil {
t.Fatalf("dropping the lease table: %v", err)
}
s.tick()
if !s.holdsLease() {
t.Error("one failed renewal stopped the scheduler; the lease had not expired yet and nobody else could have taken it")
}
}
+126
View File
@@ -0,0 +1,126 @@
package tools
import (
"regexp"
"strconv"
"strings"
"go-admin/app/other/models/tools"
)
// columnLengthPattern pulls the first parenthesized integer out of a MySQL
// COLUMN_TYPE string - the "(255)" in "varchar(255)", the "(10" in
// "decimal(10,2)". Works regardless of trailing modifiers such as
// "unsigned" or a charset clause, since it only looks for the first digits
// after the first '('.
var columnLengthPattern = regexp.MustCompile(`\((\d+)`)
// InferColumnWidth backs R2's fallback path: when a column's colWidth is
// left at its 0 sentinel (unconfigured), this reads sys_columns.column_type
// - MySQL's information_schema.COLUMNS.COLUMN_TYPE, which carries length,
// e.g. "varchar(255)", "int(11)", "decimal(10,2)", "tinyint(1)" - and
// returns a px width sized to fit inside go-admin-ui's ~580px text-column
// budget for a 1280px viewport (its AGENTS.md "列宽" section).
//
// The judgment has to be columnType, not goType: sys_tables.go:323-338
// gives every non-primary-key int/tinyint/bigint/decimal column goType
// "string" (a bare substring match on "int" that also catches "tinyint"/
// "bigint", intentional at import time but useless for telling a boolean
// flag from a bigint), so goType alone cannot distinguish a switch column
// from a price column from a name column. This is the same judgment call
// API契约.md §1.1 made, reversing the PRD's original "GoType" reading of R2.
// GoType is not consulted anywhere in this function, including for
// datetime/timestamp columns - those are matched on columnType too.
//
// Exported and pure (string in, int out) so QA can pin an exact input/output
// table against it directly (测试用例.md §2.5's own recommendation), rather
// than only being able to assert "the rendered page happens not to overflow".
func InferColumnWidth(columnType string) int {
ct := strings.ToLower(strings.TrimSpace(columnType))
switch {
case strings.HasPrefix(ct, "tinyint(1)"):
// MySQL's own shape for a boolean/status flag - a tag or a switch,
// not text, so it wants less room than a general numeric column.
return 70
case strings.Contains(ct, "datetime"), strings.Contains(ct, "timestamp"),
strings.Contains(ct, "date"), strings.Contains(ct, "time"):
return 110
case strings.HasPrefix(ct, "tinyint"), strings.HasPrefix(ct, "smallint"),
strings.HasPrefix(ct, "mediumint"), strings.HasPrefix(ct, "int"),
strings.HasPrefix(ct, "bigint"), strings.HasPrefix(ct, "decimal"),
strings.HasPrefix(ct, "float"), strings.HasPrefix(ct, "double"):
// API契约.md §1.1: "decimal/bigint/int 类给数字型窄宽度" groups these
// together rather than sizing each individually - none of them need
// more than a handful of digits' worth of width.
return 90
case strings.HasPrefix(ct, "varchar"), strings.HasPrefix(ct, "char"):
return varcharWidth(columnLength(ct))
case strings.Contains(ct, "text"), strings.Contains(ct, "blob"):
// longtext/mediumtext/text/blob: no declared length to size against,
// and content here is free-form, so this errs wide rather than
// guessing a number the actual content will not respect.
return 260
default:
// Unrecognized column_type (an enum, a json column, a driver this
// codebase does not special-case, ...). Matches the flat fallback
// vue.go.template already used for every non-datetime column before
// this function existed, so a type this does not recognize is no
// worse off than the old blanket default.
return 120
}
}
// varcharWidth tiers a char/varchar column by its declared length. The
// tiers are deliberately coarse - R2 only asks for "common tables land in
// the 580px budget", not pixel-perfect sizing per character.
func varcharWidth(n int) int {
switch {
case n <= 0:
// Length did not parse (unexpected shape) - mid tier, not the
// narrowest, since an un-lengthed varchar is unlikely to be a
// short code column.
return 150
case n <= 10:
return 90
case n <= 20:
return 110
case n <= 50:
return 150
case n <= 100:
return 200
default:
return 240
}
}
// columnLength extracts the first parenthesized integer, or 0 if the type
// string does not have one (already-lowercased input expected).
func columnLength(columnType string) int {
m := columnLengthPattern.FindStringSubmatch(columnType)
if m == nil {
return 0
}
n, err := strconv.Atoi(m[1])
if err != nil {
return 0
}
return n
}
// applyInferredColumnWidths fills in InferColumnWidth's result for every
// column still at the 0 "unconfigured" sentinel, in place, before the
// template that reads .ColWidth runs. A column the user (or F6's config
// page) already gave an explicit width is left untouched.
func applyInferredColumnWidths(columns []tools.SysColumns) {
for i := range columns {
if columns[i].ColWidth == 0 {
columns[i].ColWidth = InferColumnWidth(columns[i].ColumnType)
}
}
}
+80
View File
@@ -0,0 +1,80 @@
package tools
import (
"testing"
"go-admin/app/other/models/tools"
)
// Input/output pins for InferColumnWidth, per 测试用例.md §2.5's own
// recommendation ("QA 才能在阶段 4 补一张精确的输入→输出对照表断言, 而不是只测
// 结果凑巧没溢出这种弱结论") - this is that table, kept next to the function
// it pins rather than only living in a later QA-owned suite.
func TestInferColumnWidth(t *testing.T) {
cases := []struct {
name string
columnType string
want int
}{
{"boolean/status flag", "tinyint(1)", 70},
{"boolean flag, case-insensitive", "TINYINT(1)", 70},
{"datetime", "datetime", 110},
{"timestamp", "timestamp", 110},
{"date only", "date", 110},
{"time only", "time", 110},
{"plain tinyint (not the (1) boolean shape)", "tinyint(4)", 90},
{"smallint", "smallint(6)", 90},
{"mediumint", "mediumint(9)", 90},
{"int", "int(11)", 90},
{"bigint", "bigint(20)", 90},
{"decimal", "decimal(10,2)", 90},
{"float", "float", 90},
{"double", "double", 90},
{"varchar short code", "varchar(8)", 90},
{"varchar at the 10 boundary", "varchar(10)", 90},
{"varchar just past the 10 boundary", "varchar(11)", 110},
{"varchar at the 20 boundary", "varchar(20)", 110},
{"varchar mid length", "varchar(32)", 150},
{"varchar at the 50 boundary", "varchar(50)", 150},
{"varchar just past the 50 boundary", "varchar(51)", 200},
{"varchar(255), the common default", "varchar(255)", 240},
{"char, fixed-width", "char(2)", 90},
{"varchar with no parsed length", "varchar", 150},
{"text, no length to size against", "text", 260},
{"longtext", "longtext", 260},
{"mediumtext", "mediumtext", 260},
{"blob", "blob", 260},
{"unrecognized type falls back to the old flat default", "json", 120},
{"empty column_type falls back to the old flat default", "", 120},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := InferColumnWidth(tc.columnType); got != tc.want {
t.Errorf("InferColumnWidth(%q) = %d, want %d", tc.columnType, got, tc.want)
}
})
}
}
func TestApplyInferredColumnWidths(t *testing.T) {
columns := []tools.SysColumns{
{JsonField: "name", ColumnType: "varchar(64)", ColWidth: 0},
{JsonField: "price", ColumnType: "decimal(10,2)", ColWidth: 300}, // already configured
}
applyInferredColumnWidths(columns)
if columns[0].ColWidth == 0 {
t.Error("unconfigured column: want an inferred non-zero width, still 0")
}
if want := InferColumnWidth("varchar(64)"); columns[0].ColWidth != want {
t.Errorf("unconfigured column: want %d (InferColumnWidth's own answer), got %d", want, columns[0].ColWidth)
}
if columns[1].ColWidth != 300 {
t.Errorf("already-configured column: want the user's 300 left untouched, got %d", columns[1].ColWidth)
}
}
+104 -10
View File
@@ -22,6 +22,29 @@ type Gen struct {
api.Api
}
// genLangFuncs backs the lang-zh/lang-en templates (PRD 010 F3/F9). The
// generated files are TypeScript, and go-admin-ui's eslint config requires
// single-quoted strings with no trailing comma (@stylistic/quotes,
// @stylistic/comma-dangle: never) - text/template's builtin `printf "%q"`
// only produces Go/JSON-style double-quoted output, so this supplies a
// single-quote equivalent instead of leaning on the builtin.
var genLangFuncs = template.FuncMap{
"singleQuote": func(s string) string {
r := strings.NewReplacer(`\`, `\\`, `'`, `\'`, "\n", `\n`, "\r", `\r`)
return "'" + r.Replace(s) + "'"
},
}
// parseGenTemplate is template.ParseFiles plus genLangFuncs, for the two
// language-pack templates. template.New's name must match the file's base
// name - ParseFiles reuses the template already registered under that name
// instead of creating an unnamed second one, which is what makes Execute
// find the parsed content afterwards.
func parseGenTemplate(path string) (*template.Template, error) {
base := path[strings.LastIndex(path, "/")+1:]
return template.New(base).Funcs(genLangFuncs).ParseFiles(path)
}
func (e Gen) Preview(c *gin.Context) {
e.Context = c
log := e.GetLogger()
@@ -45,10 +68,10 @@ func (e Gen) Preview(c *gin.Context) {
e.Error(500, err, fmt.Sprintf("api模版读取失败!错误详情:%s", err.Error()))
return
}
t3, err := template.ParseFiles("template/v4/js.go.template")
t3, err := template.ParseFiles("template/v4/ts.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("js模版读取失败!错误详情:%s", err.Error()))
e.Error(500, err, fmt.Sprintf("ts模版读取失败!错误详情:%s", err.Error()))
return
}
t4, err := template.ParseFiles("template/v4/vue.go.template")
@@ -75,6 +98,22 @@ func (e Gen) Preview(c *gin.Context) {
e.Error(500, err, fmt.Sprintf("service模版读取失败!错误详情:%s", err.Error()))
return
}
// t8/t9 back F3/F9 (PRD 010): one language pack per locale, nested under
// gen/{PackageName}/{BusinessName}.ts by NOActionsGen below so go-admin-ui's
// gen-namespace.ts glob (`./*/*.ts` under each locale's gen/) picks them up.
// See docs-prd/010-代码生成器前端模板迁移Vue3/API契约.md §2.3.
t8, err := parseGenTemplate("template/v4/lang-zh.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("zh语言包模版读取失败!错误详情:%s", err.Error()))
return
}
t9, err := parseGenTemplate("template/v4/lang-en.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("en语言包模版读取失败!错误详情:%s", err.Error()))
return
}
db, err := pkg.GetOrm(c)
if err != nil {
@@ -83,7 +122,18 @@ func (e Gen) Preview(c *gin.Context) {
return
}
tab, _ := table.Get(db,false)
tab, _ := table.Get(db, false)
// MLTBName (table_name with underscores turned to dashes) is a gorm:"-"
// field - table.Get never fills it in, so every template that reads it
// (the .vue/.ts import paths, e.g. "@/api/{PackageName}/{MLTBName}")
// silently rendered it empty here. NOActionsGen has set this since it
// existed (see below); Preview never did, which is why the two paths
// are not interchangeable stand-ins for each other and should not be
// assumed to be.
tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1)
// R2: infer a width for any column the config page left at colWidth's 0
// sentinel, before vue.go.template reads .ColWidth - see column_width.go.
applyInferredColumnWidths(tab.Columns)
var b1 bytes.Buffer
err = t1.Execute(&b1, tab)
var b2 bytes.Buffer
@@ -98,15 +148,21 @@ func (e Gen) Preview(c *gin.Context) {
err = t6.Execute(&b6, tab)
var b7 bytes.Buffer
err = t7.Execute(&b7, tab)
var b8 bytes.Buffer
err = t8.Execute(&b8, tab)
var b9 bytes.Buffer
err = t9.Execute(&b9, tab)
mp := make(map[string]interface{})
mp["template/model.go.template"] = b1.String()
mp["template/api.go.template"] = b2.String()
mp["template/js.go.template"] = b3.String()
mp["template/api.ts.template"] = b3.String()
mp["template/vue.go.template"] = b4.String()
mp["template/router.go.template"] = b5.String()
mp["template/dto.go.template"] = b6.String()
mp["template/service.go.template"] = b7.String()
mp["template/lang-zh.go.template"] = b8.String()
mp["template/lang-en.go.template"] = b9.String()
e.OK(mp, "")
}
@@ -129,7 +185,7 @@ func (e Gen) GenCode(c *gin.Context) {
}
table.TableId = id
tab, _ := table.Get(db,false)
tab, _ := table.Get(db, false)
e.NOActionsGen(c, tab)
@@ -155,7 +211,7 @@ func (e Gen) GenApiToFile(c *gin.Context) {
}
table.TableId = id
tab, _ := table.Get(db,false)
tab, _ := table.Get(db, false)
e.genApiToFile(c, tab)
e.OK("", "Code generated successfully!")
@@ -165,6 +221,8 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
e.Context = c
log := e.GetLogger()
tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1)
// R2: see the matching call and comment in Preview above.
applyInferredColumnWidths(tab.Columns)
basePath := "template/v4/"
routerFile := basePath + "no_actions/router_check_role.go.template"
@@ -191,10 +249,10 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
e.Error(500, err, fmt.Sprintf("路由模版失败!错误详情:%s", err.Error()))
return
}
t4, err := template.ParseFiles(basePath + "js.go.template")
t4, err := template.ParseFiles(basePath + "ts.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("js模版解析失败!错误详情:%s", err.Error()))
e.Error(500, err, fmt.Sprintf("ts模版解析失败!错误详情:%s", err.Error()))
return
}
t5, err := template.ParseFiles(basePath + "vue.go.template")
@@ -215,6 +273,19 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
e.Error(500, err, fmt.Sprintf("service模版失败!错误详情:%s", err.Error()))
return
}
// t8/t9 back F3/F9 (PRD 010): see the matching comment in Preview above.
t8, err := parseGenTemplate(basePath + "lang-zh.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("zh语言包模版解析失败!错误详情:%s", err.Error()))
return
}
t9, err := parseGenTemplate(basePath + "lang-en.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("en语言包模版解析失败!错误详情:%s", err.Error()))
return
}
_ = pkg.PathCreate("./app/" + tab.PackageName + "/apis/")
_ = pkg.PathCreate("./app/" + tab.PackageName + "/models/")
@@ -227,6 +298,23 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
e.Error(500, err, fmt.Sprintf("views目录创建失败!错误详情:%s", err.Error()))
return
}
// gen/{PackageName}/ nests under each locale so go-admin-ui's
// gen-namespace.ts (`./*/*.ts` glob, one level under gen/) picks the file
// up - a flat gen/{BusinessName}.ts would let two tables in different
// packages silently overwrite each other's translations, since
// BusinessName only has a pattern check, no uniqueness check.
err = pkg.PathCreate(config.GenConfig.FrontPath + "/lang/zh-CN/gen/" + tab.PackageName + "/")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("zh语言包目录创建失败!错误详情:%s", err.Error()))
return
}
err = pkg.PathCreate(config.GenConfig.FrontPath + "/lang/en-US/gen/" + tab.PackageName + "/")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("en语言包目录创建失败!错误详情:%s", err.Error()))
return
}
var b1 bytes.Buffer
err = t1.Execute(&b1, tab)
@@ -242,13 +330,19 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
err = t6.Execute(&b6, tab)
var b7 bytes.Buffer
err = t7.Execute(&b7, tab)
var b8 bytes.Buffer
err = t8.Execute(&b8, tab)
var b9 bytes.Buffer
err = t9.Execute(&b9, tab)
pkg.FileCreate(b1, "./app/"+tab.PackageName+"/models/"+tab.TBName+".go")
pkg.FileCreate(b2, "./app/"+tab.PackageName+"/apis/"+tab.TBName+".go")
pkg.FileCreate(b3, "./app/"+tab.PackageName+"/router/"+tab.TBName+".go")
pkg.FileCreate(b4, config.GenConfig.FrontPath+"/api/"+tab.PackageName+"/"+tab.MLTBName+".js")
pkg.FileCreate(b4, config.GenConfig.FrontPath+"/api/"+tab.PackageName+"/"+tab.MLTBName+".ts")
pkg.FileCreate(b5, config.GenConfig.FrontPath+"/views/"+tab.PackageName+"/"+tab.MLTBName+"/index.vue")
pkg.FileCreate(b6, "./app/"+tab.PackageName+"/service/dto/"+tab.TBName+".go")
pkg.FileCreate(b7, "./app/"+tab.PackageName+"/service/"+tab.TBName+".go")
pkg.FileCreate(b8, config.GenConfig.FrontPath+"/lang/zh-CN/gen/"+tab.PackageName+"/"+tab.BusinessName+".ts")
pkg.FileCreate(b9, config.GenConfig.FrontPath+"/lang/en-US/gen/"+tab.PackageName+"/"+tab.BusinessName+".ts")
}
@@ -302,7 +396,7 @@ func (e Gen) GenMenuAndApi(c *gin.Context) {
}
table.TableId = id
tab, _ := table.Get(e.Orm,true)
tab, _ := table.Get(e.Orm, true)
tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1)
Mmenu := dto.SysMenuInsertReq{}
+18 -3
View File
@@ -5,9 +5,9 @@ import (
"strings"
"github.com/gin-gonic/gin"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
_ "github.com/go-admin-team/go-admin-core/v2/response"
"gorm.io/gorm"
"go-admin/app/other/models/tools"
@@ -80,7 +80,7 @@ func (e SysTable) Get(c *gin.Context) {
var data tools.SysTables
data.TableId, _ = pkg.StringToInt(c.Param("tableId"))
result, err := data.Get(db,true)
result, err := data.Get(db, true)
if err != nil {
log.Errorf("Get error, %s", err.Error())
e.Error(500, err, "")
@@ -107,7 +107,7 @@ func (e SysTable) GetSysTablesInfo(c *gin.Context) {
if c.Request.FormValue("tableName") != "" {
data.TBName = c.Request.FormValue("tableName")
}
result, err := data.Get(db,true)
result, err := data.Get(db, true)
if err != nil {
log.Errorf("Get error, %s", err.Error())
e.Error(500, err, "抱歉未找到相关信息")
@@ -368,6 +368,21 @@ func (e SysTable) Update(c *gin.Context) {
return
}
// PRD 010 F10: this bind-and-save path has no field-level validation of
// its own (API契约.md §1.2/§2.1, D6) - see sys_tables_validate.go for
// what each check guards and why colWidth is sanitized in place rather
// than rejected.
if err = validateAndSanitizeColumns(data.Columns); err != nil {
log.Errorf("validate columns error, %s", err.Error())
e.Error(500, err, err.Error())
return
}
if err = validateBusinessNameUnique(db, data.PackageName, data.BusinessName, data.TableId); err != nil {
log.Errorf("validate businessName error, %s", err.Error())
e.Error(500, err, err.Error())
return
}
data.UpdateBy = 0
result, err := data.Update(db)
if err != nil {
+112
View File
@@ -0,0 +1,112 @@
package tools
import (
"fmt"
"regexp"
"strings"
"gorm.io/gorm"
"go-admin/app/other/models/tools"
)
// jsonFieldPattern accepts any legal JS/TS identifier that starts with a
// lowercase letter - not businessName's rule.
//
// This used to be businessName's own pattern (^[a-z][A-Za-z]+$, requiring at
// least two letters and no digits), copied over on the theory that jsonField
// "should tighten to the same identifier shape". That theory does not hold:
// businessName is typed by a person on genInfoForm.vue, so a strict pattern
// is a reasonable guardrail on human input. jsonField is computed by the
// importer from the column name (sys_tables.go's namelist/JsonField loop) -
// nobody types it, so the same pattern only rejects names the importer
// legitimately produces. A one-letter column ("x") or a column ending in a
// digit ("address2", "a1") both import to a single camelCase word with no
// separators to re-capitalize, and both used to fail this check - meaning a
// table that merely contained such a column could never save any config
// again, unrelated columns included, since this check runs over every
// column on every Update.
//
// What still has to be rejected is a jsonField that cannot be a raw object
// key at all: empty, containing whitespace/punctuation, or leading with a
// digit (`2faEnabled: 1` is not valid JS - identifiers cannot start with a
// digit, and this is what lands as the property name in gen.go's generated
// interface / lang file, both unquoted). Hence still anchoring on a
// lowercase letter first, but no longer requiring a second character or
// forbidding digits after it.
var jsonFieldPattern = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`)
// colWidthMin/colWidthMax are API契约.md §2.1's suggested range for colWidth.
const (
colWidthMin = 40
colWidthMax = 800
)
// expressionMarkers flags the "meant to be evaluated" shapes API契约.md §2.1
// says defaultValue must not carry: it is spliced into the generated
// defaultModel() as a literal and never evaluated, so anything that looks
// like a function call or a block is rejected outright rather than
// generating code that silently does nothing.
var expressionMarkers = []string{"(", ")", "{", "}", "`", ";", "=>"}
// validateAndSanitizeColumns enforces PRD 010 F10 on the columns carried by
// a table update (sys_tables.go:357's Update handler, the one bind-and-save
// path with no field-level validation at all - see API契约.md §1.2/§2.1,
// decision D6).
//
// jsonField and defaultValue problems reject the request outright: letting
// either through would corrupt the generated i18n file silently (a
// duplicate or malformed jsonField becomes a duplicate or invalid key in
// gen/{PackageName}/{BusinessName}.ts, see the lang-zh/lang-en templates).
// An out-of-range colWidth does not reject - §2.1 says it "falls back to
// the inferred value", so this resets it to the 0 sentinel in place and lets
// R2's inference take over, the same as if the field had never been set.
func validateAndSanitizeColumns(columns []tools.SysColumns) error {
seen := make(map[string]bool, len(columns))
for i := range columns {
col := &columns[i]
if !jsonFieldPattern.MatchString(col.JsonField) {
return fmt.Errorf("jsonField 格式不合法:%q,须以小写字母开头且只能包含英文字母", col.JsonField)
}
if seen[col.JsonField] {
return fmt.Errorf("jsonField 在同一张表内重复:%q", col.JsonField)
}
seen[col.JsonField] = true
if col.ColWidth != 0 && (col.ColWidth < colWidthMin || col.ColWidth > colWidthMax) {
col.ColWidth = 0
}
for _, marker := range expressionMarkers {
if strings.Contains(col.DefaultValue, marker) {
return fmt.Errorf("defaultValue 不允许包含表达式或函数调用内容:%q", col.DefaultValue)
}
}
}
return nil
}
// validateBusinessNameUnique enforces PRD 010 F10's other half: two tables
// sharing (packageName, businessName) write the same generated language
// pack path, gen/{PackageName}/{BusinessName}.ts (see gen.go's
// NOActionsGen), so the second one silently overwrites the first's
// translations. tableID excludes the row being saved, so a table updating
// its own unchanged name does not trip the check on itself.
//
// G10's other concern - colliding with the built-in admin/* i18n namespace -
// does not apply here anymore: D9 moved generated keys to their own gen/
// namespace, so this only has to guard generated tables against each other.
func validateBusinessNameUnique(db *gorm.DB, packageName, businessName string, tableID int) error {
var count int64
err := db.Table("sys_tables").
Where("package_name = ? AND business_name = ? AND table_id != ?", packageName, businessName, tableID).
Count(&count).Error
if err != nil {
return err
}
if count > 0 {
return fmt.Errorf("packageName=%q 下 businessName=%q 已被其它表使用", packageName, businessName)
}
return nil
}
@@ -0,0 +1,157 @@
package tools
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"go-admin/app/other/models/tools"
)
func TestValidateAndSanitizeColumns_JsonFieldFormat(t *testing.T) {
cases := []struct {
name string
jsonField string
wantErr bool
}{
{"lower camelCase", "userName", false},
{"two-letter lowercase", "id", false},
// The importer's own output (sys_tables.go's namelist/JsonField
// loop), not made up: a single-letter column ("x"), and a column
// whose last name segment ends in a digit ("address2", "a1") both
// produce a jsonField with no separator left to re-capitalize.
// These three used to be rejected - the whole point of this fix.
{"single letter, real importer output for a column named x", "x", false},
{"letters then a trailing digit, real importer output for address2", "address2", false},
{"two letters then a digit, real importer output for a1", "a1", false},
{"leading underscore rejected", "_id", true},
{"leading digit rejected (not a legal identifier start)", "1name", true},
{"snake_case rejected (importer never emits an underscore)", "user_name", true},
{"dot rejected, would break the gen/{pkg}/{biz}.ts key path", "user.name", true},
{"empty rejected", "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateAndSanitizeColumns([]tools.SysColumns{{JsonField: tc.jsonField}})
if tc.wantErr && err == nil {
t.Errorf("jsonField %q: want error, got nil", tc.jsonField)
}
if !tc.wantErr && err != nil {
t.Errorf("jsonField %q: want no error, got %v", tc.jsonField, err)
}
})
}
}
func TestValidateAndSanitizeColumns_JsonFieldUniqueWithinTable(t *testing.T) {
err := validateAndSanitizeColumns([]tools.SysColumns{
{JsonField: "name"},
{JsonField: "name"},
})
if err == nil {
t.Fatal("want error for a jsonField repeated in the same table, got nil")
}
}
func TestValidateAndSanitizeColumns_ColWidthOutOfRangeIsSanitizedNotRejected(t *testing.T) {
cases := []struct {
name string
width int
want int
}{
{"zero (unconfigured) is left alone", 0, 0},
{"in range is left alone", 150, 150},
{"lower bound is left alone", colWidthMin, colWidthMin},
{"upper bound is left alone", colWidthMax, colWidthMax},
{"too small falls back to the sentinel", colWidthMin - 1, 0},
{"too large falls back to the sentinel", colWidthMax + 1, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cols := []tools.SysColumns{{JsonField: "name", ColWidth: tc.width}}
if err := validateAndSanitizeColumns(cols); err != nil {
t.Fatalf("colWidth %d: want no error (out-of-range sanitizes, it does not reject), got %v", tc.width, err)
}
if cols[0].ColWidth != tc.want {
t.Errorf("colWidth %d: want sanitized to %d, got %d", tc.width, tc.want, cols[0].ColWidth)
}
})
}
}
func TestValidateAndSanitizeColumns_DefaultValueExpressionRejected(t *testing.T) {
cases := []struct {
name string
defaultValue string
wantErr bool
}{
{"plain literal", "0", false},
{"plain string literal", "active", false},
{"empty (unconfigured)", "", false},
{"function call rejected", "Date.now()", true},
{"template literal rejected", "`x`", true},
{"arrow function rejected", "() => 1", true},
{"statement separator rejected", "1; drop", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateAndSanitizeColumns([]tools.SysColumns{{JsonField: "name", DefaultValue: tc.defaultValue}})
if tc.wantErr && err == nil {
t.Errorf("defaultValue %q: want error, got nil", tc.defaultValue)
}
if !tc.wantErr && err != nil {
t.Errorf("defaultValue %q: want no error, got %v", tc.defaultValue, err)
}
})
}
}
func newBusinessNameTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(new(tools.SysTables)); err != nil {
t.Fatalf("migrate sys_tables: %v", err)
}
return db
}
func TestValidateBusinessNameUnique(t *testing.T) {
db := newBusinessNameTestDB(t)
existing := tools.SysTables{TBName: "sys_widget", PackageName: "biz", BusinessName: "widget"}
if err := db.Table("sys_tables").Create(&existing).Error; err != nil {
t.Fatalf("seed: %v", err)
}
t.Run("same package, same businessName, different table: rejected", func(t *testing.T) {
other := tools.SysTables{TBName: "sys_widget_copy", PackageName: "biz", BusinessName: "widget"}
if err := db.Table("sys_tables").Create(&other).Error; err != nil {
t.Fatalf("seed second row: %v", err)
}
// Unscoped: a plain Delete only soft-deletes (SysTables carries
// common.ModelTime), which would leave this row's businessName
// looking taken for the next subtest - production's own delete path
// (SysTables.BatchDelete) hard-deletes for the same reason.
defer db.Table("sys_tables").Unscoped().Delete(&other)
if err := validateBusinessNameUnique(db, "biz", "widget", other.TableId); err == nil {
t.Error("want error for a businessName already used by another table in the same package, got nil")
}
})
t.Run("different package, same businessName: allowed", func(t *testing.T) {
if err := validateBusinessNameUnique(db, "other-pkg", "widget", 0); err != nil {
t.Errorf("want no error across different packages, got %v", err)
}
})
t.Run("a table checking against its own current name: allowed", func(t *testing.T) {
if err := validateBusinessNameUnique(db, "biz", "widget", existing.TableId); err != nil {
t.Errorf("want no error when the only match is the row being saved itself, got %v", err)
}
})
}
+31
View File
@@ -45,6 +45,19 @@ type SysColumns struct {
CreateBy int `gorm:"column:create_by;size:20;" json:"createBy"`
UpdateBy int `gorm:"column:update_By;size:20;" json:"updateBy"`
// ColWidth and DefaultValue back PRD 010 F1/F2 (代码生成器前端模板迁移 Vue 3).
// Both use a sentinel default (0 / "") rather than NULL - see
// docs-prd/010-代码生成器前端模板迁移Vue3/数据库变更.md §1.1: a non-pointer
// int/string field can never read NULL back out, and NULL would give
// "unconfigured" two representations instead of one. Callers test
// ColWidth == 0 / DefaultValue == "" to detect "not configured".
//
// ColWidth deliberately has no gorm size tag: this codebase's "size:N"
// convention on numeric fields maps to a narrow SQL integer type (see
// column_width_test.go), and col_width needs to hold values up to 800.
ColWidth int `gorm:"column:col_width;not null;default:0;comment:table column width in px, 0 = not configured" json:"colWidth"`
DefaultValue string `gorm:"column:default_value;size:255;not null;default:'';comment:form field default value, empty = not configured" json:"defaultValue"`
common.ModelTime
}
@@ -97,5 +110,23 @@ func (e *SysColumns) Update(tx *gorm.DB) (update SysColumns, err error) {
return
}
// Updates(&e) above skips zero-value fields (GORM's struct-form Updates
// always does), but ColWidth/DefaultValue's own "unconfigured" sentinel
// is 0/"" (see the field comments on SysColumns) - so clearing either one
// back to its sentinel is indistinguishable, to a struct-form Updates,
// from "the caller didn't touch this field" and silently does not get
// written. A map-form Updates does not skip zero values, so it is used
// here for just these two columns rather than widening this to
// Select("*") (which would also start writing every other zero-valued
// field on this struct - Sort, the Pk/Required/... bools - and that is a
// pre-existing gap in this method affecting fields outside PRD 010's
// scope, not fixed here).
if err = tx.Table("sys_columns").Model(&update).Updates(map[string]interface{}{
"col_width": e.ColWidth,
"default_value": e.DefaultValue,
}).Error; err != nil {
return
}
return
}
@@ -0,0 +1,46 @@
package tools
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
// GORM's Updates(struct) skips zero-value fields, and PRD 010 F1/F2 chose 0 /
// "" as the sentinel for "unconfigured" (docs-prd/010-代码生成器前端模板迁移Vue3/
// 数据库变更.md §1.1). Put those together and Update can set ColWidth/
// DefaultValue but never clear them back to the sentinel: the struct-form
// Updates call silently drops the very values this feature needs to write.
func TestSysColumnsUpdateClearsSentinelFields(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(new(SysColumns)); err != nil {
t.Fatalf("migrate sys_columns: %v", err)
}
col := SysColumns{TableId: 1, ColumnName: "status", ColWidth: 150, DefaultValue: "active"}
if _, err := col.Create(db); err != nil {
t.Fatalf("create: %v", err)
}
// Reset back to the sentinel - the UI action for "go back to inferred
// width / no default", not merely "never configured".
update := SysColumns{ColumnId: col.ColumnId, ColWidth: 0, DefaultValue: ""}
if _, err := update.Update(db); err != nil {
t.Fatalf("update: %v", err)
}
var got SysColumns
if err := db.Table("sys_columns").First(&got, col.ColumnId).Error; err != nil {
t.Fatalf("read back: %v", err)
}
if got.ColWidth != 0 {
t.Errorf("colWidth: want 0 (cleared), got %d - Update() did not write the sentinel back", got.ColWidth)
}
if got.DefaultValue != "" {
t.Errorf("defaultValue: want \"\" (cleared), got %q - Update() did not write the sentinel back", got.DefaultValue)
}
}
+1 -1
View File
@@ -5,4 +5,4 @@ import "go-admin/app/demo/router"
func init() {
//注册路由 fixme 其他应用的路由,在本目录新建文件放在init方法
AppRouters = append(AppRouters, router.InitRouter)
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ func init() {
rootCmd.AddCommand(app.StartCmd)
}
//Execute : apply commands
// Execute : apply commands
func Execute() {
if err := rootCmd.Execute(); err != nil {
os.Exit(-1)
+1 -1
View File
@@ -13,4 +13,4 @@ type SysApi struct {
func (SysApi) TableName() string {
return "sys_api"
}
}
+18 -18
View File
@@ -1,27 +1,27 @@
package models
type SysMenu struct {
MenuId int `json:"menuId" gorm:"primaryKey;autoIncrement"`
MenuName string `json:"menuName" gorm:"size:128;"`
Title string `json:"title" gorm:"size:128;"`
Icon string `json:"icon" gorm:"size:128;"`
Path string `json:"path" gorm:"size:128;"`
Paths string `json:"paths" gorm:"size:128;"`
MenuType string `json:"menuType" gorm:"size:1;"`
Action string `json:"action" gorm:"size:16;"`
Permission string `json:"permission" gorm:"size:255;"`
ParentId int `json:"parentId" gorm:"size:11;"`
NoCache bool `json:"noCache" gorm:"size:8;"`
Breadcrumb string `json:"breadcrumb" gorm:"size:255;"`
Component string `json:"component" gorm:"size:255;"`
Sort int `json:"sort" gorm:"size:4;"`
Visible string `json:"visible" gorm:"size:1;"`
IsFrame string `json:"isFrame" gorm:"size:1;DEFAULT:0;"`
SysApi []SysApi `json:"sysApi" gorm:"many2many:sys_menu_api_rule"`
MenuId int `json:"menuId" gorm:"primaryKey;autoIncrement"`
MenuName string `json:"menuName" gorm:"size:128;"`
Title string `json:"title" gorm:"size:128;"`
Icon string `json:"icon" gorm:"size:128;"`
Path string `json:"path" gorm:"size:128;"`
Paths string `json:"paths" gorm:"size:128;"`
MenuType string `json:"menuType" gorm:"size:1;"`
Action string `json:"action" gorm:"size:16;"`
Permission string `json:"permission" gorm:"size:255;"`
ParentId int `json:"parentId" gorm:"size:11;"`
NoCache bool `json:"noCache" gorm:"size:8;"`
Breadcrumb string `json:"breadcrumb" gorm:"size:255;"`
Component string `json:"component" gorm:"size:255;"`
Sort int `json:"sort" gorm:"size:4;"`
Visible string `json:"visible" gorm:"size:1;"`
IsFrame string `json:"isFrame" gorm:"size:1;DEFAULT:0;"`
SysApi []SysApi `json:"sysApi" gorm:"many2many:sys_menu_api_rule"`
ControlBy
ModelTime
}
func (SysMenu) TableName() string {
return "sys_menu"
}
}
+1 -1
View File
@@ -13,4 +13,4 @@ type SysPost struct {
func (SysPost) TableName() string {
return "sys_post"
}
}
+1 -1
View File
@@ -17,4 +17,4 @@ type SysRole struct {
func (SysRole) TableName() string {
return "sys_role"
}
}
@@ -0,0 +1,61 @@
package version
import (
"runtime"
"gorm.io/gorm"
jobmodels "go-admin/app/jobs/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
// Create sys_job_lease and seed the one row the scheduler competes for
// (issue #915).
//
// The row is seeded here rather than created on demand at startup. Two
// instances starting together would otherwise race to insert the very row
// they are each trying to claim, and the loser would have to tell a
// duplicate-key error apart from a real one in whichever driver it is
// running against. Seeding it makes the runtime path two UPDATE statements
// and nothing else.
//
// It is seeded free - no owner, and an expiry far enough in the past that
// the first instance to ask takes it - so that installing this migration
// does not leave the scheduler waiting out a TTL that nobody is holding.
//
// Ordered after 1786700003000 (the soft-delete conversion), so importing
// cmd/migrate/migration/models is banned here - see
// schema_coverage_test.go's TestPostConversionMigrationsAvoidFrozenSeedModels.
// sys_job_lease is AutoMigrate'd from its runtime model under
// app/jobs/models directly, and it is absent from 1786700003000's frozen
// softDeleteTables list because it embeds no common.ModelTime: a lease that
// could be soft-deleted would be a row that both does and does not hold the
// scheduler.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700009000JobSchedulerLease)
}
func _1786700009000JobSchedulerLease(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Migrator().AutoMigrate(new(jobmodels.SysJobLease)); err != nil {
return err
}
// Seeded free: no owner, and an expiry of 0 - before every clock
// reading there will ever be - so the first instance to ask takes
// it rather than waiting out a TTL nobody is holding.
lease := jobmodels.SysJobLease{
Name: jobmodels.SchedulerLeaseName,
Owner: "",
AcquiredAtMs: 0,
ExpiresAtMs: 0,
}
if err := tx.Create(&lease).Error; err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,75 @@
package version
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
jobmodels "go-admin/app/jobs/models"
common "go-admin/common/models"
)
func openJobLeaseDB(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(&common.Migration{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
return db
}
// The runtime has no insert path - two instances starting together would
// race to create the row they are both trying to claim - so the row has to
// exist when the migration finishes or nothing ever schedules anything.
func TestTheSchedulerLeaseMigrationLeavesExactlyOneFreeRow(t *testing.T) {
db := openJobLeaseDB(t)
if err := _1786700009000JobSchedulerLease(db, "1786700009000"); err != nil {
t.Fatalf("migrate: %v", err)
}
if !db.Migrator().HasTable(&jobmodels.SysJobLease{}) {
t.Fatal("sys_job_lease was not created")
}
var rows []jobmodels.SysJobLease
if err := db.Find(&rows).Error; err != nil {
t.Fatalf("reading sys_job_lease: %v", err)
}
if len(rows) != 1 {
t.Fatalf("sys_job_lease holds %d rows, want exactly 1", len(rows))
}
row := rows[0]
if row.Name != jobmodels.SchedulerLeaseName {
t.Errorf("the seeded row is named %q, want %q; acquire looks the row up by this name and would find nothing",
row.Name, jobmodels.SchedulerLeaseName)
}
if row.Owner != "" {
t.Errorf("the seeded lease is owned by %q; a fresh install would wait out a TTL held by nobody", row.Owner)
}
// Zero, not "now": the take is `expires_at_ms <= now`, so a seeded
// expiry in the future is a scheduler that does not start until it
// passes.
if row.ExpiresAtMs != 0 {
t.Errorf("the seeded lease expires at %d, want 0", row.ExpiresAtMs)
}
}
func TestTheSchedulerLeaseMigrationRecordsItsVersion(t *testing.T) {
db := openJobLeaseDB(t)
if err := _1786700009000JobSchedulerLease(db, "1786700009000"); err != nil {
t.Fatalf("migrate: %v", err)
}
var got common.Migration
if err := db.Where("version = ?", "1786700009000").First(&got).Error; err != nil {
t.Fatalf("the migration did not record its version, so it would run again on every start: %v", err)
}
}
@@ -0,0 +1,56 @@
package version
import (
"runtime"
"gorm.io/gorm"
"go-admin/app/other/models/tools"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
// Add sys_columns.col_width and sys_columns.default_value for PRD 010 F1/F2
// (代码生成器前端模板迁移 Vue 3).
//
// col_width backs R2's column-width inference fallback and default_value
// backs R1/A6's "unconfigured rows still generate a usable page" guarantee -
// see docs-prd/010-代码生成器前端模板迁移Vue3/数据库变更.md §1.1 for why both
// defaults are sentinels (0 / "") rather than NULL: a non-pointer Go int/
// string field can never read NULL back out, and NULL would give
// "unconfigured" two representations instead of one.
//
// Ordered after 1786700003000, so this reads tools.SysColumns (the runtime
// model sys_columns's Update/GetPage/GetSysTablesInfo actually query through)
// rather than cmd/migrate/migration/models, matching every migration in this
// directory since sys_columns was converted - see
// 1786700004000_generator_tables_marker.go and schema_coverage_test.go's
// TestPostConversionMigrationsAvoidFrozenSeedModels.
//
// Hard prerequisite: tools.SysColumns must already declare ColWidth and
// DefaultValue (with the gorm tags in the doc above) by the time this file
// is compiled - AddColumn reads the column definition off the struct's own
// tag, not off anything in this file. Landing this migration without that
// model change first makes HasColumn/AddColumn silently do nothing (the
// field lookup fails and AddColumn returns an error naming the missing
// field), which fails loudly rather than silently - see the "no such field"
// error - so this is caught at migrate time, not left for a report later.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700010000GenColumnLayoutFields)
}
func _1786700010000GenColumnLayoutFields(db *gorm.DB, version string) error {
m := db.Migrator()
if !m.HasColumn(&tools.SysColumns{}, "ColWidth") {
if err := m.AddColumn(&tools.SysColumns{}, "ColWidth"); err != nil {
return err
}
}
if !m.HasColumn(&tools.SysColumns{}, "DefaultValue") {
if err := m.AddColumn(&tools.SysColumns{}, "DefaultValue"); err != nil {
return err
}
}
return db.Create(&common.Migration{Version: version}).Error
}
+2 -2
View File
@@ -45,8 +45,8 @@ func (e *QiNiuKODO) getToken() (string, error) {
return putPolicy.UploadToken(mac), nil
}
//Setup 装载
//endpoint sss
// Setup 装载
// endpoint sss
func (e *QiNiuKODO) Setup(endpoint, accessKeyID, accessKeySecret, BucketName string, options ...ClientOption) error {
mac := qbox.NewMac(accessKeyID, accessKeySecret)
+2 -2
View File
@@ -10,8 +10,8 @@ type ALiYunOSS struct {
BucketName string
}
//Setup 装载
//endpoint sss
// Setup 装载
// endpoint sss
func (e *ALiYunOSS) Setup(endpoint, accessKeyID, accessKeySecret, BucketName string, options ...ClientOption) error {
client, err := oss.New(endpoint, accessKeyID, accessKeySecret)
if err != nil {
+1 -1
View File
@@ -10,7 +10,7 @@ import (
// NoCache is a middleware function that appends headers
// to prevent the client from caching the HTTP response.
func NoCache(c *gin.Context) {
c.Header("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate, value")
c.Header("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate")
c.Header("Expires", "Thu, 01 Jan 1970 00:00:00 GMT")
c.Header("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
c.Next()
+119
View File
@@ -0,0 +1,119 @@
package middleware
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestNoCache(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
NoCache(c)
if got := w.Header().Get("Cache-Control"); got != "no-cache, no-store, max-age=0, must-revalidate" {
t.Errorf("Cache-Control = %q", got)
}
if got := w.Header().Get("Expires"); got != "Thu, 01 Jan 1970 00:00:00 GMT" {
t.Errorf("Expires = %q", got)
}
if got := w.Header().Get("Last-Modified"); got == "" {
t.Error("Last-Modified should not be empty")
} else if _, err := time.Parse(http.TimeFormat, got); err != nil {
t.Errorf("Last-Modified = %q is not a valid HTTP time: %v", got, err)
}
}
func TestOptions(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("OPTIONS request", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodOptions, "/", nil)
Options(c)
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("Access-Control-Allow-Origin = %q", got)
}
if got := w.Header().Get("Access-Control-Allow-Methods"); got != "GET,POST,PUT,PATCH,DELETE,OPTIONS" {
t.Errorf("Access-Control-Allow-Methods = %q", got)
}
if got := w.Header().Get("Access-Control-Allow-Headers"); got != "authorization, origin, content-type, accept" {
t.Errorf("Access-Control-Allow-Headers = %q", got)
}
if got := w.Header().Get("Allow"); got != "HEAD,GET,POST,PUT,PATCH,DELETE,OPTIONS" {
t.Errorf("Allow = %q", got)
}
if got := w.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q", got)
}
if !c.IsAborted() {
t.Error("expected the request to be aborted")
}
if w.Code != http.StatusOK {
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
}
})
t.Run("non-OPTIONS request", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
Options(c)
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Access-Control-Allow-Origin = %q, want empty", got)
}
if c.IsAborted() {
t.Error("expected the request not to be aborted")
}
})
}
func TestSecure(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("without TLS", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
Secure(c)
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("Access-Control-Allow-Origin = %q", got)
}
if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("X-Content-Type-Options = %q", got)
}
if got := w.Header().Get("X-XSS-Protection"); got != "1; mode=block" {
t.Errorf("X-XSS-Protection = %q", got)
}
if got := w.Header().Get("Strict-Transport-Security"); got != "" {
t.Errorf("Strict-Transport-Security = %q, want empty without TLS", got)
}
})
t.Run("with TLS", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.Request.TLS = &tls.ConnectionState{}
Secure(c)
if got := w.Header().Get("Strict-Transport-Security"); got != "max-age=31536000" {
t.Errorf("Strict-Transport-Security = %q", got)
}
})
}
+1 -1
View File
@@ -38,6 +38,6 @@ var CasbinExclude = []UrlInfo{
{Url: "/", Method: "GET"},
{Url: "/api/v1/server-monitor", Method: "GET"},
{Url: "/api/v1/public/uploadFile", Method: "POST"},
{Url: "/api/v1/user/pwd/set", Method: "PUT"},
{Url: "/api/v1/user/pwd/set", Method: "PUT"},
{Url: "/api/v1/sys-user", Method: "PUT"},
}
+2 -2
View File
@@ -1,11 +1,11 @@
module github.com/go-admin-team/example-app-order
go 1.25.13
go 1.27.1
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.8.0
github.com/go-admin-team/go-admin-core/v2 v2.10.0
gorm.io/gorm v1.31.2
)
+2 -2
View File
@@ -58,8 +58,8 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-admin-team/go-admin-core/v2 v2.8.0 h1:ZTw5Z/UT1/7OltbGPEaEVerRk4z3koB6O8nDbb84tPM=
github.com/go-admin-team/go-admin-core/v2 v2.8.0/go.mod h1:LG/XvEfOplbuadKrPTPm0Nu5pN06aQUNZZC3ao4B4gs=
github.com/go-admin-team/go-admin-core/v2 v2.10.0 h1:MM1wl9s2iW3M4GFsT8SyaUWaj2Q8m1NSuundXc6DWzI=
github.com/go-admin-team/go-admin-core/v2 v2.10.0/go.mod h1:Q0FfO+8pfPNkPqk9fcRpe2sSOHfir82cjZO8Nol/8SI=
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=
+2 -2
View File
@@ -1,6 +1,6 @@
module go-admin
go 1.26.5
go 1.27.1
require (
github.com/alibaba/sentinel-golang v1.0.4
@@ -11,7 +11,7 @@ require (
github.com/casbin/casbin/v3 v3.8.1
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/go-admin-team/go-admin-core/v2 v2.8.0
github.com/go-admin-team/go-admin-core/v2 v2.10.0
github.com/google/uuid v1.6.0
github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible
github.com/mssola/user_agent v0.6.0
+2 -2
View File
@@ -145,8 +145,8 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-admin-team/go-admin-core/v2 v2.8.0 h1:ZTw5Z/UT1/7OltbGPEaEVerRk4z3koB6O8nDbb84tPM=
github.com/go-admin-team/go-admin-core/v2 v2.8.0/go.mod h1:LG/XvEfOplbuadKrPTPm0Nu5pN06aQUNZZC3ao4B4gs=
github.com/go-admin-team/go-admin-core/v2 v2.10.0 h1:MM1wl9s2iW3M4GFsT8SyaUWaj2Q8m1NSuundXc6DWzI=
github.com/go-admin-team/go-admin-core/v2 v2.10.0/go.mod h1:Q0FfO+8pfPNkPqk9fcRpe2sSOHfir82cjZO8Nol/8SI=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
+10 -12
View File
@@ -23,20 +23,18 @@ metadata:
version: v1
spec:
# One replica, and the drain window below buys nothing at one replica: there
# is nowhere to send the traffic this pod stops taking. Raising it needs two
# changes that are not this number:
# is nowhere to send the traffic this pod stops taking.
#
# The volume below is shared by every replica, and the log path in
# settings.yml lives on it, so a second pod would append to the same
# rotating file.
# The scheduler no longer stands in the way of raising this. Every pod takes
# a lease row in its own database (sys_job_lease) and only the holder
# registers the jobs, so one enabled job fires once however many pods there
# are; a pod that loses the lease stops scheduling, and one that exits hands
# it back so a successor starts without waiting out the lease. See #915.
#
# The job scheduler is per process while its handle on a job is one shared
# column. Startup runs `UPDATE sys_job SET entry_id = 0 WHERE entry_id > 0`
# across the whole table (app/jobs/jobbase.go), so a second pod erases the
# first pod's ids and writes its own, and every pod registers the whole
# enabled list in its own scheduler. Neither symptom logs anything: an
# enabled job fires once per pod, and stopping one from the UI removes an
# entry from the wrong process and still answers 200. See #915.
# What still does stand in the way: the volume below is shared by every
# replica, and the log path in settings.yml lives on it, so a second pod
# appends to the same rotating file. Give each replica its own log
# destination before raising this.
replicas: 1
selector:
matchLabels:
-47
View File
@@ -1,47 +0,0 @@
import request from '@/utils/request'
// 查询{{.ClassName}}列表
export function list{{.ClassName}}(query) {
return request({
url: '/api/v1/{{.ModuleName}}',
method: 'get',
params: query
})
}
// 查询{{.ClassName}}详细
export function get{{.ClassName}} ({{.PkJsonField}}) {
return request({
url: '/api/v1/{{.ModuleName}}/' + {{.PkJsonField}},
method: 'get'
})
}
// 新增{{.ClassName}}
export function add{{.ClassName}}(data) {
return request({
url: '/api/v1/{{.ModuleName}}',
method: 'post',
data: data
})
}
// 修改{{.ClassName}}
export function update{{.ClassName}}(data) {
return request({
url: '/api/v1/{{.ModuleName}}/'+data.{{.PkJsonField}},
method: 'put',
data: data
})
}
// 删除{{.ClassName}}
export function del{{.ClassName}}(data) {
return request({
url: '/api/v1/{{.ModuleName}}',
method: 'delete',
data: data
})
}
+6
View File
@@ -0,0 +1,6 @@
export default {
{{- range $i, $col := .Columns}}
{{- if $i}},{{end}}
{{$col.JsonField}}: {{if $col.ColumnComment}}{{singleQuote $col.ColumnComment}}{{else}}{{singleQuote $col.JsonField}}{{end}}
{{- end}}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
{{- range $i, $col := .Columns}}
{{- if $i}},{{end}}
{{$col.JsonField}}: {{if $col.ColumnComment}}{{singleQuote $col.ColumnComment}}{{else}}{{singleQuote $col.JsonField}}{{end}}
{{- end}}
}
+125
View File
@@ -0,0 +1,125 @@
{{- /*
$pkType: the primary key's TS type for get{ClassName}'s parameter.
Defaults to "number" - true for every column but string primary keys
(natural keys), which do exist (sys_tables.go:323-338 gives a primary
key column GoType "string" whenever its ColumnType is not int-shaped).
Matches vue.go.template's own $pkType derivation exactly (F4) - useForm
there is typed on the same column, and a mismatch between the two is a
TS compile error at the call site, not a runtime bug.
*/ -}}
{{- $pkType := "number" -}}
{{- $hasQuery := false -}}
{{- range .Columns -}}
{{- if and .Pk (eq .GoType "string") }}{{$pkType = "string"}}{{end -}}
{{- if eq .IsQuery "1" }}{{$hasQuery = true}}{{end -}}
{{- end -}}
import request from '@/utils/request'
import type { ApiResponse, PageQuery, PageResult, Id } from '@/types/api'
export interface {{.ClassName}} {
{{- range .Columns}}
{{.JsonField}}?: {{if eq .GoType "int" -}}
number
{{- else if eq .GoType "int64" -}}
number
{{- else if eq .GoType "float32" -}}
number
{{- else if eq .GoType "float64" -}}
number
{{- else -}}
string
{{- end}}
{{- end}}
}
{{if $hasQuery -}}
export interface {{.ClassName}}Query {
{{- range .Columns}}
{{- if eq .IsQuery "1"}}
{{.JsonField}}?: {{if eq .GoType "int" -}}
number
{{- else if eq .GoType "int64" -}}
number
{{- else if eq .GoType "float32" -}}
number
{{- else if eq .GoType "float64" -}}
number
{{- else -}}
string
{{- end}}
{{- end}}
{{- end}}
}
{{- else -}}
{{- /*
No column is marked IsQuery - a plain display table with no search form
is a normal shape, not an edge case, so this still has to produce a type
useTable<Row, Query>/list{ClassName}(query: Query & PageQuery) can use.
`export interface {ClassName}Query {}` is what naturally falls out of the
range above finding nothing to iterate, but an empty interface trips
@typescript-eslint/no-empty-object-type and fails pnpm lint.
Record<string, never> (this file's first attempt, and the type
useTable.ts's own `TQuery extends object = Record<string, never>` default
uses) looks like the obvious match but is wrong here: it is a mapped type
over *every* string key, each mapped to never, so intersecting it with
PageQuery does not leave PageQuery alone - `pageIndex` becomes
`never & number`, i.e. never, and no value can be passed for it at all.
useTable.ts itself never hits this because its one internal use of
`TQuery & PageQuery` goes through an `as` cast rather than a structural
check (composables/useTable.ts ~line 160); code that builds the object
literal directly - such as a foreign-key column's
`list{FkClass}({ pageIndex: 1, pageSize: 100 })` call in vue.go.template -
is not casting anything and hits the real error, only when the referenced
table happens to have no query columns of its own (a plain lookup/dict
table used as a dropdown source, not a rare shape).
Record<never, never> is the type with the same intent - "no query
columns" - but the mapped-type domain is `never`, so it has no keys at
all rather than "every key, mapped to never": it behaves as the empty
object type `{}` under intersection, leaving PageQuery's own pageIndex/
pageSize untouched, and confirmed separately not to trip
no-empty-object-type either (it is a generic instantiation, not a
literal `{}` type annotation).
*/ -}}
export type {{.ClassName}}Query = Record<never, never>
{{- end}}
export function list{{.ClassName}}(query: {{.ClassName}}Query & PageQuery) {
return request<ApiResponse<PageResult<{{.ClassName}}>>>({
url: '/api/v1/{{.ModuleName}}',
method: 'get',
params: query
})
}
export function get{{.ClassName}}({{.PkJsonField}}: {{$pkType}}) {
return request<ApiResponse<{{.ClassName}}>>({
url: '/api/v1/{{.ModuleName}}/' + {{.PkJsonField}},
method: 'get'
})
}
export function add{{.ClassName}}(data: {{.ClassName}}) {
return request<ApiResponse<{{.ClassName}}>>({
url: '/api/v1/{{.ModuleName}}',
method: 'post',
data
})
}
export function update{{.ClassName}}(data: {{.ClassName}}) {
return request<ApiResponse<{{.ClassName}}>>({
url: '/api/v1/{{.ModuleName}}/' + data.{{.PkJsonField}},
method: 'put',
data
})
}
export function del{{.ClassName}}(ids: Id[]) {
return request<ApiResponse<null>>({
url: '/api/v1/{{.ModuleName}}',
method: 'delete',
data: { ids: ids.map(Number) }
})
}
+377 -467
View File
@@ -1,479 +1,389 @@
{{$tableComment:=.TableComment}}
{{- /*
Vue 3 + Element Plus + TypeScript list page (PRD 010, F4).
Shape matches go-admin-ui/src/views/demo/product/index.vue, the reference
page AGENTS.md names: PageContainer + ProTable + useTable/useForm/useRemove,
<script setup lang="ts">. The old template produced slot-scope/.sync/.native
syntax that Vue 3 removed outright (PRD 010 G1) -- this is not a patch on
that file, it is a different template for a different framework version.
Every label goes through $t('gen.{PackageName}.{BusinessName}.{JsonField}'),
never a literal ColumnComment -- see src/lang/{locale}/gen/index.ts (F9) for
how that namespace is loaded. This is also why the file must not contain a
literal CJK character anywhere, comments included: D10's acceptance check is
a bare regex scan of the rendered output with no exception for "but this one
is a comment", so a Chinese aside here would fail the same test a stray
placeholder="{{"{{"}}.ColumnComment{{"}}"}}" would.
HtmlType has seven stored values (PRD 010 G8) and only four render here on
purpose: checkbox and datetime became selectable in the F7 front-end change
(editTable.vue), so they get a branch; file stays disabled there, but a row
imported or edited before that change can still carry "file" or any other
value this template does not know -- the final branch below renders those,
and anything else future work introduces, as a plain input rather than
emitting nothing (PRD 010 phase-3 constraint #1: a silently empty field is
worse than a plain one).
*/ -}}
{{- $package := .PackageName -}}
{{- $business := .BusinessName -}}
{{- /*
Whether any column needs a given import, computed once by walking .Columns
rather than at each usage site -- text/template has no way to ask "did the
loop below already import this", so the alternative is repeating the same
import line once per matching column. "$var = value" (not ":=") reassigns an
outer-scope variable from inside a range -- a text/template feature since
Go 1.11, needed here because a range body cannot otherwise leave a mark on
anything outside itself.
Each condition below must match, term for term, the condition guarding the
markup or script that actually consumes the import -- not just "this column
has a DictType/FkTableName", which is necessary but not sufficient. A column
can carry dictionary or foreign-key metadata that no rendered branch reads:
FkTableName/DictType lose to each other by priority (FK wins search, list
and the form's select branch; the form's radio branch never looks at FK at
all), and a column can carry either one while being neither queryable nor
listed nor an insertable select/radio -- created_at/updated_at are exactly
this: sys_tables.go assigns HtmlType "datetime" to any timestamp/datetime
column on import whether or not it ever reaches IsList, because GetList's
audit-column exclusion is a separate, later step. Get a term here wrong in
either direction and either an import goes unused (no-unused-vars) or a real
usage silently loses its import (a ReferenceError this template cannot see
coming, since Vue components are the last stage that runs).
*/ -}}
{{- $hasDict := false -}}
{{- $hasDictList := false -}}
{{- $hasFk := false -}}
{{- $hasDatetime := false -}}
{{- $hasRules := false -}}
{{- $hasQuery := false -}}
{{- $pkType := "number" -}}
{{- range .Columns -}}
{{- $dictUsed := and (ne .DictType "") (or (and (eq .IsQuery "1") (eq .FkTableName "")) (and (eq .IsList "1") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "select") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "radio"))) -}}
{{- $fkUsed := and (ne .FkTableName "") (or (eq .IsQuery "1") (eq .IsList "1") (and (eq .IsInsert "1") (eq .HtmlType "select"))) -}}
{{- if $dictUsed }}{{$hasDict = true}}{{end -}}
{{- if and (eq .IsList "1") (eq .FkTableName "") (ne .DictType "") }}{{$hasDictList = true}}{{end -}}
{{- if $fkUsed }}{{$hasFk = true}}{{end -}}
{{- if and (eq .IsList "1") (eq .FkTableName "") (eq .DictType "") (eq .HtmlType "datetime") }}{{$hasDatetime = true}}{{end -}}
{{- if eq .IsQuery "1" }}{{$hasQuery = true}}{{end -}}
{{- if and (eq .IsInsert "1") (eq .IsRequired "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy") }}{{$hasRules = true}}{{end -}}
{{- if and .Pk (eq .GoType "string") }}{{$pkType = "string"}}{{end -}}
{{- end -}}
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<el-form ref="queryForm" :model="queryParams" :inline="true" label-width="68px">
{{range .Columns}}
{{- $x := .IsQuery -}}
{{- if (eq $x "1") -}}
<el-form-item label="{{.ColumnComment}}" prop="{{.JsonField}}">
{{- if ne .FkTableName "" -}}
<el-select v-model="queryParams.{{.JsonField}}"
placeholder="请选择" clearable size="small" {{if eq .IsEdit "false" -}} :disabled="isEdit" {{- end }}>
<el-option
v-for="dict in {{.JsonField}}Options"
:key="dict.key"
:label="dict.value"
:value="dict.key"
/>
</el-select>
{{- else -}}
{{if eq .DictType "" -}}
<el-input v-model="queryParams.{{.JsonField}}" placeholder="请输入{{.ColumnComment}}" clearable
size="small" @keyup.enter.native="handleQuery"/>
{{- else -}}
<el-select v-model="queryParams.{{.JsonField}}"
placeholder="{{$tableComment}}{{.ColumnComment}}" clearable size="small">
<el-option
v-for="dict in {{.JsonField}}Options"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
{{- end}}
{{- end}}
</el-form-item>
{{end}}
{{- end }}
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<PageContainer>
<ProTable :table="table" selection row-key="{{.PkJsonField}}">
{{- if $hasQuery}}
<template #search>
{{- range .Columns}}
{{- if eq .IsQuery "1"}}
{{- $key := printf "gen.%s.%s.%s" $package $business .JsonField}}
<el-form-item :label="$t('{{$key}}')">
{{- if ne .FkTableName ""}}
<el-select v-model="table.query.{{.JsonField}}" clearable :placeholder="$t('common.selectPlaceholder')">
<el-option
v-for="item in {{.JsonField}}FkOptions"
:key="item.{{.FkLabelId}}"
:label="item.{{.FkLabelName}}"
:value="item.{{.FkLabelId}}"
/>
</el-select>
{{- else if ne .DictType ""}}
<el-select v-model="table.query.{{.JsonField}}" clearable :placeholder="$t('common.selectPlaceholder')">
<el-option
v-for="dict in {{.JsonField}}DictOptions"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
{{- else if eq .HtmlType "datetime"}}
<el-date-picker
v-model="table.query.{{.JsonField}}"
type="datetime"
value-format="YYYY-MM-DD[T]HH:mm:ssZ"
clearable
/>
{{- else}}
<el-input v-model="table.query.{{.JsonField}}" clearable />
{{- end}}
</el-form-item>
{{- end}}
{{- end}}
</template>
{{- end}}
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:add']"
type="primary"
icon="el-icon-plus"
size="mini"
@click="handleAdd"
>新增
</el-button>
</el-col>
<el-col :span="1.5">
<el-button
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:edit']"
type="success"
icon="el-icon-edit"
size="mini"
:disabled="single"
@click="handleUpdate"
>修改
</el-button>
</el-col>
<el-col :span="1.5">
<el-button
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']"
type="danger"
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
>删除
</el-button>
</el-col>
</el-row>
<template #toolbar>
<el-button v-permisaction="['{{.PackageName}}:{{.BusinessName}}:add']" type="primary" @click="form.openCreate()">
{{ "{{" }} $t('common.add') {{ "}}" }}
</el-button>
<el-button
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']"
type="danger"
plain
:disabled="table.multiple"
@click="remove(table.selectedIds)"
>
{{ "{{" }} $t('common.delete') {{ "}}" }}
</el-button>
</template>
{{- range .Columns}}
{{- if eq .IsList "1"}}
{{- $key := printf "gen.%s.%s.%s" $package $business .JsonField}}
{{- if ne .FkTableName ""}}
<el-table v-loading="loading" :data="{{.BusinessName}}List" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center"/>
{{- range .Columns -}}
{{- $x := .IsList -}}
{{- if (eq $x "1") }}
{{- if ne .FkTableName "" -}}
<el-table-column label="{{.ColumnComment}}" align="center" prop="{{.JsonField}}" :formatter="{{.JsonField}}Format" width="100">
<template slot-scope="scope">
{{ "{{" }} {{.JsonField}}Format(scope.row) {{"}}"}}
</template>
</el-table-column>
<el-table-column :label="$t('{{$key}}')" min-width="{{if .ColWidth}}{{.ColWidth}}{{else}}120{{end}}" show-overflow-tooltip>
<template #default="{ row }">{{ "{{" }} {{.JsonField}}Label(row.{{.JsonField}}) {{ "}}" }}</template>
</el-table-column>
{{- else if ne .DictType ""}}
{{- else -}}
{{- if ne .DictType "" -}}
<el-table-column label="{{.ColumnComment}}" align="center" prop="{{.JsonField}}"
:formatter="{{.JsonField}}Format" width="100">
<template slot-scope="scope">
{{ "{{" }} {{.JsonField}}Format(scope.row) {{"}}"}}
</template>
</el-table-column>
<el-table-column :label="$t('{{$key}}')" min-width="{{if .ColWidth}}{{.ColWidth}}{{else}}120{{end}}">
<template #default="{ row }">{{ "{{" }} dictLabel({{.JsonField}}DictOptions, row.{{.JsonField}}) {{ "}}" }}</template>
</el-table-column>
{{- else if eq .HtmlType "datetime"}}
{{- end -}}
{{- if eq .DictType "" -}}
{{- if eq .HtmlType "datetime" -}}
<el-table-column label="{{.ColumnComment}}" align="center" prop="{{.JsonField}}"
:show-overflow-tooltip="true">
<template slot-scope="scope">
<span>{{ "{{" }} parseTime(scope.row.{{.JsonField}}) {{"}}"}}</span>
</template>
</el-table-column>
{{- else -}}
<el-table-column label="{{.ColumnComment}}" align="center" prop="{{.JsonField}}"
:show-overflow-tooltip="true"/>
{{- end -}}
{{- end -}}
{{- end -}}
{{- end }}
{{- end }}
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button
slot="reference"
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:edit']"
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
>修改
</el-button>
<el-popconfirm
class="delete-popconfirm"
title="确认要删除吗?"
confirm-button-text="删除"
@confirm="handleDelete(scope.row)"
>
<el-button
slot="reference"
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']"
size="mini"
type="text"
icon="el-icon-delete"
>删除
</el-button>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-table-column :label="$t('{{$key}}')" min-width="{{if .ColWidth}}{{.ColWidth}}{{else}}110{{end}}">
<template #default="{ row }"><DateCell :value="row.{{.JsonField}}" /></template>
</el-table-column>
{{- else}}
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageIndex"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<el-table-column
:label="$t('{{$key}}')"
prop="{{.JsonField}}"
min-width="{{if .ColWidth}}{{.ColWidth}}{{else}}120{{end}}"
show-overflow-tooltip
/>
{{- end}}
{{- end}}
{{- end}}
<!-- 添加或修改对话框 -->
<el-dialog :title="title" :visible.sync="open" width="500px">
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
{{ range .Columns }}
{{- $x := .IsInsert -}}
{{- if (eq $x "1") -}}
{{- if (.Pk) }}
{{- else if eq .GoField "CreatedAt" -}}
{{- else if eq .GoField "UpdatedAt" -}}
{{- else if eq .GoField "DeletedAt" -}}
{{- else if eq .GoField "UpdateBy" -}}
{{- else if eq .GoField "CreateBy" -}}
{{- else }}
<el-form-item label="{{.ColumnComment}}" prop="{{.JsonField}}">
{{ if eq "input" .HtmlType -}}
<el-input v-model{{if eq .GoType "int64" -}}.number{{- end}}="form.{{.JsonField}}" placeholder="{{.ColumnComment}}"
{{if eq .IsEdit "false" -}}:disabled="isEdit" {{- end}}/>
{{- else if eq "select" .HtmlType -}}
{{- if ne .FkTableName "" -}}
<el-select v-model="form.{{.JsonField}}"
placeholder="请选择" {{if eq .IsEdit "false" -}} :disabled="isEdit" {{- end }}>
<el-option
v-for="dict in {{.JsonField}}Options"
:key="dict.key"
:label="dict.value"
:value="dict.key"
/>
</el-select>
{{- else -}}
<el-select v-model="form.{{.JsonField}}"
placeholder="请选择" {{if eq .IsEdit "false" -}} :disabled="isEdit" {{- end }}>
<el-option
v-for="dict in {{.JsonField}}Options"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
{{- end -}}
{{- else if eq "radio" .HtmlType -}}
<el-radio-group v-model="form.{{.JsonField}}">
<el-radio
v-for="dict in {{.JsonField}}Options"
:key="dict.value"
:label="dict.value"
>{{"{{"}} dict.label {{"}}"}}</el-radio>
</el-radio-group>
{{- else if eq "file" .HtmlType -}}
<el-input
v-model="form.{{.JsonField}}"
placeholder="图片"
/>
<el-button type="primary" @click="fileShow{{.GoField}}">选择文件</el-button>
{{- else if eq "datetime" .HtmlType -}}
<el-date-picker
v-model="form.{{.JsonField}}"
type="datetime"
placeholder="选择日期">
</el-date-picker>
{{- else if eq "textarea" .HtmlType -}}
<el-input
v-model="form.{{.JsonField}}"
type="textarea"
:rows="2"
placeholder="请输入内容">
</el-input>
{{- end }}
</el-form-item>
{{- end }}
{{- end }}
{{- end }}
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
</el-card>
</template>
</BasicLayout>
<template #actions="{ row }">
<el-button v-permisaction="['{{.PackageName}}:{{.BusinessName}}:edit']" link type="primary" @click="form.openEdit(row)">
{{ "{{" }} $t('common.edit') {{ "}}" }}
</el-button>
<el-button v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']" link type="danger" @click="remove(row.{{.PkJsonField}})">
{{ "{{" }} $t('common.delete') {{ "}}" }}
</el-button>
</template>
</ProTable>
<el-dialog
v-model="form.visible"
:title="form.title"
width="500px"
:close-on-click-modal="false"
@closed="form.reset"
>
<el-form
:ref="form.bindFormRef"
v-loading="form.loading"
:model="form.model"
{{- if $hasRules}}
:rules="form.rules"
{{- end}}
label-width="100px"
>
{{- range .Columns}}
{{- if and (eq .IsInsert "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy")}}
{{- $key := printf "gen.%s.%s.%s" $package $business .JsonField}}
<el-form-item :label="$t('{{$key}}')" prop="{{.JsonField}}">
{{- if eq .HtmlType "select"}}
{{- if ne .FkTableName ""}}
<el-select v-model="form.model.{{.JsonField}}" :placeholder="$t('common.selectPlaceholder')">
<el-option
v-for="item in {{.JsonField}}FkOptions"
:key="item.{{.FkLabelId}}"
:label="item.{{.FkLabelName}}"
:value="item.{{.FkLabelId}}"
/>
</el-select>
{{- else if ne .DictType ""}}
<el-select v-model="form.model.{{.JsonField}}" :placeholder="$t('common.selectPlaceholder')">
<el-option
v-for="dict in {{.JsonField}}DictOptions"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
{{- else}}
<el-input v-model="form.model.{{.JsonField}}" />
{{- end}}
{{- else if eq .HtmlType "radio"}}
{{- if ne .DictType ""}}
<el-radio-group v-model="form.model.{{.JsonField}}">
<el-radio v-for="dict in {{.JsonField}}DictOptions" :key="dict.value" :value="dict.value">
{{ "{{" }} dict.label {{ "}}" }}
</el-radio>
</el-radio-group>
{{- else}}
<el-input v-model="form.model.{{.JsonField}}" />
{{- end}}
{{- else if eq .HtmlType "checkbox"}}
<el-checkbox v-model="form.model.{{.JsonField}}" true-value="1" false-value="0" />
{{- else if eq .HtmlType "datetime"}}
<el-date-picker
v-model="form.model.{{.JsonField}}"
type="datetime"
value-format="YYYY-MM-DD[T]HH:mm:ssZ"
/>
{{- else if eq .HtmlType "textarea"}}
<el-input v-model="form.model.{{.JsonField}}" type="textarea" :rows="2" />
{{- else}}
<el-input v-model="form.model.{{.JsonField}}" />
{{- end}}
</el-form-item>
{{- end}}
{{- end}}
</el-form>
<template #footer>
<el-button @click="form.close">{{ "{{" }} $t('common.dialogCancel') {{ "}}" }}</el-button>
<el-button type="primary" :loading="form.submitting" @click="form.submit">
{{ "{{" }} $t('common.dialogConfirm') {{ "}}" }}
</el-button>
</template>
</el-dialog>
</PageContainer>
</template>
<script>
import {add{{.ClassName}}, del{{.ClassName}}, get{{.ClassName}}, list{{.ClassName}}, update{{.ClassName}}} from '@/api/{{ .PackageName}}/{{ .MLTBName}}'
{{ $package:=.PackageName }}
{{range .Columns}}
{{- if ne .FkTableName "" -}}
import {list{{.FkTableNameClass}} } from '@/api/{{ $package }}/{{ .FkTableNamePackage}}'
{{ end -}}
{{- end -}}
<script setup lang="ts">
{{- if $hasRules}}
import { computed } from 'vue'
{{- end}}
{{- if $hasFk}}
import { ref, onMounted } from 'vue'
{{- end}}
{{- if $hasRules}}
import { useI18n } from 'vue-i18n'
import type { FormRules } from 'element-plus'
{{- end}}
import PageContainer from '@/components/PageContainer/index.vue'
import ProTable from '@/components/ProTable/index.vue'
{{- if $hasDatetime}}
import DateCell from '@/components/DateCell/index.vue'
{{- end}}
{{- if $hasDict}}
{{- if $hasDictList}}
import { useTable, useForm, useRemove, useDict, dictLabel } from '@/composables'
{{- else}}
import { useTable, useForm, useRemove, useDict } from '@/composables'
{{- end}}
{{- else}}
import { useTable, useForm, useRemove } from '@/composables'
{{- end}}
import {
add{{.ClassName}}, del{{.ClassName}}, get{{.ClassName}}, list{{.ClassName}}, update{{.ClassName}}
} from '@/api/{{.PackageName}}/{{.MLTBName}}'
import type { {{.ClassName}}, {{.ClassName}}Query } from '@/api/{{.PackageName}}/{{.MLTBName}}'
{{- /*
Two columns pointing at the same foreign table must not import it twice --
"one FK-configured column" was never the same thing as "one distinct target
table", and gen.go has no concept of a table's FK targets being unique.
text/template has no set to check membership in, so the dedup is a nested
range: a column only imports its target if no earlier, equally-used column
already claimed the same FkTableNameClass. $fkUsed is repeated here (it also
guards the const declarations above) because a column with FkTableName set
but reaching none of them -- unqueried, unlisted, not an insert select --
has nothing that would use the import either.
*/ -}}
{{- range $i, $col := .Columns}}
{{- $fkUsed := and (ne $col.FkTableName "") (or (eq $col.IsQuery "1") (eq $col.IsList "1") (and (eq $col.IsInsert "1") (eq $col.HtmlType "select"))) -}}
{{- if $fkUsed}}
{{- $alreadyImported := false -}}
{{- range $j, $prior := $.Columns}}
{{- if lt $j $i}}
{{- $priorUsed := and (ne $prior.FkTableName "") (or (eq $prior.IsQuery "1") (eq $prior.IsList "1") (and (eq $prior.IsInsert "1") (eq $prior.HtmlType "select"))) -}}
{{- if and $priorUsed (eq $prior.FkTableNameClass $col.FkTableNameClass) }}{{$alreadyImported = true}}{{end -}}
{{- end}}
{{- end}}
{{- if not $alreadyImported}}
import { list{{$col.FkTableNameClass}} } from '@/api/{{$package}}/{{$col.FkTableNamePackage}}'
import type { {{$col.FkTableNameClass}} } from '@/api/{{$package}}/{{$col.FkTableNamePackage}}'
{{- end}}
{{- end}}
{{- end}}
{{- /*
Manage suffix, not just ClassName: this must match gen.go's
Cmenu.MenuName = tab.ClassName + "Manage" byte for byte (PRD 010 R4), or
keep-alive's include list -- built from menu_name -- never matches this
component's name and the page never caches. The old template wrote
name: '{ClassName}' with no suffix; the mismatch went unnoticed because
stores/permission.ts's loadView() rewrites the rendered component's name to
menu_name at runtime regardless of what defineOptions said (PRD 010 G7).
That fallback stays in place after this change -- it is not this template's
to remove -- but the value declared here should be right regardless of it.
*/}}
export default {
name: '{{.ClassName}}',
components: {
},
data() {
return {
// 遮罩层
loading: true,
// 选中数组
ids: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 总条数
total: 0,
// 弹出层标题
title: '',
// 是否显示弹出层
open: false,
isEdit: false,
// 类型数据字典
typeOptions: [],
{{.BusinessName}}List: [],
{{range .Columns}}
{{- if ne .DictType "" -}}
{{.JsonField}}Options: [],
{{- end -}}
{{- end }}
// 关系表类型
{{range .Columns}}
{{- if ne .FkTableName "" -}}
{{.JsonField}}Options :[],
{{ end -}}
{{- end }}
// 查询参数
queryParams: {
pageIndex: 1,
pageSize: 10,
{{ range .Columns }}
{{- if (.IsQuery) -}}
{{.JsonField}}:undefined,
{{ end -}}
{{- end }}
},
// 表单参数
form: {
},
// 表单校验
rules: {
{{- range .Columns -}}
{{- $x := .IsQuery -}}
{{- if (eq $x "1") -}}
{{.JsonField}}: [ {required: true, message: '{{.ColumnComment}}不能为空', trigger: 'blur'} ],
{{ end }}
{{- end -}}
}
}
},
created() {
this.getList()
{{range .Columns}}
{{- if ne .DictType "" -}}
this.getDicts('{{.DictType}}').then(response => {
this.{{.JsonField}}Options = response.data
})
{{ end -}}
{{- if ne .FkTableName "" -}}
this.get{{.FkTableNameClass}}Items()
{{ end -}}
{{- end -}}
},
methods: {
/** 查询参数列表 */
getList() {
this.loading = true
list{{.ClassName}}(this.addDateRange(this.queryParams, this.dateRange)).then(response => {
this.{{.BusinessName}}List = response.data.list
this.total = response.data.count
this.loading = false
}
)
},
// 取消按钮
cancel() {
this.open = false
this.reset()
},
// 表单重置
reset() {
this.form = {
{{ range .Columns}}
{{- $x := .IsInsert -}}
{{- if (eq $x "1") -}}
{{- if eq .GoField "CreatedAt" -}}
{{- else if eq .GoField "UpdatedAt" -}}
{{- else if eq .GoField "DeletedAt" -}}
{{- else if eq .GoField "UpdateBy" -}}
{{- else if eq .GoField "CreateBy" -}}
{{- else }}
{{.JsonField}}: undefined,
{{- end }}
{{- end -}}
{{- end }}
}
this.resetForm('form')
},
getImgList: function() {
this.form[this.fileIndex] = this.$refs['fileChoose'].resultList[0].fullUrl
},
fileClose: function() {
this.fileOpen = false
},
{{range .Columns}}
{{- if ne .DictType "" -}}
{{.JsonField}}Format(row) {
return this.selectDictLabel(this.{{.JsonField}}Options, row.{{.JsonField}})
},
{{ end -}}
{{- if ne .FkTableName "" -}}
{{.JsonField}}Format(row) {
return this.selectItemsLabel(this.{{.JsonField}}Options, row.{{.JsonField}})
},
{{ end -}}
{{- end -}}
// 关系
{{range .Columns}}
{{- if ne .FkTableName "" -}}
get{{.FkTableNameClass}}Items() {
this.getItems(list{{.FkTableNameClass}}, undefined).then(res => {
this.{{.JsonField}}Options = this.setItems(res, '{{.FkLabelId}}', '{{.FkLabelName}}')
})
},
{{ end -}}
{{- end -}}
// 文件
{{range .Columns}}
{{- if eq .HtmlType "file" -}}
fileShow{{.GoField}}: function() {
this.fileOpen = true
this.fileIndex = '{{.JsonField}}'
},
{{ end -}}
{{- end -}}
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageIndex = 1
this.getList()
},
/** 重置按钮操作 */
resetQuery() {
this.dateRange = []
this.resetForm('queryForm')
this.handleQuery()
},
/** 新增按钮操作 */
handleAdd() {
this.reset()
this.open = true
this.title = '添加{{.TableComment}}'
this.isEdit = false
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.{{.PkJsonField}})
this.single = selection.length !== 1
this.multiple = !selection.length
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset()
const {{.PkJsonField}} =
row.{{.PkJsonField}} || this.ids
get{{.ClassName}}({{.PkJsonField}}).then(response => {
this.form = response.data
this.open = true
this.title = '修改{{.TableComment}}'
this.isEdit = true
})
},
/** 提交按钮 */
submitForm: function () {
this.$refs['form'].validate(valid => {
if (valid) {
if (this.form.{{.PkJsonField}} !== undefined) {
update{{.ClassName}}(this.form).then(response => {
if (response.code === 200) {
this.msgSuccess(response.msg)
this.open = false
this.getList()
} else {
this.msgError(response.msg)
}
})
} else {
add{{.ClassName}}(this.form).then(response => {
if (response.code === 200) {
this.msgSuccess(response.msg)
this.open = false
this.getList()
} else {
this.msgError(response.msg)
}
})
}
}
})
},
/** 删除按钮操作 */
handleDelete(row) {
var Ids = (row.{{.PkJsonField}} && [row.{{.PkJsonField}}]) || this.ids
defineOptions({ name: '{{.ClassName}}Manage' })
{{- range .Columns}}
{{- $dictUsed := and (ne .DictType "") (or (and (eq .IsQuery "1") (eq .FkTableName "")) (and (eq .IsList "1") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "select") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "radio"))) -}}
{{- $fkUsed := and (ne .FkTableName "") (or (eq .IsQuery "1") (eq .IsList "1") (and (eq .IsInsert "1") (eq .HtmlType "select"))) -}}
{{- if $dictUsed}}
this.$confirm('是否确认删除编号为"' + Ids + '"的数据项?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(function () {
return del{{.ClassName}}( { 'ids': Ids })
}).then((response) => {
if (response.code === 200) {
this.msgSuccess(response.msg)
this.open = false
this.getList()
} else {
this.msgError(response.msg)
}
}).catch(function () {
})
}
}
}
const { {{.DictType}}: {{.JsonField}}DictOptions } = useDict('{{.DictType}}')
{{- end}}
{{- if $fkUsed}}
const {{.JsonField}}FkOptions = ref<{{.FkTableNameClass}}[]>([])
onMounted(async() => {
const res = await list{{.FkTableNameClass}}({ pageIndex: 1, pageSize: 100 })
{{.JsonField}}FkOptions.value = res.data?.list ?? []
})
{{- if eq .IsList "1"}}
const {{.JsonField}}Label = (value: unknown) =>
{{.JsonField}}FkOptions.value.find(item => item.{{.FkLabelId}} === value)?.{{.FkLabelName}} ?? value
{{- end}}
{{- end}}
{{- end}}
{{- /*
Every object literal below is built on one line, joined with ", " through a
$first flag rather than one field per line with a trailing comma after each:
comma-dangle is "never" (no comma before the closing brace) and comma-style
is "last" (a comma may not open a line), and text/template has no arithmetic
to compute "is this the last matching column" up front -- knowing that would
be what a one-field-per-line, trailing-comma-free rendering needs instead.
*/}}
const table = useTable<{{.ClassName}}, {{.ClassName}}Query>({
api: list{{.ClassName}},
idKey: '{{.PkJsonField}}'
{{- if $hasQuery}},
defaultQuery: () => ({{"{"}} {{$qFirst := true}}{{range .Columns}}{{if eq .IsQuery "1"}}{{if $qFirst}}{{$qFirst = false}}{{else}}, {{end}}{{.JsonField}}: undefined{{end}}{{end}} {{"}"}})
{{- end}}
})
{{- if $hasRules}}
const { t } = useI18n()
{{- /*
Built from the same field-label key rather than a dedicated
gen.{pkg}.{biz}.rules.{field} key: R3 derives one key per field from
PackageName+BusinessName+JsonField, and a second, validation-only key per
required field would double the language pack's surface for a message that
reads fine as the field name alone in the space Element Plus renders it --
directly under the labelled field it failed to validate.
*/}}
const rules = computed<FormRules>(() => ({ {{$rFirst := true}}
{{- range .Columns}}
{{- if and (eq .IsInsert "1") (eq .IsRequired "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy")}}
{{- $key := printf "gen.%s.%s.%s" $package $business .JsonField}}
{{- if $rFirst}}{{$rFirst = false}}{{else}},
{{end}}{{.JsonField}}: [{ required: true, message: t('{{$key}}'), trigger: '{{if or (eq .HtmlType "select") (eq .HtmlType "radio") (eq .HtmlType "datetime") (eq .HtmlType "checkbox")}}change{{else}}blur{{end}}' }]
{{- end}}
{{- end}}
}))
{{- end}}
const form = useForm<{{.ClassName}}, {{$pkType}}>({
defaultModel: () => ({{"{"}} {{.PkJsonField}}: undefined{{range .Columns}}{{if and (eq .IsInsert "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy")}}, {{.JsonField}}: {{if eq .DefaultValue ""}}undefined{{else if eq .GoType "int"}}{{.DefaultValue}}{{else}}'{{js .DefaultValue}}'{{end}}{{end}}{{end}} {{"}"}}),
idKey: '{{.PkJsonField}}',
{{- if $hasRules}}
rules,
{{- end}}
api: { get: get{{.ClassName}}, add: add{{.ClassName}}, update: update{{.ClassName}} },
onSuccess: () => table.getList()
})
const { remove } = useRemove({
api: del{{.ClassName}},
onSuccess: () => table.getList()
})
</script>
+2 -2
View File
@@ -1,6 +1,6 @@
module go-admin-e2e-apporder
go 1.26.5
go 1.27.1
require (
github.com/glebarez/go-sqlite v1.22.0
@@ -40,7 +40,7 @@ require (
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/gin-gonic/gin v1.12.0 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/go-admin-team/go-admin-core/v2 v2.8.0 // indirect
github.com/go-admin-team/go-admin-core/v2 v2.10.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect
+2 -2
View File
@@ -145,8 +145,8 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-admin-team/go-admin-core/v2 v2.8.0 h1:ZTw5Z/UT1/7OltbGPEaEVerRk4z3koB6O8nDbb84tPM=
github.com/go-admin-team/go-admin-core/v2 v2.8.0/go.mod h1:LG/XvEfOplbuadKrPTPm0Nu5pN06aQUNZZC3ao4B4gs=
github.com/go-admin-team/go-admin-core/v2 v2.10.0 h1:MM1wl9s2iW3M4GFsT8SyaUWaj2Q8m1NSuundXc6DWzI=
github.com/go-admin-team/go-admin-core/v2 v2.10.0/go.mod h1:Q0FfO+8pfPNkPqk9fcRpe2sSOHfir82cjZO8Nol/8SI=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=